diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts index 778c4d1ff1..d4373d3d57 100644 --- a/packages/api/src/files/documents/html.ts +++ b/packages/api/src/files/documents/html.ts @@ -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 { 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 { */ export async function pptxToHtml(buffer: Buffer): Promise { 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); } diff --git a/packages/api/src/files/documents/libreoffice.spec.ts b/packages/api/src/files/documents/libreoffice.spec.ts new file mode 100644 index 0000000000..8c1e5ac475 --- /dev/null +++ b/packages/api/src/files/documents/libreoffice.spec.ts @@ -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(/^/); + expect(html).toContain('Preview'); + 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(//); + 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 `` 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(/^/); + 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, + ); +}); diff --git a/packages/api/src/files/documents/libreoffice.ts b/packages/api/src/files/documents/libreoffice.ts new file mode 100644 index 0000000000..ea4f85c2ee --- /dev/null +++ b/packages/api/src/files/documents/libreoffice.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + * ` +
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; + } +}