import { spawn } from 'child_process'; import { existsSync } from 'fs'; import { mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises'; import path from 'path'; import { randomUUID } from 'node:crypto'; import { pathToFileURL } from 'url'; const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); const DEFAULT_DOCX_CONVERSION_TIMEOUT_MS = 60_000; async function ensureTempDir(): Promise { if (!existsSync(DOCSTORE_DIR)) { await mkdir(DOCSTORE_DIR, { recursive: true }); } if (!existsSync(TEMP_DIR)) { await mkdir(TEMP_DIR, { recursive: true }); } } async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise { return new Promise((resolve, reject) => { const args: string[] = []; if (profileDir) { args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`); } args.push('--headless', '--nologo', '--convert-to', 'pdf', '--outdir', outputDir, inputPath); const proc = spawn('soffice', args); let settled = false; const timeout = setTimeout(() => { if (settled) return; settled = true; proc.kill(); reject(new Error(`LibreOffice conversion timed out after ${DEFAULT_DOCX_CONVERSION_TIMEOUT_MS}ms`)); }, DEFAULT_DOCX_CONVERSION_TIMEOUT_MS); proc.on('error', (error) => { clearTimeout(timeout); if (settled) return; settled = true; reject(error); }); proc.on('close', (code) => { clearTimeout(timeout); if (settled) return; settled = true; if (code === 0) resolve(); else reject(new Error(`LibreOffice conversion failed with code ${code}`)); }); }); } async function waitForPdfReady(dir: string, timeoutMs = 20_000, intervalMs = 100): Promise { const end = Date.now() + timeoutMs; while (Date.now() < end) { const files = await readdir(dir); const pdf = files.find((f) => f.toLowerCase().endsWith('.pdf')); if (pdf) { const pdfPath = path.join(dir, pdf); try { const first = await stat(pdfPath); await new Promise((resolve) => setTimeout(resolve, intervalMs)); const second = await stat(pdfPath); if (second.size > 0 && second.size === first.size) { return pdfPath; } } catch { // Ignore transient filesystem races while LibreOffice is still writing. } } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`); } export async function convertDocxBufferToPdfBuffer(docxBytes: Buffer): Promise { await ensureTempDir(); const tempId = randomUUID(); const jobDir = path.join(TEMP_DIR, tempId); await mkdir(jobDir, { recursive: true }); const profileDir = path.join(jobDir, 'lo-profile'); await mkdir(profileDir, { recursive: true }); const inputPath = path.join(jobDir, 'input.docx'); try { await writeFile(inputPath, docxBytes); await convertDocxToPdf(inputPath, jobDir, profileDir); const pdfPath = await waitForPdfReady(jobDir); return await readFile(pdfPath); } finally { await rm(jobDir, { recursive: true, force: true }).catch(() => {}); } }