feat(export): client-side DOCX → PDF export for Editor V2 (R&D) - #3919
gpardhivvarma wants to merge 5 commits into
Conversation
Add `superdoc.export({ exportType: ['pdf'], triggerDownload: true })` — a fully
client-side, WASM-free PDF export. SuperDoc V2 already renders a pixel-accurate
paginated DOM; the exporter redraws those pages into a PDF with pdf-lib, so the
result is WYSIWYG with the editor: selectable text, embedded/subset fonts and
clickable links.
Core (packages/superdoc):
- core/export/pdf-export.ts — DOM→PDF: word-anchored + width-matched text,
per-glyph font fallback (DejaVu symbols + lazy Noto SC for CJK), URI + GoTo
link annotations, table/shading/border replay, images (canvas→PNG), true-vector
SVG (raster fallback), scroll-to-paint for virtualized pages, zoom reset.
- core/export/font-extract.ts — deobfuscate the DOCX's embedded .odttf fonts and
embed them byte-exact.
- core/export/field-resolve.ts — parse header/footer PAGE/NUMPAGES fields and draw
real page numbers (SuperDoc's layout leaves them blank).
- Wire into SuperDoc.export(); add ExportParams.pdfOptions; class names read via
@superdoc/dom-contract where the contract covers them.
- tests/consumer-typecheck fixture pinning the public export({pdf}) shape.
pdf-export-poc/: standalone deployable POC on published superdoc@2.7.0, validated
against the calibre torture-test + 6 more fixtures. FINDINGS.md covers the full
R&D, incl. why Typst was rejected and why RTL/complex-script shaping is the one
gap that needs a WASM shaper.
Note for CI: run `pnpm install` to refresh the lockfile for pdf-lib /
@pdf-lib/fontkit; fonts are bundled in the POC for now (CDN/LFS for production).
The SVG vectorizer used a single uniform scale (width/viewBox), so an SVG with a square viewBox rendered at a non-square size — e.g. a footnote separator with a `0 0 100 100` viewBox rendered 312×1 — was drawn as a large filled square (a black box over the footnote). drawSvgPath only supports uniform scaling, so when the x/y scales differ (>2%) fall back to rasterizing, which reproduces the element at its exact rendered size.
Add `mode: 'word' | 'pixel'` to the PDF exporter. 'pixel' rasterizes each page via SVG <foreignObject> (the browser's own paint pipeline) with the DOCX's embedded fonts re-declared as data-URI @font-face inside the SVG, plus an invisible selectable-text + link overlay. Verified pixel-identical to the editor (0 of 3.4M px differ) on the calibre torture-test and a Hebrew RTL doc, which also makes RTL/Arabic shaping exact. 'word' (vector) stays the default. Also fix four systematic editor-parity bugs in vector mode, found by localized cluster diffing against live editor screenshots: - floating images that paint in front of text are drawn after the text pass (was: before, so text bled through them) - bold Hebrew keeps its weight via a weight-aware DejaVu Sans Bold fallback - RTL header/footer page-number fields get a level-1 bidi visual reorder - table border strokes are inset by half their width (CSS box-model), so they land pixel-exact instead of ~1.5px off Grows the public surface: pdfOptions.mode on ExportParams, pinned in the consumer-typecheck fixture. POC updated with a mode picker; FINDINGS/README document the parity proof and methodology.
…-pdf-export # Conflicts: # packages/superdoc/package.json
…ixel The pixel-mode invisible selectable-text layer drew every token with the primary font only, which lacks Hebrew/CJK glyphs — so those embedded as .notdef with no ToUnicode and copied out as nothing (English worked, Hebrew didn't). Draw the invisible layer per-character at each glyph's measured visual position using the same covering-font fallback as the visible path (DejaVu for Hebrew), via a new opacity parameter on drawWord. Selection now aligns with the page image and copy yields real characters; bidi-aware apps paste RTL in correct order. POC: default to pixel mode and drop the word/pixel picker (pixel is the high-fidelity path). `?mode=word` still forces vector for comparison.
|
Thanks, Pardhiv—this is thoughtful work, especially the runnable demo and detailed findings. We tested the prototype and retained your research for our internal V2 PDF export evaluation. We’re closing this draft PR so we can continue that work in our internal repository. Browser PDF export isn’t available in the supported package yet, and we haven’t committed to an implementation or release date. |
|
I have read the CLA Document and I hereby sign the CLA You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot. |
What
Adds a fully client-side, WASM-free PDF export to Editor V2 — the exact API from the brief:
Live demo: https://pdf-export-poc.vercel.app (open the bundled calibre doc or Open .docx… your own; then Export PDF)
How
V2 already renders a pixel-accurate paginated DOM. The exporter redraws those pages into a PDF with pdf-lib (no server, no WASM), so output is WYSIWYG with the editor: selectable text, embedded/subset fonts, clickable links.
.odttf) and embedded byte-exact; non-embedded families use bundled substitutes, with per-glyph fallback (DejaVu symbols + a lazily-loaded Noto Sans SC for CJK)./URI+ in-document/GoTo(TOC) annotations.PAGE/NUMPAGESfields are parsed from the DOCX and drawn with real numbers (V2's layout leaves them blank).pdf-lib + fontkit are dynamically imported (only load on a PDF export).
Files
packages/superdoc/src/core/export/{pdf-export,font-extract,field-resolve}.ts— the exporter + DOCX font/field parsing.SuperDoc.export()wired forexportType: ['pdf'];ExportParams.pdfOptionsadded; DOM classes read via@superdoc/dom-contractwhere the contract covers them.tests/consumer-typecheck/src/export-pdf-params.ts— pins the publicexport({pdf})shape.pdf-export-poc/— standalone deployable POC (publishedsuperdoc@2.7.0), validated against the calibre torture-test + 6 fixtures.pdf-export-poc/FINDINGS.mdhas the full R&D write-up.R&D findings (see FINDINGS.md)
Notes for reviewers / follow-ups
pnpm installto refresh the lockfile for the newpdf-lib/@pdf-lib/fontkitdeps.@superdoc/fonts/@superdoc/font-system(which already loads the embedded bytes).doc.export.toPdf()in@superdoc/document-api.build:superdoc+check:publicin CI.