openreader/src/lib/server/documents/docx-convert.ts
Richard R c59052a03e fix(documents): allow all accepted files for upload and add docx conversion timeout
Update DocumentUploader to upload all accepted files without filtering by type,
enabling consistent handling of DOCX and other supported formats. Add a default
timeout for DOCX-to-PDF conversion to prevent hanging LibreOffice processes.
This improves reliability and ensures uploads do not silently stall.
2026-06-04 13:20:47 -06:00

94 lines
3.2 KiB
TypeScript

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<void> {
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<void> {
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<string> {
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<Buffer> {
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(() => {});
}
}