🧪 feat: LibreOffice → PDF preview path (POC, opt-in via env)

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 `<iframe src="data:application/pdf;
  base64,...">` — works in Chrome, Edge, Safari, Firefox
- CSP locks the iframe to `default-src 'none'; frame-src data:;
  connect-src 'none'; script-src 'unsafe-inline'` — no outbound
  network, no eval, no external scripts
- `#view=FitH` for first-paint sizing
- 4-second heuristic timer that swaps to a "Preview unavailable"
  fallback when the browser's PDF viewer is disabled (kiosk mode,
  Brave Shields, etc.)

Wired into `wordDocToHtml` and `pptxToHtml` as the first branch —
returns null when disabled / unavailable / oversized so the existing
pipeline takes over. XLSX intentionally NOT routed through this
path: SheetJS's HTML output is already excellent for spreadsheets
(sortable, sticky headers) and PDF rendering of sheets is awkward.

Tests (`libreoffice.spec.ts`, 30 cases — 25 always run, 5 conditional
on the binary): env-gating parser semantics matching
`OFFICE_PREVIEW_DISABLE_CDN`, fallthrough contract (never throws,
returns null on any failure), CSP lock-down, fallback structure,
binary probe caching + missing-binary path, error tagging, and
integration tests that engage when `soffice`/`libreoffice` is on
PATH (DOCX→PDF, PPTX→PDF, output-cap fallthrough). Integration
tests skip cleanly on bare CI.
This commit is contained in:
Danny Avila 2026-05-04 17:14:00 -04:00
parent 0c0b0ce887
commit 2dc32c7a42
3 changed files with 691 additions and 0 deletions

View file

