mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
✨ feat: High-fidelity DOCX preview via docx-preview in iframe
Switch the default DOCX render path from server-side mammoth → flat
HTML to client-side `docx-preview` loaded inside the Sandpack iframe.
Mammoth becomes the fallback for files above the cap.
Why
---
The Sandpack iframe is a real browser DOM. Server-side rendering
ceiling for DOCX→HTML is well below the source's visual fidelity —
mammoth strips cell shading, run colors, banners, and column layouts
because Word's layout model doesn't fit HTML's flow model. Pushing the
render into the iframe lifts that ceiling without paying the
server-side cost of jsdom or LibreOffice.
What
----
- New `wordDocToHtmlViaCdn(buffer)` builds a self-contained HTML doc
that embeds the binary as base64 and lets `docx-preview@0.3.7`
render it on load. CSS preserves dark/light mode handoff via
`prefers-color-scheme`. Bootstrap script falls back to a "preview
unavailable, please download" message if the CDN is unreachable or
the parse throws.
- `docx-preview` and its `jszip` peer dep are pinned to specific
versions on jsdelivr with SRI sha384 integrity hashes and
`crossorigin="anonymous"`. Refresh: re-fetch the file, run
`openssl dgst -sha384 -binary FILE | openssl base64 -A`.
- CSP locked down on the iframe: `default-src 'none'`, scripts only
from jsdelivr (no eval), `connect-src 'none'` so a parser bug in
docx-preview can't be turned into exfiltration of the embedded
document, `base-uri 'none'`, `form-action 'none'`. Defense in depth
on top of the Sandpack cross-origin sandbox.
- `wordDocToHtml` dispatches by size: ≤ 350 KB binary → CDN path
(high fidelity), larger → mammoth fallback (preserves the size cap
on `attachment.text`). 350 KB chosen so worst-case base64-inflated
output (~478 KB) plus wrapper overhead (~5 KB) fits under
MAX_TEXT_CACHE_BYTES (512 KB) with 40 KB headroom.
- Internal renderers exported as `_internal` for tests. Public API
unchanged — callers still go through `wordDocToHtml`.
PPTX intentionally NOT switched
-------------------------------
Surveyed the available client-side PPTX libraries:
- `pptx-preview@1.0.7` ships an ESM-only main entry plus a 1.36 MB
UMD that references `require("stream"/"events"/"buffer"/"util")` —
bundled for Node, not browser-clean. Could work but the runtime
references to undefined Node globals are a fragility risk worth
more validation than this PR can absorb.
- `pptxjs` is jQuery-era, requires four separate UMD scripts in a
specific order, less actively maintained.
- The honest answer for PPTX is the LibreOffice sidecar (DOCX/XLSX/
PPTX → PDF → PDF.js), which is the architecture every major
product (Google Drive, Claude.ai, ChatGPT) effectively uses and
the only path to ~5/5 fidelity for arbitrary user decks.
PPTX stays on the existing slide-list extraction for now. Open a
follow-up issue for the LibreOffice/Gotenberg sidecar.
Tests
-----
- 6 new in CDN-rendered describe block: wrapper structure, base64
round-trip, SRI integrity + crossorigin, CSP locks
(connect-src/eval/base-uri/form-action), fallback message wiring,
size-threshold lock.
- Adjusted 2 existing tests that asserted on mammoth-path artifacts
(literal document text in `<article class="lc-docx">`) — those
assertions move to the mammoth-fallback test that calls
`_internal.wordDocToHtmlViaMammoth` directly. Dispatcher tests now
assert CDN-path signatures instead.
packages/api files: 434/434 ✅, full unit suite 4473/4473 ✅.
This commit is contained in:
parent
f18a95486e
commit
0f936e8528
2 changed files with 306 additions and 37 deletions
|
|
@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|||
import JSZip from 'jszip';
|
||||
import { megabyte } from 'librechat-data-provider';
|
||||
import {
|
||||
_internal,
|
||||
bufferToOfficeHtml,
|
||||
csvToHtml,
|
||||
excelSheetToHtml,
|
||||
|
|
@ -18,40 +19,118 @@ const readFixture = (name: string): Buffer => fs.readFileSync(path.join(fixtures
|
|||
|
||||
describe('Office HTML producers', () => {
|
||||
describe('wordDocToHtml', () => {
|
||||
test('renders a docx with its paragraph text in a sanitized HTML document', async () => {
|
||||
/* The dispatcher chooses CDN vs mammoth by buffer size. The fixture
|
||||
* sample.docx is small (~6 KB) so it goes down the CDN path. Tests
|
||||
* that need the mammoth output specifically call
|
||||
* `_internal.wordDocToHtmlViaMammoth` directly. */
|
||||
|
||||
test('routes a small docx (≤ cap) through the CDN-rendered path', async () => {
|
||||
const html = await wordDocToHtml(readFixture('sample.docx'));
|
||||
expect(html).toMatch(/^<!DOCTYPE html>/);
|
||||
// CDN wrapper signatures.
|
||||
expect(html).toContain('id="lc-doc-data"');
|
||||
expect(html).toContain('docx.renderAsync');
|
||||
expect(html).toContain('cdn.jsdelivr.net/npm/docx-preview@');
|
||||
// Mammoth-path artifact must NOT appear.
|
||||
expect(html).not.toContain('<article class="lc-docx">');
|
||||
});
|
||||
|
||||
test('routes a docx above the size cap through the mammoth fallback', async () => {
|
||||
/* Synthesize an oversized buffer by reading the fixture and
|
||||
* verifying the dispatcher picks the mammoth path. We can't
|
||||
* cheaply forge a multi-hundred-KB DOCX that mammoth can still
|
||||
* parse, so this asserts the size predicate by calling
|
||||
* `wordDocToHtmlViaMammoth` directly — the dispatcher's `if`
|
||||
* branch is itself trivial to inspect. */
|
||||
const html = await _internal.wordDocToHtmlViaMammoth(readFixture('sample.docx'));
|
||||
expect(html).toContain('<article class="lc-docx">');
|
||||
expect(html).toContain('This is a sample DOCX file.');
|
||||
});
|
||||
|
||||
test('strips <script> tags from the body and rejects event handlers', async () => {
|
||||
// Build a docx-shaped HTML body and run it through the same sanitizer
|
||||
// by stuffing it through the producer's output check. We can't easily
|
||||
// inject script into the docx fixture, so we exercise the sanitizer
|
||||
// contract directly via the public producer with a synthesized buffer
|
||||
// that mammoth turns into a known paragraph — and assert no scripts
|
||||
// could ever appear in the resulting wrapper.
|
||||
const html = await wordDocToHtml(readFixture('sample.docx'));
|
||||
test('mammoth fallback strips <script> tags and event handlers', async () => {
|
||||
const html = await _internal.wordDocToHtmlViaMammoth(readFixture('sample.docx'));
|
||||
expect(html).not.toMatch(/<script\b/i);
|
||||
expect(html).not.toMatch(/onerror=/i);
|
||||
});
|
||||
|
||||
test('emits the docx-specific extra CSS in the wrapper', async () => {
|
||||
/* The flat output mammoth produces for direct-formatted documents
|
||||
* (e.g. python-docx with no named styles) leans on the extra CSS
|
||||
* to give tables sticky-looking first-row headers and to style
|
||||
* `<p><strong>X</strong></p>` patterns as section headings.
|
||||
* Asserting those rules survive into the document means the
|
||||
* improvement isn't accidentally removable. */
|
||||
const html = await wordDocToHtml(readFixture('sample.docx'));
|
||||
// First-row table-header heuristic.
|
||||
test('mammoth fallback emits the docx-specific extra CSS', async () => {
|
||||
const html = await _internal.wordDocToHtmlViaMammoth(readFixture('sample.docx'));
|
||||
expect(html).toContain('.lc-docx table tr:first-child td');
|
||||
// Section-heading-style heuristic for bold-only-child paragraphs.
|
||||
expect(html).toContain('.lc-docx p:has(> strong:only-child)');
|
||||
// Heading visual styling (left accent).
|
||||
expect(html).toContain('.lc-docx h2');
|
||||
});
|
||||
|
||||
describe('CDN-rendered path', () => {
|
||||
/* These tests lock in the security-relevant structure of the
|
||||
* embedded-binary HTML wrapper. If any of these assertions break
|
||||
* after a refactor, the iframe is one config tweak away from
|
||||
* being a vehicle for outbound exfiltration or supply-chain
|
||||
* compromise. */
|
||||
|
||||
test('embeds the binary as base64 that round-trips to the original bytes', async () => {
|
||||
const original = readFixture('sample.docx');
|
||||
const html = await _internal.wordDocToHtmlViaCdn(original);
|
||||
const match = html.match(
|
||||
/<script id="lc-doc-data" type="application\/octet-stream;base64">([^<]*)<\/script>/,
|
||||
);
|
||||
expect(match).not.toBeNull();
|
||||
const decoded = Buffer.from(match![1], 'base64');
|
||||
expect(decoded.equals(original)).toBe(true);
|
||||
});
|
||||
|
||||
test('pins both CDN scripts to specific versions with SRI integrity', async () => {
|
||||
const html = await _internal.wordDocToHtmlViaCdn(readFixture('sample.docx'));
|
||||
// Both deps loaded from jsdelivr at pinned versions.
|
||||
expect(html).toContain('https://cdn.jsdelivr.net/npm/jszip@3.10.1/');
|
||||
expect(html).toContain('https://cdn.jsdelivr.net/npm/docx-preview@0.3.7/');
|
||||
// Both have SRI integrity attributes.
|
||||
const integrityMatches = html.match(/integrity="sha384-[A-Za-z0-9+/=]+"/g);
|
||||
expect(integrityMatches).not.toBeNull();
|
||||
expect(integrityMatches!.length).toBe(2);
|
||||
// Both have crossorigin="anonymous" (required for SRI on cross-origin).
|
||||
const crossoriginMatches = html.match(/crossorigin="anonymous"/g);
|
||||
expect(crossoriginMatches).not.toBeNull();
|
||||
expect(crossoriginMatches!.length).toBe(2);
|
||||
});
|
||||
|
||||
test('CSP locks the iframe down: no outbound connect, no eval, scripts only from jsdelivr', async () => {
|
||||
const html = await _internal.wordDocToHtmlViaCdn(readFixture('sample.docx'));
|
||||
const cspMatch = html.match(
|
||||
/<meta http-equiv="Content-Security-Policy" content="([^"]+)">/,
|
||||
);
|
||||
expect(cspMatch).not.toBeNull();
|
||||
const csp = cspMatch![1];
|
||||
// The bomb defense: no outbound HTTP requests from the rendered
|
||||
// iframe — a parser bug in docx-preview can't be turned into
|
||||
// exfiltration of the embedded document content.
|
||||
expect(csp).toMatch(/connect-src 'none'/);
|
||||
// No `<base>` tampering, no form submission either.
|
||||
expect(csp).toMatch(/base-uri 'none'/);
|
||||
expect(csp).toMatch(/form-action 'none'/);
|
||||
// Scripts only from jsdelivr (plus inline for our renderer
|
||||
// bootstrap). No 'unsafe-eval' anywhere.
|
||||
expect(csp).toMatch(/script-src https:\/\/cdn\.jsdelivr\.net 'unsafe-inline'/);
|
||||
expect(csp).not.toMatch(/unsafe-eval/);
|
||||
});
|
||||
|
||||
test('exposes a fallback message that surfaces if the renderer fails to load', async () => {
|
||||
const html = await _internal.wordDocToHtmlViaCdn(readFixture('sample.docx'));
|
||||
// Visible loading state and a fallback that swaps in on error.
|
||||
expect(html).toContain('Loading preview…');
|
||||
expect(html).toContain('Preview unavailable');
|
||||
// The bootstrap script checks `typeof docx === 'undefined'` so
|
||||
// a CDN outage degrades gracefully rather than leaving a
|
||||
// permanently empty iframe.
|
||||
expect(html).toContain("typeof docx === 'undefined'");
|
||||
});
|
||||
|
||||
test('size-fallback threshold is the documented 350 KB', async () => {
|
||||
/* Lock the public threshold so a future refactor doesn't drift
|
||||
* away from the value referenced in the JSDoc and the
|
||||
* `MAX_TEXT_CACHE_BYTES` reasoning above it. */
|
||||
expect(_internal.MAX_DOCX_CDN_BINARY_BYTES).toBe(350 * 1024);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('excelSheetToHtml', () => {
|
||||
|
|
@ -193,7 +272,11 @@ describe('Office HTML producers', () => {
|
|||
'application/octet-stream',
|
||||
);
|
||||
expect(html).not.toBeNull();
|
||||
expect(html!).toContain('This is a sample DOCX file.');
|
||||
// sample.docx is small → CDN path. Lock the dispatcher routing
|
||||
// by checking for a CDN-path signature rather than the literal
|
||||
// document text (which only appears in the mammoth fallback).
|
||||
expect(html!).toContain('id="lc-doc-data"');
|
||||
expect(html!).toContain('docx-preview@');
|
||||
});
|
||||
|
||||
test('routes by MIME when extension is missing', async () => {
|
||||
|
|
@ -393,10 +476,12 @@ describe('Office HTML producers', () => {
|
|||
test('legitimate small office files are not impacted by the safety check', async () => {
|
||||
/* Real DOCX fixture from the test fixtures directory should still
|
||||
* render — paranoid validation that the safety check doesn't false-
|
||||
* positive on tiny legitimate inputs. */
|
||||
* positive on tiny legitimate inputs. Small fixture takes the CDN
|
||||
* path, so we assert by wrapper structure instead of doc text. */
|
||||
const fixture = readFixture('sample.docx');
|
||||
const html = await wordDocToHtml(fixture);
|
||||
expect(html).toContain('This is a sample DOCX file.');
|
||||
expect(html).toMatch(/^<!DOCTYPE html>/);
|
||||
expect(html).toContain('id="lc-doc-data"');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -300,28 +300,212 @@ const DOCX_EXTRA_CSS = `
|
|||
.lc-docx blockquote { border-left: 3px solid var(--border); color: var(--muted); margin: 0.8em 0; padding: 0.2em 0 0.2em 0.8em; }
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Convert a `.docx` buffer to a sandboxed HTML document. Uses mammoth's
|
||||
* default conversion (paragraph styles, runs, tables, hyperlinks, lists,
|
||||
* inline images as base64 `data:` URIs) augmented by `DOCX_STYLE_MAP`
|
||||
* for richer heading detection and `DOCX_EXTRA_CSS` for table-first-row
|
||||
* + section-heading heuristics that compensate for mammoth's habit of
|
||||
* stripping direct-formatted presentation.
|
||||
/* =============================================================================
|
||||
* DOCX CDN renderer (high-fidelity path)
|
||||
*
|
||||
* Pre-flights the buffer through `assertSafeZipSize` so a zip-bomb DOCX
|
||||
* is rejected BEFORE it reaches mammoth — mammoth's internal extraction
|
||||
* has no decompressed-size cap and would happily inflate a sub-1MB
|
||||
* compressed bomb to 200+ MB of XML, blocking the event loop and
|
||||
* spiking RSS. See SEC review on PR #12934.
|
||||
* Embeds the binary as base64 inside a self-contained HTML document and
|
||||
* relies on `docx-preview` loaded from a pinned CDN URL with SRI
|
||||
* integrity to render it inside the Sandpack iframe. The iframe is a
|
||||
* real browser DOM — `docx-preview`'s "browser-first" design is a
|
||||
* feature here, not a limitation: we get cell shading, run-level
|
||||
* colors/fonts, headers/footers, columns, and inline images at no
|
||||
* server-side parsing cost.
|
||||
*
|
||||
* Trade-offs vs the mammoth path:
|
||||
* + Far higher visual fidelity (4/5 vs 2/5).
|
||||
* + No server-side jsdom; no extra Node deps; iframe sandbox isolates
|
||||
* any parser bug from the API process.
|
||||
* − base64 inflates the binary by ~33%, so files above
|
||||
* `MAX_DOCX_CDN_BINARY_BYTES` fall back to the mammoth path so the
|
||||
* wrapped HTML doesn't blow the `MAX_TEXT_CACHE_BYTES` (512KB) cap
|
||||
* on `attachment.text`. Telemetry should track how often we hit the
|
||||
* fallback — if it's frequent, the next move is to lift the cap
|
||||
* for office types specifically rather than embed via signed URL.
|
||||
*
|
||||
* Library + version pinning (jsdelivr SRI hashes computed at the
|
||||
* version listed; refresh by `openssl dgst -sha384 -binary FILE |
|
||||
* openssl base64 -A` on the file at the URL):
|
||||
* docx-preview 0.3.7 — Apache-2.0, ~75KB UMD
|
||||
* jszip 3.10.1 — MIT, ~98KB (peer dep of docx-preview)
|
||||
* Both are pinned to specific minor versions; SRI guarantees the byte
|
||||
* content can't change underneath us even if the version were ever
|
||||
* republished.
|
||||
*/
|
||||
export async function wordDocToHtml(buffer: Buffer): Promise<string> {
|
||||
await assertSafeZipSize(buffer, { name: 'docx' });
|
||||
const DOCX_PREVIEW_CDN = {
|
||||
jszip: {
|
||||
src: 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js',
|
||||
integrity: 'sha384-+mbV2IY1Zk/X1p/nWllGySJSUN8uMs+gUAN10Or95UBH0fpj6GfKgPmgC5EXieXG',
|
||||
},
|
||||
docxPreview: {
|
||||
src: 'https://cdn.jsdelivr.net/npm/docx-preview@0.3.7/dist/docx-preview.min.js',
|
||||
integrity: 'sha384-Fw+ZM2MtvxCe867uRzZY5GtGP+gs0NLvrlJS768RZWuKhOHMN4Fln3i3gMt1NSyQ',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Maximum DOCX binary size (in bytes) we'll embed via the CDN-rendered
|
||||
* path. Empirical headroom: with ~33% base64 inflation and ~5KB of
|
||||
* wrapper boilerplate, 350KB of binary fits well under the 512KB
|
||||
* `MAX_TEXT_CACHE_BYTES` cap on `attachment.text` with margin to spare.
|
||||
*/
|
||||
const MAX_DOCX_CDN_BINARY_BYTES = 350 * 1024;
|
||||
|
||||
/**
|
||||
* Build the CDN-rendered HTML document for a DOCX. The base64 payload
|
||||
* lives inside a `<script type="application/octet-stream;base64">`
|
||||
* — this is HTML5-spec-compliant for "data block" scripts (browsers
|
||||
* treat unrecognized script types as opaque text) and avoids any risk
|
||||
* of HTML escaping a `</script>` substring inside the binary.
|
||||
*
|
||||
* The CSP locks the page down to the pinned CDN host: scripts only
|
||||
* from jsdelivr (with SRI), no outbound `fetch`/`XHR`, no eval, images
|
||||
* only `self`/`data:`/`blob:` (docx-preview uses `URL.createObjectURL`
|
||||
* for inline images), styles inline (`docx-preview` injects per-doc
|
||||
* styles into `<head>` at render time).
|
||||
*/
|
||||
function buildDocxCdnDocument(base64: string): string {
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
"script-src https://cdn.jsdelivr.net 'unsafe-inline'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
'font-src data:',
|
||||
"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; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
||||
#lc-render { padding: 16px; }
|
||||
#lc-fallback { padding: 24px; font-size: 14px; line-height: 1.5; color: var(--muted); text-align: center; }
|
||||
.lc-docx-loading { display: flex; align-items: center; justify-content: center; height: 60vh; color: var(--muted); font-size: 14px; }
|
||||
/* docx-preview emits its own per-document <style> tags inside #lc-render
|
||||
* — leave them be. These rules just keep the host frame consistent with
|
||||
* dark mode and bound the rendered document width. */
|
||||
#lc-render .docx-wrapper { background: transparent !important; }
|
||||
#lc-render .docx { max-width: 100%; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#lc-render .docx { color: var(--fg); }
|
||||
}
|
||||
</style>
|
||||
<script src="${DOCX_PREVIEW_CDN.jszip.src}" integrity="${DOCX_PREVIEW_CDN.jszip.integrity}" crossorigin="anonymous"></script>
|
||||
<script src="${DOCX_PREVIEW_CDN.docxPreview.src}" integrity="${DOCX_PREVIEW_CDN.docxPreview.integrity}" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="lc-render"><div class="lc-docx-loading">Loading preview…</div></div>
|
||||
<div id="lc-fallback" hidden>Preview unavailable. Please download the file to view it.</div>
|
||||
<script id="lc-doc-data" type="application/octet-stream;base64">${base64}</script>
|
||||
<script>
|
||||
(function () {
|
||||
function showFallback(reason) {
|
||||
var loading = document.querySelector('.lc-docx-loading');
|
||||
if (loading) { loading.remove(); }
|
||||
var fallback = document.getElementById('lc-fallback');
|
||||
if (fallback) {
|
||||
fallback.hidden = false;
|
||||
if (reason) { fallback.title = String(reason).slice(0, 200); }
|
||||
}
|
||||
}
|
||||
if (typeof docx === 'undefined' || typeof docx.renderAsync !== 'function') {
|
||||
showFallback('renderer-not-loaded');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var b64 = document.getElementById('lc-doc-data').textContent.trim();
|
||||
var bytes = Uint8Array.from(atob(b64), function (c) { return c.charCodeAt(0); });
|
||||
docx.renderAsync(bytes.buffer, document.getElementById('lc-render'), null, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
ignoreFonts: false,
|
||||
breakPages: true,
|
||||
ignoreLastRenderedPageBreak: true,
|
||||
experimental: false,
|
||||
trimXmlDeclaration: true,
|
||||
useBase64URL: false,
|
||||
useMathMLPolyfill: false,
|
||||
renderHeaders: true,
|
||||
renderFooters: true,
|
||||
renderFootnotes: true,
|
||||
renderEndnotes: true,
|
||||
}).then(function () {
|
||||
var loading = document.querySelector('.lc-docx-loading');
|
||||
if (loading) { loading.remove(); }
|
||||
}).catch(function (err) {
|
||||
showFallback(err && err.message);
|
||||
});
|
||||
} catch (err) {
|
||||
showFallback(err && err.message);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function wordDocToHtmlViaCdn(buffer: Buffer): Promise<string> {
|
||||
return buildDocxCdnDocument(buffer.toString('base64'));
|
||||
}
|
||||
|
||||
async function wordDocToHtmlViaMammoth(buffer: Buffer): Promise<string> {
|
||||
const { convertToHtml } = await import('mammoth');
|
||||
const result = await convertToHtml({ buffer }, { styleMap: DOCX_STYLE_MAP });
|
||||
const sanitized = await sanitizeOfficeHtml(result.value);
|
||||
return wrapAsDocument(`<article class="lc-docx">${sanitized}</article>`, DOCX_EXTRA_CSS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a `.docx` buffer to a sandboxed HTML document. Two render
|
||||
* paths, chosen by file size:
|
||||
*
|
||||
* 1. **CDN-rendered (default for files ≤ 350 KB binary)**: embeds the
|
||||
* binary as base64 and lets `docx-preview` render it inside the
|
||||
* Sandpack iframe. High visual fidelity — preserves cell shading,
|
||||
* run-level colors/fonts, headers/footers, columns, and images.
|
||||
* 2. **Mammoth (fallback for larger files)**: server-side semantic
|
||||
* HTML conversion. Lower fidelity (flat paragraphs, no shading)
|
||||
* but produces compact output that fits the
|
||||
* `MAX_TEXT_CACHE_BYTES` (512 KB) cap on `attachment.text` even
|
||||
* for large documents.
|
||||
*
|
||||
* Both paths pre-flight through `assertSafeZipSize` so a zip-bomb DOCX
|
||||
* is rejected before either renderer touches it — mammoth's internal
|
||||
* extraction has no decompressed-size cap and would happily inflate a
|
||||
* sub-1MB compressed bomb to 200+ MB of XML. See SEC review on PR
|
||||
* #12934 for the original DoS finding.
|
||||
*/
|
||||
export async function wordDocToHtml(buffer: Buffer): Promise<string> {
|
||||
await assertSafeZipSize(buffer, { name: 'docx' });
|
||||
if (buffer.length <= MAX_DOCX_CDN_BINARY_BYTES) {
|
||||
return wordDocToHtmlViaCdn(buffer);
|
||||
}
|
||||
return wordDocToHtmlViaMammoth(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only re-exports. Each path has distinct visual-fidelity and
|
||||
* size characteristics that warrant direct testing rather than
|
||||
* round-tripping through the size-based dispatcher with synthetically
|
||||
* padded fixtures. Not part of the public API — callers in production
|
||||
* code should always go through `wordDocToHtml`.
|
||||
*/
|
||||
export const _internal = {
|
||||
wordDocToHtmlViaCdn,
|
||||
wordDocToHtmlViaMammoth,
|
||||
MAX_DOCX_CDN_BINARY_BYTES,
|
||||
DOCX_PREVIEW_CDN,
|
||||
};
|
||||
|
||||
/* =============================================================================
|
||||
* XLSX / XLS / ODS → HTML (multi-sheet with pure-CSS tab strip)
|
||||
* ============================================================================= */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue