Files
LWLT-AIBOT/control-plane/src/document-converter.ts
2026-08-25 10:33:29 +08:00

361 lines
14 KiB
TypeScript

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('<!doctype html') || prefix.startsWith('<html') || (extension === '.doc' && prefix.startsWith('<body'));
}
function htmlWidthInPoints(value: string, unit: string = 'px'): number {
const numericValue = Number(value);
return unit.toLowerCase() === 'pt' ? numericValue : numericValue * 0.75;
}
function htmlWidthScaleFactor(source: string): number {
const widths = [
...Array.from(source.matchAll(HTML_WIDTH_ATTRIBUTE_PATTERN), (match) => 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<DocumentConversionResult> {
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<DocumentConversionResult> {
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 });
}
}