@ -1,5 +1,6 @@
import yauzl from 'yauzl';
import { excelMimeTypes, megabyte } from 'librechat-data-provider';
import { tryLibreOfficePreview } from './libreoffice';
import { assertSafeZipSize } from './zipSafety';
/**
@ -582,6 +583,16 @@ function isOfficePreviewCdnDisabled(): boolean {
*/
export async function wordDocToHtml(buffer: Buffer): Promise<string> {
await assertSafeZipSize(buffer, { name: 'docx' });
/* Opt-in LibreOffice path: highest fidelity for any DOCX feature
* mammoth/docx-preview can't reproduce (complex tables, drawing
* objects, charts, embedded objects). Returns null when disabled,
* binary missing, conversion failed, or output exceeds the cache
* cap — falls through to the existing pipeline in any of those
* cases so a misconfiguration doesn't break previews. */
const lo = await tryLibreOfficePreview(buffer, 'docx', OFFICE_HTML_OUTPUT_CAP);
if (lo) {
return lo;
}
if (isOfficePreviewCdnDisabled() || buffer.length > MAX_DOCX_CDN_BINARY_BYTES) {
return wordDocToHtmlViaMammoth(buffer);
}
@ -1167,6 +1178,14 @@ async function pptxToHtmlViaCdn(buffer: Buffer): Promise<string> {
*/
export async function pptxToHtml(buffer: Buffer): Promise<string> {
await assertSafeZipSize(buffer, { name: 'pptx' });
/* Opt-in LibreOffice path: PDF rendering of slides preserves layout,
* fonts, charts, and embedded objects far better than the slide-list
* fallback or even pptx-preview's canvas approach. Falls through on
* any error per the contract in `tryLibreOfficePreview`. */
const lo = await tryLibreOfficePreview(buffer, 'pptx', OFFICE_HTML_OUTPUT_CAP);
if (lo) {
return lo;
}
if (isOfficePreviewCdnDisabled()) {
return pptxToSlideListHtmlInternal(buffer);
}

View file

@ -0,0 +1,298 @@
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import {
_resetLibreOfficeProbeCache,
buildPdfEmbedDocument,
convertOfficeToPdf,
isLibreOfficeEnabled,
LibreOfficeConversionError,
LibreOfficeUnavailableError,
LIBREOFFICE_TIMEOUT_MS,
MAX_LIBREOFFICE_PDF_BYTES,
probeLibreOfficeBinary,
tryLibreOfficePreview,
} from './libreoffice';
/* Detect whether the host has a LibreOffice binary so the integration
* tests below can conditionally engage. Most CI runners don't have
* LibreOffice installed (it's a 500 MB dependency); we still exercise
* the env-gating, the wrapper builder, and the failure-fallthrough
* contract in those environments. */
function hasLibreOfficeOnPath(): boolean {
for (const candidate of ['soffice', 'libreoffice']) {
const result = spawnSync(candidate, ['--version'], { stdio: 'ignore' });
if (result.status === 0) {
return true;
}
}
return false;
}
const LIBREOFFICE_INSTALLED = hasLibreOfficeOnPath();
const itIfLibreOffice = LIBREOFFICE_INSTALLED ? it : it.skip;
/* Fixtures sit next to the source files (matches `html.spec.ts` resolution). */
const FIXTURES_DIR = __dirname;
function readFixture(name: string): Buffer {
return fs.readFileSync(path.join(FIXTURES_DIR, name));
}
describe('libreoffice (env gating + wrapper)', () => {
const ORIGINAL_FLAG = process.env.OFFICE_PREVIEW_LIBREOFFICE;
afterEach(() => {
_resetLibreOfficeProbeCache();
if (ORIGINAL_FLAG === undefined) {
delete process.env.OFFICE_PREVIEW_LIBREOFFICE;
} else {
process.env.OFFICE_PREVIEW_LIBREOFFICE = ORIGINAL_FLAG;
}
});
describe('isLibreOfficeEnabled', () => {
it.each([['true'], ['1'], ['yes'], ['TRUE'], ['Yes'], [' true ']])(
'returns true for %j (matches OFFICE_PREVIEW_DISABLE_CDN parser semantics)',
(value) => {
process.env.OFFICE_PREVIEW_LIBREOFFICE = value;
expect(isLibreOfficeEnabled()).toBe(true);
},
);
it.each([['false'], ['0'], ['no'], [''], ['anything-else']])(
'returns false for %j',
(value) => {
process.env.OFFICE_PREVIEW_LIBREOFFICE = value;
expect(isLibreOfficeEnabled()).toBe(false);
},
);
it('returns false when the env var is unset', () => {
delete process.env.OFFICE_PREVIEW_LIBREOFFICE;
expect(isLibreOfficeEnabled()).toBe(false);
});
});
describe('buildPdfEmbedDocument', () => {
/* The wrapper structure is the security-relevant surface. These
* tests lock down the CSP, the iframe shape, and the fallback
* contract — independent of whether LibreOffice is actually
* installed. Same posture as the docx-preview / pptx-preview CDN
* wrappers. */
const FAKE_PDF_B64 = 'JVBERi0xLjQK'; // "%PDF-1.4\n" base64
it('emits a complete sandboxed HTML document', () => {
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
expect(html).toMatch(/^<!DOCTYPE html>/);
expect(html).toContain('<title>Preview</title>');
expect(html).toContain('id="lc-pdf"');
expect(html).toContain(`data:application/pdf;base64,${FAKE_PDF_B64}`);
});
it('CSP locks the iframe down: no script CDN, no outbound connect, no eval', () => {
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
const cspMatch = html.match(/<meta http-equiv="Content-Security-Policy" content="([^"]+)">/);
expect(cspMatch).not.toBeNull();
const csp = cspMatch![1];
expect(csp).toMatch(/default-src 'none'/);
/* The whole point: data: URIs in nested iframes (browser PDF viewer). */
expect(csp).toMatch(/frame-src data:/);
/* No outbound HTTP from the rendered iframe — a malicious PDF
* can't beacon home from inside the viewer. */
expect(csp).toMatch(/connect-src 'none'/);
/* No external scripts (unlike docx-preview / pptx-preview the
* native browser PDF viewer doesn't need a CDN library). */
expect(csp).not.toMatch(/cdn\.jsdelivr\.net/);
expect(csp).not.toMatch(/unsafe-eval/);
expect(csp).toMatch(/base-uri 'none'/);
expect(csp).toMatch(/form-action 'none'/);
});
it('exposes a fallback message that surfaces when the browser PDF viewer is disabled', () => {
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
expect(html).toContain('id="lc-fallback"');
expect(html).toContain('PDF preview unavailable in this browser');
/* The 4-second heuristic timer that swaps to the fallback. */
expect(html).toContain('4000');
});
it('uses #view=FitH so the PDF fills the panel width on first paint', () => {
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
expect(html).toContain('#view=FitH');
});
it('embeds large base64 payloads without breaking out of the data URI', () => {
/* A `</script>` substring in the base64 wouldn't terminate the
* iframe `src=` attribute, but a stray `"` would. base64 alphabet
* is `A-Za-z0-9+/=` — none of those are dangerous. Sanity-check
* with a synthetically large payload. */
const big = 'A'.repeat(100_000);
const html = buildPdfEmbedDocument(big);
const src = html.match(/src="data:application\/pdf;base64,([^"]+)/);
expect(src).not.toBeNull();
expect(src![1].length).toBe(big.length + '#view=FitH'.length);
});
});
describe('tryLibreOfficePreview (gating + fallthrough contract)', () => {
it('returns null when the env flag is unset (default behavior)', async () => {
delete process.env.OFFICE_PREVIEW_LIBREOFFICE;
const buf = readFixture('sample.docx');
const out = await tryLibreOfficePreview(buf, 'docx', 512 * 1024);
expect(out).toBeNull();
});
it('returns null when env flag is "false"', async () => {
process.env.OFFICE_PREVIEW_LIBREOFFICE = 'false';
const buf = readFixture('sample.docx');
const out = await tryLibreOfficePreview(buf, 'docx', 512 * 1024);
expect(out).toBeNull();
});
it('never throws — falls through to null on any conversion failure', async () => {
/* Even if the binary IS available, a malformed buffer should
* cause `convertOfficeToPdf` to throw and `tryLibreOfficePreview`
* to swallow it. The dispatcher pipeline takes over from there. */
process.env.OFFICE_PREVIEW_LIBREOFFICE = 'true';
const garbage = Buffer.from('this-is-definitely-not-a-docx');
let threw = false;
try {
const out = await tryLibreOfficePreview(garbage, 'docx', 512 * 1024);
expect(out).toBeNull();
} catch {
threw = true;
}
expect(threw).toBe(false);
});
});
describe('probeLibreOfficeBinary', () => {
it("caches the probe result so we don't re-spawn `--version` on every call", async () => {
_resetLibreOfficeProbeCache();
const first = await probeLibreOfficeBinary();
const second = await probeLibreOfficeBinary();
/* Reference equality — second call returns the cached object. */
expect(second).toBe(first);
});
it('returns availability=false with a reason when the binary is missing', async () => {
_resetLibreOfficeProbeCache();
/* Force the probe to miss by clobbering PATH so neither
* `soffice` nor `libreoffice` resolves. */
const originalPath = process.env.PATH;
process.env.PATH = '/nonexistent';
try {
const probe = await probeLibreOfficeBinary();
expect(probe.available).toBe(false);
expect(probe.binary).toBeNull();
expect(probe.reason).toMatch(/found on .?PATH/i);
} finally {
process.env.PATH = originalPath;
_resetLibreOfficeProbeCache();
}
});
});
describe('error tagging', () => {
it('LibreOfficeUnavailableError preserves the .name tag for callers', () => {
const err = new LibreOfficeUnavailableError('binary missing');
expect(err.name).toBe('LibreOfficeUnavailableError');
expect(err).toBeInstanceOf(Error);
});
it('LibreOfficeConversionError carries optional stderr context', () => {
const err = new LibreOfficeConversionError('exit 1', 'soffice: oops');
expect(err.name).toBe('LibreOfficeConversionError');
expect(err.stderr).toBe('soffice: oops');
});
it('exports tunable subprocess limits so tests can read documented values', () => {
expect(LIBREOFFICE_TIMEOUT_MS).toBe(30_000);
expect(MAX_LIBREOFFICE_PDF_BYTES).toBe(50 * 1024 * 1024);
});
});
});
/* eslint-disable jest/no-standalone-expect -- the `itIfLibreOffice` alias
* collapses to `it.skip` when LibreOffice isn't installed; the lint rule
* doesn't realize the `expect`s below are still scoped to a real test
* block via the alias. Disabling locally is cleaner than restructuring
* to satisfy a static check that's wrong about this pattern. */
describe('libreoffice integration (skipped unless LibreOffice is on $PATH)', () => {
/* These tests engage only when `soffice` (or `libreoffice`) is
* actually installed. Local dev with the binary present runs them;
* stock CI runners skip. The skip is per-test rather than describe-
* level so a printout makes the gating visible. */
const ORIGINAL_FLAG = process.env.OFFICE_PREVIEW_LIBREOFFICE;
beforeEach(() => {
_resetLibreOfficeProbeCache();
});
afterEach(() => {
if (ORIGINAL_FLAG === undefined) {
delete process.env.OFFICE_PREVIEW_LIBREOFFICE;
} else {
process.env.OFFICE_PREVIEW_LIBREOFFICE = ORIGINAL_FLAG;
}
});
itIfLibreOffice('probeLibreOfficeBinary detects the installed binary', async () => {
const probe = await probeLibreOfficeBinary();
expect(probe.available).toBe(true);
expect(probe.binary).toMatch(/^(soffice|libreoffice)$/);
expect(probe.versionLine).toMatch(/LibreOffice/i);
});
itIfLibreOffice(
'convertOfficeToPdf converts a DOCX to PDF bytes',
async () => {
const buf = readFixture('sample.docx');
const pdf = await convertOfficeToPdf(buf, 'docx');
/* PDF magic bytes: 25 50 44 46 == "%PDF". */
expect(pdf.subarray(0, 4).toString('ascii')).toBe('%PDF');
expect(pdf.length).toBeGreaterThan(500);
},
35_000,
);
itIfLibreOffice(
'convertOfficeToPdf converts a PPTX to PDF bytes',
async () => {
const buf = readFixture('sample.pptx');
const pdf = await convertOfficeToPdf(buf, 'pptx');
expect(pdf.subarray(0, 4).toString('ascii')).toBe('%PDF');
},
35_000,
);
itIfLibreOffice(
'tryLibreOfficePreview produces a PDF embed doc when env=true and binary is available',
async () => {
process.env.OFFICE_PREVIEW_LIBREOFFICE = 'true';
_resetLibreOfficeProbeCache();
const buf = readFixture('sample.docx');
const out = await tryLibreOfficePreview(buf, 'docx', 512 * 1024);
expect(out).not.toBeNull();
expect(out!).toMatch(/^<!DOCTYPE html>/);
expect(out!).toContain('data:application/pdf;base64,');
expect(Buffer.byteLength(out!, 'utf-8')).toBeLessThanOrEqual(512 * 1024);
},
35_000,
);
itIfLibreOffice(
'returns null when the embedded PDF would exceed the output cap',
async () => {
/* Force the cap below the smallest possible PDF so the size
* check trips. The conversion still runs (verifying the
* subprocess works) but `tryLibreOfficePreview` declines to
* emit oversized output and the dispatcher falls through. */
process.env.OFFICE_PREVIEW_LIBREOFFICE = 'true';
_resetLibreOfficeProbeCache();
const buf = readFixture('sample.docx');
const out = await tryLibreOfficePreview(buf, 'docx', 100);
expect(out).toBeNull();
},
35_000,
);
});

