Skip to main content

Module compose

Module compose 

Source
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 ReplaceSourceText command takes [WriteStrategy::ExactOperandPatch] when (a) before and after appearance are identical (same font/size/color) and (b) the page graph reports the island editable, 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 (kerned TJ text): the whole replacement is written into the earliest span and the later spans are blanked. Text the font cannot encode is refused with PdfEditError::MissingGlyphs naming the characters; nothing is ever substituted. Every other digest-verified command falls through to [WriteStrategy::LocalTextObjectRewrite] (a catalog font). AddText always 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,” and pdf_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 the ExactOperandPatch path, where the original operand bytes/font resource are never touched at all — the safest possible form of reuse. Every LocalTextObjectRewrite/AppendTextStream resolves after.appearance.font_id through fonts and 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/Q block 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 the TJ number that moves the pen exactly as the glyph did (a Tj becomes a TJ at the same operator index), so the text that follows keeps its position; the ' and " operators carry a line move a TJ cannot, 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/Q isolation wrappers and the new streams are therefore installed once, after the last command (installing a wrapper per command shifted every original contents_index, which the island digest binds), and the ReplaceSourceText commands of one page are applied back to front, so splitting a TJ string 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 (which PdfSourceEdit’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 its Tm is the whole of the first glyph’s text matrix x CTM (IslandLayout::placement; row-vector convention, ISO 32000-1 9.4.2, as Matrix::then), with Tf kept at the original text-space size: the glyph origin, rotation, skew and scale are those of the source whether the producer put them in Tm, Td or a cm outside 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 (via ttf_parser, same source subset_for_pdf reads) scaled to /1000 units. For the in-place exact-operand-patch path, the source font’s own widths (the same /Widths or /W data 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 (within OVERFLOW_EPSILON_PT). Earlier versions estimated chars * 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§

ComposePdfPreviewRequest
Wire request for the app crate’s compose_pdf_preview command (§9.4). Field names/shape match the frontend’s ComposePdfPreviewRequest comment on the frozen signature: source_path, expected_source_sha256, source_commands.

Functions§

compose_candidate
The frozen cross-lane seam (docs/PDF-EDITOR-DISPATCH.md). Applies commands to source, 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_candidate but loads the lopdf document from base bytes while treating source_sha as the “current” source digest for every locator/staleness check — used by prepare_pdf_save (lane C2) to compose sparse-write commands on top of pdf.js’s saveDocument() 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.