import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, extname, isAbsolute, join } from 'node:path'; import { tmpdir } from 'node:os'; import { promisify } from 'node:util'; import { pathToFileURL } from 'node:url'; import type { AppConfig } from './config.js'; import { normalizeTaskArtifactFileName } from './artifact-name.js'; import { sha256Bytes } from './crypto.js'; const execFileAsync = promisify(execFile); const PDF_CONTENT_TYPE = 'application/pdf'; const XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const PDF_MAGIC = Buffer.from('%PDF-'); const XLSX_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); const PDF_EXPORT_FILTER = 'writer_pdf_Export'; const XLSX_EXPORT_FILTER = 'Calc MS Excel 2007 XML'; const PRINTABLE_HTML_CONTENT_WIDTH_PT = 550; const HTML_WIDTH_ATTRIBUTE_PATTERN = /(\bwidth\s*=\s*["']?)(\d+(?:\.\d+)?)(["']?)/gi; const HTML_WIDTH_STYLE_PATTERN = /(\bwidth\s*:\s*)(\d+(?:\.\d+)?)(pt|px)/gi; export type DocumentConversionStatus = 'converted' | 'already_pdf' | 'converted_xlsx' | 'already_xlsx' | 'source_fallback'; export interface DocumentConversionInput { fileName: string; contentType: string; content: Buffer; maxBytes: number; } export interface DocumentConversionResult { fileName: string; contentType: string; content: Buffer; byteSize: number; sha256: string; converted: boolean; conversion_status: DocumentConversionStatus; conversion_error_code?: string; } function sourceExtension(fileName: string, contentType: string): string { const extension = extname(fileName).toLowerCase(); if (extension) return extension; const normalized = contentType.toLowerCase().split(';', 1)[0].trim(); return { 'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', 'application/rtf': '.rtf', 'text/html': '.html', 'text/plain': '.txt' }[normalized] || '.bin'; } function pdfFileName(fileName: string): string { const normalized = normalizeTaskArtifactFileName(fileName) || 'team-file.bin'; const extension = extname(normalized); const stem = basename(normalized, extension).trim() || 'team-file'; return `${stem}.pdf`; } function xlsxFileName(fileName: string): string { const normalized = normalizeTaskArtifactFileName(fileName) || 'team-file.bin'; const extension = extname(normalized); const stem = basename(normalized, extension).trim() || 'team-file'; return `${stem}.xlsx`; } function isPdf(contentType: string, content: Buffer): boolean { return contentType.toLowerCase().split(';', 1)[0].trim() === PDF_CONTENT_TYPE || content.subarray(0, PDF_MAGIC.byteLength).equals(PDF_MAGIC); } function isXlsx(contentType: string, content: Buffer): boolean { return contentType.toLowerCase().split(';', 1)[0].trim() === XLSX_CONTENT_TYPE || content.subarray(0, XLSX_MAGIC.byteLength).equals(XLSX_MAGIC); } function sourceResult( input: DocumentConversionInput, errorCode?: string ): DocumentConversionResult { return { fileName: normalizeTaskArtifactFileName(input.fileName) || 'team-file.bin', contentType: input.contentType || 'application/octet-stream', content: input.content, byteSize: input.content.byteLength, sha256: sha256Bytes(input.content), converted: false, conversion_status: 'source_fallback', ...(errorCode ? { conversion_error_code: errorCode } : {}) }; } function conversionErrorCode(error: unknown): string { const value = error && typeof error === 'object' ? error as { code?: unknown; killed?: unknown; signal?: unknown; } : {}; if (value.code === 'ENOENT') return 'converter_unavailable'; if (value.killed === true || value.signal === 'SIGTERM') return 'converter_timeout'; return 'conversion_failed'; } function resolveFontConfigFile(config: AppConfig): string | undefined { const configuredCandidates = [ config.DOCUMENT_FONTCONFIG_FILE, process.env.FONTCONFIG_FILE ]; const converterCandidates = isAbsolute(config.DOCUMENT_CONVERTER_PATH) ? [ join(dirname(config.DOCUMENT_CONVERTER_PATH), '..', '..', 'native', 'poppler', 'poppler', 'etc', 'fonts', 'fonts.conf'), join(dirname(config.DOCUMENT_CONVERTER_PATH), '..', '..', 'native', 'libreoffice-headless', 'libreoffice', 'LibreOfficeDev.app', 'Contents', 'Resources', 'fontconfig', 'fonts.conf') ] : []; const systemCandidates = [ '/etc/fonts/fonts.conf', '/opt/homebrew/etc/fonts/fonts.conf', '/usr/local/etc/fonts/fonts.conf' ]; return [...configuredCandidates, ...converterCandidates, ...systemCandidates] .filter((candidate): candidate is string => Boolean(candidate)) .find((candidate) => existsSync(candidate)); } function isHtmlDocument(fileName: string, contentType: string, content: Buffer): boolean { const extension = extname(fileName).toLowerCase(); const normalizedContentType = contentType.toLowerCase().split(';', 1)[0].trim(); if (normalizedContentType === 'text/html' || extension === '.html' || extension === '.htm') { return true; } const prefix = content.subarray(0, 4096).toString('utf8').trimStart().toLowerCase(); return prefix.startsWith(' htmlWidthInPoints(match[2])), ...Array.from(source.matchAll(HTML_WIDTH_STYLE_PATTERN), (match) => htmlWidthInPoints(match[2], match[3])) ]; const maximumWidth = Math.max(...widths, 0); if (maximumWidth <= PRINTABLE_HTML_CONTENT_WIDTH_PT) return 1; return PRINTABLE_HTML_CONTENT_WIDTH_PT / maximumWidth; } function formatScaledHtmlWidth(value: string, factor: number): string { const numericValue = Number(value) * factor; if (Number.isInteger(Number(value))) return String(Math.max(1, Math.round(numericValue))); return numericValue.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); } function fitHtmlDocumentToPrintableWidth( fileName: string, contentType: string, content: Buffer ): Buffer { if (!isHtmlDocument(fileName, contentType, content)) return content; const source = content.toString('utf8'); const factor = htmlWidthScaleFactor(source); if (factor >= 1) return content; const scaledAttributes = source.replace( HTML_WIDTH_ATTRIBUTE_PATTERN, (_match: string, prefix: string, value: string, suffix: string) => ( `${prefix}${formatScaledHtmlWidth(value, factor)}${suffix}` ) ); const scaledSource = scaledAttributes.replace( HTML_WIDTH_STYLE_PATTERN, (_match: string, prefix: string, value: string, unit: string) => ( `${prefix}${formatScaledHtmlWidth(value, factor)}${unit}` ) ); return Buffer.from(scaledSource, 'utf8'); } /** * Convert a source attachment to PDF in an isolated temporary LibreOffice * profile. Conversion is best-effort by design: the caller still receives the * original source bytes when the converter is unavailable or rejects a file. */ export async function convertDocumentToPdf( config: AppConfig, input: DocumentConversionInput ): Promise { if (isPdf(input.contentType, input.content)) { return { fileName: pdfFileName(input.fileName), contentType: PDF_CONTENT_TYPE, content: input.content, byteSize: input.content.byteLength, sha256: sha256Bytes(input.content), converted: true, conversion_status: 'already_pdf' }; } const temporaryDirectory = await mkdtemp(join(tmpdir(), 'ltjt-document-conversion-')); const inputName = normalizeTaskArtifactFileName(input.fileName) || 'team-file.bin'; const inputPath = join(temporaryDirectory, `${basename(inputName, extname(inputName))}${sourceExtension(inputName, input.contentType)}`); const outputDirectory = join(temporaryDirectory, 'output'); const profileDirectory = join(temporaryDirectory, 'profile'); const outputName = pdfFileName(inputName); const outputPath = join(outputDirectory, outputName); try { await mkdir(outputDirectory, { recursive: true, mode: 0o700 }); await writeFile( inputPath, fitHtmlDocumentToPrintableWidth(inputName, input.contentType, input.content), { mode: 0o600 } ); const childEnvironment = { ...process.env }; const fontConfigFile = resolveFontConfigFile(config); if (fontConfigFile) { childEnvironment.FONTCONFIG_FILE = fontConfigFile; } else { delete childEnvironment.FONTCONFIG_FILE; } await execFileAsync(config.DOCUMENT_CONVERTER_PATH, [ '--headless', '--nologo', '--nodefault', '--norestore', '--nolockcheck', '--nofirststartwizard', `-env:UserInstallation=${pathToFileURL(profileDirectory).href}`, '--convert-to', `pdf:${PDF_EXPORT_FILTER}`, '--outdir', outputDirectory, inputPath ], { timeout: config.DOCUMENT_CONVERSION_TIMEOUT_MS, maxBuffer: 1_048_576, windowsHide: true, env: childEnvironment }); const outputFiles = (await readdir(outputDirectory)).filter((file) => file.toLowerCase().endsWith('.pdf')); if (outputFiles.length !== 1 || outputFiles[0] !== outputName) { return sourceResult(input, 'conversion_output_missing'); } const convertedContent = await readFile(outputPath); if (convertedContent.byteLength === 0 || !convertedContent.subarray(0, PDF_MAGIC.byteLength).equals(PDF_MAGIC)) { return sourceResult(input, 'conversion_output_invalid'); } if (convertedContent.byteLength > input.maxBytes) { return sourceResult(input, 'converted_file_too_large'); } return { fileName: outputName, contentType: PDF_CONTENT_TYPE, content: convertedContent, byteSize: convertedContent.byteLength, sha256: sha256Bytes(convertedContent), converted: true, conversion_status: 'converted' }; } catch (error) { return sourceResult(input, conversionErrorCode(error)); } finally { await rm(temporaryDirectory, { recursive: true, force: true, maxRetries: 2 }); } } /** * Convert the ERP visitor-list HTML-in-XLS response to a real XLSX workbook. * The original source bytes are kept by the caller as a separate archive * artifact; this result is the mobile/AgentBus delivery copy. */ export async function convertDocumentToXlsx( config: AppConfig, input: DocumentConversionInput ): Promise { if (isXlsx(input.contentType, input.content)) { return { fileName: xlsxFileName(input.fileName), contentType: XLSX_CONTENT_TYPE, content: input.content, byteSize: input.content.byteLength, sha256: sha256Bytes(input.content), converted: true, conversion_status: 'already_xlsx' }; } const temporaryDirectory = await mkdtemp(join(tmpdir(), 'ltjt-spreadsheet-conversion-')); const inputName = normalizeTaskArtifactFileName(input.fileName) || 'team-file.bin'; // LibreOffice treats an HTML file named `.html` as a Writer/Web document. // Its Calc XLSX export path can abort while exporting that document on the // bundled macOS build. ERP visitor exports are HTML-in-XLS, so keep the // spreadsheet extension for HTML sources to force Calc's HTML-table import. const inputExtension = isHtmlDocument(inputName, input.contentType, input.content) ? '.xls' : sourceExtension(inputName, input.contentType); const inputPath = join(temporaryDirectory, `${basename(inputName, extname(inputName))}${inputExtension}`); const outputDirectory = join(temporaryDirectory, 'output'); const profileDirectory = join(temporaryDirectory, 'profile'); const outputName = xlsxFileName(inputName); const outputPath = join(outputDirectory, outputName); try { await mkdir(outputDirectory, { recursive: true, mode: 0o700 }); await writeFile(inputPath, input.content, { mode: 0o600 }); const childEnvironment = { ...process.env }; const fontConfigFile = resolveFontConfigFile(config); if (fontConfigFile) { childEnvironment.FONTCONFIG_FILE = fontConfigFile; } else { delete childEnvironment.FONTCONFIG_FILE; } await execFileAsync(config.DOCUMENT_CONVERTER_PATH, [ '--headless', '--nologo', '--nodefault', '--norestore', '--nolockcheck', '--nofirststartwizard', `-env:UserInstallation=${pathToFileURL(profileDirectory).href}`, '--convert-to', `xlsx:${XLSX_EXPORT_FILTER}`, '--outdir', outputDirectory, inputPath ], { timeout: config.DOCUMENT_CONVERSION_TIMEOUT_MS, maxBuffer: 1_048_576, windowsHide: true, env: childEnvironment }); const outputFiles = (await readdir(outputDirectory)).filter((file) => file.toLowerCase().endsWith('.xlsx')); if (outputFiles.length !== 1 || outputFiles[0] !== outputName) { return sourceResult(input, 'conversion_output_missing'); } const convertedContent = await readFile(outputPath); if (convertedContent.byteLength === 0 || !convertedContent.subarray(0, XLSX_MAGIC.byteLength).equals(XLSX_MAGIC)) { return sourceResult(input, 'conversion_output_invalid'); } if (convertedContent.byteLength > input.maxBytes) { return sourceResult(input, 'converted_file_too_large'); } return { fileName: outputName, contentType: XLSX_CONTENT_TYPE, content: convertedContent, byteSize: convertedContent.byteLength, sha256: sha256Bytes(convertedContent), converted: true, conversion_status: 'converted_xlsx' }; } catch (error) { return sourceResult(input, conversionErrorCode(error)); } finally { await rm(temporaryDirectory, { recursive: true, force: true, maxRetries: 2 }); } }