View file

@ -0,0 +1,374 @@
import { spawn } from 'child_process';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
/**
* LibreOffice-backed office preview pipeline (POC).
*
* Convert a DOCX / PPTX (eventually XLSX, ODT, PDF) buffer to PDF via
* `soffice --headless --convert-to pdf`, then embed the PDF as a base64
* `data:application/pdf` URI inside a sandboxed iframe so the host browser's
* native PDF viewer renders it. Result: high-fidelity rendering for any
* format LibreOffice opens, no client-side renderer dependency, no CDN
* dependency. The trade-off is the LibreOffice binary on the server (~500 MB
* disk, ~2-3 s cold-start per conversion).
*
* Off by default. Operators opt in by setting `OFFICE_PREVIEW_LIBREOFFICE=true`
* AND ensuring `soffice` (or `libreoffice`) is on `$PATH`. When either
* condition is missing we fall through to the existing CDN/mammoth/slide-list
* pipeline so a misconfiguration doesn't break previews.
*
* Hardening:
* - Subprocess runs in an isolated temp directory (no shared profile, no
* access to the operator's home) and a stripped env (`PATH`, `HOME`,
* `TMPDIR` only). LibreOffice's `-env:UserInstallation` flag forces a
* fresh user profile per call so concurrent conversions can't collide
* on shared ~/.config/libreoffice locks.
* - 30-second timeout — soffice has been known to hang on malformed input;
* `SIGKILL` after the timer expires.
* - 50 MB PDF output cap — a runaway document or a deliberate pathology
* can't blow the server's disk.
* - Macros stay disabled (LibreOffice's default `--macro-security high`
* plus our `--norestore --invisible --nodefault` flags).
*
* What this module is NOT:
* - A LibreOffice service / pool. Each call spawns a fresh subprocess.
* If load grows we'd want to keep a long-lived `soffice` process and
* drive it via UNO, but that's a v2 concern.
* - A PDF viewer. We rely on the host browser's built-in PDF viewer
* (Chrome / Edge / Safari / Firefox all ship one). When that's missing
* or disabled, the iframe falls back to a "Preview unavailable" state
* and the user can still download the file.
*/
/** Truthy parser shared with `OFFICE_PREVIEW_DISABLE_CDN` semantics. */
function envFlagEnabled(value: string | undefined): boolean {
if (value == null) {
return false;
}
return /^(1|true|yes)$/i.test(value.trim());
}
/**
* Whether the LibreOffice path is enabled for this process. Read at call
* time (not module load) so tests can flip the env without rebuilding.
*/
export function isLibreOfficeEnabled(): boolean {
return envFlagEnabled(process.env.OFFICE_PREVIEW_LIBREOFFICE);
}
interface BinaryProbe {
available: boolean;
binary: string | null;
versionLine: string | null;
reason?: string;
}
let cachedProbe: BinaryProbe | null = null;
/**
* Probe `soffice` then `libreoffice` once per process and cache the result.
* If the operator installs LibreOffice after server start, they need to
* restart to pick it up — simpler than invalidating the cache on filesystem
* events for a feature that's already opt-in.
*/
export async function probeLibreOfficeBinary(): Promise<BinaryProbe> {
if (cachedProbe) {
return cachedProbe;
}
for (const candidate of ['soffice', 'libreoffice']) {
try {
const versionLine = await runVersion(candidate);
cachedProbe = { available: true, binary: candidate, versionLine };
return cachedProbe;
} catch {
/* try next candidate */
}
}
cachedProbe = {
available: false,
binary: null,
versionLine: null,
reason: 'neither `soffice` nor `libreoffice` found on $PATH',
};
return cachedProbe;
}
function runVersion(binary: string): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(binary, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { PATH: process.env.PATH ?? '', HOME: tmpdir() },
});
let stdout = '';
proc.stdout?.on('data', (chunk) => {
stdout += String(chunk);
});
const timer = setTimeout(() => {
proc.kill('SIGKILL');
reject(new Error('--version timed out'));
}, 5_000);
proc.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
proc.once('exit', (code) => {
clearTimeout(timer);
if (code === 0) {
resolve(stdout.split('\n')[0]?.trim() ?? '');
} else {
reject(new Error(`exit ${code}`));
}
});
});
}
/** Reset the cached probe — for tests; never called from production code. */
export function _resetLibreOfficeProbeCache(): void {
cachedProbe = null;
}
/** Maximum subprocess wall-time. Tuned for cold-start on a small DOCX. */
export const LIBREOFFICE_TIMEOUT_MS = 30_000;
/** Maximum PDF output size; refuse anything larger so a runaway doc can't fill the disk. */
export const MAX_LIBREOFFICE_PDF_BYTES = 50 * 1024 * 1024;
/** Tag-distinct error so callers can distinguish "binary missing" from "conversion failed". */
export class LibreOfficeUnavailableError extends Error {
override readonly name = 'LibreOfficeUnavailableError';
}
/** Tag-distinct error so callers can swallow conversion-side failures and fall through. */
export class LibreOfficeConversionError extends Error {
override readonly name = 'LibreOfficeConversionError';
constructor(
message: string,
readonly stderr?: string,
) {
super(message);
}
}
/**
* Spawn a LibreOffice subprocess to convert `buffer` (in `extensionHint`
* format — `docx`, `pptx`, `xlsx`, `odt`, `odp`, `ods`) to PDF. Returns
* the PDF bytes.
*
* `extensionHint` is part of the temp filename so soffice infers the input
* format correctly. We never trust the extension to gate format — the
* caller (`html.ts` dispatcher) has already routed by MIME / magic bytes.
*
* Throws:
* - `LibreOfficeUnavailableError` when the binary isn't on $PATH
* - `LibreOfficeConversionError` for subprocess failures, timeout, or
* oversized output
*/
export async function convertOfficeToPdf(buffer: Buffer, extensionHint: string): Promise<Buffer> {
const probe = await probeLibreOfficeBinary();
if (!probe.available || !probe.binary) {
throw new LibreOfficeUnavailableError(probe.reason ?? 'LibreOffice binary unavailable');
}
const safeExt = extensionHint.replace(/[^a-z0-9]/gi, '').toLowerCase() || 'bin';
const tempDir = await mkdtemp(join(tmpdir(), 'lc-libreoffice-'));
try {
const inputPath = join(tempDir, `input.${safeExt}`);
await writeFile(inputPath, buffer);
await runConversion(probe.binary, inputPath, tempDir);
const outputPath = join(tempDir, 'input.pdf');
const pdf = await readFile(outputPath);
if (pdf.length > MAX_LIBREOFFICE_PDF_BYTES) {
throw new LibreOfficeConversionError(
`PDF output ${pdf.length} bytes exceeds cap ${MAX_LIBREOFFICE_PDF_BYTES}`,
);
}
return pdf;
} finally {
/* Best-effort cleanup. If unlink fails (rare — Linux tmpfs, no
* unmounts), the OS will reclaim the dir on next reboot. */
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
}
function runConversion(binary: string, inputPath: string, tempDir: string): Promise<void> {
return new Promise((resolve, reject) => {
const userProfile = `file://${join(tempDir, 'userprof')}`;
const proc = spawn(
binary,
[
'--headless',
'--norestore',
'--invisible',
'--nodefault',
'--nofirststartwizard',
'--nolockcheck',
`-env:UserInstallation=${userProfile}`,
'--convert-to',
'pdf',
'--outdir',
tempDir,
inputPath,
],
{
stdio: ['ignore', 'pipe', 'pipe'],
/* Stripped env. PATH is required for soffice to find its own libs;
* HOME/TMPDIR pinned to the isolated temp dir so the subprocess
* can't read the operator's profile or scribble outside our tree. */
env: {
PATH: process.env.PATH ?? '',
HOME: tempDir,
TMPDIR: tempDir,
/* Suppress LibreOffice's "Recovery" dialog state on macOS. */
DBUS_SESSION_BUS_ADDRESS: 'disabled:',
},
},
);
let stderr = '';
proc.stderr?.on('data', (chunk) => {
stderr += String(chunk);
});
const timer = setTimeout(() => {
proc.kill('SIGKILL');
reject(
new LibreOfficeConversionError(
`LibreOffice timeout after ${LIBREOFFICE_TIMEOUT_MS}ms`,
stderr,
),
);
}, LIBREOFFICE_TIMEOUT_MS);
proc.once('error', (err) => {
clearTimeout(timer);
reject(new LibreOfficeConversionError(err.message, stderr));
});
proc.once('exit', (code) => {
clearTimeout(timer);
if (code === 0) {
resolve();
} else {
reject(new LibreOfficeConversionError(`soffice exit ${code}`, stderr));
}
});
});
}
/**
* Build the iframe HTML that embeds the PDF for in-panel rendering. The
* PDF is base64-encoded as a `data:` URI and pointed to by an inner
* `<iframe>` so the host browser's PDF viewer (PDF.js in Firefox, Chrome's
* built-in viewer, Safari's Preview-driven viewer) can render it directly.
*
* Why an inner iframe rather than `<embed>` or `<object>`:
* - `<embed>` and `<object>` rendering is least consistent across modern
* browsers (Chrome's pdfium plugin requires CSP `object-src data:`,
* and some headless contexts disable it).
* - `<iframe src="data:application/pdf;base64,...">` is the most
* reliable cross-browser path. Chromium's PDF viewer treats it as a
* top-level navigation and serves the built-in viewer.
*
* The inner iframe is fully sandboxed — no script, same-origin, etc. —
* and uses `#view=FitH` to size to the panel's width on first paint.
*/
export function buildPdfEmbedDocument(pdfBase64: string): string {
/* CSP scoping:
* - `default-src 'none'`: lock everything down.
* - `frame-src data:`: allow the inner `<iframe src="data:application/pdf;...">`.
* - `object-src 'self' data:`: belt-and-suspenders for browsers that
* route PDFs through `<object>` via the iframe sandbox quirk.
* - `script-src 'unsafe-inline'`: only our tiny load-detector script.
* - `style-src 'unsafe-inline'`: page chrome (no external sheets).
* - `connect-src 'none'`: rendered iframe makes no network calls.
*/
const csp = [
"default-src 'none'",
'frame-src data:',
"object-src 'self' data:",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
"img-src 'self' data: blob:",
"connect-src 'none'",
"base-uri 'none'",
"form-action 'none'",
].join('; ');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<title>Preview</title>
<style>
:root { color-scheme: light dark; --bg: #ffffff; --fg: #1f2937; --muted: #6b7280; }
@media (prefers-color-scheme: dark) { :root { --bg: #1a1a2e; --fg: #e5e7eb; --muted: #9ca3af; } }
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
#lc-pdf { width: 100%; height: 100vh; border: 0; display: block; }
#lc-fallback { display: none; padding: 24px; font-size: 14px; line-height: 1.5; color: var(--muted); text-align: center; }
#lc-fallback.visible { display: block; }
</style>
</head>
<body>
<iframe id="lc-pdf" src="data:application/pdf;base64,${pdfBase64}#view=FitH" title="PDF preview"></iframe>
<div id="lc-fallback">PDF preview unavailable in this browser. Please download the file to view it.</div>
<script>
(function () {
/* Some browsers / kiosk profiles disable the built-in PDF viewer; the
* iframe loads but stays blank. We can't reliably detect the inner
* viewer's success across browsers, so we use a 4-second heuristic:
* if the iframe never reports a load event by then, swap to the
* fallback message. False negatives (slow networks, cold viewers)
* are acceptable — the user can still download the file. */
var pdfFrame = document.getElementById('lc-pdf');
var fallback = document.getElementById('lc-fallback');
var loaded = false;
if (pdfFrame) {
pdfFrame.addEventListener('load', function () { loaded = true; });
}
setTimeout(function () {
if (!loaded && fallback) {
if (pdfFrame) { pdfFrame.style.display = 'none'; }
fallback.classList.add('visible');
}
}, 4000);
})();
</script>
</body>
</html>`;
}
/**
* 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<string | null> {
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;
}
}