Expand description
PDF editor sparse composition (Dispatch C, lane C1).
Owns docs/PDF-EDITOR-NOW.md §8 (layout validation), §9.1-9.3 (the three sparse write
strategies), and §9.4 (the compose_pdf_preview Tauri command). Implements the frozen
cross-lane seam (docs/PDF-EDITOR-DISPATCH.md):
pub fn compose_candidate(
source: &[u8],
commands: &[PdfEditCommand],
fonts: &dyn PdfFontResolver,
) -> Result<ComposedCandidate, PdfEditError>;compose_candidate validates every command’s locator/digest against fresh source bytes (via
pdf_inspect.rs’s walk_page_graph, lane B1), validates layout (§8), then applies one of the
three sparse strategies (§9.1-9.3) per command before returning composed candidate bytes plus
the pages a caller must re-render. compose_pdf_preview (§9.4) is the memory-only Tauri command
wrapping this function for committed-unsaved-edit preview re-render; prepare_pdf_save (§10.1,
pdf_save.rs, lane C2) is expected to call compose_candidate too, but owns its own save/token
plumbing and is out of scope here.
Wire/shared types (PdfEditCommand, PdfEditError, ComposedCandidate, PdfFontResolver,
PdfSourceLocator, PdfSourceSpan, PdfRect, PdfPoint, PdfTextAppearance,
PdfTextEditState, ResolvedPdfFont, GlyphId) live in pdf_model.rs (lane A1); page-graph
re-inspection comes from pdf_inspect.rs (lane B1); font subsetting/embedding helpers
(subset_for_pdf, build_to_unicode_cmap, FontCatalog) come from pdf_font.rs (lane B2).
This file only implements composition behavior against those.
§Deliberate scope/judgment calls (not fully pinned by §8/§9)
- Strategy selection. A
ReplaceSourceTextcommand takes [WriteStrategy::ExactOperandPatch] when (a)beforeandafterappearance are identical (same font/size/color) and (b) the page graph reports the islandeditable, i.e. its own font can re-encode text (inspect::ReverseTable, one decision shared with the graph) and all its glyphs share one font, size and spacing. The island may span several operands (kernedTJtext): the whole replacement is written into the earliest span and the later spans are blanked. Text the font cannot encode is refused withPdfEditError::MissingGlyphsnaming the characters; nothing is ever substituted. Every other digest-verified command falls through to [WriteStrategy::LocalTextObjectRewrite] (a catalog font).AddTextalways uses [WriteStrategy::AppendTextStream]. - Font reuse vs. embed.
PdfFontResolver(§7.2) resolves catalog fonts only — it has no notion of “the font already used at this source location,” andpdf_inspect.rs’s content-stream font decoder (TextStateFont) is a different, page-local abstraction this composer never receives. So “reuse source PDF font” (§7.3 rule 1) is only achievable for theExactOperandPatchpath, where the original operand bytes/font resource are never touched at all — the safest possible form of reuse. EveryLocalTextObjectRewrite/AppendTextStreamresolvesafter.appearance.font_idthroughfontsand embeds a fresh Type0/CIDFontType2 subset (§7.4), even when a caller happens to pass the same font family as the original run. - Local-rewrite insertion point. §9.2 says “insert … at deterministic source boundary”
without specifying which. The new
q/BT/.../ET/Qblock is never written into the middle of the touched content stream: it goes in its own stream at the END of the page’s/Contents. Removing the old glyphs never renumbers an operator, so a second, independent command’s locator (captured against the same pristine source, per §9.4’s “Applies staged source commands to fresh source bytes”) stays valid when a prior command in the same batch already rewrote a different run in the same content stream. A removed glyph’s bytes are replaced in place by theTJnumber that moves the pen exactly as the glyph did (aTjbecomes aTJat the same operator index), so the text that follows keeps its position; the'and"operators carry a line move aTJcannot, so glyphs shown by them are refused. - Batch order. All locators of a batch are resolved against the pristine source, so no
command may shift an address another command still needs. The
q/Qisolation wrappers and the new streams are therefore installed once, after the last command (installing a wrapper per command shifted every originalcontents_index, which the island digest binds), and theReplaceSourceTextcommands of one page are applied back to front, so splitting aTJstring item only ever moves items that a command already applied has finished with. - Baseline anchor for a rewritten run. §8 says “Replacement preserves original baseline
anchor.” The new run is therefore placed from the island it replaces, never from
availableBounds(whichPdfSourceEdit’s wire shape, §6, carries as the only rectangle, and which is only the verified room the run must fit). The new stream runs in the default graphics state, so itsTmis the whole of the first glyph’stext matrix x CTM(IslandLayout::placement; row-vector convention, ISO 32000-1 9.4.2, asMatrix::then), withTfkept at the original text-space size: the glyph origin, rotation, skew and scale are those of the source whether the producer put them inTm,Tdor acmoutside the text object. A read-back of the composed page refuses the batch when a replacement does not land there (verify_appended_runs). - Layout measurement.
MeasuredRun’s width for an embedded-font rewrite/add-text run is measured exactly, by summing the resolved font’s real per-glyph advances (viattf_parser, same sourcesubset_for_pdfreads) scaled to/1000units. For the in-place exact-operand-patch path, the source font’s own widths (the same/Widthsor/Wdata the page graph’s glyph boxes are built from) are summed with the walker’s pen-advance rules (Tc,Tw,Tz, text matrix and CTM scale), so a replacement no wider than the original never overflows its own box (withinOVERFLOW_EPSILON_PT). Earlier versions estimatedchars * size_pt * 0.5, which refused same-width swaps whenever the real text was narrower than half an em per character. compose_pdf_preview’s “affected pages as metadata.” Candidate bytes return as a JSON byte array because WKWebView fails while decoding Tauri’s raw-response stream. Frontend derives affected pages from request commands, so no second metadata channel is required.
Structs§
- Compose
PdfPreview Request - Wire request for the app crate’s
compose_pdf_previewcommand (§9.4). Field names/shape match the frontend’sComposePdfPreviewRequestcomment on the frozen signature:source_path,expected_source_sha256,source_commands.
Functions§
- compose_
candidate - The frozen cross-lane seam (
docs/PDF-EDITOR-DISPATCH.md). Appliescommandstosource, validating every locator/digest against fresh bytes first (§9’s “reject if stale”), then dispatching each command to its selected sparse-write strategy (§9.1-9.3). - compose_
candidate_ on_ base - Same as
compose_candidatebut loads the lopdf document frombasebytes while treatingsource_shaas the “current” source digest for every locator/staleness check — used byprepare_pdf_save(lane C2) to compose sparse-write commands on top of pdf.js’ssaveDocument()incremental-update annotation bytes (verbatim original source bytes plus appended annotation objects, per pdf.js’s own incremental-save format) while still validating locators against the original source’s digest rather than the annotated bytes’ digest.