Skip to main content

rightkit_pdf/
compose.rs

1//! PDF editor sparse composition (Dispatch C, lane C1).
2//!
3//! Owns `docs/PDF-EDITOR-NOW.md` §8 (layout validation), §9.1-9.3 (the three sparse write
4//! strategies), and §9.4 (the `compose_pdf_preview` Tauri command). Implements the frozen
5//! cross-lane seam (`docs/PDF-EDITOR-DISPATCH.md`):
6//!
7//! ```ignore
8//! pub fn compose_candidate(
9//!     source: &[u8],
10//!     commands: &[PdfEditCommand],
11//!     fonts: &dyn PdfFontResolver,
12//! ) -> Result<ComposedCandidate, PdfEditError>;
13//! ```
14//!
15//! `compose_candidate` validates every command's locator/digest against fresh `source` bytes (via
16//! `pdf_inspect.rs`'s `walk_page_graph`, lane B1), validates layout (§8), then applies one of the
17//! three sparse strategies (§9.1-9.3) per command before returning composed candidate bytes plus
18//! the pages a caller must re-render. `compose_pdf_preview` (§9.4) is the memory-only Tauri command
19//! wrapping this function for committed-unsaved-edit preview re-render; `prepare_pdf_save` (§10.1,
20//! `pdf_save.rs`, lane C2) is expected to call `compose_candidate` too, but owns its own save/token
21//! plumbing and is out of scope here.
22//!
23//! Wire/shared types (`PdfEditCommand`, `PdfEditError`, `ComposedCandidate`, `PdfFontResolver`,
24//! `PdfSourceLocator`, `PdfSourceSpan`, `PdfRect`, `PdfPoint`, `PdfTextAppearance`,
25//! `PdfTextEditState`, `ResolvedPdfFont`, `GlyphId`) live in `pdf_model.rs` (lane A1); page-graph
26//! re-inspection comes from `pdf_inspect.rs` (lane B1); font subsetting/embedding helpers
27//! (`subset_for_pdf`, `build_to_unicode_cmap`, `FontCatalog`) come from `pdf_font.rs` (lane B2).
28//! This file only implements composition behavior against those.
29//!
30//! ## Deliberate scope/judgment calls (not fully pinned by §8/§9)
31//!
32//! - **Strategy selection.** §9's `select_strategy` sketch references `same_font_encoding`,
33//!   `same_geometry`, `one_operand`, `locator_is_complete`, `local_rewrite_is_safe` as booleans
34//!   without defining them precisely. This implementation resolves them concretely: a
35//!   `ReplaceSourceText` command takes [`WriteStrategy::ExactOperandPatch`] only when (a) `before`
36//!   and `after` appearance are byte-for-byte identical (same font/size/color — "same font
37//!   encoding"), (b) the replacement text is pure ASCII (matches `pdf_text_edit.rs`'s existing
38//!   "simple-font glyph code equals ASCII byte" convention — the closest thing this codebase has to
39//!   "same geometry, reusable encoding" for a source font this composer never decodes), and (c) the
40//!   locator carries exactly one span (`one_operand`). Every other geometrically-valid, digest-
41//!   verified `ReplaceSourceText` command falls through to [`WriteStrategy::LocalTextObjectRewrite`].
42//!   `AddText` always uses [`WriteStrategy::AppendTextStream`].
43//! - **Font reuse vs. embed.** [`PdfFontResolver`] (§7.2) resolves *catalog* fonts only — it has no
44//!   notion of "the font already used at this source location," and `pdf_inspect.rs`'s
45//!   content-stream font decoder (`TextStateFont`) is a different, page-local abstraction this
46//!   composer never receives. So "reuse source PDF font" (§7.3 rule 1) is only achievable for the
47//!   `ExactOperandPatch` path, where the original operand bytes/font resource are never touched at
48//!   all — the safest possible form of reuse. Every `LocalTextObjectRewrite`/`AppendTextStream`
49//!   resolves `after.appearance.font_id` through `fonts` and embeds a fresh Type0/CIDFontType2
50//!   subset (§7.4), even when a caller happens to pass the same font family as the original run.
51//! - **Local-rewrite insertion point.** §9.2 says "insert ... at deterministic source boundary"
52//!   without specifying which. This implementation always appends the new `q/BT/.../ET/Q` block at
53//!   the END of the touched content stream's operator list, never in the middle. This is
54//!   deterministic and — critically — never renumbers any other operator's `operator_index`, so a
55//!   second, independent command's locator (captured against the same pristine source, per §9.4's
56//!   "Applies staged source commands to fresh source bytes") stays valid even when a prior command
57//!   in the same batch already rewrote a different run in the same content stream. Removed source
58//!   glyph bytes are blanked in place (byte range deleted from the operand string) rather than
59//!   deleting the whole operator, for the same reason: it never shifts a sibling span's
60//!   `operator_index`/`operand_index`.
61//! - **Baseline anchor for a rewritten run.** §8 says "Replacement preserves original baseline
62//!   anchor," but `PdfSourceEdit`'s wire shape (`pdfEditModel.ts`/`PdfEditCommand`, §6) carries no
63//!   explicit origin point — only `availableBounds: PdfRect`. This implementation anchors the new
64//!   run's text matrix at `(available_bounds.x, available_bounds.y)`, i.e. the lower-left corner of
65//!   the verified-available rect, which is the frontend-computed stand-in for the original run's
66//!   baseline start per §8's "Available expansion beyond original island requires verified empty
67//!   page space on same baseline."
68//! - **Layout measurement.** `MeasuredRun`'s width for an embedded-font rewrite/add-text run is
69//!   measured exactly, by summing the resolved font's real per-glyph advances (via `ttf_parser`,
70//!   same source `subset_for_pdf` reads) scaled to `/1000` units. For the ASCII exact-operand-patch
71//!   path, no font metrics are available at all through this composer's `PdfFontResolver` seam
72//!   (§7.2 only resolves catalog fonts, not the page's already-embedded source font), so that path
73//!   uses a conservative `chars * size_pt * 0.5` em-width estimate — the standard rough average
74//!   glyph width — before falling back to a real check on save/render (`verifyPdfCandidate`, §10.2,
75//!   is the actual ground truth downstream).
76//! - **`compose_pdf_preview`'s "affected pages as metadata."** Candidate bytes return as a JSON
77//!   byte array because WKWebView fails while decoding Tauri's raw-response stream. Frontend derives
78//!   affected pages from request commands, so no second metadata channel is required.
79use lopdf::content::{Content, Operation};
80use lopdf::{dictionary, Dictionary, Document, Object, ObjectId, Stream, StringFormat};
81use serde::Deserialize;
82
83use crate::font::{build_to_unicode_cmap, subset_for_pdf};
84use crate::inspect::{simple_font_identity_bytes, walk_page_graph};
85use crate::model::{
86    document_sha256, effective_pdf_edit_commands, ComposedCandidate, Matrix, PdfColor,
87    PdfEditCommand, PdfEditError, PdfFontResolver, PdfOcrBackgroundProof, PdfPoint, PdfRect,
88    PdfSourceLocator, PdfSourceSpan, PdfTextAppearance, PdfTextEditState, ResolvedPdfFont,
89};
90
91// -------------------------------------------------------------------------------------------
92// §9 — Strategy selection
93// -------------------------------------------------------------------------------------------
94
95/// §9's `select_strategy` outcome, concretized per this module's judgment calls (see module doc).
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97enum WriteStrategy {
98    ExactOperandPatch,
99    LocalTextObjectRewrite,
100}
101
102fn select_replace_strategy(
103    locator: &PdfSourceLocator,
104    before: &PdfTextEditState,
105    after: &PdfTextEditState,
106) -> WriteStrategy {
107    let same_font_encoding = before.appearance == after.appearance;
108    let one_operand = locator.spans.len() == 1;
109    let ascii_only = after.text.is_ascii();
110
111    if same_font_encoding && one_operand && ascii_only {
112        WriteStrategy::ExactOperandPatch
113    } else {
114        WriteStrategy::LocalTextObjectRewrite
115    }
116}
117
118// -------------------------------------------------------------------------------------------
119// Frozen seam: compose_candidate
120// -------------------------------------------------------------------------------------------
121
122/// The frozen cross-lane seam (`docs/PDF-EDITOR-DISPATCH.md`). Applies `commands` to `source`,
123/// validating every locator/digest against fresh bytes first (§9's "reject if stale"), then
124/// dispatching each command to its selected sparse-write strategy (§9.1-9.3).
125pub fn compose_candidate(
126    source: &[u8],
127    commands: &[PdfEditCommand],
128    fonts: &dyn PdfFontResolver,
129) -> Result<ComposedCandidate, PdfEditError> {
130    let current_sha = document_sha256(source);
131    compose_candidate_on_base(&current_sha, source, commands, fonts)
132}
133
134/// Same as [`compose_candidate`] but loads the lopdf document from `base` bytes while treating
135/// `source_sha` as the "current" source digest for every locator/staleness check — used by
136/// `prepare_pdf_save` (lane C2) to compose sparse-write commands on top of pdf.js's
137/// `saveDocument()` incremental-update annotation bytes (verbatim original source bytes plus
138/// appended annotation objects, per pdf.js's own incremental-save format) while still validating
139/// locators against the *original* source's digest rather than the annotated bytes' digest.
140pub fn compose_candidate_on_base(
141    source_sha: &str,
142    base: &[u8],
143    commands: &[PdfEditCommand],
144    fonts: &dyn PdfFontResolver,
145) -> Result<ComposedCandidate, PdfEditError> {
146    if commands.is_empty() {
147        return Ok(ComposedCandidate {
148            bytes: base.to_vec(),
149            affected_pages: Vec::new(),
150        });
151    }
152
153    let mut doc = Document::load_mem(base).map_err(|_| PdfEditError::MalformedContent)?;
154    if doc.is_encrypted() {
155        return Err(PdfEditError::EncryptedDocument);
156    }
157
158    let mut affected_pages: Vec<u32> = Vec::new();
159
160    for command in effective_pdf_edit_commands(commands) {
161        let page = match command {
162            PdfEditCommand::ReplaceSourceText {
163                locator,
164                before,
165                after,
166                available_bounds,
167                ..
168            } => {
169                if locator.document_sha256 != source_sha {
170                    return Err(PdfEditError::StaleSource);
171                }
172                apply_replace_source_text(
173                    &mut doc,
174                    source_sha,
175                    locator,
176                    before,
177                    after,
178                    *available_bounds,
179                    fonts,
180                )?
181            }
182            PdfEditCommand::AddText {
183                page,
184                anchor,
185                text,
186                appearance,
187                ..
188            } => apply_add_text(&mut doc, *page, *anchor, text, appearance, fonts)?,
189            PdfEditCommand::CorrectOcrLine {
190                page,
191                rect,
192                text,
193                appearance,
194                cover_color,
195                background_proof,
196                ..
197            } => apply_correct_ocr_text(
198                &mut doc,
199                *page,
200                *rect,
201                text,
202                appearance,
203                *cover_color,
204                *background_proof,
205                fonts,
206            )?,
207        };
208        if !affected_pages.contains(&page) {
209            affected_pages.push(page);
210        }
211    }
212
213    doc.compress();
214    let mut bytes = Vec::new();
215    doc.save_to(&mut bytes)
216        .map_err(|_| PdfEditError::MalformedContent)?;
217
218    affected_pages.sort_unstable();
219    Ok(ComposedCandidate {
220        bytes,
221        affected_pages,
222    })
223}
224
225// -------------------------------------------------------------------------------------------
226// §9.1/§9.2 — ReplaceSourceText
227// -------------------------------------------------------------------------------------------
228
229#[allow(clippy::too_many_arguments)]
230fn apply_replace_source_text(
231    doc: &mut Document,
232    current_sha: &str,
233    locator: &PdfSourceLocator,
234    before: &PdfTextEditState,
235    after: &PdfTextEditState,
236    available_bounds: PdfRect,
237    fonts: &dyn PdfFontResolver,
238) -> Result<u32, PdfEditError> {
239    if locator.spans.is_empty() {
240        return Err(PdfEditError::UnsupportedEncoding);
241    }
242
243    // Re-inspect the page fresh (never trust the caller's cached graph) and require an island
244    // whose digest still matches exactly — this is what actually catches "same visible text but
245    // changed operand digest" once `document_sha256` alone has already passed (§5.1).
246    let graph = walk_page_graph(doc, current_sha, locator.page)?;
247    let island = graph
248        .islands
249        .iter()
250        .find(|isl| isl.locator.island_digest == locator.island_digest)
251        .ok_or(PdfEditError::SourceDigestMismatch)?;
252    if !island.editable {
253        return Err(PdfEditError::UnsupportedEncoding);
254    }
255    for span in &locator.spans {
256        let matches = island.locator.spans.iter().any(|current| {
257            current.stream_ref == span.stream_ref
258                && current.contents_index == span.contents_index
259                && current.operator_index == span.operator_index
260                && current.operand_index == span.operand_index
261                && current.glyph_start == span.glyph_start
262                && current.glyph_end == span.glyph_end
263                && current.expected_digest == span.expected_digest
264        });
265        if !matches {
266            return Err(PdfEditError::SourceDigestMismatch);
267        }
268    }
269
270    // This composer's sparse strategies only ever touch one content stream per command — every
271    // span must agree on which one.
272    let stream_ref = locator.spans[0].stream_ref;
273    let contents_index = locator.spans[0].contents_index;
274    if !locator
275        .spans
276        .iter()
277        .all(|s| s.stream_ref == stream_ref && s.contents_index == contents_index)
278    {
279        return Err(PdfEditError::UnsupportedEncoding);
280    }
281    let stream_id: ObjectId = (stream_ref.0, stream_ref.1);
282    let page_id = pages_lookup(doc, locator.page)?;
283
284    match select_replace_strategy(locator, before, after) {
285        WriteStrategy::ExactOperandPatch => {
286            apply_exact_operand_patch(
287                doc,
288                page_id,
289                stream_id,
290                &locator.spans[0],
291                after,
292                available_bounds,
293            )?;
294        }
295        WriteStrategy::LocalTextObjectRewrite => {
296            apply_local_text_object_rewrite(
297                doc,
298                page_id,
299                stream_id,
300                &locator.spans,
301                after,
302                available_bounds,
303                fonts,
304            )?;
305        }
306    }
307
308    Ok(locator.page)
309}
310
311fn pages_lookup(doc: &Document, page: u32) -> Result<ObjectId, PdfEditError> {
312    doc.get_pages()
313        .get(&page)
314        .copied()
315        .ok_or(PdfEditError::MalformedContent)
316}
317
318// -------------------------------------------------------------------------------------------
319// §9.1 — Exact operand patch
320// -------------------------------------------------------------------------------------------
321
322fn apply_exact_operand_patch(
323    doc: &mut Document,
324    page_id: ObjectId,
325    stream_id: ObjectId,
326    span: &PdfSourceSpan,
327    after: &PdfTextEditState,
328    available_bounds: PdfRect,
329) -> Result<(), PdfEditError> {
330    validate_layout_conservative(&after.text, after.appearance.size_pt, available_bounds)?;
331
332    let content = decode_stream_content(doc, stream_id)?;
333    let resource_name = active_font_resource_name(&content, span.operator_index)
334        .ok_or(PdfEditError::UnsupportedEncoding)?;
335    // `after.text.is_ascii()` (§9's strategy selection) only says the Rust string is ASCII-shaped
336    // — it says nothing about whether this specific font's byte encoding maps those characters to
337    // the same byte values. Many embedded subset fonts remap codes via `/Differences`, where
338    // writing raw ASCII bytes directly silently produces wrong or blank glyphs instead of the
339    // requested text. Verify against the font's own encoding and fail closed (no fallback exists
340    // for a same-font edit whose font_id isn't a resolvable catalog font — see module doc) rather
341    // than ever writing an unverified byte sequence.
342    let replacement = simple_font_identity_bytes(doc, page_id, &resource_name, &after.text)
343        .ok_or(PdfEditError::UnsupportedEncoding)?;
344
345    let mut content = content;
346    splice_operand_bytes(
347        &mut content,
348        span.operator_index,
349        span.operand_index,
350        span.glyph_start,
351        span.glyph_end,
352        &replacement,
353    )?;
354    write_stream_content(doc, stream_id, &content)
355}
356
357/// Best-effort resolution of the `Tf` resource name governing `operator_index` — the most recent
358/// `Tf` operator at or before it in the stream's linear operator order. Matches this module's
359/// existing simplification (the walker's own `TextState.font` tracking is likewise a linear scan,
360/// not `q`/`Q`-scoped): good enough for the vast majority of real content streams, and a wrong
361/// guess here fails closed via [`simple_font_identity_bytes`]'s reverse-encoding check rather than
362/// producing wrong output.
363fn active_font_resource_name(content: &Content, operator_index: u32) -> Option<Vec<u8>> {
364    let mut name: Option<Vec<u8>> = None;
365    for op in content.operations.iter().take(operator_index as usize + 1) {
366        if op.operator == "Tf" {
367            if let Some(Object::Name(n)) = op.operands.first() {
368                name = Some(n.clone());
369            }
370        }
371    }
372    name
373}
374
375/// Conservative overflow check for the ASCII exact-patch path, where no real font metrics are
376/// reachable (see module doc's "Layout measurement" note): average glyph width ≈ `0.5 * size_pt`.
377fn validate_layout_conservative(
378    text: &str,
379    size_pt: f64,
380    available: PdfRect,
381) -> Result<(), PdfEditError> {
382    let width = text.chars().count() as f64 * size_pt * 0.5;
383    let run_bounds = PdfRect {
384        x: available.x,
385        y: available.y,
386        width,
387        height: available.height,
388    };
389    if !available.contains(run_bounds) {
390        return Err(PdfEditError::TextOverflow);
391    }
392    Ok(())
393}
394
395// -------------------------------------------------------------------------------------------
396// §9.2 — Local text-object rewrite
397// -------------------------------------------------------------------------------------------
398
399#[allow(clippy::too_many_arguments)]
400fn apply_local_text_object_rewrite(
401    doc: &mut Document,
402    page_id: ObjectId,
403    stream_id: ObjectId,
404    spans: &[PdfSourceSpan],
405    after: &PdfTextEditState,
406    available_bounds: PdfRect,
407    fonts: &dyn PdfFontResolver,
408) -> Result<(), PdfEditError> {
409    let resolved = fonts.resolve(&after.appearance.font_id)?;
410    // Validates full glyph coverage up front (§7.3 rule 3: "fail before candidate creation").
411    fonts.glyphs_for_text(&resolved, &after.text)?;
412
413    let anchor = PdfPoint {
414        x: available_bounds.x,
415        y: available_bounds.y,
416    };
417    let advance_1000 = measure_advance_1000(&resolved, &after.text);
418    validate_layout_measured(
419        advance_1000,
420        after.appearance.size_pt,
421        anchor,
422        available_bounds,
423    )?;
424
425    let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, &after.text)?;
426    let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
427
428    let mut content = decode_stream_content(doc, stream_id)?;
429    let reset_ctm = inverse_matrix(content_ctm(&content.operations));
430
431    // Blank covered glyph bytes from the end backward so earlier spans' byte offsets (captured
432    // against the pristine operand) stay valid while later ones are removed.
433    let mut ordered: Vec<&PdfSourceSpan> = spans.iter().collect();
434    ordered.sort_by(|a, b| {
435        b.operator_index
436            .cmp(&a.operator_index)
437            .then(b.operand_index.cmp(&a.operand_index))
438            .then(b.glyph_start.cmp(&a.glyph_start))
439    });
440    for span in ordered {
441        splice_operand_bytes(
442            &mut content,
443            span.operator_index,
444            span.operand_index,
445            span.glyph_start,
446            span.glyph_end,
447            &[],
448        )?;
449    }
450
451    // §9.2 step 4/5: append the new run at a deterministic boundary (this module's own choice:
452    // the end of the stream's operator list — see module doc) rather than in the middle.
453    content.operations.extend(build_text_object_operations(
454        &resource_name,
455        &after.appearance,
456        anchor,
457        &cid_codes,
458        reset_ctm,
459    ));
460
461    write_stream_content(doc, stream_id, &content)
462}
463
464fn validate_layout_measured(
465    advance_1000: f64,
466    size_pt: f64,
467    anchor: PdfPoint,
468    available: PdfRect,
469) -> Result<(), PdfEditError> {
470    // Anchored at `available`'s own lower-left corner (§8: "Available expansion beyond original
471    // island requires verified empty page space on same baseline" — `available_bounds` IS that
472    // verified, baseline-anchored usable rect already, per `validate_layout_conservative`'s
473    // matching convention). No extra guessed descender offset below `anchor.y`/`available.y`: that
474    // previously pushed `run_bounds` below `available`'s own bottom edge and produced a false
475    // `TextOverflow` even for text that fits comfortably.
476    let width = (advance_1000 / 1000.0 * size_pt).max(0.0);
477    let run_bounds = PdfRect {
478        x: anchor.x,
479        y: available.y,
480        width,
481        height: available.height,
482    };
483    if !available.contains(run_bounds) {
484        return Err(PdfEditError::TextOverflow);
485    }
486    Ok(())
487}
488
489fn measure_advance_1000(resolved: &ResolvedPdfFont, text: &str) -> f64 {
490    let Ok(face) = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index) else {
491        return text.chars().count() as f64 * 500.0;
492    };
493    let units_per_em = face.units_per_em() as f64;
494    if units_per_em <= 0.0 {
495        return text.chars().count() as f64 * 500.0;
496    }
497    text.chars()
498        .filter_map(|c| face.glyph_index(c))
499        .map(|gid| face.glyph_hor_advance(gid).unwrap_or(0) as f64 * 1000.0 / units_per_em)
500        .sum()
501}
502
503// -------------------------------------------------------------------------------------------
504// §9.3 — Add Text
505// -------------------------------------------------------------------------------------------
506
507fn apply_add_text(
508    doc: &mut Document,
509    page: u32,
510    anchor: PdfPoint,
511    text: &str,
512    appearance: &PdfTextAppearance,
513    fonts: &dyn PdfFontResolver,
514) -> Result<u32, PdfEditError> {
515    if doc.is_encrypted() {
516        return Err(PdfEditError::EncryptedDocument);
517    }
518    let page_id = pages_lookup(doc, page)?;
519
520    let resolved = fonts.resolve(&appearance.font_id)?;
521    fonts.glyphs_for_text(&resolved, text)?;
522
523    let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
524    let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
525    let reset_ctm = inverse_matrix(page_content_ctm(doc, page_id)?);
526
527    let mut content = Content::decode(&[]).map_err(|_| PdfEditError::MalformedContent)?;
528    content.operations =
529        build_text_object_operations(&resource_name, appearance, anchor, &cid_codes, reset_ctm);
530    let encoded = content
531        .encode()
532        .map_err(|_| PdfEditError::MalformedContent)?;
533    let new_stream_id = doc.add_object(Stream::new(dictionary! {}, encoded));
534
535    append_content_stream(doc, page_id, new_stream_id)?;
536
537    Ok(page)
538}
539
540/// Covers one OCR line's proven near-uniform background, then appends a fresh Type0 text object.
541/// Existing page content streams and source operators remain untouched.
542#[allow(clippy::too_many_arguments)]
543fn apply_correct_ocr_text(
544    doc: &mut Document,
545    page: u32,
546    rect: PdfRect,
547    text: &str,
548    appearance: &PdfTextAppearance,
549    cover_color: PdfColor,
550    background_proof: PdfOcrBackgroundProof,
551    fonts: &dyn PdfFontResolver,
552) -> Result<u32, PdfEditError> {
553    let page_id = pages_lookup(doc, page)?;
554    let page_bounds = page_bounds(doc, page_id)?;
555    validate_ocr_correction(
556        page_bounds,
557        rect,
558        text,
559        appearance,
560        cover_color,
561        background_proof,
562        page_rotation(doc, page_id)?,
563    )?;
564
565    let resolved = fonts.resolve(&appearance.font_id)?;
566    fonts.glyphs_for_text(&resolved, text)?;
567    let width = measure_advance_1000(&resolved, text) / 1000.0 * appearance.size_pt;
568    if width > rect.width || appearance.size_pt > rect.height {
569        return Err(PdfEditError::TextOverflow);
570    }
571    let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
572    let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
573    let reset_ctm = inverse_matrix(page_content_ctm(doc, page_id)?);
574
575    let mut content = Content::decode(&[]).map_err(|_| PdfEditError::MalformedContent)?;
576    content.operations = build_ocr_correction_operations(
577        &resource_name,
578        appearance,
579        rect,
580        cover_color,
581        &cid_codes,
582        reset_ctm,
583    );
584    let encoded = content
585        .encode()
586        .map_err(|_| PdfEditError::MalformedContent)?;
587    let new_stream_id = doc.add_object(Stream::new(dictionary! {}, encoded));
588    append_content_stream(doc, page_id, new_stream_id)?;
589    Ok(page)
590}
591
592fn validate_ocr_correction(
593    page_bounds: PdfRect,
594    rect: PdfRect,
595    text: &str,
596    appearance: &PdfTextAppearance,
597    cover_color: PdfColor,
598    background_proof: PdfOcrBackgroundProof,
599    rotation: i64,
600) -> Result<(), PdfEditError> {
601    if rotation != 0 {
602        return Err(PdfEditError::UnsupportedTransform);
603    }
604    if !matches!(background_proof, PdfOcrBackgroundProof::NearUniform)
605        || !is_finite_color(cover_color)
606        || cover_color.alpha < 1.0
607        || !is_finite_color(appearance.color)
608        || !appearance.size_pt.is_finite()
609        || appearance.size_pt <= 0.0
610        || text.is_empty()
611        || !is_finite_rect(page_bounds)
612        || page_bounds.width <= 0.0
613        || page_bounds.height <= 0.0
614        || !is_finite_rect(rect)
615        || rect.width <= 0.0
616        || rect.height <= 0.0
617        || !page_bounds.contains(rect)
618    {
619        return Err(PdfEditError::TextOverflow);
620    }
621    Ok(())
622}
623
624fn is_finite_rect(rect: PdfRect) -> bool {
625    rect.x.is_finite() && rect.y.is_finite() && rect.width.is_finite() && rect.height.is_finite()
626}
627
628fn is_finite_color(color: PdfColor) -> bool {
629    color.r.is_finite()
630        && color.g.is_finite()
631        && color.b.is_finite()
632        && color.alpha.is_finite()
633        && (0.0..=1.0).contains(&color.r)
634        && (0.0..=1.0).contains(&color.g)
635        && (0.0..=1.0).contains(&color.b)
636        && (0.0..=1.0).contains(&color.alpha)
637}
638
639fn page_bounds(doc: &Document, page_id: ObjectId) -> Result<PdfRect, PdfEditError> {
640    let values = inherited_page_array(doc, page_id, b"CropBox")
641        .or_else(|| inherited_page_array(doc, page_id, b"MediaBox"))
642        .ok_or(PdfEditError::MalformedContent)?;
643    if values.len() != 4 {
644        return Err(PdfEditError::MalformedContent);
645    }
646    let x0 = values[0];
647    let y0 = values[1];
648    let width = values[2] - x0;
649    let height = values[3] - y0;
650    Ok(PdfRect {
651        x: x0,
652        y: y0,
653        width,
654        height,
655    })
656}
657
658fn page_rotation(doc: &Document, page_id: ObjectId) -> Result<i64, PdfEditError> {
659    Ok(inherited_page_number(doc, page_id, b"Rotate").unwrap_or(0))
660}
661
662fn inherited_page_array(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<Vec<f64>> {
663    for _ in 0..64 {
664        let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
665        if let Ok(value) = dict.get(key) {
666            let value = match value {
667                Object::Reference(id) => doc.get_object(*id).ok()?,
668                value => value,
669            };
670            let array = value.as_array().ok()?;
671            return array.iter().map(object_number).collect();
672        }
673        page_id = match dict.get(b"Parent").ok()? {
674            Object::Reference(id) => *id,
675            _ => return None,
676        };
677    }
678    None
679}
680
681fn object_number(value: &Object) -> Option<f64> {
682    match value {
683        Object::Integer(value) => Some(*value as f64),
684        Object::Real(value) => Some(*value as f64),
685        _ => None,
686    }
687}
688
689fn inherited_page_number(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<i64> {
690    for _ in 0..64 {
691        let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
692        if let Ok(value) = dict.get(key) {
693            let value = match value {
694                Object::Reference(id) => doc.get_object(*id).ok()?,
695                value => value,
696            };
697            return value.as_i64().ok();
698        }
699        page_id = match dict.get(b"Parent").ok()? {
700            Object::Reference(id) => *id,
701            _ => return None,
702        };
703    }
704    None
705}
706
707/// Appends `new_stream_id` to the page's `/Contents` array without touching any existing stream
708/// object — §9.3: "Do not mutate existing content operators," and §5.2's stream-boundary rule.
709fn append_content_stream(
710    doc: &mut Document,
711    page_id: ObjectId,
712    new_stream_id: ObjectId,
713) -> Result<(), PdfEditError> {
714    let page_obj = doc
715        .get_object(page_id)
716        .map_err(|_| PdfEditError::MalformedContent)?;
717    let page_dict = page_obj
718        .as_dict()
719        .map_err(|_| PdfEditError::MalformedContent)?;
720
721    let mut ids: Vec<ObjectId> = match page_dict.get(b"Contents") {
722        Ok(Object::Reference(id)) => vec![*id],
723        Ok(Object::Array(items)) => items
724            .iter()
725            .filter_map(|o| match o {
726                Object::Reference(id) => Some(*id),
727                _ => None,
728            })
729            .collect(),
730        _ => Vec::new(),
731    };
732    ids.push(new_stream_id);
733
734    let array = Object::Array(ids.into_iter().map(Object::Reference).collect());
735    if let Ok(Object::Dictionary(dict)) = doc.get_object_mut(page_id) {
736        dict.set("Contents", array);
737    }
738    Ok(())
739}
740
741// -------------------------------------------------------------------------------------------
742// §7.4-adjacent — embedding a fresh Type0/CIDFontType2 subset for a resolved catalog font
743// -------------------------------------------------------------------------------------------
744
745/// Embeds a Type0/CIDFontType2/FontFile2/ToUnicode object graph (§7.4) for exactly the glyphs
746/// `text` needs, via `pdf_font.rs`'s [`subset_for_pdf`]. Returns the new Type0 font's object id
747/// plus `text`'s per-character CID codes (`/CIDToGIDMap /Identity`, so CID == subset glyph id).
748///
749/// The CID for each character is recomputed here (not read back from [`subset_for_pdf`], which
750/// exposes only de-duplicated widths/`ToUnicode`, not a per-position code list) using the exact
751/// same "first-appearance order, glyph 0 reserved for `.notdef`" numbering `subset_for_pdf` itself
752/// documents and relies on: "assigns CIDs to equal the subset font's own new (post-subsetting)
753/// glyph ids ... in the exact order we call `remap`." Recomputing over the same `text.chars()` /
754/// `face.glyph_index` sequence that `subset_for_pdf` iterates internally reproduces that same
755/// mapping without needing a new shared API between the two modules.
756fn embed_type0_font(
757    doc: &mut Document,
758    resolved: &ResolvedPdfFont,
759    text: &str,
760) -> Result<(ObjectId, Vec<u16>), PdfEditError> {
761    let subset = subset_for_pdf(&resolved.bytes, resolved.face_index, text)?;
762
763    let face = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index)
764        .map_err(|_| PdfEditError::UnsupportedEncoding)?;
765    let mut seen_old_gids: Vec<u16> = Vec::new();
766    let mut cid_codes: Vec<u16> = Vec::with_capacity(text.chars().count());
767    for c in text.chars() {
768        let old_gid = face.glyph_index(c).ok_or(PdfEditError::MissingGlyph)?.0;
769        let new_gid = match seen_old_gids.iter().position(|&g| g == old_gid) {
770            Some(pos) => (pos + 1) as u16,
771            None => {
772                seen_old_gids.push(old_gid);
773                seen_old_gids.len() as u16
774            }
775        };
776        cid_codes.push(new_gid);
777    }
778
779    let font_file_id = doc.add_object(Stream::new(
780        dictionary! { "Length1" => subset.font_program.len() as i64 },
781        subset.font_program.clone(),
782    ));
783
784    let mut widths_sorted = subset.widths.clone();
785    widths_sorted.sort_by_key(|w| w.cid);
786    let mut w_array: Vec<Object> = Vec::with_capacity(widths_sorted.len() * 2);
787    for w in &widths_sorted {
788        w_array.push(Object::Integer(w.cid as i64));
789        w_array.push(Object::Array(vec![Object::Integer(
790            w.width_1000.round() as i64
791        )]));
792    }
793
794    let base_name: Vec<u8> = resolved.family.replace(' ', "").into_bytes();
795
796    let descriptor_id = doc.add_object(dictionary! {
797        "Type" => "FontDescriptor",
798        "FontName" => Object::Name(base_name.clone()),
799        "Flags" => 4,
800        "FontBBox" => Object::Array(vec![Object::Integer(0), Object::Integer(0), Object::Integer(1000), Object::Integer(1000)]),
801        "ItalicAngle" => 0,
802        "Ascent" => 900,
803        "Descent" => -200,
804        "CapHeight" => 700,
805        "StemV" => 80,
806        "FontFile2" => font_file_id,
807    });
808
809    let descendant_id = doc.add_object(dictionary! {
810        "Type" => "Font",
811        "Subtype" => "CIDFontType2",
812        "BaseFont" => Object::Name(base_name.clone()),
813        "CIDSystemInfo" => dictionary! {
814            "Registry" => Object::String(b"Adobe".to_vec(), StringFormat::Literal),
815            "Ordering" => Object::String(b"Identity".to_vec(), StringFormat::Literal),
816            "Supplement" => 0,
817        },
818        "FontDescriptor" => descriptor_id,
819        "DW" => 1000,
820        "W" => Object::Array(w_array),
821        "CIDToGIDMap" => "Identity",
822    });
823
824    let to_unicode_bytes = build_to_unicode_cmap(&subset.to_unicode).into_bytes();
825    let to_unicode_id = doc.add_object(Stream::new(dictionary! {}, to_unicode_bytes));
826
827    let type0_id = doc.add_object(dictionary! {
828        "Type" => "Font",
829        "Subtype" => "Type0",
830        "BaseFont" => Object::Name(base_name),
831        "Encoding" => "Identity-H",
832        "DescendantFonts" => Object::Array(vec![Object::Reference(descendant_id)]),
833        "ToUnicode" => to_unicode_id,
834    });
835
836    Ok((type0_id, cid_codes))
837}
838
839/// Adds `font_obj_id` to page `page_id`'s own `/Resources/Font` dictionary under a fresh,
840/// collision-free name (§9.3: "Add resource under collision-free generated name"). Always writes a
841/// page-owned copy of `/Resources` (and of `/Resources/Font`) rather than mutating whatever
842/// dictionary object the page's `/Resources` currently points to, so a `/Resources` dictionary
843/// shared with another page is never silently changed for both.
844fn add_font_resource(
845    doc: &mut Document,
846    page_id: ObjectId,
847    font_obj_id: ObjectId,
848) -> Result<Vec<u8>, PdfEditError> {
849    let page_obj = doc
850        .get_object(page_id)
851        .map_err(|_| PdfEditError::MalformedContent)?;
852    let page_dict = page_obj
853        .as_dict()
854        .map_err(|_| PdfEditError::MalformedContent)?;
855
856    let existing_resources = dict_entry_clone(doc, page_dict, b"Resources");
857    let mut fonts_dict = dict_entry_clone(doc, &existing_resources, b"Font");
858
859    let mut counter = 1u32;
860    let mut name = format!("VREdit{counter}");
861    while fonts_dict.has(name.as_bytes()) {
862        counter += 1;
863        name = format!("VREdit{counter}");
864    }
865    fonts_dict.set(name.as_str(), Object::Reference(font_obj_id));
866
867    let mut new_resources = existing_resources;
868    new_resources.set("Font", Object::Dictionary(fonts_dict));
869
870    if let Ok(Object::Dictionary(page_dict_mut)) = doc.get_object_mut(page_id) {
871        page_dict_mut.set("Resources", Object::Dictionary(new_resources));
872    }
873
874    Ok(name.into_bytes())
875}
876
877/// Clones the dictionary `dict[key]` resolves to — following one `/Reference` hop — or an empty
878/// dictionary when the key is absent, not a dictionary, or points at a broken/non-dictionary
879/// object. `add_font_resource` uses it so `/Resources` and `/Resources/Font` are always copied out
880/// before mutation (a dictionary shared with another page must never be mutated in place).
881fn dict_entry_clone(doc: &Document, dict: &Dictionary, key: &[u8]) -> Dictionary {
882    match dict.get(key) {
883        Ok(Object::Reference(id)) => doc
884            .get_object(*id)
885            .ok()
886            .and_then(|o| o.as_dict().ok())
887            .cloned()
888            .unwrap_or_else(|| dictionary! {}),
889        Ok(Object::Dictionary(d)) => d.clone(),
890        _ => dictionary! {},
891    }
892}
893
894fn matrix_from_operands(operands: &[Object]) -> Option<Matrix> {
895    let values: Vec<f64> = operands.iter().filter_map(object_number).collect();
896    match values.as_slice() {
897        [a, b, c, d, e, f] => Some(Matrix {
898            a: *a,
899            b: *b,
900            c: *c,
901            d: *d,
902            e: *e,
903            f: *f,
904        }),
905        _ => None,
906    }
907}
908
909fn content_ctm(operations: &[Operation]) -> Matrix {
910    let mut ctm = Matrix::IDENTITY;
911    let mut stack = Vec::new();
912    for operation in operations {
913        match operation.operator.as_str() {
914            "q" => stack.push(ctm),
915            "Q" => {
916                if let Some(saved) = stack.pop() {
917                    ctm = saved;
918                }
919            }
920            "cm" => {
921                if let Some(matrix) = matrix_from_operands(&operation.operands) {
922                    ctm = matrix.then(ctm);
923                }
924            }
925            _ => {}
926        }
927    }
928    ctm
929}
930
931fn page_content_ctm(doc: &Document, page_id: ObjectId) -> Result<Matrix, PdfEditError> {
932    let page = doc
933        .get_object(page_id)
934        .map_err(|_| PdfEditError::MalformedContent)?
935        .as_dict()
936        .map_err(|_| PdfEditError::MalformedContent)?;
937    let contents = page
938        .get(b"Contents")
939        .map_err(|_| PdfEditError::MalformedContent)?;
940    let ids: Vec<ObjectId> = match contents {
941        Object::Reference(id) => vec![*id],
942        Object::Array(items) => items
943            .iter()
944            .filter_map(|item| match item {
945                Object::Reference(id) => Some(*id),
946                _ => None,
947            })
948            .collect(),
949        _ => Vec::new(),
950    };
951    let mut ctm = Matrix::IDENTITY;
952    let mut stack = Vec::new();
953    for id in ids {
954        let stream = doc
955            .get_object(id)
956            .map_err(|_| PdfEditError::MalformedContent)?
957            .as_stream()
958            .map_err(|_| PdfEditError::MalformedContent)?;
959        let bytes = stream
960            .decompressed_content()
961            .map_err(|_| PdfEditError::MalformedContent)?;
962        let content = Content::decode(&bytes).map_err(|_| PdfEditError::MalformedContent)?;
963        for operation in content.operations {
964            match operation.operator.as_str() {
965                "q" => stack.push(ctm),
966                "Q" => {
967                    if let Some(saved) = stack.pop() {
968                        ctm = saved;
969                    }
970                }
971                "cm" => {
972                    if let Some(matrix) = matrix_from_operands(&operation.operands) {
973                        ctm = matrix.then(ctm);
974                    }
975                }
976                _ => {}
977            }
978        }
979    }
980    Ok(ctm)
981}
982
983fn inverse_matrix(matrix: Matrix) -> Option<Matrix> {
984    if (matrix.a - 1.0).abs() < f64::EPSILON
985        && matrix.b.abs() < f64::EPSILON
986        && matrix.c.abs() < f64::EPSILON
987        && (matrix.d - 1.0).abs() < f64::EPSILON
988        && matrix.e.abs() < f64::EPSILON
989        && matrix.f.abs() < f64::EPSILON
990    {
991        return None;
992    }
993    let determinant = matrix.a * matrix.d - matrix.b * matrix.c;
994    if !determinant.is_finite() || determinant.abs() < f64::EPSILON {
995        return None;
996    }
997    let a = matrix.d / determinant;
998    let b = -matrix.b / determinant;
999    let c = -matrix.c / determinant;
1000    let d = matrix.a / determinant;
1001    Some(Matrix {
1002        a,
1003        b,
1004        c,
1005        d,
1006        e: -(matrix.e * a + matrix.f * c),
1007        f: -(matrix.e * b + matrix.f * d),
1008    })
1009}
1010
1011fn matrix_operation(matrix: Matrix) -> Operation {
1012    Operation::new(
1013        "cm",
1014        vec![
1015            Object::Real(matrix.a as f32),
1016            Object::Real(matrix.b as f32),
1017            Object::Real(matrix.c as f32),
1018            Object::Real(matrix.d as f32),
1019            Object::Real(matrix.e as f32),
1020            Object::Real(matrix.f as f32),
1021        ],
1022    )
1023}
1024
1025/// One isolated `q / BT / Tf / rg / Tm / Tj / ET / Q` block showing `cid_codes` (Identity-H,
1026/// 2-byte-per-code) through `resource_name` at `anchor`, in `appearance.color`/`appearance.size_pt`
1027/// — the shared building block for both §9.2's appended local rewrite and §9.3's Add Text stream.
1028fn build_text_object_operations(
1029    resource_name: &[u8],
1030    appearance: &PdfTextAppearance,
1031    anchor: PdfPoint,
1032    cid_codes: &[u16],
1033    reset_ctm: Option<Matrix>,
1034) -> Vec<Operation> {
1035    let mut hex_bytes = Vec::with_capacity(cid_codes.len() * 2);
1036    for code in cid_codes {
1037        hex_bytes.extend_from_slice(&code.to_be_bytes());
1038    }
1039
1040    let mut operations = vec![Operation::new("q", vec![])];
1041    if let Some(matrix) = reset_ctm {
1042        operations.push(matrix_operation(matrix));
1043    }
1044    operations.extend([
1045        Operation::new("BT", vec![]),
1046        Operation::new(
1047            "Tf",
1048            vec![
1049                Object::Name(resource_name.to_vec()),
1050                Object::Real(appearance.size_pt as f32),
1051            ],
1052        ),
1053        Operation::new(
1054            "rg",
1055            vec![
1056                Object::Real(appearance.color.r as f32),
1057                Object::Real(appearance.color.g as f32),
1058                Object::Real(appearance.color.b as f32),
1059            ],
1060        ),
1061        Operation::new(
1062            "Tm",
1063            vec![
1064                Object::Integer(1),
1065                Object::Integer(0),
1066                Object::Integer(0),
1067                Object::Integer(1),
1068                Object::Real(anchor.x as f32),
1069                Object::Real(anchor.y as f32),
1070            ],
1071        ),
1072        Operation::new(
1073            "Tj",
1074            vec![Object::String(hex_bytes, StringFormat::Hexadecimal)],
1075        ),
1076        Operation::new("ET", vec![]),
1077        Operation::new("Q", vec![]),
1078    ]);
1079    operations
1080}
1081
1082/// Isolated correction stream: opaque cover first, then embedded text with the same Type0 font
1083/// and ToUnicode mapping used by ordinary AddText/local rewrites.
1084fn build_ocr_correction_operations(
1085    resource_name: &[u8],
1086    appearance: &PdfTextAppearance,
1087    rect: PdfRect,
1088    cover_color: PdfColor,
1089    cid_codes: &[u16],
1090    reset_ctm: Option<Matrix>,
1091) -> Vec<Operation> {
1092    let mut operations = vec![Operation::new("q", vec![])];
1093    if let Some(matrix) = reset_ctm {
1094        operations.push(matrix_operation(matrix));
1095    }
1096    operations.extend([
1097        Operation::new(
1098            "rg",
1099            vec![
1100                Object::Real(cover_color.r as f32),
1101                Object::Real(cover_color.g as f32),
1102                Object::Real(cover_color.b as f32),
1103            ],
1104        ),
1105        Operation::new(
1106            "re",
1107            vec![
1108                Object::Real(rect.x as f32),
1109                Object::Real(rect.y as f32),
1110                Object::Real(rect.width as f32),
1111                Object::Real(rect.height as f32),
1112            ],
1113        ),
1114        Operation::new("f", vec![]),
1115        Operation::new("Q", vec![]),
1116    ]);
1117    operations.extend(build_text_object_operations(
1118        resource_name,
1119        appearance,
1120        PdfPoint {
1121            x: rect.x,
1122            y: rect.y + rect.height * 0.18,
1123        },
1124        cid_codes,
1125        reset_ctm,
1126    ));
1127    operations
1128}
1129
1130// -------------------------------------------------------------------------------------------
1131// Content-stream mutation helpers shared by §9.1/§9.2
1132// -------------------------------------------------------------------------------------------
1133
1134fn decode_stream_content(doc: &Document, id: ObjectId) -> Result<Content, PdfEditError> {
1135    let obj = doc
1136        .get_object(id)
1137        .map_err(|_| PdfEditError::MalformedContent)?;
1138    let stream = obj
1139        .as_stream()
1140        .map_err(|_| PdfEditError::MalformedContent)?;
1141    let bytes = stream
1142        .decompressed_content()
1143        .map_err(|_| PdfEditError::MalformedContent)?;
1144    Content::decode(&bytes).map_err(|_| PdfEditError::MalformedContent)
1145}
1146
1147/// Replaces the stream object at `id` in place (same object number/generation, so every other
1148/// span's `stream_ref` in this batch stays valid) with freshly encoded, uncompressed content.
1149fn write_stream_content(
1150    doc: &mut Document,
1151    id: ObjectId,
1152    content: &Content,
1153) -> Result<(), PdfEditError> {
1154    let encoded = content
1155        .encode()
1156        .map_err(|_| PdfEditError::MalformedContent)?;
1157    doc.objects
1158        .insert(id, Stream::new(dictionary! {}, encoded).into());
1159    Ok(())
1160}
1161
1162/// Splices `replacement` into the exact glyph byte range `[glyph_start, glyph_end)` of the string
1163/// operand identified by `operator_index`/`operand_index` (§5.1's `PdfSourceSpan` addressing
1164/// scheme) — a `Tj`/`'` operand's sole string, `"`'s third operand, or one string item inside a
1165/// `TJ` array. An empty `replacement` blanks the covered bytes (§9.2 step 2: "remove only source
1166/// glyph bytes covered by locator").
1167fn splice_operand_bytes(
1168    content: &mut Content,
1169    operator_index: u32,
1170    operand_index: u32,
1171    glyph_start: u32,
1172    glyph_end: u32,
1173    replacement: &[u8],
1174) -> Result<(), PdfEditError> {
1175    let op = content
1176        .operations
1177        .get_mut(operator_index as usize)
1178        .ok_or(PdfEditError::SourceDigestMismatch)?;
1179
1180    match op.operator.as_str() {
1181        "Tj" | "'" => {
1182            let operand = op
1183                .operands
1184                .get_mut(0)
1185                .ok_or(PdfEditError::SourceDigestMismatch)?;
1186            splice_string_object(operand, glyph_start, glyph_end, replacement)
1187        }
1188        "\"" => {
1189            let operand = op
1190                .operands
1191                .get_mut(2)
1192                .ok_or(PdfEditError::SourceDigestMismatch)?;
1193            splice_string_object(operand, glyph_start, glyph_end, replacement)
1194        }
1195        "TJ" => {
1196            let Some(Object::Array(items)) = op.operands.get_mut(0) else {
1197                return Err(PdfEditError::SourceDigestMismatch);
1198            };
1199            let item = items
1200                .get_mut(operand_index as usize)
1201                .ok_or(PdfEditError::SourceDigestMismatch)?;
1202            splice_string_object(item, glyph_start, glyph_end, replacement)
1203        }
1204        _ => Err(PdfEditError::SourceDigestMismatch),
1205    }
1206}
1207
1208fn splice_string_object(
1209    obj: &mut Object,
1210    start: u32,
1211    end: u32,
1212    replacement: &[u8],
1213) -> Result<(), PdfEditError> {
1214    let Object::String(bytes, _fmt) = obj else {
1215        return Err(PdfEditError::SourceDigestMismatch);
1216    };
1217    let (start, end) = (start as usize, end as usize);
1218    if start > end || end > bytes.len() {
1219        return Err(PdfEditError::SourceDigestMismatch);
1220    }
1221    let mut new_bytes = Vec::with_capacity(bytes.len() - (end - start) + replacement.len());
1222    new_bytes.extend_from_slice(&bytes[..start]);
1223    new_bytes.extend_from_slice(replacement);
1224    new_bytes.extend_from_slice(&bytes[end..]);
1225    *bytes = new_bytes;
1226    Ok(())
1227}
1228
1229// -------------------------------------------------------------------------------------------
1230// §9.4 — preview wire types (the Tauri command itself lives in the app crate)
1231// -------------------------------------------------------------------------------------------
1232
1233/// Wire request for the app crate's `compose_pdf_preview` command (§9.4). Field
1234/// names/shape match the frontend's
1235/// `ComposePdfPreviewRequest` comment on the frozen signature: `source_path`,
1236/// `expected_source_sha256`, `source_commands`.
1237#[derive(Debug, Deserialize)]
1238#[serde(rename_all = "camelCase")]
1239pub struct ComposePdfPreviewRequest {
1240    pub source_path: String,
1241    pub expected_source_sha256: String,
1242    pub source_commands: Vec<PdfEditCommand>,
1243}
1244
1245// -------------------------------------------------------------------------------------------
1246// Tests (§14.1 composition fixtures)
1247// -------------------------------------------------------------------------------------------
1248
1249#[cfg(test)]
1250mod tests {
1251    use super::*;
1252    use lopdf::{dictionary, Object as LObject};
1253
1254    use crate::model::{GlyphId, PdfColor, PdfEditCommand, PdfOcrBackgroundProof, PdfPageGraph};
1255
1256    // -----------------------------------------------------------------------------------
1257    // Fixture builders — shared with the rest of the crate via `crate::test_fixtures`.
1258    // -----------------------------------------------------------------------------------
1259
1260    use crate::test_fixtures::{build_one_page_pdf as build_pdf, pdf_bytes as doc_bytes};
1261
1262    fn locate_island(
1263        doc: &Document,
1264        sha: &str,
1265        page: u32,
1266        text: &str,
1267    ) -> crate::model::PdfTextIsland {
1268        let graph = walk_page_graph(doc, sha, page).expect("inspect fixture page");
1269        graph
1270            .islands
1271            .into_iter()
1272            .find(|isl| isl.text == text)
1273            .unwrap_or_else(|| panic!("no island with text {text:?}"))
1274    }
1275
1276    struct FailingFontResolver;
1277    impl PdfFontResolver for FailingFontResolver {
1278        fn resolve(&self, _id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
1279            Err(PdfEditError::UnsupportedEncoding)
1280        }
1281        fn glyphs_for_text(
1282            &self,
1283            _font: &ResolvedPdfFont,
1284            _text: &str,
1285        ) -> Result<Vec<GlyphId>, PdfEditError> {
1286            Err(PdfEditError::UnsupportedEncoding)
1287        }
1288    }
1289
1290    fn installable_font() -> Vec<u8> {
1291        // fsType 0 = installable; the shared builder carries the proven sfnt layout.
1292        crate::test_fixtures::build_test_ttf(
1293            0,
1294            &[('A', 1), ('B', 2), ('C', 3), ('H', 4), ('i', 5)],
1295            600,
1296        )
1297    }
1298
1299    struct TestFontResolver {
1300        id: &'static str,
1301        bytes: Vec<u8>,
1302    }
1303
1304    impl PdfFontResolver for TestFontResolver {
1305        fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
1306            if id != self.id {
1307                return Err(PdfEditError::UnsupportedEncoding);
1308            }
1309            Ok(ResolvedPdfFont {
1310                id: self.id.to_string(),
1311                family: "Test Font".to_string(),
1312                bytes: self.bytes.clone(),
1313                face_index: 0,
1314                embeddable: true,
1315                subsettable: true,
1316            })
1317        }
1318
1319        fn glyphs_for_text(
1320            &self,
1321            font: &ResolvedPdfFont,
1322            text: &str,
1323        ) -> Result<Vec<GlyphId>, PdfEditError> {
1324            // The mock shares `pdf_font.rs`'s real mapping so tests exercise the same
1325            // MissingGlyph/UnsupportedEncoding behavior the production resolver has.
1326            crate::font::glyph_ids_for_text(&font.bytes, font.face_index, text)
1327        }
1328    }
1329
1330    fn appearance(font_id: &str, size_pt: f64) -> PdfTextAppearance {
1331        PdfTextAppearance {
1332            font_id: font_id.to_string(),
1333            family: font_id.to_string(),
1334            size_pt,
1335            color: PdfColor {
1336                r: 0.0,
1337                g: 0.0,
1338                b: 0.0,
1339                alpha: 1.0,
1340            },
1341        }
1342    }
1343
1344    /// The resolver every successful-embed test uses: catalog id `"test-font"` backed by the
1345    /// installable five-glyph fixture font.
1346    fn test_resolver() -> TestFontResolver {
1347        TestFontResolver {
1348            id: "test-font",
1349            bytes: installable_font(),
1350        }
1351    }
1352
1353    /// A `ReplaceSourceText` command whose `before` mirrors `island` exactly (its text and
1354    /// appearance — what a real edit session captures) and whose `after` carries the new
1355    /// text/appearance. Mutate `island.locator` in place before calling to model a stale or
1356    /// tampered locator.
1357    fn replace_cmd(
1358        id: &str,
1359        island: &crate::model::PdfTextIsland,
1360        after_text: &str,
1361        after_appearance: PdfTextAppearance,
1362        available_bounds: PdfRect,
1363    ) -> PdfEditCommand {
1364        PdfEditCommand::ReplaceSourceText {
1365            id: id.to_string(),
1366            locator: island.locator.clone(),
1367            before: PdfTextEditState {
1368                text: island.text.clone(),
1369                appearance: island.appearance.clone(),
1370            },
1371            after: PdfTextEditState {
1372                text: after_text.to_string(),
1373                appearance: after_appearance,
1374            },
1375            available_bounds,
1376        }
1377    }
1378
1379    /// Reopen a composed candidate's bytes and re-walk `page`'s island graph — the shared tail of
1380    /// every compose-then-verify test. Also returns the reopened `Document` for tests that inspect
1381    /// raw page objects (annotations, appended content streams).
1382    fn reinspect(candidate: &ComposedCandidate, page: u32) -> (Document, PdfPageGraph) {
1383        let out = Document::load_mem(&candidate.bytes).expect("reopen candidate");
1384        let graph =
1385            walk_page_graph(&out, &document_sha256(&candidate.bytes), page).expect("reinspect");
1386        (out, graph)
1387    }
1388
1389    // -----------------------------------------------------------------------------------
1390    // §9.1 Exact operand patch
1391    // -----------------------------------------------------------------------------------
1392
1393    #[test]
1394    fn exact_operand_patch_replaces_single_tj_text_in_place() {
1395        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1396        let source = doc_bytes(&doc);
1397        let sha = document_sha256(&source);
1398        let island = locate_island(&doc, &sha, 1, "Hello");
1399
1400        let command = replace_cmd(
1401            "edit-1",
1402            &island,
1403            "Howdy",
1404            island.appearance.clone(),
1405            PdfRect {
1406                x: 72.0,
1407                y: 695.0,
1408                width: 400.0,
1409                height: 20.0,
1410            },
1411        );
1412
1413        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
1414            .expect("exact patch composes");
1415        assert_eq!(candidate.affected_pages, vec![1]);
1416
1417        let (_, graph) = reinspect(&candidate, 1);
1418        assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
1419        assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
1420    }
1421
1422    #[test]
1423    fn corrected_source_text_survives_native_staging_verification() {
1424        let source = include_bytes!("../tests/fixtures/source-correction-2page.pdf");
1425        let doc = Document::load_mem(source).expect("fixture");
1426        let sha = document_sha256(source);
1427        let island = locate_island(&doc, &sha, 1, "ORIGINAL SOURCE TEXT");
1428        let command = replace_cmd(
1429            "correction",
1430            &island,
1431            "SOURCE TEST 930",
1432            island.appearance.clone(),
1433            PdfRect {
1434                x: 72.0,
1435                y: 717.0,
1436                width: 146.028,
1437                height: 12.0,
1438            },
1439        );
1440        // Fresh font HashMaps previously chose WinAnsi's unmeasured 0xA0 space alias randomly.
1441        for _ in 0..32 {
1442            assert_eq!(
1443                simple_font_identity_bytes(
1444                    &doc,
1445                    *doc.get_pages().get(&1).expect("page"),
1446                    b"F1",
1447                    "SOURCE TEST 930"
1448                ),
1449                Some(b"SOURCE TEST 930".to_vec())
1450            );
1451            let candidate =
1452                compose_candidate(source, std::slice::from_ref(&command), &FailingFontResolver)
1453                    .expect("compose");
1454            let (_, graph) = reinspect(&candidate, 1);
1455            assert!(graph
1456                .islands
1457                .iter()
1458                .any(|island| island.text.contains("SOURCE TEST 930")));
1459            assert!(graph
1460                .islands
1461                .iter()
1462                .any(|island| island.text == "SIBLING SOURCE TEXT"));
1463            let store = crate::save::PdfSaveTokenStore::default();
1464            crate::save::stage_prepared_save(
1465                &store,
1466                "/fixture.pdf",
1467                &sha,
1468                &sha,
1469                candidate.bytes,
1470                candidate.affected_pages,
1471                crate::save::expected_text_checks(std::slice::from_ref(&command)),
1472            )
1473            .expect("verified stage");
1474        }
1475    }
1476
1477    #[test]
1478    fn exact_operand_patch_rejects_overflow_before_mutating() {
1479        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hi) Tj ET");
1480        let source = doc_bytes(&doc);
1481        let sha = document_sha256(&source);
1482        let island = locate_island(&doc, &sha, 1, "Hi");
1483
1484        // Tiny available rect: the long replacement cannot fit.
1485        let command = replace_cmd(
1486            "edit-overflow",
1487            &island,
1488            "This replacement text is far too long",
1489            island.appearance.clone(),
1490            PdfRect {
1491                x: 72.0,
1492                y: 695.0,
1493                width: 10.0,
1494                height: 10.0,
1495            },
1496        );
1497
1498        let err = compose_candidate(&source, &[command], &FailingFontResolver)
1499            .expect_err("overflow must reject");
1500        assert!(matches!(err, PdfEditError::TextOverflow));
1501    }
1502
1503    // -----------------------------------------------------------------------------------
1504    // compose_candidate_on_base — composing on top of pdf.js's annotation incremental update
1505    // -----------------------------------------------------------------------------------
1506
1507    #[test]
1508    fn compose_candidate_on_base_preserves_annotations_while_editing_text() {
1509        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1510        let source = doc_bytes(&doc);
1511        let sha = document_sha256(&source);
1512        let island = locate_island(&doc, &sha, 1, "Hello");
1513
1514        // Simulate pdf.js's `saveDocument()` incremental update landing on the frontend: same
1515        // document, but with a FreeText annotation now present on page 1's /Annots. This is the
1516        // `base` prepare_pdf_save composes on top of; `sha`/`island.locator` are still captured
1517        // against the original, unannotated `source` bytes, exactly as the real edit session does.
1518        let mut annotated = doc.clone();
1519        let page_id = *annotated.get_pages().get(&1).expect("page 1 exists");
1520        let annot_id = annotated.add_object(dictionary! {
1521            "Type" => "Annot",
1522            "Subtype" => "FreeText",
1523            "Rect" => LObject::Array(vec![
1524                LObject::Real(10.0),
1525                LObject::Real(10.0),
1526                LObject::Real(100.0),
1527                LObject::Real(30.0),
1528            ]),
1529            "Contents" => LObject::String(b"note".to_vec(), lopdf::StringFormat::Literal),
1530        });
1531        if let Ok(LObject::Dictionary(page_dict)) = annotated.get_object_mut(page_id) {
1532            page_dict.set("Annots", LObject::Array(vec![LObject::Reference(annot_id)]));
1533        }
1534        let base = doc_bytes(&annotated);
1535
1536        let command = replace_cmd(
1537            "edit-1",
1538            &island,
1539            "Howdy",
1540            island.appearance.clone(),
1541            PdfRect {
1542                x: 72.0,
1543                y: 695.0,
1544                width: 400.0,
1545                height: 20.0,
1546            },
1547        );
1548
1549        let candidate = compose_candidate_on_base(&sha, &base, &[command], &FailingFontResolver)
1550            .expect("compose on annotated base");
1551
1552        let (out, graph) = reinspect(&candidate, 1);
1553        let out_page_id = *out.get_pages().get(&1).expect("page 1 exists in candidate");
1554        let out_page_dict = out
1555            .get_object(out_page_id)
1556            .expect("page object")
1557            .as_dict()
1558            .expect("page dict");
1559        assert!(
1560            out_page_dict.has(b"Annots"),
1561            "FreeText annotation must survive text composition"
1562        );
1563
1564        assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
1565        assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
1566    }
1567
1568    #[test]
1569    fn compose_candidate_on_base_with_no_commands_returns_base_verbatim() {
1570        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1571        let base = doc_bytes(&doc);
1572        let sha = document_sha256(&base);
1573        let candidate = compose_candidate_on_base(&sha, &base, &[], &FailingFontResolver)
1574            .expect("empty commands compose trivially");
1575        assert_eq!(candidate.bytes, base);
1576        assert!(candidate.affected_pages.is_empty());
1577    }
1578
1579    // -----------------------------------------------------------------------------------
1580    // Stale source / digest mismatch
1581    // -----------------------------------------------------------------------------------
1582
1583    #[test]
1584    fn stale_document_sha256_is_rejected() {
1585        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1586        let source = doc_bytes(&doc);
1587        let sha = document_sha256(&source);
1588        let mut island = locate_island(&doc, &sha, 1, "Hello");
1589        island.locator.document_sha256 =
1590            "0000000000000000000000000000000000000000000000000000000000000000".to_string();
1591
1592        let command = replace_cmd(
1593            "edit-stale",
1594            &island,
1595            "Howdy",
1596            island.appearance.clone(),
1597            PdfRect {
1598                x: 72.0,
1599                y: 695.0,
1600                width: 400.0,
1601                height: 20.0,
1602            },
1603        );
1604
1605        let err = compose_candidate(&source, &[command], &FailingFontResolver)
1606            .expect_err("stale source must reject");
1607        assert!(matches!(err, PdfEditError::StaleSource));
1608    }
1609
1610    #[test]
1611    fn tampered_island_digest_with_valid_document_sha_is_source_digest_mismatch() {
1612        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1613        let source = doc_bytes(&doc);
1614        let sha = document_sha256(&source);
1615        let mut island = locate_island(&doc, &sha, 1, "Hello");
1616
1617        // `document_sha256` still matches (nothing about the document actually changed), but the
1618        // specific island_digest given doesn't correspond to any island the fresh walk produces —
1619        // models "same visible text but changed operand digest" (§14.1).
1620        island.locator.island_digest = format!("{}-tampered", island.locator.island_digest);
1621
1622        let command = replace_cmd(
1623            "edit-tampered",
1624            &island,
1625            "Howdy",
1626            island.appearance.clone(),
1627            PdfRect {
1628                x: 72.0,
1629                y: 695.0,
1630                width: 400.0,
1631                height: 20.0,
1632            },
1633        );
1634
1635        let err = compose_candidate(&source, &[command], &FailingFontResolver)
1636            .expect_err("tampered digest must reject");
1637        assert!(matches!(err, PdfEditError::SourceDigestMismatch));
1638    }
1639
1640    // -----------------------------------------------------------------------------------
1641    // §9.2 Local text-object rewrite (split runs / changed appearance)
1642    // -----------------------------------------------------------------------------------
1643
1644    #[test]
1645    fn local_rewrite_replaces_adjacent_tj_operators_via_embedded_font_round_trip() {
1646        // Standard-14 metrics place the second run after the first; Td 0 0 would overlap it.
1647        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (CAR YOU) Tj 55 0 Td ( LIST.) Tj ET");
1648        let source = doc_bytes(&doc);
1649        let sha = document_sha256(&source);
1650        let island = locate_island(&doc, &sha, 1, "CAR YOU LIST.");
1651        // This fixture's grouping produces >1 span (two Tj operators), so exact-patch's
1652        // `one_operand` gate cannot apply regardless of appearance — always local rewrite.
1653        assert!(island.locator.spans.len() > 1);
1654
1655        let resolver = test_resolver();
1656        let command = replace_cmd(
1657            "edit-split",
1658            &island,
1659            "Hi",
1660            appearance("test-font", 20.0),
1661            PdfRect {
1662                x: 72.0,
1663                y: 690.0,
1664                width: 400.0,
1665                height: 40.0,
1666            },
1667        );
1668
1669        let candidate =
1670            compose_candidate(&source, &[command], &resolver).expect("local rewrite composes");
1671        let (_, graph) = reinspect(&candidate, 1);
1672
1673        // The old run's text is gone (bytes blanked in place) and the new run round-trips through
1674        // the freshly embedded Type0/CIDFontType2/ToUnicode subset exactly as "Hi".
1675        assert!(!graph.islands.iter().any(|isl| isl.text == "CAR YOU LIST."));
1676        assert!(graph
1677            .islands
1678            .iter()
1679            .any(|isl| isl.text == "Hi" && isl.editable));
1680    }
1681
1682    #[test]
1683    fn local_rewrite_used_when_appearance_changes_even_for_single_span() {
1684        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1685        let source = doc_bytes(&doc);
1686        let sha = document_sha256(&source);
1687        let island = locate_island(&doc, &sha, 1, "Hello");
1688        assert_eq!(island.locator.spans.len(), 1);
1689
1690        let resolver = test_resolver();
1691        // Different font_id/size than `before` — same_font_encoding is false, so this must
1692        // route to LocalTextObjectRewrite despite a single-span locator.
1693        let command = replace_cmd(
1694            "edit-appearance",
1695            &island,
1696            "Hi",
1697            appearance("test-font", 24.0),
1698            PdfRect {
1699                x: 72.0,
1700                y: 690.0,
1701                width: 400.0,
1702                height: 40.0,
1703            },
1704        );
1705
1706        let candidate =
1707            compose_candidate(&source, &[command], &resolver).expect("compose succeeds");
1708        let (_, graph) = reinspect(&candidate, 1);
1709        assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
1710    }
1711
1712    // -----------------------------------------------------------------------------------
1713    // §9.3 Add Text
1714    // -----------------------------------------------------------------------------------
1715
1716    #[test]
1717    fn add_text_appends_isolated_stream_without_touching_existing_content() {
1718        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1719        let source = doc_bytes(&doc);
1720
1721        let resolver = test_resolver();
1722        let command = PdfEditCommand::AddText {
1723            id: "add-1".to_string(),
1724            page: 1,
1725            anchor: PdfPoint { x: 100.0, y: 100.0 },
1726            text: "Hi".to_string(),
1727            appearance: appearance("test-font", 18.0),
1728        };
1729
1730        let candidate =
1731            compose_candidate(&source, &[command], &resolver).expect("add text composes");
1732        assert_eq!(candidate.affected_pages, vec![1]);
1733
1734        let (_, graph) = reinspect(&candidate, 1);
1735
1736        assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
1737        assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
1738    }
1739
1740    #[test]
1741    fn add_text_missing_glyph_rejects_before_any_mutation() {
1742        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1743        let source = doc_bytes(&doc);
1744        // `installable_font` only maps A, B, C, H, i.
1745        let resolver = test_resolver();
1746        let command = PdfEditCommand::AddText {
1747            id: "add-missing".to_string(),
1748            page: 1,
1749            anchor: PdfPoint { x: 100.0, y: 100.0 },
1750            text: "Zzz".to_string(),
1751            appearance: appearance("test-font", 18.0),
1752        };
1753
1754        let err = compose_candidate(&source, &[command], &resolver)
1755            .expect_err("missing glyph must reject");
1756        assert!(matches!(err, PdfEditError::MissingGlyph));
1757    }
1758
1759    #[test]
1760    fn add_text_unknown_font_id_rejects_with_unsupported_encoding() {
1761        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1762        let source = doc_bytes(&doc);
1763        let command = PdfEditCommand::AddText {
1764            id: "add-unknown-font".to_string(),
1765            page: 1,
1766            anchor: PdfPoint { x: 100.0, y: 100.0 },
1767            text: "Hi".to_string(),
1768            appearance: appearance("does-not-exist", 18.0),
1769        };
1770
1771        let err = compose_candidate(&source, &[command], &FailingFontResolver)
1772            .expect_err("unknown font id must reject");
1773        assert!(matches!(err, PdfEditError::UnsupportedEncoding));
1774    }
1775
1776    fn ocr_command(id: &str, line_id: &str, text: &str, rect: PdfRect) -> PdfEditCommand {
1777        PdfEditCommand::CorrectOcrLine {
1778            id: id.to_string(),
1779            page: 1,
1780            line_id: line_id.to_string(),
1781            rect,
1782            text: text.to_string(),
1783            appearance: appearance("test-font", 18.0),
1784            cover_color: PdfColor {
1785                r: 1.0,
1786                g: 1.0,
1787                b: 1.0,
1788                alpha: 1.0,
1789            },
1790            background_proof: PdfOcrBackgroundProof::NearUniform,
1791        }
1792    }
1793
1794    #[test]
1795    fn correct_ocr_text_appends_cover_and_type0_text_stream() {
1796        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1797        let source = doc_bytes(&doc);
1798        let resolver = test_resolver();
1799        let command = ocr_command(
1800            "ocr-1",
1801            "line-1",
1802            "Hi",
1803            PdfRect {
1804                x: 100.0,
1805                y: 100.0,
1806                width: 100.0,
1807                height: 30.0,
1808            },
1809        );
1810        let candidate = compose_candidate(&source, &[command], &resolver).expect("compose OCR");
1811        let (out, graph) = reinspect(&candidate, 1);
1812        let page_id = out.get_pages()[&1];
1813        let page = out
1814            .get_object(page_id)
1815            .expect("page")
1816            .as_dict()
1817            .expect("dict");
1818        let contents = page
1819            .get(b"Contents")
1820            .expect("contents")
1821            .as_array()
1822            .expect("array");
1823        let stream_id = contents
1824            .last()
1825            .expect("new stream")
1826            .as_reference()
1827            .expect("ref");
1828        let stream = out
1829            .get_object(stream_id)
1830            .expect("stream")
1831            .as_stream()
1832            .expect("stream");
1833        let content = Content::decode(&stream.decompressed_content().expect("decode stream"))
1834            .expect("content operations");
1835        let operators: Vec<&str> = content
1836            .operations
1837            .iter()
1838            .map(|operation| operation.operator.as_str())
1839            .collect();
1840        assert_eq!(&operators[..5], &["q", "rg", "re", "f", "Q"]);
1841        assert!(operators.windows(2).any(|window| window == ["BT", "Tf"]));
1842
1843        assert!(graph.islands.iter().any(|island| island.text == "Hi"));
1844    }
1845
1846    #[test]
1847    fn correct_ocr_text_duplicate_line_id_last_wins() {
1848        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1849        let source = doc_bytes(&doc);
1850        let resolver = test_resolver();
1851        let rect = PdfRect {
1852            x: 100.0,
1853            y: 100.0,
1854            width: 100.0,
1855            height: 30.0,
1856        };
1857        let commands = vec![
1858            ocr_command("ocr-old", "line-1", "Hi", rect),
1859            ocr_command("ocr-new", "line-1", "Hi", rect),
1860        ];
1861        let candidate = compose_candidate(&source, &commands, &resolver).expect("compose OCR");
1862        let (_, graph) = reinspect(&candidate, 1);
1863        assert_eq!(
1864            graph
1865                .islands
1866                .iter()
1867                .filter(|island| island.text == "Hi")
1868                .count(),
1869            1
1870        );
1871    }
1872
1873    #[test]
1874    fn correct_ocr_text_rejects_rotation_and_out_of_bounds_rect() {
1875        let mut rotated = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1876        let page_id = rotated.get_pages()[&1];
1877        rotated
1878            .get_object_mut(page_id)
1879            .expect("page")
1880            .as_dict_mut()
1881            .expect("dict")
1882            .set("Rotate", 90);
1883        let rotated_source = doc_bytes(&rotated);
1884        let resolver = test_resolver();
1885        let rect = PdfRect {
1886            x: 100.0,
1887            y: 100.0,
1888            width: 100.0,
1889            height: 30.0,
1890        };
1891        assert!(matches!(
1892            compose_candidate(
1893                &rotated_source,
1894                &[ocr_command("r", "line", "Hi", rect)],
1895                &resolver
1896            ),
1897            Err(PdfEditError::UnsupportedTransform)
1898        ));
1899
1900        let source = doc_bytes(&build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET"));
1901        let bad_rect = PdfRect {
1902            x: 600.0,
1903            y: 100.0,
1904            width: 100.0,
1905            height: 30.0,
1906        };
1907        assert!(matches!(
1908            compose_candidate(
1909                &source,
1910                &[ocr_command("b", "line", "Hi", bad_rect)],
1911                &resolver
1912            ),
1913            Err(PdfEditError::TextOverflow)
1914        ));
1915    }
1916
1917    #[test]
1918    fn empty_command_list_returns_source_bytes_unchanged() {
1919        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1920        let source = doc_bytes(&doc);
1921        let candidate = compose_candidate(&source, &[], &FailingFontResolver)
1922            .expect("empty batch always succeeds");
1923        assert_eq!(candidate.bytes, source);
1924        assert!(candidate.affected_pages.is_empty());
1925    }
1926}