Skip to content

feat(export): client-side DOCX → PDF export for Editor V2 (R&D) - #3919

Draft
gpardhivvarma wants to merge 5 commits into
superdoc:mainfrom
gpardhivvarma:feat/v2-client-side-pdf-export
Draft

feat(export): client-side DOCX → PDF export for Editor V2 (R&D)#3919
gpardhivvarma wants to merge 5 commits into
superdoc:mainfrom
gpardhivvarma:feat/v2-client-side-pdf-export

Conversation

@gpardhivvarma

@gpardhivvarma gpardhivvarma commented Aug 19, 2026

Copy link
Copy Markdown

What

Adds a fully client-side, WASM-free PDF export to Editor V2 — the exact API from the brief:

const result = await superdoc.export({ exportType: ['pdf'], triggerDownload: true });

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.

  • Text — word-anchored at the browser's measured coords + horizontally width-matched, so spacing is exact under any font.
  • Fonts — the DOCX's own embedded fonts are deobfuscated (.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).
  • Links — external /URI + in-document /GoTo (TOC) annotations.
  • Header/footer page numbersPAGE/NUMPAGES fields are parsed from the DOCX and drawn with real numbers (V2's layout leaves them blank).
  • Tables/shading/borders/highlights, images (canvas→PNG), and true-vector SVG (raster fallback for complex SVG).
  • Page virtualization handled via scroll-to-paint behind a progress overlay; zoom reset for correct geometry.

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 for exportType: ['pdf']; ExportParams.pdfOptions added; DOM classes read via @superdoc/dom-contract where the contract covers them.
  • tests/consumer-typecheck/src/export-pdf-params.ts — pins the public export({pdf}) shape.
  • pdf-export-poc/ — standalone deployable POC (published superdoc@2.7.0), validated against the calibre torture-test + 6 fixtures. pdf-export-poc/FINDINGS.md has the full R&D write-up.

R&D findings (see FINDINGS.md)

  • Typst was evaluated and rejected: reflow engine, no DOCX input, ~15 MB+ WASM — it would re-layout the doc, not mirror it.
  • The one real gap: RTL / complex-script shaping (Arabic joining, bidi). pdf-lib has no HarfBuzz, so RTL scrambles — this is precisely where a WASM shaping engine would be worth its size.

Notes for reviewers / follow-ups

  • Run pnpm install to refresh the lockfile for the new pdf-lib / @pdf-lib/fontkit deps.
  • Substitute + fallback fonts are bundled in the POC; production should CDN-host / git-LFS the 8.5 MB CJK font and ideally reuse @superdoc/fonts / @superdoc/font-system (which already loads the embedded bytes).
  • Optional parity: also expose programmatic doc.export.toPdf() in @superdoc/document-api.
  • Draft — the monorepo build wasn't run in the R&D sandbox (Node mismatch + private V2 engine not vendored); modules are validated by isolated typecheck + the standalone POC. Please run build:superdoc + check:public in CI.

Review in cubic

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant