Reference
PDF Export and Print Design
Absence from a support list is an overstatement by omission, so every family the display list can carry is listed here, including the ones that are not covered.
Generated from this repository's own design document:
docs/98-PDF-EXPORT-AND-PRINT-DESIGN.md
Status: Phase 0 implemented in crates/casual-doc-pdf and reachable through the casual-doc-io export registry as application.pdf; Phase 1 (semantic features) and Phase 2 (tagged PDF / PDF-A) remain design-only. Everything below the Implementation status section is the original design, unchanged; its effort estimates now overstate what Phase 0 still costs, because Phase 0 is done. Read Implementation status for what actually exists, family by family, and for what is still missing. Date: 2026-08-05; implementation status updated 2026-09-26. Depends on: ADR-003 (backend-neutral display list); casual-doc-layout::display::DisplayList; casual-doc-render (tiny-skia backend, reference implementation); paginate.rs per-page display lists; casual-doc-io format registry; 40-FONT-MANAGEMENT-DESIGN.md; 94-ORACLE-VISUAL-FIDELITY-HARNESS-DESIGN.md; 21-PARSER-LIMITS.md; 20-ERROR-CODE-REGISTRY.md. Owner decision still required: the Phase-2 scope gate (§10 item 2). ADR-031 is resolved below. Addresses: 104 HF-030 (the output half), 105 RM-04 Phase 0. Still open after it: the print dialog (105 OO-010), live-font parity, and the semantic features listed below.
Implementation status (2026-09-21)
What exists
| Piece | Where |
|---|---|
Display-list transcription, container, xref, deterministic /ID |
crates/casual-doc-pdf/src/{writer,content}.rs |
TrueType subsetting, Type0/CID font emission, ToUnicode |
crates/casual-doc-pdf/src/{subset,font}.rs |
| Image XObjects (JPEG passthrough, decode-and-Flate otherwise) | crates/casual-doc-pdf/src/picture.rs |
| A PDF reader the guards use to read the output back | crates/casual-doc-pdf/src/inspect.rs |
Export-only application.pdf registry adapter |
crates/casual-doc-io/src/pdf.rs |
| Guards | crates/casual-doc-pdf/tests/real_text_export.rs, casual-doc-io/src/pdf.rs tests |
Entry points: casual_doc_pdf::export_document (lay out, then write) and casual_doc_pdf::write_pdf (write display lists a host already holds — the seam for a viewer that must not paginate twice).
Support, family by family
Absence from a support list is an overstatement by omission, so every family the display list can carry is listed here, including the ones that are not covered.
| Family | State | Notes |
|---|---|---|
Text runs (PaintItem::Glyphs) |
Supported | Type0/Identity-H; the CIDs are the shaper's glyph ids, and per-glyph TJ adjustments reproduce the shaper's advances exactly. w:w character scaling maps to Tz. |
| Text extraction (select / search / copy) | Supported | A ToUnicode CMap per face. Its baseline is the inversion of the face's cmap; over it, the real source text of each glyph is resolved from the display list's cluster offsets against the document (GlyphRun::node names the paragraph; crates/casual-doc-pdf/src/cluster.rs), so a ligature maps to every character it stands for — <GID> <00660069> for fi, not the presentation form U+FB01 the cmap inversion produced — and a substituted glyph the cmap cannot reach maps to its characters instead of to nothing. A multi-character cluster always wins over the inversion; a single-character one only fills a gap, because there the inversion is already exact while the model text can legitimately differ from the shaped text (w:caps, w:vanish). Still on the inversion alone: a run with no source characters (list marker, tab-leader fill, inline math box, recomputed PAGE field), a cluster drawn by several glyphs (see below), and any export driven through write_pdf without a PdfTextSource. The inversion-only path additionally requires the font source to serve the same face the shaper used: glyph ids mean nothing except against the face they were shaped with, so a caller that shapes with system fonts and then exports through BundledFontSource gets a text layer of unrelated code points, silently and with unmapped_glyphs reading 0. The cluster path is immune — it takes its characters from the model, not from the face. This is why display_lists_without_a_document_still_export_a_text_layer names a bundled face outright instead of asking for "Calibri" and relying on substitution: on Windows, which HAS Calibri, the shaper used the system face while the export embedded the bundled one, and the test extracted "\u{2d8}\u{2fe}\u{24d}…" for "plain words" — green on macOS and Linux, red only there. Anything neither source names is counted and reported as pdf.font.unmapped_glyphs, over the glyphs the document actually draws. |
A cluster drawn by several glyphs (a decomposed é) |
Left to the per-glyph inversion, deliberately | ToUnicode is keyed by glyph id, not by position, so giving the cluster's whole text to its first glyph would emit that text again for every later glyph of the cluster that is itself mapped. Left alone the cluster copies as base-plus-marks, which is canonically equivalent to it. bfrange is not used: the crate's own reader supports bfchar only, and a range cannot express one glyph standing for several characters. |
Font embedding and subsetting (TrueType glyf) |
Supported | The glyph identity space is retained and unused outlines emptied; cvt /fpgm/prep are kept, composites closed transitively, and the PostScript name carries an ABCDEF+ subset tag. A committed guard holds every embedded face for a one-page fixture under 40 KB. |
| Font embedding (CFF/OpenType outlines, collection members) | Partial | Embedded whole as FontFile3 /OpenType, not subset, and reported as pdf.font.not_subsetted. A CFF charstring subsetter is not written. |
A face whose licence forbids embedding (OS/2.fsType) |
Supported, as a refusal | The export fails, naming the face. No substitution, no outlines, no rasterized text, no silent drop. A face flagged no-subsetting is embedded whole with a finding. |
| Page geometry | Supported | /MediaBox from pagination; twips → points with the y axis flipped. No device scale is applied anywhere: a PDF is resolution-free. |
Rules, borders, table furniture, paragraph and run shading (Rect) |
Supported | Vector re fills and strokes. |
Ellipse, RoundedRect, Polygon, Line |
Supported | Vector paths, Béziers for the curved forms. |
Floating DrawingML shapes (PaintItem::Shape) |
Supported | Solid and gradient fills, dashable outlines, rotation/flip as a cm matrix, and arrowheads built from the same construction the raster backend uses. |
| Gradients | Supported | Axial (ShadingType 2) and radial (ShadingType 3) with a stitched function over the stops — genuinely vector, no bitmap, sharp at any zoom. |
Clips (PushClip/PopClip) |
Supported | q … re W n … Q, and the stream stays balanced even if a display list does not. |
| Constant alpha | Supported | One ExtGState per distinct alpha value. |
| Underline, strike-through, double strike | Supported | Derived from the face's own metrics, mirroring casual-doc-render including the words, wavy, dotted, dashed, dotDash, double and thick styles. The raster backend's one-device-pixel floor becomes a half-point floor, which is the only deliberate divergence: a device-pixel floor has no meaning in a resolution-free file. |
Pictures (PaintItem::Image) |
Supported | Embedded once as an XObject however often placed. JPEG bytes are stored verbatim as DCTDecode — no decode, no re-encode; everything else is decoded once to 8-bit RGB with an /SMask when it has alpha. A crop is a clip plus an oversized placement, so no sample is resampled. Rotation and flip become a cm. Decode limits match the raster backend's. |
| An undecodable or unserved picture | Supported, as a report | Draws the same bordered box with a corner-to-corner cross the raster backend draws, and reports pdf.image.unresolved / pdf.image.missing_bytes. |
| SVG pictures | Not covered | The native raster backend rasterizes them through resvg; the PDF path draws the placeholder instead. |
Emphasis marks (w:em) |
Not covered | The raster backend does not draw them either, so this is parity rather than a PDF gap. |
| Document metadata | Supported, opt-in | /Info Title/Author/Subject/Keywords/CreationDate/ModDate from DocumentProperties. Dates come from the document; the clock is never read. No XMP packet yet. |
| Determinism | Supported | Identical input gives identical bytes: object numbering is assignment-ordered and the file /ID is a content hash. |
| Hyperlink annotations | Not covered | A link's text is exported; the clickable /Annots entry is not. Needs the §6 semantic side-channel. |
Outline / bookmark tree, internal GoTo destinations |
Not covered | Same dependency. |
| Page ranges, image-quality knob | Not covered | |
Tagged PDF (/StructTreeRoot, reading order, heading/list/table tags, alt text, /Lang) |
Not covered — a separate roadmap row | Nothing here forecloses it: a structure tree would hang off the same per-page transcription, and list-marker runs are already flagged in the display list so they can be marked as artifacts. |
| PDF/A, encryption, comment/markup export | Not covered | Phase 2, still gated (§10). |
| RTL / bidi reading order in the text layer | Partial | Glyphs are placed in visual order at the right positions, so the page is correct; an extractor recovers visual, not logical, order. |
| Page background, footnote separators, headers and footers, text boxes, nested tables, floating art | Supported by construction | They are ordinary paint items in the same display list; the exporter has no notion of them and needs none, which is the point of transcribing the shared seam. Exercised end to end on the real-producer-header-footer, real-producer-footnotes and real-producer-rich (nested table) fixtures, whose text all comes back out of the file. |
ADR-031 (PDF writer and subsetter, build vs buy) — resolved: hand-rolled, no new dependency
Step 0 left this open. The decision taken is (A) hand-roll both, for a reason that only became visible once the problem was concrete:
- The subsetter does not need to renumber glyphs. Because the file addresses glyphs through
Identity-Hwith/CIDToGIDMap /Identity, keeping the glyph identity space and emptying the outlines of unused glyphs is both simpler and stronger for parity than a renumbering subsetter — it removes the entire class of "the PDF drew glyph 41 where the shaper chose 42" defects. That is a few hundred lines, not the multi-sprint estimate this design assumed, because the expensive half of a general subsetter is the half we deliberately do not do. - The container is an object table, a cross-reference table and a trailer. Owning it is what makes byte-determinism a property of the code rather than a hope about a dependency's release notes.
deny.toml,unsafe_code = forbidand thedependency-policyjob all stay simple with zero new supply-chain surface.
Three crates already in the tree are reused rather than added, so Cargo.lock gains no new package: flate2 (FlateDecode; MIT OR Apache-2.0; already pulled by the zip stack), skrifa (font metrics and the cmap inversion; Apache-2.0 OR MIT; already pulled by parley/fontique and casual-doc-render) and image (picture decoding; MIT OR Apache-2.0; the same version and codec feature set casual-doc-render builds).
Reachability — what is wired and what is not
"Built" is not "reachable" (SKILL.md §9 rule 4), so plainly:
- Reachable now from any Rust host.
casual_doc_io::register_pdf_exporter(&mut registry)addsapplication.pdftoFormatRegistry::export_formats(), andregistry.export(...)returns bytes, MIME type, suggested extension and a compatibility report in the same shape DOCX and ODT return. Registration is opt-in rather than part ofbuiltin_registryso a host decides when PDF appears in its save-as list. - Reachable from the product. The built-in registry includes the export-only PDF adapter, so
casual-doc-wasmexposesapplication.pdffromavailableExportFormats()and dispatches it throughexportAs(...). The web File surface offers Export as PDF… through the sameexportDocumentAspath as the other formats. A browser regression downloads the file and checks the PDF 1.7 header,Type0text font, andToUnicodemap. Physical Print remains a separate 150-DPI raster path and is still open underOO-010/ PDF-PRINT-0.
Remaining work, in the order to take it
- Font parity on the registry path.
export_documentpaginates with a fresh shaper, so a host's live font registry (the browser's network-fetched CJK faces) cannot simply be handed over: those ids belong to a different registry and handing them over would embed the wrong face rather than a fallback. Either let a caller seed the export's pagination with an existingFontRegistry, or have the browser host drivewrite_pdfwith the display lists it already holds. Until then the registry path embeds bundled and system-tier faces only. - Exact
ToUnicode— done for model text, open for the rest.GlyphRuncarries anode: Option<NodeId>anchor (a fixed-size anchor, not aStringper run) and the exporter resolves each glyph's cluster against that paragraph's own text, so ligature and substituted glyphs now map to their real characters. What is still on thecmapinversion alone, and would need work of its own: a run with no source characters at all (list marker, tab-leader fill, inline math box, recomputedPAGEfield — none of these has model text to name, so this is a limit of the concept rather than a gap in the implementation), a cluster drawn by several glyphs, an offset where the model text and the shaped text diverge (w:vanishcontributes model bytes but no glyphs,w:capsrewrites case — the same divergence that misplaces a caret in those paragraphs, so the fix belongs to the offset accounting, not here), andwrite_pdfcalled without aPdfTextSource, which the browser viewer seam will need once it drives export itself (item 1). - Phase 1 semantics (§6): the
StructureTreebridge first, then XMP metadata, hyperlink annotations, destinations, the outline tree and page ranges. - CFF subsetting, so OpenType faces stop being embedded whole.
- Oracle extension (§9): diff the PDF's text layer and page geometry against the LibreOffice-PDF oracle.
- Fuzz targets for the subsetter and the picture path, as §9 specifies. The subsetter is the highest-risk surface here: it parses font bytes that came out of an imported document.
Guards, and the mutations that drive them red
Each guard was driven red by mutating the production code before it was trusted (SKILL.md §4). The mutations used: dropping /ToUnicode from the font dictionary; shifting the text matrix by 10 pt; forcing whole-face embedding; skipping glyph emission entirely; deriving the file /ID from a counter; ignoring OS/2.fsType; dropping the placeholder mark; disabling picture de-duplication; dropping /Title; skipping the composite-glyph closure; claiming can_import; downgrading a degraded finding to Mapped; and — for the exact-ToUnicode guards — restoring the cmap-inversion-only map, and separately dropping the GlyphRun::node anchor the shaper populates. Both turn "Rich document-properties fixture body." back into "…fixture…" and "often afflicted by fjords" back into "oen afflicted by ords", which is what the reader saw before the fix. Those two guards also assert the bundled face really substitutes — fewer glyphs than characters, and at least one drawn glyph genuinely outside the face's cmap — because without that the assertions would hold whether or not it did.
The guards assert the guarantee rather than the mechanism: the file is parsed back and interrogated the way a reader would — text extractable and matching the document, every run within 0.01 pt of where layout put it, fonts embedded and subset under a six-character tag, no page delivered as a picture, repeat exports byte-identical, and a lost picture reported through the registry's compatibility report.
Problem
The engine can render a document to screen (DisplayList → casual-doc-render CPU raster) and paginate it against a LibreOffice oracle, but it cannot produce a PDF. The support matrix (18-SUPPORT-MATRIX.md) lists "PDF render/export — Backend decision pending", the architecture doc names "Headless raster/PDF" as the third paint target, and the ADR register lists "PDF generation backend" as a pending decision. This document specifies that backend end to end.
Two hard requirements shape the whole design:
- A real, proper PDF — never a page of images. Output must carry a genuine text layer (selectable, searchable, copyable), vector graphics, and embedded subset fonts. Text-as-outlines or rasterized pages are explicitly rejected.
- Two simultaneous parity targets:
- Editor parity (structural): the PDF must look identical to what the user sees in the editor. Any drift is a defect.
- Word feature parity (capability): the PDF must expose the same feature set Microsoft Word's "Export → Create PDF" produces (outline/bookmarks, hyperlinks, metadata, tagged/accessible structure, PDF/A option). This is feature parity, not an independent rendering target — layout fidelity to Word is earned in the layout engine, not here.
The parity model (the single most important design constraint)
Editor parity is structural, not aspirational
The editor pipeline is model → layout → DisplayList → tiny-skia. The PDF exporter consumes the exact same DisplayList, produced by the same layout pass, and maps each PaintItem to PDF content-stream operators. Because both backends share pagination, shaping, and glyph positions, editor↔PDF drift is architecturally impossible — provided the exporter never does its own layout. This yields the governing rule of the whole feature:
The PDF exporter performs zero layout, zero re-shaping, and zero metric adjustment. It is a deterministic transcription of the shared
DisplayList. It is never permitted to be "smarter" than the editor.
Any attempt to improve fidelity inside the exporter (nudging positions to look "more like Word") would create the editor↔PDF drift the requirement forbids. The exporter's only job is faithfulness to the display list.
Word parity is a layout goal the PDF inherits — with one exception
Because the PDF must match both the editor and Word, the editor must match Word. The exporter cannot add Word rendering fidelity the editor lacks; it inherits exactly the layout fidelity the on-screen renderer already has (tracked separately under the fidelity-corpus / pagination work, 60/94/P1F). **Word feature parity, by contrast, is this document's job** — see §6 (semantic features) and §10 (tagged PDF / PDF-A).
The font parity trap (must-fix, not optional)
The one place editor↔PDF parity most easily breaks is fonts. The system-fonts build lays out with real system faces (Arial/Times/Calibri); the deterministic / WASM build uses bundled substitutes (Roboto). Different face → different metrics → different line breaks → different pagination. Therefore:
**The PDF must embed and emit the exact same font faces the layout pass used — the same
FontId, the same bytes fromFontRegistry, the same glyph indices at the same advances. The exporter must never re-resolve fonts independently.**
GlyphRun already carries glyph indices into those specific faces, so embedding the same face and emitting the same GIDs at the same advances produces a PDF that is glyph-for-glyph identical to the editor. This is enforced by a CI golden (§9).
Goals
- A
casual-doc-pdfbackend that transcribes a per-pageDisplayListinto a valid, deterministic PDF (ISO 32000-1) with a real text layer and embedded subset fonts. - Byte-deterministic output: identical input → identical bytes (no timestamps, no random file IDs; IDs derived from content). Same discipline as the rest of the repo.
- Word feature parity delivered in phases (§6, §10): outline/bookmarks, internal + external links, document metadata; then tagged/accessible PDF and PDF/A as a gated second phase.
- Print support that reuses the PDF pipeline (§8), with host-owned device policy.
- Integration through the existing
casual-doc-ioregistry and the WASM/SDKexport_as/available_export_formatsseam. - Security: untrusted font bytes and image bytes handled under the
21-PARSER-LIMITSdiscipline; bounded, fail-closed, no panics on adversarial input.
Non-Goals
- Any rendering/layout fidelity work — that lives in the layout engine, not here. This document adds no shaping, line-breaking, or pagination logic.
- Round-trip PDF import. This is export-only.
- Experimental local PDF semantic reconstruction is evaluated separately in doc 131 and proposed ADR-034. It does not alter this design's export-only scope or the current
can_import: falseproduct behavior. - Text-as-outlines or rasterized-page output (explicitly rejected).
- Editing PDFs, PDF forms (AcroForm) authoring, digital signatures, JavaScript, or embedded multimedia.
- Font procurement. Which faces are legally embeddable is a licensing decision (§7), out of engineering scope; this document only specifies the embedding mechanism and the fallback policy.
Architecture
┌─────────────────────── shared layout pass ───────────────────────┐
model ─▶ layout ─┤ paginate.rs → Vec<DisplayList> (one per page) + FontRegistry │
│ (NEW) StructureTree — logical/semantic side-channel (§6) │
└───────────────┬───────────────────────────┬──────────────────────┘
│ │
┌───────────▼──────────┐ ┌──────────▼───────────┐
│ casual-doc-render │ │ casual-doc-pdf (NEW) │
│ tiny-skia (screen) │ │ DisplayList → PDF │
│ REFERENCE traversal │ │ ops + semantics │
└───────────────────────┘ └──────────┬───────────┘
│
┌──────────────────────────┼───────────────────────┐
│ pdf-writer (container) │ font subsetter │ image XObjects │
└──────────────────────────┼───────────────────────┘
│
casual-doc-io registry ◀───┘ format_id = "pdf"
WASM/SDK export_as("pdf", …) → bytes / print target (§8)
New crate casual-doc-pdf (naming per workspace convention). It depends on casual-doc-layout (for DisplayList/PaintItem/GlyphRun/FontRegistry) and casual-doc-model (for Definitions.media, colors, metadata). It must not depend on casual-doc-render — it is a sibling backend consuming the same seam (ADR-003). It reuses casual-doc-render's traversal shape as the reference for correctness, not as a dependency.
Step-by-step design
The work is ordered so each step lands as an adversarially-reviewed slice (the repo's working pattern) and so nothing downstream is blocked by an undecided upstream.
Step 0 — ADR-031: PDF-writer + subsetter build-vs-buy (BLOCKING)
Decide, against deny.toml and unsafe_code = forbid, whether to:
- (A) Hand-roll a minimal PDF writer and TrueType/CFF subsetter (max control, zero new supply-chain surface, deterministic by construction; higher eng cost — the subsetter is real work); or
- (B) Vet crates — e.g. a low-level PDF writer + a font-subsetting crate — against the dependency policy (lower eng cost; requires audit,
cargo-denyclearance, determinism verification, and pinning).
This choice moves Phase 0 by ~1.5 sprints (§ effort). No code starts before it is recorded as ADR-031. Recommendation: prefer (B) for the writer (well-trodden, low risk) and evaluate (A) vs (B) for the subsetter specifically, since subsetting is the correctness- and security-critical part.
Step 1 — PDF container writer
- Object model: indirect objects, xref table (or cross-reference streams for compactness), document catalog, page tree, per-page resource dictionaries.
- Content streams: Flate-compressed (reuse the workspace
zip/flate stack already present). - Determinism: object numbering is assignment-order stable; the file
/IDis derived from a content hash, not time/random; no/CreationDate//ModDateunless sourced fromDocumentProperties(and then normalized). This mirrors the repo's existing byte-fixed-point contracts. - Page geometry comes straight from pagination (page size + margins already in twips; convert twips → PDF points, 1 pt = 20 twips, origin flip to PDF's bottom-left).
Step 2 — Vector paint mapping (DisplayList → content operators)
Walk PaintItem in back-to-front order (identical order to casual-doc-render) and emit operators. The mapping is mostly mechanical because the display primitives already exist:
PaintItem |
PDF operators |
|---|---|
Rect { fill, stroke } |
re + f/S/B with rg/RG colors, w line width |
Ellipse / RoundedRect |
Bézier path approximation (same math as the raster backend) |
Polygon |
m/l…/h + fill/stroke |
Line { from, to, stroke } |
m/l/S; dash via d |
Shape { geometry, fill, stroke, head/tail_end, transform } |
path build + sh shading for gradients; cm for transform; arrowheads as explicit paths |
Image { media, rect, crop, transform } |
image XObject (Step 4), cm place, clip for crop |
Glyphs { run } |
text object (Step 3) |
PushClip/PopClip |
q + re W n … Q |
Gradients map to PDF axial/radial shadings (ShadingType 2/3); dashes to d; ShapeTransform (rotate/flip about center) to a cm matrix. All geometry is device- independent twips → points; no device scale is applied (PDF is resolution-free), which is the one deliberate divergence from the raster backend's paint path.
Step 3 — Text as a real, selectable layer
For each GlyphRun:
- Open a text object (
BT/ET), set the embedded font + size (Tf), set fill color (rg), set the text matrix (Tm) fromrun.origin(+character_scale_percentas a horizontal-scaleTz, matching how the raster backend scales outlines). - Emit glyphs by glyph index into a
Type0/CID font withIdentity-Hencoding and explicit positioning fromGlyph.advance(TJwith advance adjustments), so the on-page positions equal the display list exactly. - Decorations (
underline/strikethrough/double_strike),highlight, and runshadingare painted as rects behind/over the run — identical to the raster backend — not as font features. ToUnicodeCMap:Glyph.clusteris the UTF-8 byte offset into the paragraph's source text. This is exactly the data needed to build theToUnicodemap (glyph → source scalar[s]), which makes selection, search, and copy work. This is what separates a "proper" PDF from an outline dump, and the mapping data already exists in the display list.- List-marker runs (
is_marker) are emitted as text too, but tagged as artifacts in the Phase-2 structure tree (§10) so they don't pollute the reading order.
Step 4 — Image XObjects
- Resolve
PaintItem::Image.mediaagainstDefinitions.media(the exporter is given the media bytes by the host, exactly as the raster backend is). - JPEG →
DCTDecodepassthrough (no re-encode). PNG/other → decode thenFlateDecode(orDCTDecodefor photographic content per the size/quality knob, §6). - Honor
crop(source sub-rect via clip + scaled placement) andtransform(rotate/flip viacm). - Reuse
casual-doc-render's decode limits verbatim (MAX_DECODED_IMAGE_*from21-PARSER-LIMITS): bounded dimensions/pixels/bytes, fail-closed with a finding.
Step 5 — Font embedding + subsetting (the long pole)
For every FontId referenced by any emitted GlyphRun:
- Pull the exact face bytes from
FontRegistry(face_bytes/FontBytes/DynFace) — the same face the shaper used (parity guarantee, §"font parity trap"). - Subset: retain only the referenced glyph indices. TrueType path: rebuild
glyf/loca/cmap/hmtx/maxp/head/hheafor the kept set; CFF/OTF path: subset the CharStrings INDEX..ttc/.otccollections: extract the single face by index (DynFacealready carries the face index). - Wrap as a
Type0font with a CID-keyed descendant (CIDFontType2for TrueType /CIDFontType0for CFF),IdentityCIDToGIDMap, aWarray from the run advances /hmtx, aFontDescriptor(flags, bbox, ascent/descent/stemV), and the subset embedded (FontFile2/FontFile3). Subset-tag the PostScript name (ABCDEF+Name). - Untrusted-font hardening: the face bytes may originate from an imported document. Subsetting parses untrusted tables → apply
21-PARSER-LIMITSbounds (table sizes, glyph counts, offsets), reject malformed tables fail-closed with a reported finding, and never panic. This is the highest-risk security surface in the feature and gets its own fuzz target (§9). - Fallback policy (no images): if a face genuinely cannot be embedded (unsupported outline format, or a licensing flag the host declines), the run is emitted with a substitute embeddable face and a loss finding — never rasterized, never dropped silently. Word offers a "bitmap text when fonts can't be saved" fallback; we do not (it violates requirement 1).
Step 6 — Semantic features (Word feature parity, Phase 1)
A flat display list has no logical structure, so feature parity needs a semantic side-channel carried from the model through layout to page positions. Introduce a StructureTree (logical order: headings, paragraphs, lists, tables, links, figures) with per-node page + rect resolved from the same layout pass. Every Phase-1 feature hangs off this one bridge:
- Document metadata →
/Infodict + XMP packet, fromDocumentProperties(title/author/subject/keywords/lang). Nearly free. - External hyperlinks →
Linkannotations withURIactions; rects come from the link runs (layout must expose link geometry — a small, additive layout change). The URI scheme allowlist mirrors the import/export security allowlist (nojavascript:/unsafedata:), consistent with the ODT work. - Internal links / TOC / cross-refs →
GoToactions to named destinations minted from bookmark anchors (model already has bookmarks; T1 ODT work modeled them). - Outline / bookmark tree →
/Outlinesbuilt from Heading-style paragraphs (named-style identity exists), i.e. Word's "Create bookmarks using Headings". - Page range / selection → emit a subset of pages.
- Image quality knob (Standard vs Minimum size) → image re-encode/downsample policy in Step 4.
Step 7 — Public surface + registry wiring
- Register a
"pdf"export adapter incasual-doc-io(export-only descriptor). - Route through the existing WASM/SDK
export_as(format_id, mode)and advertise it inavailable_export_formats.modecarries options (page range, metadata inclusion, image quality, and — Phase 2 — tagged/PDF-A toggles). - SDK facade method on
casual-doc-sdk(consumers depend on the SDK, not the crate — ADR-004). Emit a compatibility/loss report (unembeddable fonts, dropped features) in the same shape as the other adapters' findings.
Fonts and licensing (must be settled early)
Requirement 1 (real text) forces font embedding, which forces redistribution rights for the embedded faces. Two independent concerns:
- Mechanism (this doc, Step 5): subset + embed the exact layout face.
- Rights (owner decision, out of eng scope): the bundled substitute faces must be embeddable (verify their licenses permit PDF embedding — most open faces do). To reach true Word layout parity you additionally need metric-compatible or genuine MS faces (Calibri/Cambria → Carlito/Caladea substitutes are the standard answer) in the layout engine, not here. Flag: the closer you want editor+PDF to match Word's line breaks, the more this licensing choice matters — but it is a layout- engine input, and this backend faithfully embeds whatever the layout used.
Print reuses the PDF pipeline; it is not a second renderer.
- Web/WASM host: "Print" = generate the PDF (same path) and hand the blob to the browser print dialog (
window.print()on a PDF object/hidden frame). No new rendering code. ~1–2 eng-days. - Native host: OS device policy (CUPS/Quartz, GDI/XPS) is host-owned (AGENTS.md — hosts own device/resource policy; the runtime must not). The contract: the runtime hands the host either the PDF or per-page
DisplayLists; the host drives the printer. A thin host-side adapter, ~2–4 eng-days, not core work.
Net: budget print as a small addition to PDF, provided "print = PDF routed to a print target" is accepted (the standard approach for an embeddable engine).
Security
- Untrusted font bytes (Step 5) and image bytes (Step 4) are parsed under
21-PARSER-LIMITS: bounded sizes/counts/offsets, fail-closed, no panics, reported findings. Dedicated fuzz targets for the subsetter and the image path. - URI/link schemes mirror the existing import/export allowlist; blocked schemes degrade to a reported remainder, never emitted (a non-PDF-origin model must not smuggle a
javascript:link into an annotation). - No JavaScript, launch actions, embedded files, or auto-actions are ever written.
- Determinism is a security property here too (reproducible builds / no entropy leak): no time, no RNG, content-derived
/ID.
Testing and gates
Every slice must pass the standard gates: cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check, WASM check, plus:
- Determinism golden: export a fixture twice → byte-identical; export₂ == export₃ fixed point (the repo's standard contract).
- Editor↔PDF parity golden (the key invariant): for each corpus page, assert the PDF text-run positions/advances/glyph-ids equal the shared
DisplayListgeometry (zero/epsilon). This converts "the PDF matches the editor" from a hope into a CI gate — if the exporter ever does something the display list didn't say, it fails. - Structural validity: validate against a PDF parser (and, if PDF/A is in scope, a veraPDF-class check on the re-bless workflow, not per-CI).
- Oracle extension: extend the
docs/94harness to diff the PDF's page geometry + text layer against the LibreOffice-PDF oracle (Word as tie-break authority per §"Word parity"). Reuses the existing pinned-sofficere-bless flow. - Fuzz: font subsetter and image decode paths get
cargo fuzztargets with the all-fields-explicit limit literals (note: adding a newPdfExportLimitsfield breaks those literals — update them orcargo fuzz buildfails, same gotcha as ODT). - Text-layer proof: automated
pdftotextextraction of a known fixture equals the source text (selectable/searchable proof), and a copy-paste spot check.
Docs to update per slice: this doc, 18-SUPPORT-MATRIX.md, 08-ADR-REGISTER.md (ADR-031 + any Phase-2 ADRs), 14-EXECUTION-TRACKER.md, and a new 99-PDF-EXPORT-PROFILE.md (supported/loss/limits table, mirroring 95/96).
Phasing, slices, and effort
Estimates are planning-grade (±40%), for one experienced Rust engineer fluent in this codebase, and include the per-slice adversarial review + all gates. Sprint = 2 weeks ≈ 8 productive eng-days after reviews. Biggest swing = ADR-031 (crate vs hand-roll). Excluded: layout-engine Word-fidelity work and font procurement.
Phase 0 — proper vector PDF (selectable text, embedded fonts, images)
| Slice | Work | Eng-days (crate) | Eng-days (hand-rolled) |
|---|---|---|---|
| PDF-PH0-0 | ADR-031 decision + writer skeleton (objects, xref, page tree, deterministic ID, Flate) | 2–4 | 4–7 |
| PDF-PH0-1 | Vector paint mapping (rects/lines/ellipse/roundrect/polygon/shapes/gradients/dashes/clips) | 4–6 | 4–6 |
| PDF-PH0-2 | Font subsetting + embedding (TrueType+CFF, Type0/CID, ToUnicode, .ttc, dynamic blobs, untrusted-font limits) |
5–9 | 12–20 |
| PDF-PH0-3 | Image XObjects (JPEG passthrough, PNG→Flate, crop/transform, decode limits) | 3–5 | 3–5 |
| PDF-PH0-4 | Registry/SDK/WASM wiring + determinism & parity goldens + oracle extension | 3–5 | 3–5 |
| Phase 0 total | ~17–29 | ~26–43 |
→ ≈ 2–3.5 sprints (crate) / 3.5–5.5 sprints (hand-rolled). Font subsetting is the long pole and the reason ADR-031 matters.
Phase 1 — semantic features (Word-equivalent for ~95% of users)
| Slice | Work | Eng-days |
|---|---|---|
| PDF-PH1-0 | StructureTree semantic bridge (layout emits link rects + heading/anchor structure) |
3–5 |
| PDF-PH1-1 | Metadata (/Info + XMP) |
1–2 |
| PDF-PH1-2 | External hyperlink annotations (scheme allowlist) | 2–3 |
| PDF-PH1-3 | Internal links / TOC / cross-ref GoTo destinations |
3–5 |
| PDF-PH1-4 | Outline / bookmark tree from Heading styles | 2–4 |
| PDF-PH1-5 | Page range / selection + image-quality knob | 1–2 |
| Phase 1 total | ~12–21 |
→ ≈ 1.5–2.5 sprints.
| Slice | Work | Eng-days |
|---|---|---|
| PDF-PRINT-0 | Web: PDF → browser print handoff | 1–2 |
| PDF-PRINT-1 | Native host print adapter contract (host-side) | 2–4 |
| Print total | ~3–6 |
→ ≈ 0.5 sprint.
Phase 2 — accessibility / archival (GATED — decide in/out up front, §10)
| Slice | Work | Eng-days |
|---|---|---|
| PDF-PH2-0 | Tagged PDF (structure tree → marked content, reading order, heading/list/table tags, alt text, Lang, artifacts) |
15–25 |
| PDF-PH2-1 | PDF/A conformance (ICC/OutputIntent, XMP+PDF/A id, embedding guarantees, validation; requires tagging) | 8–14 |
| PDF-PH2-2 | Encryption / password (AES + encrypt dict) | 3–5 |
| PDF-PH2-3 | Comments / markup export toggle | 3–5 |
| Phase 2 total | ~29–49 |
→ ≈ 3.5–6 sprints.
Roll-up
| Target | Eng-days | Sprints (1 eng) | Calendar (1 eng) |
|---|---|---|---|
| Ship-worthy: Phase 0 + Phase 1 + Print (no tagging/PDF-A) | ~32–56 | ~4–7 | ~8–14 weeks |
| Word feature-complete (+ Phase 2) | ~61–105 | ~8–13 | ~16–26 weeks |
Parallelism: Phase 0's font work (PH0-2) and paint work (PH0-1) can run as two concurrent tracks (2 engineers compress Phase 0 to ≈2 sprints). Phase 1 largely serializes after the container exists.
Open decisions (§10)
- ADR-031 — writer/subsetter build-vs-buy (BLOCKING; §Step 0). Moves Phase 0 by ~1.5 sprints.
- Phase-2 in or out. Tagged PDF + PDF/A roughly doubles the project. Decide up front, because the
StructureTree(§6) should be designed with tagging in mind from day one rather than retrofitted. Drive by whether accessibility/archival compliance is an actual customer requirement. - Font substitute licensing (§7) — owner/legal, not engineering. Caps how close to Word's line breaks either the editor or the PDF can get.
- Oracle tie-break policy — reaffirm §"Word parity": LibreOffice PDF = automatable CI oracle, Word = correctness authority on disagreement (already the repo's posture for on-screen rendering; PDF stays consistent with it).
Recommendation
Adopt Phase 0 + Phase 1 + Print as the committed scope (≈ 4–7 sprints, one engineer) — a proper vector PDF with selectable text, embedded fonts, links, outline, metadata, and print: genuine Word-equivalence for what users notice, with editor parity guaranteed structurally and by a CI golden. Gate Phase 2 (tagged PDF / PDF-A, ≈ doubles the effort) on a concrete accessibility/archival requirement. Settle ADR-031 first; nothing else starts before it.