361 lines
14 KiB
TypeScript
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 });
|
|
}
|
|
}
|