From 2dc32c7a42c27008b1f6333d68d6bf5e5e51fbc8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 4 May 2026 17:14:00 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20feat:=20LibreOffice=20=E2=86=92?= =?UTF-8?q?=20PDF=20preview=20path=20(POC,=20opt-in=20via=20env)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the plan-mode discussion: prove out a LibreOffice subprocess pipeline as an alternative to the docx-preview / pptx-preview CDN renderers. LibreOffice handles every office format Microsoft and LibreOffice itself can open (DOCX, PPTX, XLSX, ODT, ODP, ODS, RTF, many more), produces a PDF, and the host browser's built-in PDF viewer renders it inside the Sandpack iframe via a `data:` URI. No client-side JS dependency, no CDN dependency, true high fidelity for any feature LibreOffice supports. Off by default. Operators opt in by setting both: - `OFFICE_PREVIEW_LIBREOFFICE=true` - LibreOffice (`soffice` or `libreoffice`) on the server's `$PATH` When either is missing, the dispatcher falls through to the existing CDN/mammoth/slide-list pipeline so a misconfiguration doesn't break previews. Hardening (`packages/api/src/files/documents/libreoffice.ts`): - Fresh subprocess per call with isolated temp dir, stripped env (PATH/HOME/TMPDIR only), and `-env:UserInstallation` so concurrent conversions can't collide on shared `~/.config/libreoffice` locks - 30-second wall-time cap; SIGKILL on timeout - 50 MB PDF output cap to bound disk pressure - 512 KB output cap on the wrapped HTML so the SSE/cache contract stays intact (base64 inflates ~33%, effective PDF cap ~380 KB) - Macros disabled by default flags (`--norestore --invisible --nodefault --nofirststartwizard --nolockcheck`) - Tag-distinct `LibreOfficeUnavailableError` / `LibreOfficeConversionError` so callers can swallow appropriately Iframe wrapper (`buildPdfEmbedDocument`): - Native browser PDF viewer via ` +
PDF preview unavailable in this browser. Please download the file to view it.
+ + +`; +} + +/** + * Try the LibreOffice path. Returns the wrapped HTML on success, or `null` + * if LibreOffice isn't enabled / available / converted successfully — the + * caller falls through to the existing pipeline. Never throws. + * + * `extensionHint`: `docx` / `pptx` / `xlsx` / `odt` / `odp` / `ods` etc. + * + * Output-size guarantee: returns `null` if the embedded HTML would exceed + * the 512 KB `attachment.text` cache cap. Base64 inflates by ~33% so the + * effective PDF cap is ~380 KB; LibreOffice's PDF/A-1 default produces + * compact output for typical chat-emitted documents. + */ +export async function tryLibreOfficePreview( + buffer: Buffer, + extensionHint: string, + outputCap: number, +): Promise { + if (!isLibreOfficeEnabled()) { + return null; + } + try { + const pdf = await convertOfficeToPdf(buffer, extensionHint); + const base64 = pdf.toString('base64'); + const html = buildPdfEmbedDocument(base64); + if (Buffer.byteLength(html, 'utf-8') > outputCap) { + return null; + } + return html; + } catch (err) { + /* Swallow both unavailable + conversion errors. The dispatcher + * pipeline is the authoritative renderer; LibreOffice is opportunistic + * augmentation. The caller's logger will surface the underlying + * pipeline if they care to instrument it. */ + void err; + return null; + } +}