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//! ```text
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.** A `ReplaceSourceText` command takes
33//!   [`WriteStrategy::ExactOperandPatch`] when (a) `before` and `after` appearance are identical
34//!   (same font/size/color) and (b) the page graph reports the island `editable`, i.e. its own font
35//!   can re-encode text (`inspect::ReverseTable`, one decision shared with the graph) and all its
36//!   glyphs share one font, size and spacing. The island may span several operands (kerned `TJ`
37//!   text): the whole replacement is written into the earliest span and the later spans are
38//!   blanked. Text the font cannot encode is refused with `PdfEditError::MissingGlyphs` naming the
39//!   characters; nothing is ever substituted. Every other digest-verified command falls through
40//!   to [`WriteStrategy::LocalTextObjectRewrite`] (a catalog font). `AddText` always uses
41//!   [`WriteStrategy::AppendTextStream`].
42//! - **Font reuse vs. embed.** [`PdfFontResolver`] (§7.2) resolves *catalog* fonts only — it has no
43//!   notion of "the font already used at this source location," and `pdf_inspect.rs`'s
44//!   content-stream font decoder (`TextStateFont`) is a different, page-local abstraction this
45//!   composer never receives. So "reuse source PDF font" (§7.3 rule 1) is only achievable for the
46//!   `ExactOperandPatch` path, where the original operand bytes/font resource are never touched at
47//!   all — the safest possible form of reuse. Every `LocalTextObjectRewrite`/`AppendTextStream`
48//!   resolves `after.appearance.font_id` through `fonts` and embeds a fresh Type0/CIDFontType2
49//!   subset (§7.4), even when a caller happens to pass the same font family as the original run.
50//! - **Local-rewrite insertion point.** §9.2 says "insert ... at deterministic source boundary"
51//!   without specifying which. The new `q/BT/.../ET/Q` block is never written into the middle of
52//!   the touched content stream: it goes in its own stream at the END of the page's `/Contents`.
53//!   Removing the old glyphs never renumbers an operator, so a second, independent command's
54//!   locator (captured against the same pristine source, per §9.4's "Applies staged source
55//!   commands to fresh source bytes") stays valid when a prior command in the same batch already
56//!   rewrote a different run in the same content stream. A removed glyph's bytes are replaced in
57//!   place by the `TJ` number that moves the pen exactly as the glyph did (a `Tj` becomes a `TJ`
58//!   at the same operator index), so the text that follows keeps its position; the `'` and `"`
59//!   operators carry a line move a `TJ` cannot, so glyphs shown by them are refused.
60//! - **Batch order.** All locators of a batch are resolved against the pristine source, so no
61//!   command may shift an address another command still needs. The `q`/`Q` isolation wrappers and
62//!   the new streams are therefore installed once, after the last command (installing a wrapper
63//!   per command shifted every original `contents_index`, which the island digest binds), and the
64//!   `ReplaceSourceText` commands of one page are applied back to front, so splitting a `TJ`
65//!   string item only ever moves items that a command already applied has finished with.
66//! - **Baseline anchor for a rewritten run.** §8 says "Replacement preserves original baseline
67//!   anchor." The new run is therefore placed from the island it replaces, never from
68//!   `availableBounds` (which `PdfSourceEdit`'s wire shape, §6, carries as the only rectangle, and
69//!   which is only the verified room the run must fit). The new stream runs in the default
70//!   graphics state, so its `Tm` is the whole of the first glyph's `text matrix x CTM`
71//!   (`IslandLayout::placement`; row-vector convention, ISO 32000-1 9.4.2, as `Matrix::then`),
72//!   with `Tf` kept at the original text-space size: the glyph origin, rotation, skew and scale
73//!   are those of the source whether the producer put them in `Tm`, `Td` or a `cm` outside the
74//!   text object. A read-back of the composed page refuses the batch when a replacement does not
75//!   land there (`verify_appended_runs`).
76//! - **Layout measurement.** `MeasuredRun`'s width for an embedded-font rewrite/add-text run is
77//!   measured exactly, by summing the resolved font's real per-glyph advances (via `ttf_parser`,
78//!   same source `subset_for_pdf` reads) scaled to `/1000` units. For the in-place exact-operand-patch
79//!   path, the source font's own widths (the same `/Widths` or `/W` data the page graph's glyph
80//!   boxes are built from) are summed with the walker's pen-advance rules (`Tc`, `Tw`, `Tz`,
81//!   text matrix and CTM scale), so a replacement no wider than the original never overflows its
82//!   own box (within `OVERFLOW_EPSILON_PT`). Earlier versions estimated `chars * size_pt * 0.5`,
83//!   which refused same-width swaps whenever the real text was narrower than half an em per
84//!   character.
85//! - **`compose_pdf_preview`'s "affected pages as metadata."** Candidate bytes return as a JSON
86//!   byte array because WKWebView fails while decoding Tauri's raw-response stream. Frontend derives
87//!   affected pages from request commands, so no second metadata channel is required.
88use lopdf::content::{Content, Operation};
89use lopdf::{dictionary, Dictionary, Document, Object, ObjectId, Stream, StringFormat};
90use serde::Deserialize;
91
92use crate::font::{build_to_unicode_cmap, subset_for_pdf};
93use crate::inspect::{
94    self, content_stream_ids, encode_middle_for_font, is_line_moving_show, walk_page_graph,
95    ContentBudget, ReencodeFailure,
96};
97use crate::model::{
98    document_sha256, effective_pdf_edit_commands, ComposedCandidate, GapNumber, GlyphSourceAddress,
99    Matrix, PdfColor, PdfEditCommand, PdfEditError, PdfFontResolver, PdfOcrBackgroundProof,
100    PdfPageGraph, PdfPoint, PdfRect, PdfSourceLocator, PdfSourceSpan, PdfTextAppearance,
101    PdfTextEditState, PdfTextIsland, ResolvedPdfFont, RunItem,
102};
103
104/// Slack, in device-space points, when a replacement's measured width is compared with the
105/// available box. A replacement whose advance is no wider than the original's lands within float
106/// summation error of the island's own box (the app also round-trips the box through JSON), so
107/// an exact `<=` would refuse a same-width swap. 0.05 pt is about 1/1400 inch: invisible, and well
108/// below the width of any glyph.
109const OVERFLOW_EPSILON_PT: f64 = 0.05;
110
111// -------------------------------------------------------------------------------------------
112// §9 — Strategy selection
113// -------------------------------------------------------------------------------------------
114
115/// §9's `select_strategy` outcome, concretized per this module's judgment calls (see module doc).
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum WriteStrategy {
118    ExactOperandPatch,
119    LocalTextObjectRewrite,
120}
121
122/// `in_place_editable` is the page graph's own verdict for the island (`PdfTextIsland::editable`):
123/// the island's font can re-encode text, and its glyphs share one font, size and spacing. That is
124/// the single re-encodability decision; this function does not re-derive it.
125fn select_replace_strategy(
126    before: &PdfTextEditState,
127    after: &PdfTextEditState,
128    in_place_editable: bool,
129) -> WriteStrategy {
130    let same_font_encoding = before.appearance == after.appearance;
131
132    if same_font_encoding && in_place_editable {
133        WriteStrategy::ExactOperandPatch
134    } else {
135        WriteStrategy::LocalTextObjectRewrite
136    }
137}
138
139// -------------------------------------------------------------------------------------------
140// Frozen seam: compose_candidate
141// -------------------------------------------------------------------------------------------
142
143/// The frozen cross-lane seam (`docs/PDF-EDITOR-DISPATCH.md`). Applies `commands` to `source`,
144/// validating every locator/digest against fresh bytes first (§9's "reject if stale"), then
145/// dispatching each command to its selected sparse-write strategy (§9.1-9.3).
146pub fn compose_candidate(
147    source: &[u8],
148    commands: &[PdfEditCommand],
149    fonts: &dyn PdfFontResolver,
150) -> Result<ComposedCandidate, PdfEditError> {
151    let current_sha = document_sha256(source);
152    compose_candidate_on_base(&current_sha, source, commands, fonts)
153}
154
155/// Same as [`compose_candidate`] but loads the lopdf document from `base` bytes while treating
156/// `source_sha` as the "current" source digest for every locator/staleness check — used by
157/// `prepare_pdf_save` (lane C2) to compose sparse-write commands on top of pdf.js's
158/// `saveDocument()` incremental-update annotation bytes (verbatim original source bytes plus
159/// appended annotation objects, per pdf.js's own incremental-save format) while still validating
160/// locators against the *original* source's digest rather than the annotated bytes' digest.
161pub fn compose_candidate_on_base(
162    source_sha: &str,
163    base: &[u8],
164    commands: &[PdfEditCommand],
165    fonts: &dyn PdfFontResolver,
166) -> Result<ComposedCandidate, PdfEditError> {
167    if commands.is_empty() {
168        return Ok(ComposedCandidate {
169            bytes: base.to_vec(),
170            affected_pages: Vec::new(),
171        });
172    }
173
174    let mut doc = inspect::load_pdf(base).map_err(|_| PdfEditError::MalformedContent)?;
175    if doc.is_encrypted() {
176        return Err(PdfEditError::EncryptedDocument);
177    }
178
179    // What every page can name before any edit; checked again below.
180    let resources_before = inspect::resource_fingerprint(&doc)?;
181    let mut affected_pages: Vec<u32> = Vec::new();
182
183    // Locators are all captured against the pristine source, so the commands run in an order that
184    // never lets one shift an address another still needs (see the module docs), and every
185    // isolation wrapper and new stream waits until the last command has been applied.
186    let effective = effective_pdf_edit_commands(commands);
187    let mut isolated: Vec<(usize, Isolated)> = Vec::new();
188    let mut appended: Vec<(usize, AppendedRun)> = Vec::new();
189    for slot in back_to_front_order(&effective) {
190        let command = effective[slot];
191        let (page, block) = match command {
192            PdfEditCommand::ReplaceSourceText {
193                locator,
194                before,
195                after,
196                available_bounds,
197                ..
198            } => {
199                if locator.document_sha256 != source_sha {
200                    return Err(PdfEditError::StaleSource);
201                }
202                let (page, block, run) = apply_replace_source_text(
203                    &mut doc,
204                    source_sha,
205                    locator,
206                    before,
207                    after,
208                    *available_bounds,
209                    fonts,
210                )?;
211                appended.extend(run.map(|run| (slot, run)));
212                (page, block)
213            }
214            PdfEditCommand::AddText {
215                page,
216                anchor,
217                text,
218                appearance,
219                ..
220            } => apply_add_text(&mut doc, *page, *anchor, text, appearance, fonts)?,
221            PdfEditCommand::CorrectOcrLine {
222                page,
223                rect,
224                text,
225                appearance,
226                cover_color,
227                background_proof,
228                ..
229            } => apply_correct_ocr_text(
230                &mut doc,
231                *page,
232                *rect,
233                text,
234                appearance,
235                *cover_color,
236                *background_proof,
237                fonts,
238            )?,
239        };
240        if let Some(block) = block {
241            isolated.push((slot, block));
242        }
243        if !affected_pages.contains(&page) {
244            affected_pages.push(page);
245        }
246    }
247    let streams = install_isolated_content(&mut doc, isolated)?;
248    // The structural checks below, and the save-side recheck, only ask whether the text reads back.
249    // A replacement drawn somewhere else, at another size, reads back just as well.
250    for (slot, run) in &mut appended {
251        run.stream = streams.get(slot).copied();
252    }
253    let appended: Vec<AppendedRun> = appended.into_iter().map(|(_, run)| run).collect();
254    verify_appended_runs(&doc, &appended)?;
255
256    // Never trust the composer blindly: an edit may add a resource, but a page must still resolve
257    // every font, XObject and graphics state it resolved in the source. (Adding a font to a page
258    // whose `/Resources` lived on an ancestor `/Pages` node once hid all of them.)
259    if !inspect::resources_preserved(&resources_before, &inspect::resource_fingerprint(&doc)?) {
260        return Err(PdfEditError::VerificationFailed);
261    }
262
263    doc.compress();
264    let mut bytes = Vec::new();
265    doc.save_to(&mut bytes)
266        .map_err(|_| PdfEditError::MalformedContent)?;
267
268    affected_pages.sort_unstable();
269    Ok(ComposedCandidate {
270        bytes,
271        affected_pages,
272    })
273}
274
275// -------------------------------------------------------------------------------------------
276// §9.1/§9.2 — ReplaceSourceText
277// -------------------------------------------------------------------------------------------
278
279/// A block of operations to run on a page in its own content stream, outside the page's original
280/// graphics state: `(page object, operations)`. Collected while the batch runs and installed once
281/// at the end, in command order (see [`install_isolated_content`]).
282type Isolated = (ObjectId, Vec<Operation>);
283
284/// The first source address a command touches: `(contents_index, operator_index, operand_index,
285/// glyph_start)` of its earliest span.
286fn command_address(command: &PdfEditCommand) -> (u32, u32, u32, u32) {
287    match command {
288        PdfEditCommand::ReplaceSourceText { locator, .. } => locator
289            .spans
290            .iter()
291            .map(|span| {
292                (
293                    span.contents_index,
294                    span.operator_index,
295                    span.operand_index,
296                    span.glyph_start,
297                )
298            })
299            .min()
300            .unwrap_or_default(),
301        _ => (0, 0, 0, 0),
302    }
303}
304
305/// The order in which a batch is applied: the original order, except that the `ReplaceSourceText`
306/// commands aimed at one page take that page's slots back to front by source address. A command
307/// only ever changes bytes at or after its own address, so applying the later ones first leaves
308/// every earlier address exactly where the (pristine-source) locators say it is. Ties keep the
309/// caller's order, so a second command on the same island still meets a changed island and is
310/// refused.
311fn back_to_front_order(commands: &[&PdfEditCommand]) -> Vec<usize> {
312    let mut by_page: std::collections::BTreeMap<u32, Vec<usize>> =
313        std::collections::BTreeMap::new();
314    for (index, command) in commands.iter().enumerate() {
315        if let PdfEditCommand::ReplaceSourceText { locator, .. } = command {
316            by_page.entry(locator.page).or_default().push(index);
317        }
318    }
319    for list in by_page.values_mut() {
320        list.sort_by(|a, b| {
321            command_address(commands[*b])
322                .cmp(&command_address(commands[*a]))
323                .then(a.cmp(b))
324        });
325    }
326    let mut taken: std::collections::BTreeMap<u32, usize> = std::collections::BTreeMap::new();
327    commands
328        .iter()
329        .enumerate()
330        .map(|(index, command)| match command {
331            PdfEditCommand::ReplaceSourceText { locator, .. } => {
332                let next = taken.entry(locator.page).or_insert(0);
333                let chosen = by_page[&locator.page][*next];
334                *next += 1;
335                chosen
336            }
337            _ => index,
338        })
339        .collect()
340}
341
342#[allow(clippy::too_many_arguments)]
343fn apply_replace_source_text(
344    doc: &mut Document,
345    current_sha: &str,
346    locator: &PdfSourceLocator,
347    before: &PdfTextEditState,
348    after: &PdfTextEditState,
349    available_bounds: PdfRect,
350    fonts: &dyn PdfFontResolver,
351) -> Result<(u32, Option<Isolated>, Option<AppendedRun>), PdfEditError> {
352    if locator.spans.is_empty() {
353        return Err(PdfEditError::UnsupportedEncoding);
354    }
355
356    // Re-inspect the page fresh (never trust the caller's cached graph) and require an island
357    // whose digest still matches exactly — this is what actually catches "same visible text but
358    // changed operand digest" once `document_sha256` alone has already passed (§5.1).
359    let graph = walk_page_graph(doc, current_sha, locator.page)?;
360    let island = graph
361        .islands
362        .iter()
363        .find(|isl| isl.locator.island_digest == locator.island_digest)
364        .ok_or(PdfEditError::SourceDigestMismatch)?;
365    // A rejected island has no spans and nothing to remove. Whether the island is `editable` in
366    // place is decided per strategy below: a changed appearance redraws in a catalog font and
367    // needs no re-encoding through the source font.
368    if island.locator.spans.is_empty() {
369        return Err(PdfEditError::UnsupportedEncoding);
370    }
371    // The caller's locator must name exactly the spans the page has now: same count, same order,
372    // each equal. Membership alone is not enough - a locator with a span removed still passes it
373    // and the edit would delete the named operands, leave the rest of the island and append the
374    // replacement. The spans every mutation below works from are the freshly inspected ones, never
375    // the caller's copy.
376    if locator.spans.len() != island.locator.spans.len()
377        || !locator
378            .spans
379            .iter()
380            .zip(&island.locator.spans)
381            .all(|(given, current)| spans_equal(given, current))
382    {
383        return Err(PdfEditError::SourceDigestMismatch);
384    }
385    let spans: &[PdfSourceSpan] = &island.locator.spans;
386
387    // This composer's sparse strategies only ever touch one content stream per command — every
388    // span must agree on which one.
389    let stream_ref = spans[0].stream_ref;
390    let contents_index = spans[0].contents_index;
391    if !spans
392        .iter()
393        .all(|s| s.stream_ref == stream_ref && s.contents_index == contents_index)
394    {
395        return Err(PdfEditError::UnsupportedEncoding);
396    }
397    let stream_id: ObjectId = (stream_ref.0, stream_ref.1);
398    let page_id = pages_lookup(doc, locator.page)?;
399
400    // An island the graph calls non-editable is refused here, by the composer, before any
401    // strategy is chosen. Unchanged appearance is a request to keep the source font; an island
402    // that cannot be written through it (a positioned run, a mixed state, a font that cannot be
403    // re-encoded) must not fall through to a catalog-font redraw nobody asked for. A *changed*
404    // appearance is the explicit request to redraw in a catalog font (`substitution_editable`).
405    if before.appearance == after.appearance && !island.editable {
406        return Err(PdfEditError::UnsupportedEncoding);
407    }
408
409    // Both strategies rewrite the stream object in place; a page that shares it with another page
410    // gets its own copy first, so the other pages stay as they were.
411    isolate_shared_content_stream(doc, page_id, stream_id)?;
412
413    let (block, run) = match select_replace_strategy(before, after, island.editable) {
414        WriteStrategy::ExactOperandPatch => {
415            apply_exact_operand_patch(
416                doc,
417                page_id,
418                locator.page,
419                stream_id,
420                island,
421                after,
422                available_bounds,
423            )?;
424            (None, None)
425        }
426        WriteStrategy::LocalTextObjectRewrite => {
427            let (block, run) = apply_local_text_object_rewrite(
428                doc,
429                locator.page,
430                page_id,
431                stream_id,
432                island,
433                after,
434                available_bounds,
435                fonts,
436            )?;
437            (Some(block), run)
438        }
439    };
440
441    Ok((locator.page, block, run))
442}
443
444/// Most content-stream references the pages that share one stream may carry together. Each is
445/// copied into the rewritten page arrays, so this is what an isolation may allocate for them.
446const MAX_ISOLATED_REFERENCES: usize = 1 << 20;
447
448/// Gives every *other* page that lists `stream_id` in its `/Contents` its own byte-identical copy
449/// of the stream, so rewriting `stream_id` in place changes only `page_id`. The edited page keeps
450/// the original object id on purpose: every locator in a batch is addressed by it. A later command
451/// aimed at one of the other pages therefore sees a different stream id and is refused as stale
452/// instead of being applied to the wrong page.
453///
454/// One copy is made, however many pages share the stream: they keep sharing it with each other
455/// (as they did with the original), so the allocation is one stream, not one per sharing page. The
456/// page arrays rewritten for them are bounded by [`MAX_ISOLATED_REFERENCES`], checked before they
457/// are collected.
458fn isolate_shared_content_stream(
459    doc: &mut Document,
460    page_id: ObjectId,
461    stream_id: ObjectId,
462) -> Result<(), PdfEditError> {
463    let mut sharers: Vec<(ObjectId, Vec<ObjectId>)> = Vec::new();
464    let mut references = 0usize;
465    for other in doc.get_pages().into_values() {
466        if other == page_id {
467            continue;
468        }
469        let Ok(Object::Dictionary(dict)) = doc.get_object(other) else {
470            continue;
471        };
472        let Ok(contents) = dict.get(b"Contents") else {
473            continue;
474        };
475        let ids = content_stream_ids(doc, contents)?;
476        if ids.contains(&stream_id) {
477            references = references
478                .checked_add(ids.len())
479                .filter(|total| *total <= MAX_ISOLATED_REFERENCES)
480                .ok_or(PdfEditError::MalformedContent)?;
481            sharers.push((other, ids));
482        }
483    }
484    if sharers.is_empty() {
485        return Ok(());
486    }
487    let original = doc
488        .get_object(stream_id)
489        .map_err(|_| PdfEditError::MalformedContent)?
490        .clone();
491    let copy_id = doc.add_object(original);
492    for (other, ids) in sharers {
493        let array = Object::Array(
494            ids.into_iter()
495                .map(|id| Object::Reference(if id == stream_id { copy_id } else { id }))
496                .collect(),
497        );
498        if let Ok(Object::Dictionary(dict)) = doc.get_object_mut(other) {
499            dict.set("Contents", array);
500        }
501    }
502    Ok(())
503}
504
505/// Whether two spans address the same operand range with the same content digest. `expected_text`
506/// is not compared: the digest binds it.
507fn spans_equal(a: &PdfSourceSpan, b: &PdfSourceSpan) -> bool {
508    a.stream_ref == b.stream_ref
509        && a.contents_index == b.contents_index
510        && a.operator_index == b.operator_index
511        && a.operand_index == b.operand_index
512        && a.glyph_start == b.glyph_start
513        && a.glyph_end == b.glyph_end
514        && a.expected_digest == b.expected_digest
515}
516
517fn pages_lookup(doc: &Document, page: u32) -> Result<ObjectId, PdfEditError> {
518    doc.get_pages()
519        .get(&page)
520        .copied()
521        .ok_or(PdfEditError::MalformedContent)
522}
523
524// -------------------------------------------------------------------------------------------
525// §9.1 — Exact operand patch
526// -------------------------------------------------------------------------------------------
527
528/// Which run items an edit replaces, and the new text for them.
529struct RunEdit {
530    /// `run[first..end]` is replaced; it always contains at least one real glyph.
531    first: usize,
532    end: usize,
533    middle: String,
534}
535
536/// Diffs `old` (the island text) against `new` and widens the changed middle to whole run items,
537/// so everything outside it - words, kerns and gaps - is left byte for byte.
538fn plan_run_edit(run: &[RunItem], old: &[char], new: &[char]) -> Result<RunEdit, PdfEditError> {
539    let mut prefix = 0;
540    while prefix < old.len() && prefix < new.len() && old[prefix] == new[prefix] {
541        prefix += 1;
542    }
543    let mut suffix = 0;
544    while suffix < old.len() - prefix
545        && suffix < new.len() - prefix
546        && old[old.len() - 1 - suffix] == new[new.len() - 1 - suffix]
547    {
548        suffix += 1;
549    }
550    let old_end = old.len() - suffix;
551
552    let mut bounds: Vec<(usize, usize)> = Vec::with_capacity(run.len());
553    let mut at = 0;
554    for item in run {
555        let n = item.text.chars().count();
556        bounds.push((at, at + n));
557        at += n;
558    }
559    if at != old.len() {
560        return Err(PdfEditError::UnsupportedEncoding);
561    }
562    let is_glyph = |i: usize| run[i].glyph.is_some();
563
564    let (mut first, mut end);
565    if prefix < old_end {
566        first = bounds
567            .iter()
568            .position(|&(s, e)| s <= prefix && prefix < e)
569            .ok_or(PdfEditError::UnsupportedEncoding)?;
570        end = bounds
571            .iter()
572            .rposition(|&(s, _)| s < old_end)
573            .ok_or(PdfEditError::UnsupportedEncoding)?
574            + 1;
575    } else {
576        // Pure insertion: anchor on the glyph before the cursor, else the glyph after it.
577        let before = (prefix > 0)
578            .then(|| bounds.iter().position(|&(s, e)| s < prefix && prefix <= e))
579            .flatten()
580            .filter(|&i| is_glyph(i));
581        let after = bounds
582            .iter()
583            .position(|&(s, e)| s <= prefix && prefix < e)
584            .filter(|&i| is_glyph(i));
585        let anchor = before.or(after).ok_or(PdfEditError::UnsupportedEncoding)?;
586        first = anchor;
587        end = anchor + 1;
588    }
589    if !(first..end).any(is_glyph) {
590        if first > 0 && is_glyph(first - 1) {
591            first -= 1;
592        } else if end < run.len() && is_glyph(end) {
593            end += 1;
594        } else {
595            return Err(PdfEditError::UnsupportedEncoding);
596        }
597    }
598    let region_start = bounds[first].0;
599    let region_end = bounds[end - 1].1;
600    let middle: String = new[region_start..new.len() - (old.len() - region_end)]
601        .iter()
602        .collect();
603    Ok(RunEdit { first, end, middle })
604}
605
606fn gap_sum(item: &RunItem) -> Option<f64> {
607    item.gap_numbers
608        .as_ref()
609        .map(|numbers| numbers.iter().map(|n| n.value).sum())
610}
611
612fn number_object(value: f64) -> Object {
613    if value.fract() == 0.0 && value.abs() < 1e9 {
614        Object::Integer(value as i64)
615    } else {
616        Object::Real(value as f32)
617    }
618}
619
620/// Sets the `TJ` number at `number` to 0, keeping the array length (and every other item's index).
621fn zero_tj_number(content: &mut Content, number: &GapNumber) -> Result<(), PdfEditError> {
622    let op = content
623        .operations
624        .get_mut(number.operator_index as usize)
625        .ok_or(PdfEditError::SourceDigestMismatch)?;
626    if op.operator != "TJ" {
627        return Err(PdfEditError::SourceDigestMismatch);
628    }
629    let Some(Object::Array(items)) = op.operands.get_mut(0) else {
630        return Err(PdfEditError::SourceDigestMismatch);
631    };
632    let item = items
633        .get_mut(number.item_index as usize)
634        .ok_or(PdfEditError::SourceDigestMismatch)?;
635    *item = Object::Integer(0);
636    Ok(())
637}
638
639/// Writes `pieces` (words, in order) into the string holding byte range `[start, end)` of the
640/// slot glyph, with `gaps` (TJ numbers) between consecutive pieces. Bytes of that string before
641/// and after the range stay as they are. A `Tj` becomes a `TJ` when gaps are needed.
642fn write_pieces(
643    content: &mut Content,
644    slot: &GlyphSourceAddress,
645    range: (u32, u32),
646    pieces: &[Vec<u8>],
647    gaps: &[f64],
648) -> Result<(), PdfEditError> {
649    if gaps.is_empty() {
650        return splice_operand_bytes(
651            content,
652            slot.operator_index,
653            slot.operand_index,
654            range.0,
655            range.1,
656            &pieces[0],
657        );
658    }
659    // Gaps need a TJ array. `'` and `"` carry a line move with them, so they cannot be split.
660    let cannot_split = || PdfEditError::MissingGlyphs(vec![' ']);
661    let op = content
662        .operations
663        .get_mut(slot.operator_index as usize)
664        .ok_or(PdfEditError::SourceDigestMismatch)?;
665    let (bytes, format) = match (op.operator.as_str(), op.operands.first()) {
666        ("Tj", Some(Object::String(bytes, format))) => (bytes.clone(), *format),
667        ("TJ", Some(Object::Array(items))) => match items.get(slot.operand_index as usize) {
668            Some(Object::String(bytes, format)) => (bytes.clone(), *format),
669            _ => return Err(PdfEditError::SourceDigestMismatch),
670        },
671        _ => return Err(cannot_split()),
672    };
673    let (start, end) = (range.0 as usize, range.1 as usize);
674    if start > end || end > bytes.len() {
675        return Err(PdfEditError::SourceDigestMismatch);
676    }
677    let mut new_items: Vec<Object> = Vec::with_capacity(pieces.len() * 2);
678    for (k, piece) in pieces.iter().enumerate() {
679        let mut text: Vec<u8> = Vec::new();
680        if k == 0 {
681            text.extend_from_slice(&bytes[..start]);
682        }
683        text.extend_from_slice(piece);
684        if k + 1 == pieces.len() {
685            text.extend_from_slice(&bytes[end..]);
686        }
687        if k > 0 {
688            new_items.push(number_object(gaps[k - 1]));
689        }
690        new_items.push(Object::String(text, format));
691    }
692    if op.operator == "Tj" {
693        op.operator = "TJ".to_string();
694        op.operands = vec![Object::Array(new_items)];
695    } else if let Some(Object::Array(items)) = op.operands.get_mut(0) {
696        let at = slot.operand_index as usize;
697        items.splice(at..=at, new_items);
698    }
699    Ok(())
700}
701
702fn apply_exact_operand_patch(
703    doc: &mut Document,
704    page_id: ObjectId,
705    page: u32,
706    stream_id: ObjectId,
707    island: &PdfTextIsland,
708    after: &PdfTextEditState,
709    available_bounds: PdfRect,
710) -> Result<(), PdfEditError> {
711    let layout = island.layout.ok_or(PdfEditError::UnsupportedEncoding)?;
712    let old: Vec<char> = island.text.chars().collect();
713    let new: Vec<char> = after.text.chars().collect();
714    if old == new {
715        return Ok(());
716    }
717    let plan = plan_run_edit(&island.run, &old, &new)?;
718    let region = &island.run[plan.first..plan.end];
719
720    // Re-encode through the very table that made the island `editable` (`inspect::ReverseTable`).
721    // Nothing is ever substituted: a character the font has no code for is a typed refusal that
722    // names it, before any mutation. Only the changed middle is encoded; every other word, kern
723    // and gap of the run is left exactly as it is.
724    let middle = encode_middle_for_font(doc, page_id, &island.appearance.font_id, &plan.middle)
725        .map_err(|failure| match failure {
726            ReencodeFailure::Blocked => PdfEditError::UnsupportedEncoding,
727            ReencodeFailure::MissingGlyphs(chars) => PdfEditError::MissingGlyphs(chars),
728        })?;
729
730    // A word gap in the new middle with no space glyph in the font is regenerated from the gap it
731    // replaces (same order), else from the island's first gap: the same TJ numbers, so the
732    // original gap width. No reference gap means no faithful space: refuse, never drop it.
733    let size = layout.font_size;
734    let th = layout.horizontal_scale;
735    let gap_text = |value: f64| -(value / 1000.0) * size * th;
736    let mut gap_values: Vec<f64> = Vec::new();
737    if !middle.space_glyph {
738        // Gaps are the only space this font can show, and the page graph reads exactly one gap
739        // between neighbouring glyphs. Two spaces in a row (an empty word between them), or a
740        // space at either edge of the replaced region, would come back as a different text.
741        if middle.pieces.len() > 1 && middle.pieces.iter().any(|piece| piece.char_count() == 0) {
742            return Err(PdfEditError::MissingGlyphs(vec![' ']));
743        }
744        let replaced: Vec<f64> = region.iter().filter_map(gap_sum).collect();
745        let reference = island.run.iter().find_map(gap_sum);
746        for k in 0..middle.pieces.len().saturating_sub(1) {
747            let value = replaced
748                .get(k)
749                .copied()
750                .or(reference)
751                .ok_or_else(|| PdfEditError::MissingGlyphs(vec![' ']))?;
752            gap_values.push(value);
753        }
754    }
755
756    // Same number of glyphs in, same number out, no gap involved: write each new code over the
757    // old glyph in place. Code widths are equal, so no byte offset or array index moves, and the
758    // kerns between glyphs stay where they were (a flattened word would end `sum(kerns)` too far
759    // to the right and eat into the gap after it).
760    let region_glyphs: Vec<&RunItem> = region.iter().filter(|i| i.glyph.is_some()).collect();
761    let code_len = |i: &&RunItem| {
762        i.glyph
763            .as_ref()
764            .map_or(0, |g| (g.glyph_end - g.glyph_start) as usize)
765    };
766    let positional = !region_glyphs.is_empty()
767        && region_glyphs.len() == region.len()
768        && region_glyphs.iter().all(|i| i.text.chars().count() == 1)
769        && middle.pieces.len() == 1
770        && gap_values.is_empty()
771        && middle.pieces[0].char_count() == region_glyphs.len()
772        && region_glyphs
773            .iter()
774            .all(|i| code_len(i) * region_glyphs.len() == middle.pieces[0].bytes.len());
775
776    // Fit: the island's length with the region's old extent swapped for the new run's pen length.
777    let scale = layout.axis.0.hypot(layout.axis.1);
778    if !(scale.is_finite() && scale > 0.0) {
779        return Err(PdfEditError::UnsupportedEncoding);
780    }
781    let advance = |piece: &inspect::EncodedRun| {
782        piece.advance(size, layout.char_spacing, layout.word_spacing, th)
783    };
784    let mut new_region_text: f64 = middle.pieces.iter().map(advance).sum();
785    // Between two words the pen also moves by the last glyph's own `Tc` (and `Tw`, when that glyph
786    // is single-byte code 32 - a font without a space glyph can still map code 32 to a letter).
787    for (piece, value) in middle.pieces.iter().zip(&gap_values) {
788        new_region_text += layout.char_spacing * th + gap_text(*value);
789        if piece.ends_with_word_space() {
790            new_region_text += layout.word_spacing * th;
791        }
792    }
793    let island_len = island.run.last().map_or(0.0, |i| i.end) - island.run[0].start;
794    let old_region = region[region.len() - 1].end - region[0].start;
795    let region_reaches_island_end = plan.end == island.run.len();
796    let new_len_text = if positional {
797        // Pen end is unchanged by the in-place write except for the glyph widths themselves and
798        // for `Tw`: the single-byte code 32 advances the pen by `Tw` more than any other code, so
799        // writing it over another code (or another over it) moves everything after it. This is
800        // decided by the *code*, never by the character: code 32 mapped to a letter still takes
801        // `Tw`, and a space reached through another code does not. The island's very last glyph
802        // has no trailing advance inside the island, so it contributes no `Tw`.
803        let old_widths: f64 = region.iter().map(|i| i.end - i.start).sum();
804        let new_widths = middle.pieces[0].advance(size, 0.0, 0.0, th);
805        let mut tw_delta = 0.0;
806        for (k, item) in region_glyphs.iter().enumerate() {
807            if region_reaches_island_end && k + 1 == region_glyphs.len() {
808                continue;
809            }
810            let was_space = item.word_space;
811            let is_space = middle.pieces[0].word_space_at(k);
812            tw_delta += layout.word_spacing * th * (f64::from(is_space) - f64::from(was_space));
813        }
814        island_len / scale + new_widths - old_widths / scale + tw_delta
815    } else {
816        // The region's last glyph is followed by its own `Tc`/`Tw`: single-byte code 32 there
817        // carries `Tw` across to whatever follows, and the new last code may or may not be it.
818        let trailing_tw = if region_reaches_island_end {
819            0.0
820        } else {
821            let was_space = region
822                .last()
823                .is_some_and(|i| i.glyph.is_some() && i.word_space);
824            let is_space = middle
825                .pieces
826                .last()
827                .is_some_and(|piece| piece.ends_with_word_space());
828            layout.word_spacing * th * (f64::from(is_space) - f64::from(was_space))
829        };
830        (island_len - old_region) / scale + new_region_text + trailing_tw
831    };
832    validate_layout_advance(new_len_text, layout.axis, available_bounds)?;
833
834    let mut content = decode_stream_content(doc, stream_id, &mut ContentBudget::new())?;
835
836    if positional {
837        let bytes = &middle.pieces[0].bytes;
838        let width = bytes.len() / region_glyphs.len();
839        for (item, chunk) in region_glyphs.iter().zip(bytes.chunks(width)) {
840            let g = item.glyph.as_ref().expect("filtered to glyphs");
841            splice_operand_bytes(
842                &mut content,
843                g.operator_index,
844                g.operand_index,
845                g.glyph_start,
846                g.glyph_end,
847                chunk,
848            )?;
849        }
850        write_stream_content(doc, stream_id, &content)?;
851        return verify_written(doc, page, island, &after.text, available_bounds);
852    }
853
854    // 1. Gap numbers inside the region are replaced by the regenerated gaps: zero them in place
855    //    (array length and every other index unchanged).
856    for item in region {
857        for number in item.gap_numbers.iter().flatten() {
858            zero_tj_number(&mut content, number)?;
859        }
860    }
861    // 2. Region glyphs other than the slot's operand are blanked, last first, so no remaining
862    //    byte offset shifts.
863    let glyphs: Vec<&GlyphSourceAddress> = region.iter().filter_map(|i| i.glyph.as_ref()).collect();
864    let slot = glyphs[0];
865    // The region is flattened into one run of new words: every kern that sat between its old
866    // glyphs (inside a word, or across the blanked strings) would otherwise survive in front of
867    // the new text and push it - and everything after it - sideways. The fit above counts the
868    // new region as the new words and the regenerated gaps and nothing else.
869    zero_numbers_between(&mut content, slot, glyphs[glyphs.len() - 1])?;
870    let same_operand = |g: &GlyphSourceAddress| {
871        g.operator_index == slot.operator_index && g.operand_index == slot.operand_index
872    };
873    for g in glyphs.iter().rev().filter(|g| !same_operand(g)) {
874        splice_operand_bytes(
875            &mut content,
876            g.operator_index,
877            g.operand_index,
878            g.glyph_start,
879            g.glyph_end,
880            &[],
881        )?;
882    }
883    // 3. The slot's own bytes become the new words (and gaps).
884    let slot_end = glyphs
885        .iter()
886        .filter(|g| same_operand(g))
887        .map(|g| g.glyph_end)
888        .max()
889        .unwrap_or(slot.glyph_end);
890    let pieces: Vec<Vec<u8>> = middle.pieces.iter().map(|p| p.bytes.clone()).collect();
891    write_pieces(
892        &mut content,
893        slot,
894        (slot.glyph_start, slot_end),
895        &pieces,
896        &gap_values,
897    )?;
898    write_stream_content(doc, stream_id, &content)?;
899    verify_written(doc, page, island, &after.text, available_bounds)
900}
901
902/// Sets every numeric `TJ` item strictly between glyph `first` and glyph `last` to 0 (array length
903/// and every index unchanged). The numbers inside the span a flatten rewrites are the kerns of
904/// the text being replaced.
905fn zero_numbers_between(
906    content: &mut Content,
907    first: &GlyphSourceAddress,
908    last: &GlyphSourceAddress,
909) -> Result<(), PdfEditError> {
910    if (last.operator_index, last.operand_index) < (first.operator_index, first.operand_index) {
911        return Err(PdfEditError::SourceDigestMismatch);
912    }
913    for op_index in first.operator_index..=last.operator_index {
914        let op = content
915            .operations
916            .get_mut(op_index as usize)
917            .ok_or(PdfEditError::SourceDigestMismatch)?;
918        if op.operator != "TJ" {
919            continue;
920        }
921        let Some(Object::Array(items)) = op.operands.get_mut(0) else {
922            continue;
923        };
924        let from = if op_index == first.operator_index {
925            first.operand_index as usize + 1
926        } else {
927            0
928        };
929        let to = if op_index == last.operator_index {
930            (last.operand_index as usize).min(items.len())
931        } else {
932            items.len()
933        };
934        for item in items.iter_mut().take(to).skip(from) {
935            if matches!(item, Object::Integer(_) | Object::Real(_)) {
936                *item = Object::Integer(0);
937            }
938        }
939    }
940    Ok(())
941}
942
943/// Reads the page back after an in-place write and checks what was actually written against what
944/// was asked: the island that starts where the edited one started must read as exactly
945/// `wanted` (spaces included - a requested spacing the encoding cannot represent comes back
946/// different), and that text must still fit `available`, measured from the pen positions the
947/// walker now sees rather than from the arithmetic that predicted them. Any mismatch is a refusal;
948/// the caller drops the document, so nothing partial is returned.
949fn verify_written(
950    doc: &Document,
951    page: u32,
952    island: &PdfTextIsland,
953    wanted: &str,
954    available: PdfRect,
955) -> Result<(), PdfEditError> {
956    if wanted.is_empty() {
957        return Ok(());
958    }
959    let first = island
960        .locator
961        .spans
962        .first()
963        .ok_or(PdfEditError::VerificationFailed)?;
964    let graph = walk_page_graph(doc, "", page).map_err(|_| PdfEditError::VerificationFailed)?;
965    let written = graph
966        .islands
967        .iter()
968        .find(|candidate| {
969            candidate.locator.spans.first().is_some_and(|span| {
970                span.stream_ref == first.stream_ref
971                    && span.contents_index == first.contents_index
972                    && span.operator_index == first.operator_index
973                    && span.operand_index == first.operand_index
974                    && span.glyph_start == first.glyph_start
975            })
976        })
977        .ok_or(PdfEditError::VerificationFailed)?;
978
979    // The run items that make up `wanted`. Anything after them is a neighbour the island may now
980    // touch, not part of this edit.
981    let wanted_chars = wanted.chars().count();
982    let mut text = String::new();
983    let mut last_item = None;
984    for (index, item) in written.run.iter().enumerate() {
985        text.push_str(&item.text);
986        if text.chars().count() >= wanted_chars {
987            last_item = Some(index);
988            break;
989        }
990    }
991    let last_item = last_item.ok_or(PdfEditError::VerificationFailed)?;
992    if text != wanted {
993        return Err(PdfEditError::VerificationFailed);
994    }
995    let layout = written.layout.ok_or(PdfEditError::VerificationFailed)?;
996    let scale = layout.axis.0.hypot(layout.axis.1);
997    if !(scale.is_finite() && scale > 0.0) {
998        return Err(PdfEditError::VerificationFailed);
999    }
1000    let extent = written.run[last_item].end - written.run[0].start;
1001    validate_layout_advance(extent / scale, layout.axis, available)
1002}
1003
1004/// Overflow check for an in-place write. `advance` is the replacement's text-space length along
1005/// the baseline (see `EncodedRun::advance`); `axis` is the device-space vector of one text unit.
1006/// The projected extents must fit `available`, within [`OVERFLOW_EPSILON_PT`]. The measure is the
1007/// glyph *advances*, exactly what the island's own box is built from, so a replacement no wider
1008/// than the original never overflows its own box.
1009fn validate_layout_advance(
1010    advance: f64,
1011    axis: (f64, f64),
1012    available: PdfRect,
1013) -> Result<(), PdfEditError> {
1014    let width = (advance * axis.0).abs();
1015    let height = (advance * axis.1).abs();
1016    if !(width.is_finite() && height.is_finite())
1017        || width > available.width + OVERFLOW_EPSILON_PT
1018        || height > available.height + OVERFLOW_EPSILON_PT
1019    {
1020        return Err(PdfEditError::TextOverflow);
1021    }
1022    Ok(())
1023}
1024
1025// -------------------------------------------------------------------------------------------
1026// §9.2 — Local text-object rewrite
1027// -------------------------------------------------------------------------------------------
1028
1029#[allow(clippy::too_many_arguments)]
1030fn apply_local_text_object_rewrite(
1031    doc: &mut Document,
1032    page: u32,
1033    page_id: ObjectId,
1034    stream_id: ObjectId,
1035    island: &PdfTextIsland,
1036    after: &PdfTextEditState,
1037    available_bounds: PdfRect,
1038    fonts: &dyn PdfFontResolver,
1039) -> Result<(Isolated, Option<AppendedRun>), PdfEditError> {
1040    let resolved = fonts.resolve(&after.appearance.font_id)?;
1041    // Validates full glyph coverage up front (§7.3 rule 3: "fail before candidate creation").
1042    fonts.glyphs_for_text(&resolved, &after.text)?;
1043
1044    // The new block runs in the default graphics state, so it must carry the source run's whole
1045    // placement itself (see `build_text_object_operations`). A matrix that cannot place text is a
1046    // refusal, never a guess.
1047    let layout = island.layout.ok_or(PdfEditError::UnsupportedEncoding)?;
1048    let placement = layout.placement;
1049    let finite = [
1050        placement.a,
1051        placement.b,
1052        placement.c,
1053        placement.d,
1054        placement.e,
1055        placement.f,
1056    ]
1057    .iter()
1058    .all(|value| value.is_finite());
1059    let scale = layout.axis.0.hypot(layout.axis.1);
1060    if !(finite && scale > 0.0 && after.appearance.size_pt.is_finite()) {
1061        return Err(PdfEditError::UnsupportedTransform);
1062    }
1063
1064    // Fit, in the units the block is drawn in: the text-space length of the new advances
1065    // (`Tf` size x advance / 1000; `Tz`, `Tc` and `Tw` are reset to defaults) is carried to device
1066    // space by the placement's axis, exactly as the in-place path measures. `Tf` is text-space
1067    // here, so the matrix's scale is applied once, by `validate_layout_advance`, and not in `Tf`.
1068    let advance_1000 = measure_advance_1000(&resolved, &after.text);
1069    validate_layout_advance(
1070        advance_1000 / 1000.0 * after.appearance.size_pt,
1071        layout.axis,
1072        available_bounds,
1073    )?;
1074
1075    // Every removed glyph and the `TJ` number that keeps the pen where the glyph left it. Worked
1076    // out before anything is mutated, so a glyph whose advance cannot be kept is a refusal that
1077    // leaves the document alone.
1078    let removed = removed_glyphs(island)?;
1079
1080    let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, &after.text)?;
1081    let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
1082
1083    let mut content = decode_stream_content(doc, stream_id, &mut ContentBudget::new())?;
1084    remove_glyphs_keeping_pen(&mut content, removed)?;
1085    write_stream_content(doc, stream_id, &content)?;
1086
1087    // §9.2 step 4/5: the new run goes in its own stream at the end of the page's `/Contents`,
1088    // outside the original content's graphics state. Appended inside the edited stream it would
1089    // inherit that stream's clip, alpha and blend mode, and a clip cannot be reset from inside.
1090    // The stream is installed with the rest of the batch (see `install_isolated_content`).
1091    let operations =
1092        build_text_object_operations(&resource_name, &after.appearance, placement, &cid_codes);
1093    // A run with no text shows no glyph, so there is no first glyph to find on read-back.
1094    let run = (!after.text.is_empty()).then_some(AppendedRun {
1095        page,
1096        stream: None,
1097        placement,
1098        size: after.appearance.size_pt,
1099    });
1100    Ok(((page_id, operations), run))
1101}
1102
1103/// What the composed page must show for one appended replacement run, worked out from the island
1104/// it replaces and not from the operations that were emitted, so that a mistake in emitting them
1105/// is caught.
1106struct AppendedRun {
1107    page: u32,
1108    /// The content stream the block was installed as; set once the batch's streams exist. The
1109    /// run's glyphs are the ones the walker attributes to this stream: an island they were grouped
1110    /// into may also hold a neighbour's glyphs (the join rule does not compare fonts), so the
1111    /// island is not the unit to check.
1112    stream: Option<ObjectId>,
1113    /// `text matrix x CTM` of the source run's first glyph: where the block's first glyph must be.
1114    placement: Matrix,
1115    /// The `Tf` size the block uses, in the text-space units of `placement`.
1116    size: f64,
1117}
1118
1119/// How far, in device points, a replacement's first glyph origin may sit from the source's.
1120const APPENDED_ORIGIN_TOLERANCE_PT: f64 = 0.5;
1121
1122/// How far, relative to the expected length, the replacement's em vectors (one `Tf` size along
1123/// text x and along text y, in device space) may differ from the source's. Covers size, rotation
1124/// and skew together.
1125const APPENDED_SIZE_TOLERANCE: f64 = 0.02;
1126
1127/// Reads the composed page back and refuses the batch unless every appended replacement run sits
1128/// where the run it replaced sat and is drawn as large and as turned. The text of the run is not
1129/// compared here (the save-side recheck and the structural walk already do that); this is the
1130/// geometry, which a structural read-back cannot see: a run drawn at the page origin, ten times
1131/// too large, reads back as exactly the requested text.
1132///
1133/// The run is found by its own content stream, not by the island it was grouped into: the join
1134/// rule does not compare fonts, so a run that starts within the join window of a preceding
1135/// same-colour run on the same baseline is part of that run's island, whose first glyph is not
1136/// the replacement's. The first glyph the walker attributes to the run's stream is compared.
1137fn verify_appended_runs(doc: &Document, runs: &[AppendedRun]) -> Result<(), PdfEditError> {
1138    use std::collections::btree_map::Entry;
1139    let mut graphs: std::collections::BTreeMap<u32, PdfPageGraph> =
1140        std::collections::BTreeMap::new();
1141    for run in runs {
1142        let stream = run.stream.ok_or(PdfEditError::VerificationFailed)?;
1143        let graph = match graphs.entry(run.page) {
1144            Entry::Occupied(entry) => entry.into_mut(),
1145            Entry::Vacant(entry) => entry.insert(
1146                walk_page_graph(doc, "", run.page).map_err(|_| PdfEditError::VerificationFailed)?,
1147            ),
1148        };
1149        let written = graph
1150            .islands
1151            .iter()
1152            .flat_map(|island| island.run.iter())
1153            .find(|item| {
1154                item.glyph
1155                    .is_some_and(|glyph| glyph.stream_ref == (stream.0, stream.1))
1156            })
1157            .and_then(|item| item.placement)
1158            .ok_or(PdfEditError::VerificationFailed)?;
1159        if !appended_run_placed(run, written) {
1160            return Err(PdfEditError::VerificationFailed);
1161        }
1162    }
1163    Ok(())
1164}
1165
1166/// Whether the first glyph the walker now sees for the run (`written`: its text matrix x CTM and
1167/// `Tf` size) is where `run` says it must be: origin within [`APPENDED_ORIGIN_TOLERANCE_PT`], and
1168/// the device-space vectors of one `Tf` size along text x and along text y each within
1169/// [`APPENDED_SIZE_TOLERANCE`] of the expected ones. Text rise is not part of either matrix, so it
1170/// is compared on neither side.
1171fn appended_run_placed(run: &AppendedRun, written: (Matrix, f64)) -> bool {
1172    let (want, (have, have_size)) = (run.placement, written);
1173    let origin = (have.e - want.e).hypot(have.f - want.f);
1174    let em_x = |m: Matrix, size: f64| (m.a * size, m.b * size);
1175    let em_y = |m: Matrix, size: f64| (m.c * size, m.d * size);
1176    let near = |want: (f64, f64), have: (f64, f64)| {
1177        (have.0 - want.0).hypot(have.1 - want.1) <= APPENDED_SIZE_TOLERANCE * want.0.hypot(want.1)
1178    };
1179    origin <= APPENDED_ORIGIN_TOLERANCE_PT
1180        && near(em_x(want, run.size), em_x(have, have_size))
1181        && near(em_y(want, run.size), em_y(have, have_size))
1182}
1183
1184/// One glyph an edit removes from a string operand, with the `TJ` number that keeps the pen where
1185/// the glyph left it.
1186struct RemovedGlyph {
1187    operator_index: u32,
1188    operand_index: u32,
1189    start: u32,
1190    end: u32,
1191    adjust: f64,
1192}
1193
1194/// The glyphs of `island`, in reading order, each with its pen-keeping `TJ` number. A glyph whose
1195/// advance cannot be expressed that way (a zero font size) is refused.
1196fn removed_glyphs(island: &PdfTextIsland) -> Result<Vec<RemovedGlyph>, PdfEditError> {
1197    let mut removed = Vec::new();
1198    for item in &island.run {
1199        let Some(glyph) = &item.glyph else { continue };
1200        removed.push(RemovedGlyph {
1201            operator_index: glyph.operator_index,
1202            operand_index: glyph.operand_index,
1203            start: glyph.glyph_start,
1204            end: glyph.glyph_end,
1205            adjust: item.pen_adjust.ok_or(PdfEditError::UnsupportedEncoding)?,
1206        });
1207    }
1208    Ok(removed)
1209}
1210
1211/// Replaces each removed glyph by the `TJ` number that moves the pen exactly as the glyph did, so
1212/// every glyph shown after it - in this operator or any later one - stays where it was.
1213///
1214/// A `TJ` string item is split around the removed ranges (items after it shift, which is why
1215/// commands run back to front); a `Tj` becomes a `TJ` at the same operator index, so no operator
1216/// is ever renumbered. `'` and `"` carry a line move a `TJ` cannot, so glyphs shown by them are
1217/// refused rather than left with the pen short. Operators are rewritten last to first and, inside
1218/// one `TJ`, items last to first, so no address still to be rewritten has moved.
1219fn remove_glyphs_keeping_pen(
1220    content: &mut Content,
1221    mut removed: Vec<RemovedGlyph>,
1222) -> Result<(), PdfEditError> {
1223    removed.sort_by_key(|g| (g.operator_index, g.operand_index, g.start));
1224    let mut end = removed.len();
1225    while end > 0 {
1226        let operator_index = removed[end - 1].operator_index;
1227        let mut begin = end;
1228        while begin > 0 && removed[begin - 1].operator_index == operator_index {
1229            begin -= 1;
1230        }
1231        let glyphs = &removed[begin..end];
1232        let op = content
1233            .operations
1234            .get_mut(operator_index as usize)
1235            .ok_or(PdfEditError::SourceDigestMismatch)?;
1236        let operator = op.operator.clone();
1237        match operator.as_str() {
1238            "TJ" => {
1239                let Some(Object::Array(items)) = op.operands.get_mut(0) else {
1240                    return Err(PdfEditError::SourceDigestMismatch);
1241                };
1242                let mut hi = glyphs.len();
1243                while hi > 0 {
1244                    let item_index = glyphs[hi - 1].operand_index;
1245                    let mut lo = hi;
1246                    while lo > 0 && glyphs[lo - 1].operand_index == item_index {
1247                        lo -= 1;
1248                    }
1249                    let replacement = match items.get(item_index as usize) {
1250                        Some(Object::String(bytes, format)) => {
1251                            pieces_around_removed(bytes, *format, &glyphs[lo..hi])?
1252                        }
1253                        _ => return Err(PdfEditError::SourceDigestMismatch),
1254                    };
1255                    let at = item_index as usize;
1256                    items.splice(at..=at, replacement).for_each(drop);
1257                    hi = lo;
1258                }
1259            }
1260            "Tj" => {
1261                let replacement = match op.operands.first() {
1262                    Some(Object::String(bytes, format)) => {
1263                        pieces_around_removed(bytes, *format, glyphs)?
1264                    }
1265                    _ => return Err(PdfEditError::SourceDigestMismatch),
1266                };
1267                op.operator = "TJ".to_string();
1268                op.operands = vec![Object::Array(replacement)];
1269            }
1270            other if is_line_moving_show(other) => return Err(PdfEditError::UnsupportedEncoding),
1271            _ => return Err(PdfEditError::SourceDigestMismatch),
1272        }
1273        end = begin;
1274    }
1275    Ok(())
1276}
1277
1278/// `bytes` with each removed range (ascending, non-overlapping) replaced by its pen-keeping `TJ`
1279/// number; the bytes between and after the ranges stay as strings of the same format.
1280fn pieces_around_removed(
1281    bytes: &[u8],
1282    format: StringFormat,
1283    glyphs: &[RemovedGlyph],
1284) -> Result<Vec<Object>, PdfEditError> {
1285    let mut out = Vec::with_capacity(glyphs.len() * 2 + 1);
1286    let mut at = 0usize;
1287    for glyph in glyphs {
1288        let (start, end) = (glyph.start as usize, glyph.end as usize);
1289        if start < at || end < start || end > bytes.len() {
1290            return Err(PdfEditError::SourceDigestMismatch);
1291        }
1292        if start > at {
1293            out.push(Object::String(bytes[at..start].to_vec(), format));
1294        }
1295        out.push(number_object(glyph.adjust));
1296        at = end;
1297    }
1298    if at < bytes.len() {
1299        out.push(Object::String(bytes[at..].to_vec(), format));
1300    }
1301    Ok(out)
1302}
1303
1304fn measure_advance_1000(resolved: &ResolvedPdfFont, text: &str) -> f64 {
1305    let Ok(face) = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index) else {
1306        return text.chars().count() as f64 * 500.0;
1307    };
1308    let units_per_em = face.units_per_em() as f64;
1309    if units_per_em <= 0.0 {
1310        return text.chars().count() as f64 * 500.0;
1311    }
1312    text.chars()
1313        .filter_map(|c| face.glyph_index(c))
1314        .map(|gid| face.glyph_hor_advance(gid).unwrap_or(0) as f64 * 1000.0 / units_per_em)
1315        .sum()
1316}
1317
1318// -------------------------------------------------------------------------------------------
1319// §9.3 — Add Text
1320// -------------------------------------------------------------------------------------------
1321
1322fn apply_add_text(
1323    doc: &mut Document,
1324    page: u32,
1325    anchor: PdfPoint,
1326    text: &str,
1327    appearance: &PdfTextAppearance,
1328    fonts: &dyn PdfFontResolver,
1329) -> Result<(u32, Option<Isolated>), PdfEditError> {
1330    if doc.is_encrypted() {
1331        return Err(PdfEditError::EncryptedDocument);
1332    }
1333    let page_id = pages_lookup(doc, page)?;
1334
1335    let resolved = fonts.resolve(&appearance.font_id)?;
1336    fonts.glyphs_for_text(&resolved, text)?;
1337
1338    let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
1339    let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
1340
1341    Ok((
1342        page,
1343        Some((
1344            page_id,
1345            // Add Text gives a point in page space and an upright size; the block runs in the
1346            // default state, so the matrix is the plain translation.
1347            build_text_object_operations(
1348                &resource_name,
1349                appearance,
1350                Matrix::translation(anchor.x, anchor.y),
1351                &cid_codes,
1352            ),
1353        )),
1354    ))
1355}
1356
1357/// Covers one OCR line's proven near-uniform background, then appends a fresh Type0 text object.
1358/// Existing page content streams and source operators remain untouched.
1359#[allow(clippy::too_many_arguments)]
1360fn apply_correct_ocr_text(
1361    doc: &mut Document,
1362    page: u32,
1363    rect: PdfRect,
1364    text: &str,
1365    appearance: &PdfTextAppearance,
1366    cover_color: PdfColor,
1367    background_proof: PdfOcrBackgroundProof,
1368    fonts: &dyn PdfFontResolver,
1369) -> Result<(u32, Option<Isolated>), PdfEditError> {
1370    let page_id = pages_lookup(doc, page)?;
1371    let page_bounds = page_bounds(doc, page_id)?;
1372    validate_ocr_correction(
1373        page_bounds,
1374        rect,
1375        text,
1376        appearance,
1377        cover_color,
1378        background_proof,
1379        page_rotation(doc, page_id)?,
1380    )?;
1381
1382    let resolved = fonts.resolve(&appearance.font_id)?;
1383    fonts.glyphs_for_text(&resolved, text)?;
1384    let width = measure_advance_1000(&resolved, text) / 1000.0 * appearance.size_pt;
1385    if width > rect.width || appearance.size_pt > rect.height {
1386        return Err(PdfEditError::TextOverflow);
1387    }
1388    let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
1389    let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
1390
1391    Ok((
1392        page,
1393        Some((
1394            page_id,
1395            build_ocr_correction_operations(
1396                &resource_name,
1397                appearance,
1398                rect,
1399                cover_color,
1400                &cid_codes,
1401            ),
1402        )),
1403    ))
1404}
1405
1406fn validate_ocr_correction(
1407    page_bounds: PdfRect,
1408    rect: PdfRect,
1409    text: &str,
1410    appearance: &PdfTextAppearance,
1411    cover_color: PdfColor,
1412    background_proof: PdfOcrBackgroundProof,
1413    rotation: i64,
1414) -> Result<(), PdfEditError> {
1415    if rotation != 0 {
1416        return Err(PdfEditError::UnsupportedTransform);
1417    }
1418    if !matches!(background_proof, PdfOcrBackgroundProof::NearUniform)
1419        || !is_finite_color(cover_color)
1420        || cover_color.alpha < 1.0
1421        || !is_finite_color(appearance.color)
1422        || !appearance.size_pt.is_finite()
1423        || appearance.size_pt <= 0.0
1424        || text.is_empty()
1425        || !is_finite_rect(page_bounds)
1426        || page_bounds.width <= 0.0
1427        || page_bounds.height <= 0.0
1428        || !is_finite_rect(rect)
1429        || rect.width <= 0.0
1430        || rect.height <= 0.0
1431        || !page_bounds.contains(rect)
1432    {
1433        return Err(PdfEditError::TextOverflow);
1434    }
1435    Ok(())
1436}
1437
1438fn is_finite_rect(rect: PdfRect) -> bool {
1439    rect.x.is_finite() && rect.y.is_finite() && rect.width.is_finite() && rect.height.is_finite()
1440}
1441
1442fn is_finite_color(color: PdfColor) -> bool {
1443    color.r.is_finite()
1444        && color.g.is_finite()
1445        && color.b.is_finite()
1446        && color.alpha.is_finite()
1447        && (0.0..=1.0).contains(&color.r)
1448        && (0.0..=1.0).contains(&color.g)
1449        && (0.0..=1.0).contains(&color.b)
1450        && (0.0..=1.0).contains(&color.alpha)
1451}
1452
1453fn page_bounds(doc: &Document, page_id: ObjectId) -> Result<PdfRect, PdfEditError> {
1454    let values = inherited_page_array(doc, page_id, b"CropBox")
1455        .or_else(|| inherited_page_array(doc, page_id, b"MediaBox"))
1456        .ok_or(PdfEditError::MalformedContent)?;
1457    if values.len() != 4 {
1458        return Err(PdfEditError::MalformedContent);
1459    }
1460    let x0 = values[0];
1461    let y0 = values[1];
1462    let width = values[2] - x0;
1463    let height = values[3] - y0;
1464    Ok(PdfRect {
1465        x: x0,
1466        y: y0,
1467        width,
1468        height,
1469    })
1470}
1471
1472fn page_rotation(doc: &Document, page_id: ObjectId) -> Result<i64, PdfEditError> {
1473    Ok(inherited_page_number(doc, page_id, b"Rotate").unwrap_or(0))
1474}
1475
1476fn inherited_page_array(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<Vec<f64>> {
1477    let mut seen = std::collections::HashSet::new();
1478    for _ in 0..64 {
1479        if !seen.insert(page_id) {
1480            return None; // a /Parent cycle
1481        }
1482        let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
1483        if let Ok(value) = dict.get(key) {
1484            let value = match value {
1485                Object::Reference(id) => doc.get_object(*id).ok()?,
1486                value => value,
1487            };
1488            let array = value.as_array().ok()?;
1489            return array.iter().map(object_number).collect();
1490        }
1491        page_id = match dict.get(b"Parent").ok()? {
1492            Object::Reference(id) => *id,
1493            _ => return None,
1494        };
1495    }
1496    None
1497}
1498
1499fn object_number(value: &Object) -> Option<f64> {
1500    match value {
1501        Object::Integer(value) => Some(*value as f64),
1502        Object::Real(value) => Some(*value as f64),
1503        _ => None,
1504    }
1505}
1506
1507fn inherited_page_number(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<i64> {
1508    let mut seen = std::collections::HashSet::new();
1509    for _ in 0..64 {
1510        if !seen.insert(page_id) {
1511            return None; // a /Parent cycle
1512        }
1513        let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
1514        if let Ok(value) = dict.get(key) {
1515            let value = match value {
1516                Object::Reference(id) => doc.get_object(*id).ok()?,
1517                value => value,
1518            };
1519            return value.as_i64().ok();
1520        }
1521        page_id = match dict.get(b"Parent").ok()? {
1522            Object::Reference(id) => *id,
1523            _ => return None,
1524        };
1525    }
1526    None
1527}
1528
1529/// Most unbalanced `Q` operators the original content may carry before the page is refused.
1530const MAX_STRAY_RESTORES: i64 = 1 << 16;
1531
1532/// Installs the batch's new blocks, each as its own content stream **isolated from the page's
1533/// original content**, once every command has been applied. Blocks go on a page in command order
1534/// (`blocks` carries each one's command slot).
1535///
1536/// Graphics state carries from one content stream into the next (ISO 32000-1 7.8.2), so a
1537/// clipping path (`W n`), an `ExtGState` with `/ca 0` or a blend mode, or a leftover CTM in the
1538/// original content applies to anything appended after it: the added text or OCR cover would be
1539/// invisible while a structural read-back still succeeded. Resetting parameters from inside the
1540/// new block cannot undo a clip. So the original streams are wrapped: a stream of `q` goes first
1541/// and a stream of `Q` follows the last original stream, and the new streams run in the default
1542/// state. The wrapper is sized from the original content's own balance - one extra `q` for each
1543/// stray `Q` the original would otherwise use to pop the wrapper, and enough `Q` to close every
1544/// `q` the original left open - so a new stream always starts at nesting depth zero. Each block is
1545/// itself balanced, so the blocks of one page do not affect each other.
1546///
1547/// Installing at the end of the batch, not per command, is what keeps the originals' addresses
1548/// still: the wrapper stream is prepended, which shifts every original `contents_index`, and the
1549/// island digest binds it.
1550///
1551/// Original streams are not modified (§9.3: "Do not mutate existing content operators"). Content
1552/// that cannot be parsed strictly cannot be counted, so it is refused as malformed; the count is
1553/// charged to a [`ContentBudget`] before anything is decoded or allocated. A stream that merely
1554/// ends in the operands of an operator that opens the next stream is not malformed (ISO 32000-1
1555/// 7.8.2 cuts at token boundaries, not operation boundaries) and is counted as it stands.
1556///
1557/// Returns the content stream each block became, by the block's command slot.
1558fn install_isolated_content(
1559    doc: &mut Document,
1560    mut blocks: Vec<(usize, Isolated)>,
1561) -> Result<std::collections::BTreeMap<usize, ObjectId>, PdfEditError> {
1562    blocks.sort_by_key(|(slot, _)| *slot);
1563    let mut by_page: std::collections::BTreeMap<ObjectId, Vec<(usize, Vec<Operation>)>> =
1564        std::collections::BTreeMap::new();
1565    for (slot, (page_id, operations)) in blocks {
1566        by_page.entry(page_id).or_default().push((slot, operations));
1567    }
1568    let mut streams = std::collections::BTreeMap::new();
1569    for (page_id, operations) in by_page {
1570        streams.extend(isolate_page_content(doc, page_id, operations)?);
1571    }
1572    Ok(streams)
1573}
1574
1575fn isolate_page_content(
1576    doc: &mut Document,
1577    page_id: ObjectId,
1578    blocks: Vec<(usize, Vec<Operation>)>,
1579) -> Result<Vec<(usize, ObjectId)>, PdfEditError> {
1580    let page_obj = doc
1581        .get_object(page_id)
1582        .map_err(|_| PdfEditError::MalformedContent)?;
1583    let page_dict = page_obj
1584        .as_dict()
1585        .map_err(|_| PdfEditError::MalformedContent)?;
1586
1587    // `/Contents` may be an indirect reference to an array of streams; expand it so the new page
1588    // array lists streams, never the array object itself.
1589    let ids: Vec<ObjectId> = match page_dict.get(b"Contents") {
1590        Ok(contents) => content_stream_ids(doc, contents)?,
1591        Err(_) => return Err(PdfEditError::MalformedContent),
1592    };
1593
1594    // Nesting balance of the original content: the lowest point it reaches below its start
1595    // (stray `Q`) and where it ends (unclosed `q`). One budget for the page: a stream listed many
1596    // times is decoded and charged many times.
1597    let mut budget = ContentBudget::new();
1598    let mut net: i64 = 0;
1599    let mut lowest: i64 = 0;
1600    for id in &ids {
1601        // Read, never written back, so a stream may end in the operands of an operator that opens
1602        // the next one (see `ContentBudget::content_to_read`).
1603        for operation in budget.content_to_read(doc, *id)?.operations {
1604            match operation.operator.as_str() {
1605                "q" => net += 1,
1606                "Q" => {
1607                    net -= 1;
1608                    lowest = lowest.min(net);
1609                }
1610                _ => {}
1611            }
1612        }
1613    }
1614    // Both the stray `Q`s and the unclosed `q`s become operations of the wrapper streams, so both
1615    // are bounded before those are built.
1616    if -lowest > MAX_STRAY_RESTORES || net > MAX_STRAY_RESTORES {
1617        return Err(PdfEditError::MalformedContent);
1618    }
1619    let opening = 1 - lowest; // the wrapper's own `q` plus one per stray `Q`
1620    let closing = opening + net; // every level still open when the original ends
1621
1622    let repeated = |operator: &str, count: i64| -> Vec<Operation> {
1623        (0..count)
1624            .map(|_| Operation::new(operator, vec![]))
1625            .collect()
1626    };
1627    let mut add_stream = |operations: Vec<Operation>| -> Result<ObjectId, PdfEditError> {
1628        let mut content = Content::decode(&[]).map_err(|_| PdfEditError::MalformedContent)?;
1629        content.operations = operations;
1630        let encoded = content
1631            .encode()
1632            .map_err(|_| PdfEditError::MalformedContent)?;
1633        Ok(doc.add_object(Stream::new(dictionary! {}, encoded)))
1634    };
1635    let prefix_id = add_stream(repeated("q", opening))?;
1636    let suffix_id = add_stream(repeated("Q", closing))?;
1637    let mut added = Vec::with_capacity(blocks.len());
1638    let mut slots = Vec::with_capacity(blocks.len());
1639    for (slot, operations) in blocks {
1640        added.push(add_stream(operations)?);
1641        slots.push(slot);
1642    }
1643    let installed: Vec<(usize, ObjectId)> = slots.into_iter().zip(added.iter().copied()).collect();
1644
1645    let mut all: Vec<ObjectId> = Vec::with_capacity(ids.len() + 2 + added.len());
1646    all.push(prefix_id);
1647    all.extend(ids);
1648    all.push(suffix_id);
1649    all.extend(added);
1650    let array = Object::Array(all.into_iter().map(Object::Reference).collect());
1651    if let Ok(Object::Dictionary(dict)) = doc.get_object_mut(page_id) {
1652        dict.set("Contents", array);
1653    }
1654    Ok(installed)
1655}
1656
1657// -------------------------------------------------------------------------------------------
1658// §7.4-adjacent — embedding a fresh Type0/CIDFontType2 subset for a resolved catalog font
1659// -------------------------------------------------------------------------------------------
1660
1661/// Embeds a Type0/CIDFontType2/FontFile2/ToUnicode object graph (§7.4) for exactly the glyphs
1662/// `text` needs, via `pdf_font.rs`'s [`subset_for_pdf`]. Returns the new Type0 font's object id
1663/// plus `text`'s per-character CID codes (`/CIDToGIDMap /Identity`, so CID == subset glyph id).
1664///
1665/// The CID for each character is recomputed here (not read back from [`subset_for_pdf`], which
1666/// exposes only de-duplicated widths/`ToUnicode`, not a per-position code list) using the exact
1667/// same "first-appearance order, glyph 0 reserved for `.notdef`" numbering `subset_for_pdf` itself
1668/// documents and relies on: "assigns CIDs to equal the subset font's own new (post-subsetting)
1669/// glyph ids ... in the exact order we call `remap`." Recomputing over the same `text.chars()` /
1670/// `face.glyph_index` sequence that `subset_for_pdf` iterates internally reproduces that same
1671/// mapping without needing a new shared API between the two modules.
1672fn embed_type0_font(
1673    doc: &mut Document,
1674    resolved: &ResolvedPdfFont,
1675    text: &str,
1676) -> Result<(ObjectId, Vec<u16>), PdfEditError> {
1677    let subset = subset_for_pdf(&resolved.bytes, resolved.face_index, text)?;
1678
1679    let face = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index)
1680        .map_err(|_| PdfEditError::UnsupportedEncoding)?;
1681    let mut seen_old_gids: Vec<u16> = Vec::new();
1682    let mut cid_codes: Vec<u16> = Vec::with_capacity(text.chars().count());
1683    for c in text.chars() {
1684        let old_gid = face.glyph_index(c).ok_or(PdfEditError::MissingGlyph)?.0;
1685        let new_gid = match seen_old_gids.iter().position(|&g| g == old_gid) {
1686            Some(pos) => (pos + 1) as u16,
1687            None => {
1688                seen_old_gids.push(old_gid);
1689                seen_old_gids.len() as u16
1690            }
1691        };
1692        cid_codes.push(new_gid);
1693    }
1694
1695    let font_file_id = doc.add_object(Stream::new(
1696        dictionary! { "Length1" => subset.font_program.len() as i64 },
1697        subset.font_program.clone(),
1698    ));
1699
1700    let mut widths_sorted = subset.widths.clone();
1701    widths_sorted.sort_by_key(|w| w.cid);
1702    let mut w_array: Vec<Object> = Vec::with_capacity(widths_sorted.len() * 2);
1703    for w in &widths_sorted {
1704        w_array.push(Object::Integer(w.cid as i64));
1705        w_array.push(Object::Array(vec![Object::Integer(
1706            w.width_1000.round() as i64
1707        )]));
1708    }
1709
1710    let base_name: Vec<u8> = resolved.family.replace(' ', "").into_bytes();
1711
1712    let descriptor_id = doc.add_object(dictionary! {
1713        "Type" => "FontDescriptor",
1714        "FontName" => Object::Name(base_name.clone()),
1715        "Flags" => 4,
1716        "FontBBox" => Object::Array(vec![Object::Integer(0), Object::Integer(0), Object::Integer(1000), Object::Integer(1000)]),
1717        "ItalicAngle" => 0,
1718        "Ascent" => 900,
1719        "Descent" => -200,
1720        "CapHeight" => 700,
1721        "StemV" => 80,
1722        "FontFile2" => font_file_id,
1723    });
1724
1725    let descendant_id = doc.add_object(dictionary! {
1726        "Type" => "Font",
1727        "Subtype" => "CIDFontType2",
1728        "BaseFont" => Object::Name(base_name.clone()),
1729        "CIDSystemInfo" => dictionary! {
1730            "Registry" => Object::String(b"Adobe".to_vec(), StringFormat::Literal),
1731            "Ordering" => Object::String(b"Identity".to_vec(), StringFormat::Literal),
1732            "Supplement" => 0,
1733        },
1734        "FontDescriptor" => descriptor_id,
1735        "DW" => 1000,
1736        "W" => Object::Array(w_array),
1737        "CIDToGIDMap" => "Identity",
1738    });
1739
1740    let to_unicode_bytes = build_to_unicode_cmap(&subset.to_unicode).into_bytes();
1741    let to_unicode_id = doc.add_object(Stream::new(dictionary! {}, to_unicode_bytes));
1742
1743    let type0_id = doc.add_object(dictionary! {
1744        "Type" => "Font",
1745        "Subtype" => "Type0",
1746        "BaseFont" => Object::Name(base_name),
1747        "Encoding" => "Identity-H",
1748        "DescendantFonts" => Object::Array(vec![Object::Reference(descendant_id)]),
1749        "ToUnicode" => to_unicode_id,
1750    });
1751
1752    Ok((type0_id, cid_codes))
1753}
1754
1755/// Deepest `/Parent` chain followed when looking for the `/Resources` a page inherits. Real page
1756/// trees are a handful of levels; the same bound as inspection's inherited-attribute lookup.
1757const MAX_RESOURCE_INHERIT_DEPTH: usize = 64;
1758
1759/// The page's *effective* `/Resources`, cloned: the nearest `/Resources` entry on the page itself
1760/// or, when it has none, on the closest ancestor `/Pages` node that does (a page attribute is
1761/// inherited from there, ISO 32000-1 7.7.3.4). The nearest one is used whole, never merged with
1762/// a farther one - exactly what a viewer reads. An empty dictionary means nothing is inherited.
1763/// A `/Parent` cycle or a chain deeper than [`MAX_RESOURCE_INHERIT_DEPTH`] is refused: the
1764/// resources it hides cannot be preserved, so nothing may be written over them.
1765fn effective_resources(doc: &Document, page_id: ObjectId) -> Result<Dictionary, PdfEditError> {
1766    let mut seen = std::collections::HashSet::new();
1767    let mut id = page_id;
1768    for _ in 0..=MAX_RESOURCE_INHERIT_DEPTH {
1769        if !seen.insert(id) {
1770            return Err(PdfEditError::MalformedContent); // a /Parent cycle
1771        }
1772        let node = doc
1773            .get_object(id)
1774            .map_err(|_| PdfEditError::MalformedContent)?
1775            .as_dict()
1776            .map_err(|_| PdfEditError::MalformedContent)?;
1777        if node.has(b"Resources") {
1778            return Ok(dict_entry_clone(doc, node, b"Resources"));
1779        }
1780        match node.get(b"Parent") {
1781            Ok(Object::Reference(parent)) => id = *parent,
1782            _ => return Ok(dictionary! {}), // the chain ends: nothing to inherit
1783        }
1784    }
1785    Err(PdfEditError::MalformedContent) // longer than any real page tree
1786}
1787
1788/// Adds `font_obj_id` to page `page_id`'s own `/Resources/Font` dictionary under a fresh,
1789/// collision-free name (§9.3: "Add resource under collision-free generated name"). Always writes a
1790/// page-owned copy of `/Resources` (and of `/Resources/Font`) rather than mutating whatever
1791/// dictionary object the page's `/Resources` currently points to, so a `/Resources` dictionary
1792/// shared with another page is never silently changed for both. The copy starts from the page's
1793/// effective resources ([`effective_resources`]), so a page that inherits `/Resources` from an
1794/// ancestor keeps every font, XObject and graphics state it already resolved.
1795fn add_font_resource(
1796    doc: &mut Document,
1797    page_id: ObjectId,
1798    font_obj_id: ObjectId,
1799) -> Result<Vec<u8>, PdfEditError> {
1800    let existing_resources = effective_resources(doc, page_id)?;
1801    let mut fonts_dict = dict_entry_clone(doc, &existing_resources, b"Font");
1802
1803    let mut counter = 1u32;
1804    let mut name = format!("VREdit{counter}");
1805    while fonts_dict.has(name.as_bytes()) {
1806        counter += 1;
1807        name = format!("VREdit{counter}");
1808    }
1809    fonts_dict.set(name.as_str(), Object::Reference(font_obj_id));
1810
1811    let mut new_resources = existing_resources;
1812    new_resources.set("Font", Object::Dictionary(fonts_dict));
1813
1814    if let Ok(Object::Dictionary(page_dict_mut)) = doc.get_object_mut(page_id) {
1815        page_dict_mut.set("Resources", Object::Dictionary(new_resources));
1816    }
1817
1818    Ok(name.into_bytes())
1819}
1820
1821/// Clones the dictionary `dict[key]` resolves to — following one `/Reference` hop — or an empty
1822/// dictionary when the key is absent, not a dictionary, or points at a broken/non-dictionary
1823/// object. `add_font_resource` uses it so `/Resources` and `/Resources/Font` are always copied out
1824/// before mutation (a dictionary shared with another page must never be mutated in place).
1825fn dict_entry_clone(doc: &Document, dict: &Dictionary, key: &[u8]) -> Dictionary {
1826    match dict.get(key) {
1827        Ok(Object::Reference(id)) => doc
1828            .get_object(*id)
1829            .ok()
1830            .and_then(|o| o.as_dict().ok())
1831            .cloned()
1832            .unwrap_or_else(|| dictionary! {}),
1833        Ok(Object::Dictionary(d)) => d.clone(),
1834        _ => dictionary! {},
1835    }
1836}
1837
1838/// One isolated `q / BT / Tf / Tr Tc Tw Tz Ts / rg / Tm / Tj / ET / Q` block showing `cid_codes`
1839/// (Identity-H, 2-byte-per-code) through `resource_name`, in
1840/// `appearance.color`/`appearance.size_pt` - the shared building block for §9.2's appended
1841/// local rewrite, §9.3's Add Text stream and the OCR correction text.
1842///
1843/// `placement` is the block's whole `Tm`, all six numbers, and the block runs in the default
1844/// graphics state (identity CTM, see [`install_isolated_content`]), so it is also the matrix that
1845/// takes text space to page space. The text rendering matrix of the first glyph is
1846/// `[size 0 0; 0 size 0; 0 0 1] x Tm x CTM` (ISO 32000-1 9.4.4; row vectors, `Matrix::then`
1847/// applies its left operand first). A caller that replaces a source run passes that run's
1848/// `Tm_source x CTM_source` with `Tf` kept at the source's text-space size: the products are then
1849/// equal, rotation, skew, negative and non-uniform scale included, whether the producer put the
1850/// run's position in `Tm`, `Td` or a `cm` outside the text object. Add Text and the OCR text pass
1851/// an upright `Matrix::translation(x, y)`: the point is already in page space and the size in
1852/// points.
1853///
1854/// Not reproduced: a source run that relied on horizontal scaling (`Tz`) or text rise (`Ts`)
1855/// draws without them, as this block resets both (and `Tc`, `Tw`, the render mode) to defaults;
1856/// the replacement is also measured without them.
1857///
1858/// The `q`/`Q` pair keeps this block's state from leaking out, but it cannot keep the page's
1859/// state from leaking in: the text state parameters a page's last text object left behind (`3 Tr`
1860/// is invisible text, a stray `Tc`/`Tw`/`Tz`/`Ts` moves or scales it) carry into a stream
1861/// appended after it. So every parameter that changes how the string is drawn is set explicitly.
1862/// What a block cannot reset - a clipping path, an `ExtGState` alpha or blend mode, a CTM - is
1863/// removed by [`install_isolated_content`], which closes the original content's graphics state
1864/// before the block runs.
1865fn build_text_object_operations(
1866    resource_name: &[u8],
1867    appearance: &PdfTextAppearance,
1868    placement: Matrix,
1869    cid_codes: &[u16],
1870) -> Vec<Operation> {
1871    let mut hex_bytes = Vec::with_capacity(cid_codes.len() * 2);
1872    for code in cid_codes {
1873        hex_bytes.extend_from_slice(&code.to_be_bytes());
1874    }
1875
1876    let mut operations = vec![Operation::new("q", vec![])];
1877    operations.extend([
1878        Operation::new("BT", vec![]),
1879        Operation::new(
1880            "Tf",
1881            vec![
1882                Object::Name(resource_name.to_vec()),
1883                Object::Real(appearance.size_pt as f32),
1884            ],
1885        ),
1886        // Fill, no extra character or word spacing, 100 % width, no rise.
1887        Operation::new("Tr", vec![Object::Integer(0)]),
1888        Operation::new("Tc", vec![Object::Integer(0)]),
1889        Operation::new("Tw", vec![Object::Integer(0)]),
1890        Operation::new("Tz", vec![Object::Integer(100)]),
1891        Operation::new("Ts", vec![Object::Integer(0)]),
1892        Operation::new(
1893            "rg",
1894            vec![
1895                Object::Real(appearance.color.r as f32),
1896                Object::Real(appearance.color.g as f32),
1897                Object::Real(appearance.color.b as f32),
1898            ],
1899        ),
1900        Operation::new(
1901            "Tm",
1902            [
1903                placement.a,
1904                placement.b,
1905                placement.c,
1906                placement.d,
1907                placement.e,
1908                placement.f,
1909            ]
1910            .map(number_object)
1911            .to_vec(),
1912        ),
1913        Operation::new(
1914            "Tj",
1915            vec![Object::String(hex_bytes, StringFormat::Hexadecimal)],
1916        ),
1917        Operation::new("ET", vec![]),
1918        Operation::new("Q", vec![]),
1919    ]);
1920    operations
1921}
1922
1923/// Isolated correction stream: opaque cover first, then embedded text with the same Type0 font
1924/// and ToUnicode mapping used by ordinary AddText/local rewrites.
1925fn build_ocr_correction_operations(
1926    resource_name: &[u8],
1927    appearance: &PdfTextAppearance,
1928    rect: PdfRect,
1929    cover_color: PdfColor,
1930    cid_codes: &[u16],
1931) -> Vec<Operation> {
1932    let mut operations = vec![Operation::new("q", vec![])];
1933    operations.extend([
1934        Operation::new(
1935            "rg",
1936            vec![
1937                Object::Real(cover_color.r as f32),
1938                Object::Real(cover_color.g as f32),
1939                Object::Real(cover_color.b as f32),
1940            ],
1941        ),
1942        Operation::new(
1943            "re",
1944            vec![
1945                Object::Real(rect.x as f32),
1946                Object::Real(rect.y as f32),
1947                Object::Real(rect.width as f32),
1948                Object::Real(rect.height as f32),
1949            ],
1950        ),
1951        Operation::new("f", vec![]),
1952        Operation::new("Q", vec![]),
1953    ]);
1954    // `rect` is in page space and the block runs in the default state: no text-space value is
1955    // involved, so the upright translation is exact.
1956    operations.extend(build_text_object_operations(
1957        resource_name,
1958        appearance,
1959        Matrix::translation(rect.x, rect.y + rect.height * 0.18),
1960        cid_codes,
1961    ));
1962    operations
1963}
1964
1965// -------------------------------------------------------------------------------------------
1966// Content-stream mutation helpers shared by §9.1/§9.2
1967// -------------------------------------------------------------------------------------------
1968
1969fn decode_stream_content(
1970    doc: &Document,
1971    id: ObjectId,
1972    budget: &mut ContentBudget,
1973) -> Result<Content, PdfEditError> {
1974    // Strict: the lenient decoder silently drops everything after the first byte it cannot
1975    // parse, and a stream rewritten from a truncated parse would permanently lose that tail. Both
1976    // the decode and the parse are charged to `budget` before they allocate.
1977    budget.content(doc, id, true)
1978}
1979
1980/// Replaces the stream object at `id` in place (same object number/generation, so every other
1981/// span's `stream_ref` in this batch stays valid) with freshly encoded, uncompressed content.
1982fn write_stream_content(
1983    doc: &mut Document,
1984    id: ObjectId,
1985    content: &Content,
1986) -> Result<(), PdfEditError> {
1987    let encoded = content
1988        .encode()
1989        .map_err(|_| PdfEditError::MalformedContent)?;
1990    doc.objects
1991        .insert(id, Stream::new(dictionary! {}, encoded).into());
1992    Ok(())
1993}
1994
1995/// Splices `replacement` into the exact glyph byte range `[glyph_start, glyph_end)` of the string
1996/// operand identified by `operator_index`/`operand_index` (§5.1's `PdfSourceSpan` addressing
1997/// scheme) — a `Tj`/`'` operand's sole string, `"`'s third operand, or one string item inside a
1998/// `TJ` array. An empty `replacement` blanks the covered bytes (§9.2 step 2: "remove only source
1999/// glyph bytes covered by locator").
2000fn splice_operand_bytes(
2001    content: &mut Content,
2002    operator_index: u32,
2003    operand_index: u32,
2004    glyph_start: u32,
2005    glyph_end: u32,
2006    replacement: &[u8],
2007) -> Result<(), PdfEditError> {
2008    let op = content
2009        .operations
2010        .get_mut(operator_index as usize)
2011        .ok_or(PdfEditError::SourceDigestMismatch)?;
2012
2013    match op.operator.as_str() {
2014        "Tj" | "'" => {
2015            let operand = op
2016                .operands
2017                .get_mut(0)
2018                .ok_or(PdfEditError::SourceDigestMismatch)?;
2019            splice_string_object(operand, glyph_start, glyph_end, replacement)
2020        }
2021        "\"" => {
2022            let operand = op
2023                .operands
2024                .get_mut(2)
2025                .ok_or(PdfEditError::SourceDigestMismatch)?;
2026            splice_string_object(operand, glyph_start, glyph_end, replacement)
2027        }
2028        "TJ" => {
2029            let Some(Object::Array(items)) = op.operands.get_mut(0) else {
2030                return Err(PdfEditError::SourceDigestMismatch);
2031            };
2032            let item = items
2033                .get_mut(operand_index as usize)
2034                .ok_or(PdfEditError::SourceDigestMismatch)?;
2035            splice_string_object(item, glyph_start, glyph_end, replacement)
2036        }
2037        _ => Err(PdfEditError::SourceDigestMismatch),
2038    }
2039}
2040
2041fn splice_string_object(
2042    obj: &mut Object,
2043    start: u32,
2044    end: u32,
2045    replacement: &[u8],
2046) -> Result<(), PdfEditError> {
2047    let Object::String(bytes, _fmt) = obj else {
2048        return Err(PdfEditError::SourceDigestMismatch);
2049    };
2050    let (start, end) = (start as usize, end as usize);
2051    if start > end || end > bytes.len() {
2052        return Err(PdfEditError::SourceDigestMismatch);
2053    }
2054    let mut new_bytes = Vec::with_capacity(bytes.len() - (end - start) + replacement.len());
2055    new_bytes.extend_from_slice(&bytes[..start]);
2056    new_bytes.extend_from_slice(replacement);
2057    new_bytes.extend_from_slice(&bytes[end..]);
2058    *bytes = new_bytes;
2059    Ok(())
2060}
2061
2062// -------------------------------------------------------------------------------------------
2063// §9.4 — preview wire types (the Tauri command itself lives in the app crate)
2064// -------------------------------------------------------------------------------------------
2065
2066/// Wire request for the app crate's `compose_pdf_preview` command (§9.4). Field
2067/// names/shape match the frontend's
2068/// `ComposePdfPreviewRequest` comment on the frozen signature: `source_path`,
2069/// `expected_source_sha256`, `source_commands`.
2070#[derive(Debug, Deserialize)]
2071#[serde(rename_all = "camelCase")]
2072pub struct ComposePdfPreviewRequest {
2073    pub source_path: String,
2074    pub expected_source_sha256: String,
2075    pub source_commands: Vec<PdfEditCommand>,
2076}
2077
2078// -------------------------------------------------------------------------------------------
2079// Tests (§14.1 composition fixtures)
2080// -------------------------------------------------------------------------------------------
2081
2082#[cfg(test)]
2083mod tests {
2084    use super::*;
2085    use lopdf::{dictionary, Object as LObject};
2086
2087    use crate::inspect::simple_font_identity_bytes;
2088    use crate::model::{GlyphId, PdfColor, PdfEditCommand, PdfOcrBackgroundProof, PdfPageGraph};
2089
2090    // -----------------------------------------------------------------------------------
2091    // Fixture builders — shared with the rest of the crate via `crate::test_fixtures`.
2092    // -----------------------------------------------------------------------------------
2093
2094    use crate::test_fixtures::{build_one_page_pdf as build_pdf, pdf_bytes as doc_bytes};
2095
2096    /// [`super::decode_stream_content`] with a fresh page budget, for reading results back.
2097    fn decode_stream_content(doc: &Document, id: ObjectId) -> Result<Content, PdfEditError> {
2098        super::decode_stream_content(doc, id, &mut ContentBudget::new())
2099    }
2100
2101    fn locate_island(
2102        doc: &Document,
2103        sha: &str,
2104        page: u32,
2105        text: &str,
2106    ) -> crate::model::PdfTextIsland {
2107        let graph = walk_page_graph(doc, sha, page).expect("inspect fixture page");
2108        graph
2109            .islands
2110            .into_iter()
2111            .find(|isl| isl.text == text)
2112            .unwrap_or_else(|| panic!("no island with text {text:?}"))
2113    }
2114
2115    struct FailingFontResolver;
2116    impl PdfFontResolver for FailingFontResolver {
2117        fn resolve(&self, _id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
2118            Err(PdfEditError::UnsupportedEncoding)
2119        }
2120        fn glyphs_for_text(
2121            &self,
2122            _font: &ResolvedPdfFont,
2123            _text: &str,
2124        ) -> Result<Vec<GlyphId>, PdfEditError> {
2125            Err(PdfEditError::UnsupportedEncoding)
2126        }
2127    }
2128
2129    fn installable_font() -> Vec<u8> {
2130        // fsType 0 = installable; the shared builder carries the proven sfnt layout.
2131        crate::test_fixtures::build_test_ttf(
2132            0,
2133            &[('A', 1), ('B', 2), ('C', 3), ('H', 4), ('i', 5)],
2134            600,
2135        )
2136    }
2137
2138    struct TestFontResolver {
2139        id: &'static str,
2140        bytes: Vec<u8>,
2141    }
2142
2143    impl PdfFontResolver for TestFontResolver {
2144        fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
2145            if id != self.id {
2146                return Err(PdfEditError::UnsupportedEncoding);
2147            }
2148            Ok(ResolvedPdfFont {
2149                id: self.id.to_string(),
2150                family: "Test Font".to_string(),
2151                bytes: self.bytes.clone(),
2152                face_index: 0,
2153                embeddable: true,
2154                subsettable: true,
2155            })
2156        }
2157
2158        fn glyphs_for_text(
2159            &self,
2160            font: &ResolvedPdfFont,
2161            text: &str,
2162        ) -> Result<Vec<GlyphId>, PdfEditError> {
2163            // The mock shares `pdf_font.rs`'s real mapping so tests exercise the same
2164            // MissingGlyph/UnsupportedEncoding behavior the production resolver has.
2165            crate::font::glyph_ids_for_text(&font.bytes, font.face_index, text)
2166        }
2167    }
2168
2169    fn appearance(font_id: &str, size_pt: f64) -> PdfTextAppearance {
2170        PdfTextAppearance {
2171            font_id: font_id.to_string(),
2172            family: font_id.to_string(),
2173            size_pt,
2174            color: PdfColor {
2175                r: 0.0,
2176                g: 0.0,
2177                b: 0.0,
2178                alpha: 1.0,
2179            },
2180        }
2181    }
2182
2183    /// The resolver every successful-embed test uses: catalog id `"test-font"` backed by the
2184    /// installable five-glyph fixture font.
2185    fn test_resolver() -> TestFontResolver {
2186        TestFontResolver {
2187            id: "test-font",
2188            bytes: installable_font(),
2189        }
2190    }
2191
2192    /// A `ReplaceSourceText` command whose `before` mirrors `island` exactly (its text and
2193    /// appearance — what a real edit session captures) and whose `after` carries the new
2194    /// text/appearance. Mutate `island.locator` in place before calling to model a stale or
2195    /// tampered locator.
2196    fn replace_cmd(
2197        id: &str,
2198        island: &crate::model::PdfTextIsland,
2199        after_text: &str,
2200        after_appearance: PdfTextAppearance,
2201        available_bounds: PdfRect,
2202    ) -> PdfEditCommand {
2203        PdfEditCommand::ReplaceSourceText {
2204            id: id.to_string(),
2205            locator: island.locator.clone(),
2206            before: PdfTextEditState {
2207                text: island.text.clone(),
2208                appearance: island.appearance.clone(),
2209            },
2210            after: PdfTextEditState {
2211                text: after_text.to_string(),
2212                appearance: after_appearance,
2213            },
2214            available_bounds,
2215        }
2216    }
2217
2218    /// Reopen a composed candidate's bytes and re-walk `page`'s island graph — the shared tail of
2219    /// every compose-then-verify test. Also returns the reopened `Document` for tests that inspect
2220    /// raw page objects (annotations, appended content streams).
2221    fn reinspect(candidate: &ComposedCandidate, page: u32) -> (Document, PdfPageGraph) {
2222        let out = Document::load_mem(&candidate.bytes).expect("reopen candidate");
2223        let graph =
2224            walk_page_graph(&out, &document_sha256(&candidate.bytes), page).expect("reinspect");
2225        (out, graph)
2226    }
2227
2228    // -----------------------------------------------------------------------------------
2229    // §9.1 Exact operand patch
2230    // -----------------------------------------------------------------------------------
2231
2232    #[test]
2233    fn exact_operand_patch_replaces_single_tj_text_in_place() {
2234        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2235        let source = doc_bytes(&doc);
2236        let sha = document_sha256(&source);
2237        let island = locate_island(&doc, &sha, 1, "Hello");
2238
2239        let command = replace_cmd(
2240            "edit-1",
2241            &island,
2242            "Howdy",
2243            island.appearance.clone(),
2244            PdfRect {
2245                x: 72.0,
2246                y: 695.0,
2247                width: 400.0,
2248                height: 20.0,
2249            },
2250        );
2251
2252        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
2253            .expect("exact patch composes");
2254        assert_eq!(candidate.affected_pages, vec![1]);
2255
2256        let (_, graph) = reinspect(&candidate, 1);
2257        assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
2258        assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
2259    }
2260
2261    #[test]
2262    fn corrected_source_text_survives_native_staging_verification() {
2263        let source = include_bytes!("../tests/fixtures/source-correction-2page.pdf");
2264        let doc = Document::load_mem(source).expect("fixture");
2265        let sha = document_sha256(source);
2266        let island = locate_island(&doc, &sha, 1, "ORIGINAL SOURCE TEXT");
2267        let command = replace_cmd(
2268            "correction",
2269            &island,
2270            "SOURCE TEST 930",
2271            island.appearance.clone(),
2272            PdfRect {
2273                x: 72.0,
2274                y: 717.0,
2275                width: 146.028,
2276                height: 12.0,
2277            },
2278        );
2279        // Fresh font HashMaps previously chose WinAnsi's unmeasured 0xA0 space alias randomly.
2280        for _ in 0..32 {
2281            assert_eq!(
2282                simple_font_identity_bytes(
2283                    &doc,
2284                    *doc.get_pages().get(&1).expect("page"),
2285                    b"F1",
2286                    "SOURCE TEST 930"
2287                ),
2288                Some(b"SOURCE TEST 930".to_vec())
2289            );
2290            let candidate =
2291                compose_candidate(source, std::slice::from_ref(&command), &FailingFontResolver)
2292                    .expect("compose");
2293            let (_, graph) = reinspect(&candidate, 1);
2294            assert!(graph
2295                .islands
2296                .iter()
2297                .any(|island| island.text.contains("SOURCE TEST 930")));
2298            assert!(graph
2299                .islands
2300                .iter()
2301                .any(|island| island.text == "SIBLING SOURCE TEXT"));
2302            let store = crate::save::PdfSaveTokenStore::default();
2303            crate::save::stage_prepared_save(
2304                &store,
2305                "/fixture.pdf",
2306                &sha,
2307                &sha,
2308                candidate.bytes,
2309                candidate.affected_pages,
2310                crate::save::expected_text_checks(std::slice::from_ref(&command)),
2311            )
2312            .expect("verified stage");
2313        }
2314    }
2315
2316    #[test]
2317    fn exact_operand_patch_rejects_overflow_before_mutating() {
2318        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hi) Tj ET");
2319        let source = doc_bytes(&doc);
2320        let sha = document_sha256(&source);
2321        let island = locate_island(&doc, &sha, 1, "Hi");
2322
2323        // Tiny available rect: the long replacement cannot fit.
2324        let command = replace_cmd(
2325            "edit-overflow",
2326            &island,
2327            "This replacement text is far too long",
2328            island.appearance.clone(),
2329            PdfRect {
2330                x: 72.0,
2331                y: 695.0,
2332                width: 10.0,
2333                height: 10.0,
2334            },
2335        );
2336
2337        let err = compose_candidate(&source, &[command], &FailingFontResolver)
2338            .expect_err("overflow must reject");
2339        assert!(matches!(err, PdfEditError::TextOverflow));
2340    }
2341
2342    // -----------------------------------------------------------------------------------
2343    // compose_candidate_on_base — composing on top of pdf.js's annotation incremental update
2344    // -----------------------------------------------------------------------------------
2345
2346    #[test]
2347    fn compose_candidate_on_base_preserves_annotations_while_editing_text() {
2348        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2349        let source = doc_bytes(&doc);
2350        let sha = document_sha256(&source);
2351        let island = locate_island(&doc, &sha, 1, "Hello");
2352
2353        // Simulate pdf.js's `saveDocument()` incremental update landing on the frontend: same
2354        // document, but with a FreeText annotation now present on page 1's /Annots. This is the
2355        // `base` prepare_pdf_save composes on top of; `sha`/`island.locator` are still captured
2356        // against the original, unannotated `source` bytes, exactly as the real edit session does.
2357        let mut annotated = doc.clone();
2358        let page_id = *annotated.get_pages().get(&1).expect("page 1 exists");
2359        let annot_id = annotated.add_object(dictionary! {
2360            "Type" => "Annot",
2361            "Subtype" => "FreeText",
2362            "Rect" => LObject::Array(vec![
2363                LObject::Real(10.0),
2364                LObject::Real(10.0),
2365                LObject::Real(100.0),
2366                LObject::Real(30.0),
2367            ]),
2368            "Contents" => LObject::String(b"note".to_vec(), lopdf::StringFormat::Literal),
2369        });
2370        if let Ok(LObject::Dictionary(page_dict)) = annotated.get_object_mut(page_id) {
2371            page_dict.set("Annots", LObject::Array(vec![LObject::Reference(annot_id)]));
2372        }
2373        let base = doc_bytes(&annotated);
2374
2375        let command = replace_cmd(
2376            "edit-1",
2377            &island,
2378            "Howdy",
2379            island.appearance.clone(),
2380            PdfRect {
2381                x: 72.0,
2382                y: 695.0,
2383                width: 400.0,
2384                height: 20.0,
2385            },
2386        );
2387
2388        let candidate = compose_candidate_on_base(&sha, &base, &[command], &FailingFontResolver)
2389            .expect("compose on annotated base");
2390
2391        let (out, graph) = reinspect(&candidate, 1);
2392        let out_page_id = *out.get_pages().get(&1).expect("page 1 exists in candidate");
2393        let out_page_dict = out
2394            .get_object(out_page_id)
2395            .expect("page object")
2396            .as_dict()
2397            .expect("page dict");
2398        assert!(
2399            out_page_dict.has(b"Annots"),
2400            "FreeText annotation must survive text composition"
2401        );
2402
2403        assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
2404        assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
2405    }
2406
2407    #[test]
2408    fn compose_candidate_on_base_with_no_commands_returns_base_verbatim() {
2409        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2410        let base = doc_bytes(&doc);
2411        let sha = document_sha256(&base);
2412        let candidate = compose_candidate_on_base(&sha, &base, &[], &FailingFontResolver)
2413            .expect("empty commands compose trivially");
2414        assert_eq!(candidate.bytes, base);
2415        assert!(candidate.affected_pages.is_empty());
2416    }
2417
2418    // -----------------------------------------------------------------------------------
2419    // Stale source / digest mismatch
2420    // -----------------------------------------------------------------------------------
2421
2422    #[test]
2423    fn stale_document_sha256_is_rejected() {
2424        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2425        let source = doc_bytes(&doc);
2426        let sha = document_sha256(&source);
2427        let mut island = locate_island(&doc, &sha, 1, "Hello");
2428        island.locator.document_sha256 =
2429            "0000000000000000000000000000000000000000000000000000000000000000".to_string();
2430
2431        let command = replace_cmd(
2432            "edit-stale",
2433            &island,
2434            "Howdy",
2435            island.appearance.clone(),
2436            PdfRect {
2437                x: 72.0,
2438                y: 695.0,
2439                width: 400.0,
2440                height: 20.0,
2441            },
2442        );
2443
2444        let err = compose_candidate(&source, &[command], &FailingFontResolver)
2445            .expect_err("stale source must reject");
2446        assert!(matches!(err, PdfEditError::StaleSource));
2447    }
2448
2449    #[test]
2450    fn tampered_island_digest_with_valid_document_sha_is_source_digest_mismatch() {
2451        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2452        let source = doc_bytes(&doc);
2453        let sha = document_sha256(&source);
2454        let mut island = locate_island(&doc, &sha, 1, "Hello");
2455
2456        // `document_sha256` still matches (nothing about the document actually changed), but the
2457        // specific island_digest given doesn't correspond to any island the fresh walk produces —
2458        // models "same visible text but changed operand digest" (§14.1).
2459        island.locator.island_digest = format!("{}-tampered", island.locator.island_digest);
2460
2461        let command = replace_cmd(
2462            "edit-tampered",
2463            &island,
2464            "Howdy",
2465            island.appearance.clone(),
2466            PdfRect {
2467                x: 72.0,
2468                y: 695.0,
2469                width: 400.0,
2470                height: 20.0,
2471            },
2472        );
2473
2474        let err = compose_candidate(&source, &[command], &FailingFontResolver)
2475            .expect_err("tampered digest must reject");
2476        assert!(matches!(err, PdfEditError::SourceDigestMismatch));
2477    }
2478
2479    // -----------------------------------------------------------------------------------
2480    // §9.2 Local text-object rewrite (split runs / changed appearance)
2481    // -----------------------------------------------------------------------------------
2482
2483    #[test]
2484    fn local_rewrite_replaces_adjacent_tj_operators_via_embedded_font_round_trip() {
2485        // Standard-14 metrics place the second run after the first; Td 0 0 would overlap it.
2486        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (CAR YOU) Tj 55 0 Td ( LIST.) Tj ET");
2487        let source = doc_bytes(&doc);
2488        let sha = document_sha256(&source);
2489        let island = locate_island(&doc, &sha, 1, "CAR YOU LIST.");
2490        // This fixture's grouping produces >1 span (two Tj operators), so exact-patch's
2491        // `one_operand` gate cannot apply regardless of appearance — always local rewrite.
2492        assert!(island.locator.spans.len() > 1);
2493
2494        let resolver = test_resolver();
2495        let command = replace_cmd(
2496            "edit-split",
2497            &island,
2498            "Hi",
2499            appearance("test-font", 20.0),
2500            PdfRect {
2501                x: 72.0,
2502                y: 690.0,
2503                width: 400.0,
2504                height: 40.0,
2505            },
2506        );
2507
2508        let candidate =
2509            compose_candidate(&source, &[command], &resolver).expect("local rewrite composes");
2510        let (_, graph) = reinspect(&candidate, 1);
2511
2512        // The old run's text is gone (bytes blanked in place) and the new run round-trips through
2513        // the freshly embedded Type0/CIDFontType2/ToUnicode subset exactly as "Hi".
2514        assert!(!graph.islands.iter().any(|isl| isl.text == "CAR YOU LIST."));
2515        assert!(graph
2516            .islands
2517            .iter()
2518            .any(|isl| isl.text == "Hi" && isl.editable));
2519    }
2520
2521    #[test]
2522    fn local_rewrite_used_when_appearance_changes_even_for_single_span() {
2523        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2524        let source = doc_bytes(&doc);
2525        let sha = document_sha256(&source);
2526        let island = locate_island(&doc, &sha, 1, "Hello");
2527        assert_eq!(island.locator.spans.len(), 1);
2528
2529        let resolver = test_resolver();
2530        // Different font_id/size than `before` — same_font_encoding is false, so this must
2531        // route to LocalTextObjectRewrite despite a single-span locator.
2532        let command = replace_cmd(
2533            "edit-appearance",
2534            &island,
2535            "Hi",
2536            appearance("test-font", 24.0),
2537            PdfRect {
2538                x: 72.0,
2539                y: 690.0,
2540                width: 400.0,
2541                height: 40.0,
2542            },
2543        );
2544
2545        let candidate =
2546            compose_candidate(&source, &[command], &resolver).expect("compose succeeds");
2547        let (_, graph) = reinspect(&candidate, 1);
2548        assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
2549    }
2550
2551    // -----------------------------------------------------------------------------------
2552    // §9.3 Add Text
2553    // -----------------------------------------------------------------------------------
2554
2555    #[test]
2556    fn add_text_appends_isolated_stream_without_touching_existing_content() {
2557        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2558        let source = doc_bytes(&doc);
2559
2560        let resolver = test_resolver();
2561        let command = PdfEditCommand::AddText {
2562            id: "add-1".to_string(),
2563            page: 1,
2564            anchor: PdfPoint { x: 100.0, y: 100.0 },
2565            text: "Hi".to_string(),
2566            appearance: appearance("test-font", 18.0),
2567        };
2568
2569        let candidate =
2570            compose_candidate(&source, &[command], &resolver).expect("add text composes");
2571        assert_eq!(candidate.affected_pages, vec![1]);
2572
2573        let (_, graph) = reinspect(&candidate, 1);
2574
2575        assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
2576        assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
2577    }
2578
2579    #[test]
2580    fn add_text_missing_glyph_rejects_before_any_mutation() {
2581        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2582        let source = doc_bytes(&doc);
2583        // `installable_font` only maps A, B, C, H, i.
2584        let resolver = test_resolver();
2585        let command = PdfEditCommand::AddText {
2586            id: "add-missing".to_string(),
2587            page: 1,
2588            anchor: PdfPoint { x: 100.0, y: 100.0 },
2589            text: "Zzz".to_string(),
2590            appearance: appearance("test-font", 18.0),
2591        };
2592
2593        let err = compose_candidate(&source, &[command], &resolver)
2594            .expect_err("missing glyph must reject");
2595        assert!(matches!(err, PdfEditError::MissingGlyph));
2596    }
2597
2598    #[test]
2599    fn add_text_unknown_font_id_rejects_with_unsupported_encoding() {
2600        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2601        let source = doc_bytes(&doc);
2602        let command = PdfEditCommand::AddText {
2603            id: "add-unknown-font".to_string(),
2604            page: 1,
2605            anchor: PdfPoint { x: 100.0, y: 100.0 },
2606            text: "Hi".to_string(),
2607            appearance: appearance("does-not-exist", 18.0),
2608        };
2609
2610        let err = compose_candidate(&source, &[command], &FailingFontResolver)
2611            .expect_err("unknown font id must reject");
2612        assert!(matches!(err, PdfEditError::UnsupportedEncoding));
2613    }
2614
2615    fn ocr_command(id: &str, line_id: &str, text: &str, rect: PdfRect) -> PdfEditCommand {
2616        PdfEditCommand::CorrectOcrLine {
2617            id: id.to_string(),
2618            page: 1,
2619            line_id: line_id.to_string(),
2620            rect,
2621            text: text.to_string(),
2622            appearance: appearance("test-font", 18.0),
2623            cover_color: PdfColor {
2624                r: 1.0,
2625                g: 1.0,
2626                b: 1.0,
2627                alpha: 1.0,
2628            },
2629            background_proof: PdfOcrBackgroundProof::NearUniform,
2630        }
2631    }
2632
2633    #[test]
2634    fn correct_ocr_text_appends_cover_and_type0_text_stream() {
2635        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2636        let source = doc_bytes(&doc);
2637        let resolver = test_resolver();
2638        let command = ocr_command(
2639            "ocr-1",
2640            "line-1",
2641            "Hi",
2642            PdfRect {
2643                x: 100.0,
2644                y: 100.0,
2645                width: 100.0,
2646                height: 30.0,
2647            },
2648        );
2649        let candidate = compose_candidate(&source, &[command], &resolver).expect("compose OCR");
2650        let (out, graph) = reinspect(&candidate, 1);
2651        let page_id = out.get_pages()[&1];
2652        let page = out
2653            .get_object(page_id)
2654            .expect("page")
2655            .as_dict()
2656            .expect("dict");
2657        let contents = page
2658            .get(b"Contents")
2659            .expect("contents")
2660            .as_array()
2661            .expect("array");
2662        let stream_id = contents
2663            .last()
2664            .expect("new stream")
2665            .as_reference()
2666            .expect("ref");
2667        let stream = out
2668            .get_object(stream_id)
2669            .expect("stream")
2670            .as_stream()
2671            .expect("stream");
2672        let content = Content::decode(&stream.decompressed_content().expect("decode stream"))
2673            .expect("content operations");
2674        let operators: Vec<&str> = content
2675            .operations
2676            .iter()
2677            .map(|operation| operation.operator.as_str())
2678            .collect();
2679        assert_eq!(&operators[..5], &["q", "rg", "re", "f", "Q"]);
2680        assert!(operators.windows(2).any(|window| window == ["BT", "Tf"]));
2681
2682        assert!(graph.islands.iter().any(|island| island.text == "Hi"));
2683    }
2684
2685    #[test]
2686    fn correct_ocr_text_duplicate_line_id_last_wins() {
2687        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2688        let source = doc_bytes(&doc);
2689        let resolver = test_resolver();
2690        let rect = PdfRect {
2691            x: 100.0,
2692            y: 100.0,
2693            width: 100.0,
2694            height: 30.0,
2695        };
2696        let commands = vec![
2697            ocr_command("ocr-old", "line-1", "Hi", rect),
2698            ocr_command("ocr-new", "line-1", "Hi", rect),
2699        ];
2700        let candidate = compose_candidate(&source, &commands, &resolver).expect("compose OCR");
2701        let (_, graph) = reinspect(&candidate, 1);
2702        assert_eq!(
2703            graph
2704                .islands
2705                .iter()
2706                .filter(|island| island.text == "Hi")
2707                .count(),
2708            1
2709        );
2710    }
2711
2712    #[test]
2713    fn correct_ocr_text_rejects_rotation_and_out_of_bounds_rect() {
2714        let mut rotated = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2715        let page_id = rotated.get_pages()[&1];
2716        rotated
2717            .get_object_mut(page_id)
2718            .expect("page")
2719            .as_dict_mut()
2720            .expect("dict")
2721            .set("Rotate", 90);
2722        let rotated_source = doc_bytes(&rotated);
2723        let resolver = test_resolver();
2724        let rect = PdfRect {
2725            x: 100.0,
2726            y: 100.0,
2727            width: 100.0,
2728            height: 30.0,
2729        };
2730        assert!(matches!(
2731            compose_candidate(
2732                &rotated_source,
2733                &[ocr_command("r", "line", "Hi", rect)],
2734                &resolver
2735            ),
2736            Err(PdfEditError::UnsupportedTransform)
2737        ));
2738
2739        let source = doc_bytes(&build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET"));
2740        let bad_rect = PdfRect {
2741            x: 600.0,
2742            y: 100.0,
2743            width: 100.0,
2744            height: 30.0,
2745        };
2746        assert!(matches!(
2747            compose_candidate(
2748                &source,
2749                &[ocr_command("b", "line", "Hi", bad_rect)],
2750                &resolver
2751            ),
2752            Err(PdfEditError::TextOverflow)
2753        ));
2754    }
2755
2756    #[test]
2757    fn empty_command_list_returns_source_bytes_unchanged() {
2758        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2759        let source = doc_bytes(&doc);
2760        let candidate = compose_candidate(&source, &[], &FailingFontResolver)
2761            .expect("empty batch always succeeds");
2762        assert_eq!(candidate.bytes, source);
2763        assert!(candidate.affected_pages.is_empty());
2764    }
2765    // -----------------------------------------------------------------------------------
2766    // Honest editability, typed missing glyphs, exact overflow, multi-span and CID writes
2767    // -----------------------------------------------------------------------------------
2768
2769    use crate::test_fixtures::{
2770        custom_page_pdf, differences_encoding, helvetica_font, identity_h_font, subset_type1_font,
2771    };
2772
2773    /// Axis-aligned bounds of an island's glyph quads: exactly what a UI derives for "the
2774    /// island's own box".
2775    fn island_box(island: &crate::model::PdfTextIsland) -> PdfRect {
2776        let mut min_x = f64::MAX;
2777        let mut min_y = f64::MAX;
2778        let mut max_x = f64::MIN;
2779        let mut max_y = f64::MIN;
2780        for quad in &island.quads {
2781            for corner in 0..4 {
2782                min_x = min_x.min(quad[corner * 2]);
2783                max_x = max_x.max(quad[corner * 2]);
2784                min_y = min_y.min(quad[corner * 2 + 1]);
2785                max_y = max_y.max(quad[corner * 2 + 1]);
2786            }
2787        }
2788        PdfRect {
2789            x: min_x,
2790            y: min_y,
2791            width: max_x - min_x,
2792            height: max_y - min_y,
2793        }
2794    }
2795
2796    const PAGE_WIDE: PdfRect = PdfRect {
2797        x: 0.0,
2798        y: 0.0,
2799        width: 612.0,
2800        height: 792.0,
2801    };
2802
2803    #[test]
2804    fn same_glyph_swap_fits_the_islands_own_box() {
2805        // Ledger #93: "Page 1: external link below" -> "Page 1: xternale link below" (same length,
2806        // same glyph multiset) was `text_overflow` in the island's own box and fine page-wide,
2807        // because the old fit check guessed half an em per character (27 x 12 = 324 pt) against a
2808        // box that is 286.8 pt of real Helvetica advances.
2809        let source = include_bytes!("../tests/fixtures/proof-text-50page.pdf");
2810        let doc = Document::load_mem(source).expect("fixture");
2811        let sha = document_sha256(source);
2812        let island = locate_island(&doc, &sha, 1, "Page 1: external link below");
2813        assert!(island.editable, "{:?}", island.edit_blocker);
2814        let own = island_box(&island);
2815        assert!((own.width - 286.8).abs() < 0.01, "{}", own.width);
2816
2817        for (label, bounds) in [("own box", own), ("page wide", PAGE_WIDE)] {
2818            let command = replace_cmd(
2819                "swap",
2820                &island,
2821                "Page 1: xternale link below",
2822                island.appearance.clone(),
2823                bounds,
2824            );
2825            let candidate = compose_candidate(source, &[command], &FailingFontResolver)
2826                .unwrap_or_else(|error| panic!("{label}: {error}"));
2827            let (_, graph) = reinspect(&candidate, 1);
2828            assert!(
2829                graph
2830                    .islands
2831                    .iter()
2832                    .any(|isl| isl.text == "Page 1: xternale link below"),
2833                "{label}"
2834            );
2835        }
2836
2837        // The check is still real: one point narrower than the true width overflows.
2838        let narrow = PdfRect {
2839            width: own.width - 1.0,
2840            ..own
2841        };
2842        let command = replace_cmd(
2843            "swap",
2844            &island,
2845            "Page 1: xternale link below",
2846            island.appearance.clone(),
2847            narrow,
2848        );
2849        assert!(matches!(
2850            compose_candidate(source, &[command], &FailingFontResolver),
2851            Err(PdfEditError::TextOverflow)
2852        ));
2853    }
2854
2855    #[test]
2856    fn narrower_replacement_never_overflows_its_own_box_under_character_and_word_spacing() {
2857        let doc = build_pdf(b"BT /F1 12 Tf 1 Tc 3 Tw 72 700 Td (a b c) Tj ET");
2858        let source = doc_bytes(&doc);
2859        let sha = document_sha256(&source);
2860        let island = locate_island(&doc, &sha, 1, "a b c");
2861        let own = island_box(&island);
2862        // Same widths in a different order: the spacing terms must match the walker's exactly.
2863        let command = replace_cmd("order", &island, "c b a", island.appearance.clone(), own);
2864        compose_candidate(&source, &[command], &FailingFontResolver)
2865            .expect("same advance fits its own box");
2866    }
2867
2868    fn subset_with_differences(first_widths: &[i64]) -> Document {
2869        let encoding = differences_encoding(
2870            None,
2871            true,
2872            vec![
2873                LObject::Integer(65),
2874                LObject::Name(b"A".to_vec()),
2875                LObject::Name(b"B".to_vec()),
2876            ],
2877        );
2878        custom_page_pdf(
2879            |d| {
2880                vec![(
2881                    "F1",
2882                    subset_type1_font(d, 65, first_widths, Some(encoding), None),
2883                )]
2884            },
2885            &[b"BT /F1 12 Tf 72 700 Td (AB) Tj ET"],
2886            false,
2887        )
2888    }
2889
2890    #[test]
2891    fn missing_glyph_error_names_every_character_the_subset_lacks() {
2892        let doc = subset_with_differences(&[500, 600, 700]);
2893        let source = doc_bytes(&doc);
2894        let sha = document_sha256(&source);
2895        let island = locate_island(&doc, &sha, 1, "AB");
2896        assert!(island.editable);
2897
2898        let command = replace_cmd(
2899            "miss",
2900            &island,
2901            "ABC!C",
2902            island.appearance.clone(),
2903            PAGE_WIDE,
2904        );
2905        let error = compose_candidate(&source, &[command], &FailingFontResolver)
2906            .expect_err("missing glyphs are refused");
2907        assert_eq!(error.missing_chars(), Some(&['C', '!'][..]));
2908        assert_eq!(error.code(), "missing_glyph");
2909        assert!(error.to_string().contains("'C', '!'"), "{error}");
2910    }
2911
2912    #[test]
2913    fn graph_editable_and_compose_agree_for_every_island() {
2914        // F1: Helvetica (re-encodable). F2: an embedded symbolic subset with a built-in encoding
2915        // (never re-encodable). Replacing an island with its own text, same appearance, in its own
2916        // box must succeed exactly when the graph says `editable`.
2917        let doc = custom_page_pdf(
2918            |d| {
2919                vec![
2920                    ("F1", helvetica_font(d)),
2921                    ("F2", subset_type1_font(d, 65, &[500, 600], None, None)),
2922                ]
2923            },
2924            &[b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET BT /F2 12 Tf 72 600 Td (AB) Tj ET"],
2925            false,
2926        );
2927        let source = doc_bytes(&doc);
2928        let sha = document_sha256(&source);
2929        let graph = walk_page_graph(&doc, &sha, 1).expect("graph");
2930        assert_eq!(graph.islands.len(), 2);
2931        for island in &graph.islands {
2932            let command = replace_cmd(
2933                "same",
2934                island,
2935                &island.text,
2936                island.appearance.clone(),
2937                island_box(island),
2938            );
2939            let outcome = compose_candidate(&source, &[command], &FailingFontResolver);
2940            if island.editable {
2941                assert!(outcome.is_ok(), "{}: {:?}", island.text, outcome.err());
2942            } else {
2943                assert!(
2944                    matches!(outcome, Err(PdfEditError::UnsupportedEncoding)),
2945                    "{}",
2946                    island.text
2947                );
2948            }
2949        }
2950        assert_eq!(
2951            graph.islands.iter().filter(|i| i.editable).count(),
2952            1,
2953            "exactly the Helvetica island is editable"
2954        );
2955    }
2956
2957    #[test]
2958    fn kerned_tj_replacement_writes_one_run_into_the_first_span() {
2959        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td [(Hel) -20 (lo)] TJ ET");
2960        let source = doc_bytes(&doc);
2961        let sha = document_sha256(&source);
2962        let island = locate_island(&doc, &sha, 1, "Hello");
2963        assert_eq!(island.locator.spans.len(), 2);
2964
2965        let command = replace_cmd(
2966            "kern",
2967            &island,
2968            "Help!",
2969            island.appearance.clone(),
2970            PAGE_WIDE,
2971        );
2972        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
2973            .expect("multi-operand island composes in place");
2974        let (out, graph) = reinspect(&candidate, 1);
2975        assert!(graph.islands.iter().any(|isl| isl.text == "Help!"));
2976        assert!(!graph.islands.iter().any(|isl| isl.text.contains("lo")));
2977
2978        // The numeric adjustment survives; the second string is blanked, not removed.
2979        let page_id = out.get_pages()[&1];
2980        let stream_id = content_stream_ids(
2981            &out,
2982            out.get_object(page_id)
2983                .expect("page")
2984                .as_dict()
2985                .expect("dict")
2986                .get(b"Contents")
2987                .expect("contents"),
2988        )
2989        .expect("ids")[0];
2990        let content = decode_stream_content(&out, stream_id).expect("content");
2991        let tj = content
2992            .operations
2993            .iter()
2994            .find(|op| op.operator == "TJ")
2995            .expect("TJ kept");
2996        let Some(Object::Array(items)) = tj.operands.first() else {
2997            panic!("TJ array");
2998        };
2999        // Only the changed tail is rewritten; "Hel" and the kern in front of it are untouched.
3000        assert!(matches!(&items[0], Object::String(b, _) if b == b"Hel"));
3001        assert!(matches!(&items[1], Object::Integer(-20)));
3002        assert!(matches!(&items[2], Object::String(b, _) if b == b"p!"));
3003    }
3004
3005    #[test]
3006    fn adjacent_tj_operators_replace_in_place_when_the_font_can_encode() {
3007        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj (World) Tj ET");
3008        let source = doc_bytes(&doc);
3009        let sha = document_sha256(&source);
3010        let island = locate_island(&doc, &sha, 1, "HelloWorld");
3011        assert_eq!(island.locator.spans.len(), 2);
3012        let command = replace_cmd(
3013            "two-tj",
3014            &island,
3015            "Hi there",
3016            island.appearance.clone(),
3017            PAGE_WIDE,
3018        );
3019        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3020            .expect("two Tj operators compose in place");
3021        let (_, graph) = reinspect(&candidate, 1);
3022        assert!(graph.islands.iter().any(|isl| isl.text == "Hi there"));
3023        assert!(!graph.islands.iter().any(|isl| isl.text.contains("World")));
3024    }
3025
3026    #[test]
3027    fn identity_h_island_is_rewritten_with_two_byte_codes_and_refuses_unknown_characters() {
3028        let doc = custom_page_pdf(
3029            |d| {
3030                vec![(
3031                    "F1",
3032                    identity_h_font(
3033                        d,
3034                        &[(1, 600), (2, 300), (3, 500)],
3035                        &[(1, 'H'), (2, 'i'), (3, '!')],
3036                    ),
3037                )]
3038            },
3039            &[b"BT /F1 12 Tf 72 700 Td <00010002> Tj ET"],
3040            false,
3041        );
3042        let source = doc_bytes(&doc);
3043        let sha = document_sha256(&source);
3044        let island = locate_island(&doc, &sha, 1, "Hi");
3045        assert!(island.editable);
3046
3047        let command = replace_cmd("cid", &island, "i!H", island.appearance.clone(), PAGE_WIDE);
3048        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3049            .expect("CID island composes in place");
3050        let (_, graph) = reinspect(&candidate, 1);
3051        assert!(graph.islands.iter().any(|isl| isl.text == "i!H"));
3052
3053        let command = replace_cmd(
3054            "cid-miss",
3055            &island,
3056            "Hx",
3057            island.appearance.clone(),
3058            PAGE_WIDE,
3059        );
3060        let error = compose_candidate(&source, &[command], &FailingFontResolver)
3061            .expect_err("unknown character refused");
3062        assert_eq!(error.missing_chars(), Some(&['x'][..]));
3063    }
3064
3065    #[test]
3066    fn add_text_on_an_indirect_array_contents_page_keeps_the_stream_list_flat() {
3067        let doc = custom_page_pdf(
3068            |d| vec![("F1", helvetica_font(d))],
3069            &[b"BT /F1 12 Tf 72 700 Td ", b"(Existing) Tj ET"],
3070            true,
3071        );
3072        let source = doc_bytes(&doc);
3073        let command = PdfEditCommand::AddText {
3074            id: "add".to_string(),
3075            page: 1,
3076            anchor: PdfPoint { x: 100.0, y: 100.0 },
3077            text: "Hi".to_string(),
3078            appearance: appearance("test-font", 18.0),
3079        };
3080        let candidate =
3081            compose_candidate(&source, &[command], &test_resolver()).expect("add text composes");
3082        let (out, graph) = reinspect(&candidate, 1);
3083        assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
3084        assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
3085
3086        let page_id = out.get_pages()[&1];
3087        let contents = out
3088            .get_object(page_id)
3089            .expect("page")
3090            .as_dict()
3091            .expect("dict")
3092            .get(b"Contents")
3093            .expect("contents")
3094            .as_array()
3095            .expect("a direct array now");
3096        // q wrapper, the two original streams, Q wrapper, the added stream.
3097        assert_eq!(contents.len(), 5);
3098        for item in contents {
3099            let id = item.as_reference().expect("reference");
3100            out.get_object(id)
3101                .expect("object")
3102                .as_stream()
3103                .expect("every entry is a stream, never the old array object");
3104        }
3105    }
3106
3107    #[test]
3108    fn unparseable_stream_tail_is_refused_rather_than_truncated_on_write() {
3109        // The lenient parser stops at the unterminated string and would drop it on rewrite.
3110        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET (unterminated");
3111        let source = doc_bytes(&doc);
3112        let sha = document_sha256(&source);
3113        let island = locate_island(&doc, &sha, 1, "Hello");
3114        let command = replace_cmd(
3115            "tail",
3116            &island,
3117            "Howdy",
3118            island.appearance.clone(),
3119            PAGE_WIDE,
3120        );
3121        assert!(matches!(
3122            compose_candidate(&source, &[command], &FailingFontResolver),
3123            Err(PdfEditError::MalformedContent)
3124        ));
3125    }
3126    // -----------------------------------------------------------------------------------
3127    // TJ-positioned word gaps: synthesised spaces, byte-for-byte preservation, pen positions
3128    // -----------------------------------------------------------------------------------
3129
3130    use crate::test_fixtures::tex_style_pdf;
3131
3132    const KERNED: &[u8] = b"BT /F1 10 Tf 72 700 Td [(hello) -333 (big) -333 (world)] TJ ET";
3133
3134    fn lefts(island: &crate::model::PdfTextIsland) -> Vec<f64> {
3135        island.quads.iter().map(|q| q[0]).collect()
3136    }
3137
3138    /// The first `TJ` array of the first content stream, as strings (`"<n>"` for numbers).
3139    fn tj_shape(candidate: &ComposedCandidate) -> Vec<String> {
3140        let (out, _) = reinspect(candidate, 1);
3141        let page_id = out.get_pages()[&1];
3142        let ids = content_stream_ids(
3143            &out,
3144            out.get_object(page_id)
3145                .expect("page")
3146                .as_dict()
3147                .expect("dict")
3148                .get(b"Contents")
3149                .expect("contents"),
3150        )
3151        .expect("ids");
3152        let content = decode_stream_content(&out, ids[0]).expect("content");
3153        let tj = content
3154            .operations
3155            .iter()
3156            .find(|op| op.operator == "TJ")
3157            .expect("TJ");
3158        let Some(Object::Array(items)) = tj.operands.first() else {
3159            panic!("TJ array");
3160        };
3161        items
3162            .iter()
3163            .map(|item| match item {
3164                Object::String(bytes, _) => String::from_utf8_lossy(bytes).into_owned(),
3165                Object::Integer(n) => format!("<{n}>"),
3166                Object::Real(n) => format!("<{n}>"),
3167                other => panic!("{other:?}"),
3168            })
3169            .collect()
3170    }
3171
3172    fn edit_text(
3173        doc: &Document,
3174        island: &crate::model::PdfTextIsland,
3175        after: &str,
3176    ) -> Result<ComposedCandidate, PdfEditError> {
3177        let command = replace_cmd(
3178            "gap",
3179            island,
3180            after,
3181            island.appearance.clone(),
3182            island_box_wide(island),
3183        );
3184        compose_candidate(&doc_bytes(doc), &[command], &FailingFontResolver)
3185    }
3186
3187    /// The island's own box widened to the page, as the app does when the line has room.
3188    fn island_box_wide(island: &crate::model::PdfTextIsland) -> PdfRect {
3189        let own = island_box(island);
3190        PdfRect {
3191            width: PAGE_WIDE.width - own.x,
3192            ..own
3193        }
3194    }
3195
3196    #[test]
3197    fn tj_word_gaps_become_spaces_in_island_text_with_recorded_widths() {
3198        let doc = tex_style_pdf(false, KERNED);
3199        let sha = document_sha256(&doc_bytes(&doc));
3200        let island = locate_island(&doc, &sha, 1, "hello big world");
3201        assert!(island.editable, "{:?}", island.edit_blocker);
3202        assert_eq!(island.quads.len(), 13, "gaps are not glyphs");
3203        let gaps: Vec<(usize, f64)> = island
3204            .gaps
3205            .iter()
3206            .map(|g| (g.char_index, g.width_pt))
3207            .collect();
3208        assert_eq!(gaps.len(), 2);
3209        assert_eq!((gaps[0].0, gaps[1].0), (5, 9));
3210        assert!(
3211            (gaps[0].1 - 3.33).abs() < 0.01 && (gaps[1].1 - 3.33).abs() < 0.01,
3212            "{gaps:?}"
3213        );
3214        let json = serde_json::to_value(&island).expect("serializes");
3215        assert_eq!(json["gaps"][0]["charIndex"], 5);
3216    }
3217
3218    #[test]
3219    fn small_kerns_letter_spacing_and_real_spaces_do_not_become_gaps() {
3220        let doc = build_pdf(b"BT /F1 12 Tf 3 Tc 72 700 Td [(h) (e) (l) (l) (o)] TJ ET");
3221        let sha = document_sha256(&doc_bytes(&doc));
3222        let island = locate_island(&doc, &sha, 1, "hello");
3223        assert!(island.gaps.is_empty());
3224
3225        let doc = tex_style_pdf(true, b"BT /F1 10 Tf 72 700 Td [(hello ) -333 (big)] TJ ET");
3226        let sha = document_sha256(&doc_bytes(&doc));
3227        let island = locate_island(&doc, &sha, 1, "hello big");
3228        assert!(
3229            island.gaps.is_empty(),
3230            "a real space already marks the word break"
3231        );
3232    }
3233
3234    #[test]
3235    fn word_rotation_keeps_every_gap_and_every_glyph_position() {
3236        for space_glyph in [false, true] {
3237            let doc = tex_style_pdf(space_glyph, KERNED);
3238            let sha = document_sha256(&doc_bytes(&doc));
3239            let island = locate_island(&doc, &sha, 1, "hello big world");
3240            let candidate = edit_text(&doc, &island, "hello big orldw").expect("composes");
3241            let (_, graph) = reinspect(&candidate, 1);
3242            let after = graph
3243                .islands
3244                .iter()
3245                .find(|i| i.text == "hello big orldw")
3246                .expect("island text equals the request, spaces included");
3247            assert_eq!(after.gaps.len(), 2);
3248            let (before_x, after_x) = (lefts(&island), lefts(after));
3249            assert_eq!(before_x.len(), after_x.len());
3250            for (b, a) in before_x.iter().zip(&after_x) {
3251                assert!((b - a).abs() < 0.01, "glyph moved: {b} -> {a}");
3252            }
3253            // The untouched words and both adjustments are byte for byte what they were.
3254            assert_eq!(
3255                tj_shape(&candidate),
3256                ["hello", "<-333>", "big", "<-333>", "orldw"]
3257            );
3258        }
3259    }
3260
3261    #[test]
3262    fn inserting_a_word_regenerates_a_gap_of_the_original_width() {
3263        let doc = tex_style_pdf(false, KERNED);
3264        let sha = document_sha256(&doc_bytes(&doc));
3265        let island = locate_island(&doc, &sha, 1, "hello big world");
3266        let candidate = edit_text(&doc, &island, "hello a big world").expect("composes");
3267        assert_eq!(
3268            tj_shape(&candidate),
3269            ["hello", "<-333>", "a", "<-333>", "big", "<-333>", "world"]
3270        );
3271        let (_, graph) = reinspect(&candidate, 1);
3272        let after = graph
3273            .islands
3274            .iter()
3275            .find(|i| i.text == "hello a big world")
3276            .expect("exact text");
3277        let (b, a) = (lefts(&island), lefts(after));
3278        for i in 0..5 {
3279            assert!((b[i] - a[i]).abs() < 0.01, "hello moved");
3280        }
3281        // "big" and "world" move by exactly the inserted word plus one regenerated gap.
3282        let shift = 500.0 / 1000.0 * 10.0 + 3.33;
3283        for i in 5..13 {
3284            assert!((a[i + 1] - b[i] - shift).abs() < 0.01, "glyph {i}");
3285        }
3286    }
3287
3288    #[test]
3289    fn removing_a_word_drops_its_gap_instead_of_leaving_two() {
3290        let doc = tex_style_pdf(false, KERNED);
3291        let sha = document_sha256(&doc_bytes(&doc));
3292        let island = locate_island(&doc, &sha, 1, "hello big world");
3293        let candidate = edit_text(&doc, &island, "hello world").expect("composes");
3294        assert_eq!(
3295            tj_shape(&candidate),
3296            ["hello", "<-333>", "", "<0>", "world"]
3297        );
3298        let (_, graph) = reinspect(&candidate, 1);
3299        let after = graph
3300            .islands
3301            .iter()
3302            .find(|i| i.text == "hello world")
3303            .expect("exact text");
3304        let (b, a) = (lefts(&island), lefts(after));
3305        let shift = 3.0 * 5.0 + 3.33;
3306        for i in 0..5 {
3307            assert!((b[i] - a[i]).abs() < 0.01, "hello moved");
3308        }
3309        for i in 8..13 {
3310            assert!((b[i] - a[i - 3] - shift).abs() < 0.01, "world glyph {i}");
3311        }
3312    }
3313
3314    #[test]
3315    fn a_real_space_glyph_is_used_when_the_font_has_one() {
3316        let doc = tex_style_pdf(true, b"BT /F1 10 Tf 72 700 Td [(hello) -333 (big)] TJ ET");
3317        let sha = document_sha256(&doc_bytes(&doc));
3318        let island = locate_island(&doc, &sha, 1, "hello big");
3319        let candidate = edit_text(&doc, &island, "hello a big").expect("composes");
3320        assert_eq!(tj_shape(&candidate), ["hello", "<-333>", "a big"]);
3321        let (_, graph) = reinspect(&candidate, 1);
3322        assert!(graph.islands.iter().any(|i| i.text == "hello a big"));
3323    }
3324
3325    #[test]
3326    fn a_new_space_with_no_glyph_and_no_reference_gap_is_refused_not_dropped() {
3327        let doc = tex_style_pdf(false, b"BT /F1 10 Tf 72 700 Td (hello) Tj ET");
3328        let sha = document_sha256(&doc_bytes(&doc));
3329        let island = locate_island(&doc, &sha, 1, "hello");
3330        let error = edit_text(&doc, &island, "hello a").expect_err("refused");
3331        assert_eq!(error.missing_chars(), Some(&[' '][..]));
3332    }
3333
3334    #[test]
3335    fn a_gap_made_by_a_position_move_is_a_positioned_run_and_not_editable() {
3336        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj 35 0 Td (World) Tj ET");
3337        let sha = document_sha256(&doc_bytes(&doc));
3338        let island = locate_island(&doc, &sha, 1, "Hello World");
3339        assert_eq!(island.gaps.len(), 1);
3340        assert!(!island.editable);
3341        assert_eq!(
3342            island.edit_blocker,
3343            Some(crate::model::PdfEditBlocker::PositionedTextRun)
3344        );
3345        let error = edit_text(&doc, &island, "Hallo World").expect_err("refused");
3346        assert!(
3347            matches!(error, PdfEditError::UnsupportedEncoding),
3348            "{error}"
3349        );
3350    }
3351    #[test]
3352    fn a_positioning_operator_between_two_show_operators_makes_the_run_positioned() {
3353        // "Hel" is 18 pt wide, so the Td lands "lo" flush against it: no gap, but a seam. Merging
3354        // both strings into one would move whatever the second operator positioned.
3355        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hel) Tj 18 0 Td (lo) Tj ET");
3356        let sha = document_sha256(&doc_bytes(&doc));
3357        let island = locate_island(&doc, &sha, 1, "Hello");
3358        assert!(!island.editable);
3359        assert_eq!(
3360            island.edit_blocker,
3361            Some(crate::model::PdfEditBlocker::PositionedTextRun)
3362        );
3363    }
3364
3365    #[test]
3366    fn same_length_edit_overwrites_glyphs_in_place_and_keeps_inner_kerns() {
3367        let doc = tex_style_pdf(
3368            false,
3369            b"BT /F1 10 Tf 72 700 Td [(h) 40 (e) 40 (llo) -333 (big)] TJ ET",
3370        );
3371        let sha = document_sha256(&doc_bytes(&doc));
3372        let island = locate_island(&doc, &sha, 1, "hello big");
3373        let candidate = edit_text(&doc, &island, "elloh big").expect("composes");
3374        assert_eq!(
3375            tj_shape(&candidate),
3376            ["e", "<40>", "l", "<40>", "loh", "<-333>", "big"]
3377        );
3378        let (_, graph) = reinspect(&candidate, 1);
3379        let after = graph
3380            .islands
3381            .iter()
3382            .find(|i| i.text == "elloh big")
3383            .expect("gap survives: exact text");
3384        for (b, a) in lefts(&island).iter().zip(lefts(after)) {
3385            assert!((b - a).abs() < 0.01);
3386        }
3387    }
3388
3389    // -----------------------------------------------------------------------------------
3390    // Astra read of 0.2.1, 2026-10-11: findings 2, 4, 5, 6, 7, 8 (composition side)
3391    // -----------------------------------------------------------------------------------
3392
3393    fn rect(width: f64) -> PdfRect {
3394        PdfRect {
3395            x: 72.0,
3396            y: 0.0,
3397            width,
3398            height: 800.0,
3399        }
3400    }
3401
3402    /// The island whose text is `text` in the candidate's page 1, with its box width.
3403    fn island_after(
3404        candidate: &ComposedCandidate,
3405        text: &str,
3406    ) -> (crate::model::PdfTextIsland, f64) {
3407        let (_, graph) = reinspect(candidate, 1);
3408        let island = graph
3409            .islands
3410            .into_iter()
3411            .find(|i| i.text == text)
3412            .unwrap_or_else(|| panic!("no island {text:?} in the candidate"));
3413        let width = island_box(&island).width;
3414        (island, width)
3415    }
3416
3417    #[test]
3418    fn finding2_array_cmap_edit_writes_the_codes_the_reader_sees() {
3419        // Codes 1,2,3 are A, Z, B. Only codes that decode to the requested letters may be written.
3420        let doc = custom_page_pdf(
3421            |d| {
3422                vec![(
3423                    "F1",
3424                    crate::test_fixtures::identity_h_font_with_cmap(
3425                        d,
3426                        &[(1, 500), (2, 500), (3, 500)],
3427                        crate::test_fixtures::raw_to_unicode_cmap(
3428                            "1 beginbfrange\n<0001> <0003> [<0041> <005A> <0042>]\nendbfrange",
3429                        ),
3430                    ),
3431                )]
3432            },
3433            &[b"BT /F1 12 Tf 72 700 Td <000100020003> Tj ET"],
3434            false,
3435        );
3436        let source = doc_bytes(&doc);
3437        let sha = document_sha256(&source);
3438        let island = locate_island(&doc, &sha, 1, "AZB");
3439
3440        // The old reader read ABC, so "ACB" was writable and landed on A, B, Z.
3441        let command = replace_cmd("c", &island, "ACB", island.appearance.clone(), PAGE_WIDE);
3442        let error = compose_candidate(&source, &[command], &FailingFontResolver)
3443            .expect_err("C has no code in this font");
3444        assert_eq!(error.missing_chars(), Some(&['C'][..]));
3445
3446        let command = replace_cmd("c", &island, "BZA", island.appearance.clone(), PAGE_WIDE);
3447        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3448            .expect("letters the font has");
3449        let (_, graph) = reinspect(&candidate, 1);
3450        assert!(graph.islands.iter().any(|i| i.text == "BZA"));
3451    }
3452
3453    #[test]
3454    fn finding4_flattened_edit_leaves_no_internal_kerns_and_fits_what_it_wrote() {
3455        // Replacing the kerned word "hello" with "aa" used to leave -100 -100 behind between the
3456        // blanked strings: the fit predicted 28.33 pt, the page drew 30.33 pt.
3457        let doc = tex_style_pdf(
3458            false,
3459            b"BT /F1 10 Tf 72 700 Td [(h) -100 (e) -100 (llo) -333 (big)] TJ ET",
3460        );
3461        let source = doc_bytes(&doc);
3462        let sha = document_sha256(&source);
3463        let island = locate_island(&doc, &sha, 1, "hello big");
3464        // 5 glyphs x 5 pt + 2 pt of kern + 3.33 pt gap + 3 x 5 pt.
3465        assert!((island_box(&island).width - 45.33).abs() < 0.01);
3466
3467        let command = replace_cmd(
3468            "flat",
3469            &island,
3470            "aa big",
3471            island.appearance.clone(),
3472            rect(28.33),
3473        );
3474        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3475            .expect("fits the width it predicted");
3476        let (written, width) = island_after(&candidate, "aa big");
3477        assert!(
3478            (width - 28.33).abs() < 0.01,
3479            "predicted 28.33 pt, page draws {width} pt"
3480        );
3481        assert_eq!(written.gaps.len(), 1);
3482        // Every number between the old glyphs is zero; the gap before "big" is untouched.
3483        let shape = tj_shape(&candidate);
3484        assert!(shape.contains(&"<-333>".to_string()), "{shape:?}");
3485        assert!(!shape.contains(&"<-100>".to_string()), "{shape:?}");
3486    }
3487
3488    #[test]
3489    fn finding4_flatten_across_two_operators_zeroes_the_kerns_between_them() {
3490        let doc = tex_style_pdf(
3491            false,
3492            b"BT /F1 10 Tf 72 700 Td [(he) -100 (l)] TJ [(lo) -333 (big)] TJ ET",
3493        );
3494        let source = doc_bytes(&doc);
3495        let sha = document_sha256(&source);
3496        let island = locate_island(&doc, &sha, 1, "hello big");
3497        let command = replace_cmd(
3498            "flat2",
3499            &island,
3500            "aa big",
3501            island.appearance.clone(),
3502            rect(28.33),
3503        );
3504        let candidate =
3505            compose_candidate(&source, &[command], &FailingFontResolver).expect("composes");
3506        let (_, width) = island_after(&candidate, "aa big");
3507        assert!((width - 28.33).abs() < 0.01, "{width}");
3508    }
3509
3510    #[test]
3511    fn finding5_same_length_edit_that_adds_a_space_accounts_for_word_spacing() {
3512        // 10 Tw: the new space (2.5 pt wide) advances the pen 12.5 pt, so "a b" is 22.5 pt wide,
3513        // not the 12.5 pt the glyph-width difference alone predicts.
3514        let doc = tex_style_pdf(true, b"BT /F1 10 Tf 10 Tw 72 700 Td (axb) Tj ET");
3515        let source = doc_bytes(&doc);
3516        let sha = document_sha256(&source);
3517        let island = locate_island(&doc, &sha, 1, "axb");
3518        let own = island_box(&island);
3519        assert!((own.width - 15.0).abs() < 0.01);
3520
3521        let command = replace_cmd("tw", &island, "a b", island.appearance.clone(), own);
3522        assert!(matches!(
3523            compose_candidate(&source, &[command], &FailingFontResolver),
3524            Err(PdfEditError::TextOverflow)
3525        ));
3526
3527        let command = replace_cmd("tw", &island, "a b", island.appearance.clone(), rect(22.5));
3528        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3529            .expect("fits when given the width it needs");
3530        let (_, width) = island_after(&candidate, "a b");
3531        assert!((width - 22.5).abs() < 0.01, "{width}");
3532    }
3533
3534    #[test]
3535    fn finding5_removing_a_space_shrinks_by_word_spacing_too() {
3536        let doc = tex_style_pdf(true, b"BT /F1 10 Tf 10 Tw 72 700 Td (a b) Tj ET");
3537        let source = doc_bytes(&doc);
3538        let sha = document_sha256(&source);
3539        let island = locate_island(&doc, &sha, 1, "a b");
3540        assert!((island_box(&island).width - 22.5).abs() < 0.01);
3541        let command = replace_cmd("tw", &island, "axb", island.appearance.clone(), rect(15.0));
3542        let candidate =
3543            compose_candidate(&source, &[command], &FailingFontResolver).expect("narrower fits");
3544        let (_, width) = island_after(&candidate, "axb");
3545        assert!((width - 15.0).abs() < 0.01, "{width}");
3546    }
3547
3548    #[test]
3549    fn finding6_editing_one_page_leaves_a_page_that_shares_its_stream_alone() {
3550        let mut doc = Document::with_version("1.7");
3551        let pages_id = doc.new_object_id();
3552        let font = helvetica_font(&mut doc);
3553        let resources = doc.add_object(dictionary! { "Font" => dictionary! { "F1" => font } });
3554        let shared = doc.add_object(Stream::new(
3555            dictionary! {},
3556            b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET".to_vec(),
3557        ));
3558        let mut page_ids = Vec::new();
3559        for _ in 0..2 {
3560            page_ids.push(doc.add_object(dictionary! {
3561                "Type" => "Page",
3562                "Parent" => pages_id,
3563                "Contents" => shared,
3564                "Resources" => resources,
3565                "MediaBox" => Object::Array(vec![
3566                    Object::Integer(0),
3567                    Object::Integer(0),
3568                    Object::Integer(612),
3569                    Object::Integer(792),
3570                ]),
3571            }));
3572        }
3573        doc.objects.insert(
3574            pages_id,
3575            Object::Dictionary(dictionary! {
3576                "Type" => "Pages",
3577                "Kids" => page_ids.iter().map(|id| Object::Reference(*id)).collect::<Vec<_>>(),
3578                "Count" => 2,
3579            }),
3580        );
3581        let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
3582        doc.trailer.set("Root", catalog);
3583
3584        let source = doc_bytes(&doc);
3585        let sha = document_sha256(&source);
3586        let island = locate_island(&doc, &sha, 1, "Hello");
3587        let command = replace_cmd("p1", &island, "Howdy", island.appearance.clone(), PAGE_WIDE);
3588        let candidate =
3589            compose_candidate(&source, &[command], &FailingFontResolver).expect("composes");
3590        // Truthful: only page 1's content changed; page 2 got its own byte-identical copy.
3591        assert_eq!(candidate.affected_pages, vec![1]);
3592
3593        let (_, page1) = reinspect(&candidate, 1);
3594        let (_, page2) = reinspect(&candidate, 2);
3595        assert!(page1.islands.iter().any(|i| i.text == "Howdy"));
3596        assert!(!page1.islands.iter().any(|i| i.text == "Hello"));
3597        assert!(
3598            page2.islands.iter().any(|i| i.text == "Hello"),
3599            "page 2 must still read Hello: {:?}",
3600            page2.islands.iter().map(|i| &i.text).collect::<Vec<_>>()
3601        );
3602        assert!(!page2.islands.iter().any(|i| i.text == "Howdy"));
3603
3604        // A second command aimed at the other page, captured against the same pristine source,
3605        // is refused as stale (its stream was copied) rather than applied to the wrong page.
3606        let island2 = {
3607            let graph = walk_page_graph(&doc, &sha, 2).expect("page 2");
3608            graph.islands.into_iter().next().expect("island")
3609        };
3610        let c1 = replace_cmd("p1", &island, "Howdy", island.appearance.clone(), PAGE_WIDE);
3611        let c2 = replace_cmd("p2", &island2, "Hi", island2.appearance.clone(), PAGE_WIDE);
3612        let outcome = compose_candidate(&source, &[c1, c2], &FailingFontResolver);
3613        assert!(
3614            matches!(outcome, Err(PdfEditError::SourceDigestMismatch)),
3615            "{:?}",
3616            outcome.map(|c| c.affected_pages)
3617        );
3618    }
3619
3620    #[test]
3621    fn finding7_non_editable_island_with_unchanged_appearance_is_refused_by_the_composer() {
3622        // A Td between the strings makes this a positioned run. A *working* resolver for the
3623        // island's own font id is provided, so the refusal can only come from the blocker.
3624        let doc = build_pdf(b"BT /F1 10 Tf 72 700 Td (A) Tj 6.67 0 Td (B) Tj ET");
3625        let source = doc_bytes(&doc);
3626        let sha = document_sha256(&source);
3627        let island = locate_island(&doc, &sha, 1, "AB");
3628        assert!(!island.editable);
3629        assert_eq!(
3630            island.edit_blocker,
3631            Some(crate::model::PdfEditBlocker::PositionedTextRun)
3632        );
3633        let resolver = TestFontResolver {
3634            id: "F1",
3635            bytes: installable_font(),
3636        };
3637        // The resolver really would have produced a candidate for this text and appearance.
3638        assert!(resolver.resolve("F1").is_ok());
3639        assert!(resolver
3640            .glyphs_for_text(&resolver.resolve("F1").unwrap(), "AC")
3641            .is_ok());
3642
3643        let command = replace_cmd("ac", &island, "AC", island.appearance.clone(), PAGE_WIDE);
3644        let outcome = compose_candidate(&source, &[command], &resolver);
3645        assert!(
3646            matches!(outcome, Err(PdfEditError::UnsupportedEncoding)),
3647            "{:?}",
3648            outcome.map(|c| c.bytes.len())
3649        );
3650    }
3651
3652    #[test]
3653    fn finding7_every_blocker_is_refused_before_strategy_selection() {
3654        // F1 Helvetica; F2 an embedded symbolic subset; mixed sizes; cross-stream split.
3655        let doc = custom_page_pdf(
3656            |d| {
3657                vec![
3658                    ("F1", helvetica_font(d)),
3659                    ("F2", subset_type1_font(d, 65, &[500, 600], None, None)),
3660                ]
3661            },
3662            &[
3663                b"BT /F2 12 Tf 72 600 Td (AB) Tj ET BT /F1 12 Tf 72 500 Td (Hel) Tj ET \
3664                  BT /F1 12 Tf 72 400 Td (Big) Tj /F1 20 Tf (Bad) Tj ET",
3665            ],
3666            false,
3667        );
3668        let source = doc_bytes(&doc);
3669        let sha = document_sha256(&source);
3670        let graph = walk_page_graph(&doc, &sha, 1).expect("graph");
3671        let resolver = TestFontResolver {
3672            id: "F1",
3673            bytes: installable_font(),
3674        };
3675        let mut refused = 0;
3676        for island in graph.islands.iter().filter(|i| !i.editable) {
3677            let command = replace_cmd(
3678                "same",
3679                island,
3680                &island.text,
3681                island.appearance.clone(),
3682                PAGE_WIDE,
3683            );
3684            assert!(
3685                matches!(
3686                    compose_candidate(&source, &[command], &resolver),
3687                    Err(PdfEditError::UnsupportedEncoding)
3688                ),
3689                "{:?} ({:?})",
3690                island.text,
3691                island.edit_blocker
3692            );
3693            refused += 1;
3694        }
3695        assert!(refused >= 2, "fixture must contain non-editable islands");
3696    }
3697
3698    #[test]
3699    fn finding7_a_changed_appearance_still_redraws_a_non_editable_island() {
3700        // The explicit substitution request is unchanged (see also the split-run test above).
3701        let doc = build_pdf(b"BT /F1 10 Tf 72 700 Td (A) Tj 6.67 0 Td (B) Tj ET");
3702        let source = doc_bytes(&doc);
3703        let sha = document_sha256(&source);
3704        let island = locate_island(&doc, &sha, 1, "AB");
3705        assert!(!island.editable && island.substitution_editable);
3706        let command = replace_cmd(
3707            "sub",
3708            &island,
3709            "Hi",
3710            appearance("test-font", 12.0),
3711            PdfRect {
3712                x: 72.0,
3713                y: 690.0,
3714                width: 200.0,
3715                height: 30.0,
3716            },
3717        );
3718        compose_candidate(&source, &[command], &test_resolver()).expect("substitution composes");
3719    }
3720
3721    #[test]
3722    fn finding8_repeated_spaces_in_a_space_less_font_are_refused_not_returned() {
3723        let doc = tex_style_pdf(false, b"BT /F1 10 Tf 72 700 Td [(hello) -333 (big)] TJ ET");
3724        let source = doc_bytes(&doc);
3725        let sha = document_sha256(&source);
3726        let island = locate_island(&doc, &sha, 1, "hello big");
3727        for after in ["hello  big", "hello big ", " hello big"] {
3728            let outcome = edit_text(&doc, &island, after);
3729            let error = outcome.expect_err(after);
3730            assert_eq!(error.missing_chars(), Some(&[' '][..]), "{after:?}");
3731        }
3732        // One space is still fine.
3733        edit_text(&doc, &island, "hello bog").expect("unchanged spacing");
3734    }
3735
3736    #[test]
3737    fn composed_text_always_reads_back_as_requested_or_is_refused() {
3738        // The backstop behind 4, 5 and 8: verify_written rejects a write whose read-back differs.
3739        let doc = tex_style_pdf(true, b"BT /F1 10 Tf 72 700 Td (ab) Tj ET");
3740        let source = doc_bytes(&doc);
3741        let sha = document_sha256(&source);
3742        let island = locate_island(&doc, &sha, 1, "ab");
3743        // A document that does not contain the wanted text fails the check.
3744        let outcome = verify_written(&doc, 1, &island, "zz", PAGE_WIDE);
3745        assert!(matches!(outcome, Err(PdfEditError::VerificationFailed)));
3746        verify_written(&doc, 1, &island, "ab", PAGE_WIDE).expect("matches what is there");
3747    }
3748    // -----------------------------------------------------------------------------------
3749    // Second Astra read, 2026-10-11: findings 2, 4 and 7 (composition side)
3750    // -----------------------------------------------------------------------------------
3751
3752    use crate::test_fixtures::{code_32_letter_font, code_5_space_font, inherited_resources_pdf};
3753
3754    fn add_hi(id: &str) -> PdfEditCommand {
3755        PdfEditCommand::AddText {
3756            id: id.to_string(),
3757            page: 1,
3758            anchor: PdfPoint { x: 100.0, y: 100.0 },
3759            text: "Hi".to_string(),
3760            appearance: appearance("test-font", 18.0),
3761        }
3762    }
3763
3764    /// The operations of the last content stream of `page` (the one `AddText` appends).
3765    fn appended_operations(doc: &Document, page: u32) -> Vec<Operation> {
3766        let page_id = doc.get_pages()[&page];
3767        let contents = doc
3768            .get_object(page_id)
3769            .expect("page")
3770            .as_dict()
3771            .expect("dict")
3772            .get(b"Contents")
3773            .expect("contents");
3774        let ids = content_stream_ids(doc, contents).expect("ids");
3775        decode_stream_content(doc, *ids.last().expect("a stream"))
3776            .expect("content")
3777            .operations
3778    }
3779
3780    #[test]
3781    fn finding2_adding_a_font_to_a_page_that_inherits_its_resources_keeps_what_it_inherited() {
3782        // /Resources (F1 and Im1) live on the /Pages node only. The old composer gave the page a
3783        // /Resources holding just the new font, so F1 and Im1 stopped resolving.
3784        let doc = inherited_resources_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET /Im1 Do");
3785        let source = doc_bytes(&doc);
3786        let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
3787            .expect("add text composes");
3788        let (out, graph) = reinspect(&candidate, 1);
3789
3790        let existing = graph
3791            .islands
3792            .iter()
3793            .find(|island| island.text == "Existing")
3794            .expect("the inherited font still decodes the existing line");
3795        assert!(existing.editable, "{:?}", existing.edit_blocker);
3796        assert!(graph.islands.iter().any(|island| island.text == "Hi"));
3797
3798        let fingerprint = inspect::resource_fingerprint(&out).expect("fingerprint");
3799        let names: Vec<String> = fingerprint[&1]
3800            .keys()
3801            .map(|(category, name)| {
3802                format!(
3803                    "{}/{}",
3804                    String::from_utf8_lossy(category),
3805                    String::from_utf8_lossy(name)
3806                )
3807            })
3808            .collect();
3809        assert_eq!(names, ["Font/F1", "Font/VREdit1", "XObject/Im1"]);
3810
3811        // The ancestor's own resources are untouched: a sibling page would still see only F1.
3812        let page_id = out.get_pages()[&1];
3813        let parent = out
3814            .get_object(page_id)
3815            .expect("page")
3816            .as_dict()
3817            .expect("dict")
3818            .get(b"Parent")
3819            .expect("parent")
3820            .as_reference()
3821            .expect("reference");
3822        let inherited_fonts = out
3823            .get_object(parent)
3824            .expect("pages")
3825            .as_dict()
3826            .expect("dict")
3827            .get(b"Resources")
3828            .expect("resources")
3829            .as_dict()
3830            .expect("dict")
3831            .get(b"Font")
3832            .expect("fonts")
3833            .as_dict()
3834            .expect("dict");
3835        assert_eq!(inherited_fonts.len(), 1);
3836        assert!(inherited_fonts.has(b"F1"));
3837    }
3838
3839    #[test]
3840    fn finding2_a_second_added_text_extends_the_page_local_copy() {
3841        let doc = inherited_resources_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
3842        let source = doc_bytes(&doc);
3843        let candidate = compose_candidate(&source, &[add_hi("a"), add_hi("b")], &test_resolver())
3844            .expect("two adds compose");
3845        let (out, graph) = reinspect(&candidate, 1);
3846        assert!(graph.islands.iter().any(|island| island.text == "Existing"));
3847        let fingerprint = inspect::resource_fingerprint(&out).expect("fingerprint");
3848        let fonts: Vec<&[u8]> = fingerprint[&1]
3849            .keys()
3850            .filter(|(category, _)| category.as_slice() == b"Font")
3851            .map(|(_, name)| name.as_slice())
3852            .collect();
3853        assert_eq!(fonts, [&b"F1"[..], &b"VREdit1"[..], &b"VREdit2"[..]]);
3854    }
3855
3856    #[test]
3857    fn finding2_an_unresolvable_resource_chain_is_refused_not_overwritten() {
3858        // /Pages -> page -> /Pages: the inherited /Resources cannot be found, so nothing may be
3859        // written over whatever they hold.
3860        let mut doc = inherited_resources_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
3861        let page_id = doc.get_pages()[&1];
3862        let pages_id = doc
3863            .get_object(page_id)
3864            .expect("page")
3865            .as_dict()
3866            .expect("dict")
3867            .get(b"Parent")
3868            .expect("parent")
3869            .as_reference()
3870            .expect("reference");
3871        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
3872            pages.remove(b"Resources");
3873            pages.set("Parent", Object::Reference(page_id));
3874        }
3875        let outcome = compose_candidate(&doc_bytes(&doc), &[add_hi("a")], &test_resolver());
3876        assert!(
3877            matches!(outcome, Err(PdfEditError::MalformedContent)),
3878            "{:?}",
3879            outcome.map(|c| c.bytes.len())
3880        );
3881    }
3882
3883    #[test]
3884    fn finding7_added_text_does_not_inherit_the_pages_trailing_text_state() {
3885        // The page's last text object leaves invisible (3 Tr), spaced, narrowed and raised text
3886        // state behind; the appended stream used to inherit all of it.
3887        let doc = build_pdf(b"BT /F1 10 Tf 3 Tr 5 Tc 7 Tw 50 Tz 4 Ts (hidden) Tj ET");
3888        let source = doc_bytes(&doc);
3889        let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
3890            .expect("add text composes");
3891        let (out, graph) = reinspect(&candidate, 1);
3892
3893        // What the walker measures for the added line: no extra spacing, full width, no rise.
3894        let added = graph
3895            .islands
3896            .iter()
3897            .find(|island| island.text == "Hi")
3898            .expect("added island");
3899        let layout = added.layout.expect("layout");
3900        assert_eq!(
3901            (
3902                layout.char_spacing,
3903                layout.word_spacing,
3904                layout.horizontal_scale
3905            ),
3906            (0.0, 0.0, 1.0)
3907        );
3908        assert!(
3909            (added.quads[0][1] - (100.0 - 0.25 * 18.0)).abs() < 1e-9,
3910            "no inherited text rise: {}",
3911            added.quads[0][1]
3912        );
3913
3914        // What the page will draw: every parameter set explicitly inside an isolating q/Q.
3915        let ops = appended_operations(&out, 1);
3916        let names: Vec<&str> = ops.iter().map(|op| op.operator.as_str()).collect();
3917        assert_eq!(
3918            names,
3919            ["q", "BT", "Tf", "Tr", "Tc", "Tw", "Tz", "Ts", "rg", "Tm", "Tj", "ET", "Q"]
3920        );
3921        let operand = |name: &str| {
3922            ops.iter()
3923                .find(|op| op.operator == name)
3924                .map(|op| op.operands.clone())
3925        };
3926        assert_eq!(operand("Tr"), Some(vec![Object::Integer(0)]));
3927        assert_eq!(operand("Tc"), Some(vec![Object::Integer(0)]));
3928        assert_eq!(operand("Tw"), Some(vec![Object::Integer(0)]));
3929        assert_eq!(operand("Tz"), Some(vec![Object::Integer(100)]));
3930        assert_eq!(operand("Ts"), Some(vec![Object::Integer(0)]));
3931    }
3932
3933    #[test]
3934    fn finding7_the_ocr_correction_text_block_resets_the_same_state() {
3935        let doc = build_pdf(b"BT /F1 10 Tf 3 Tr 5 Tc (hidden) Tj ET");
3936        let source = doc_bytes(&doc);
3937        let command = ocr_command(
3938            "ocr",
3939            "line-1",
3940            "Hi",
3941            PdfRect {
3942                x: 100.0,
3943                y: 100.0,
3944                width: 100.0,
3945                height: 30.0,
3946            },
3947        );
3948        let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
3949        let (out, _) = reinspect(&candidate, 1);
3950        let ops = appended_operations(&out, 1);
3951        let names: Vec<&str> = ops.iter().map(|op| op.operator.as_str()).collect();
3952        assert_eq!(
3953            names,
3954            [
3955                "q", "rg", "re", "f", "Q", // cover
3956                "q", "BT", "Tf", "Tr", "Tc", "Tw", "Tz", "Ts", "rg", "Tm", "Tj", "ET", "Q"
3957            ]
3958        );
3959    }
3960
3961    // -----------------------------------------------------------------------------------
3962    // Third Astra read, 2026-10-11: findings 4 and 5
3963    // -----------------------------------------------------------------------------------
3964
3965    /// Walks every `/Contents` stream of `page` in order and returns the nesting depth reached
3966    /// just before the last stream, the lowest depth seen, and how many streams the page has.
3967    fn nesting_before_last_stream(doc: &Document, page: u32) -> (i64, i64, usize) {
3968        let page_id = doc.get_pages()[&page];
3969        let contents = doc
3970            .get_object(page_id)
3971            .expect("page")
3972            .as_dict()
3973            .expect("dict")
3974            .get(b"Contents")
3975            .expect("contents");
3976        let ids = content_stream_ids(doc, contents).expect("ids");
3977        let (mut depth, mut lowest) = (0_i64, 0_i64);
3978        for id in &ids[..ids.len() - 1] {
3979            for op in decode_stream_content(doc, *id).expect("content").operations {
3980                match op.operator.as_str() {
3981                    "q" => depth += 1,
3982                    "Q" => {
3983                        depth -= 1;
3984                        lowest = lowest.min(depth);
3985                    }
3986                    _ => {}
3987                }
3988            }
3989        }
3990        (depth, lowest, ids.len())
3991    }
3992
3993    #[test]
3994    fn finding4_added_text_runs_outside_the_original_graphics_state() {
3995        // A leftover clip, a transparency ExtGState, unclosed `q`s and stray `Q`s: each leaks into
3996        // a stream appended after it, and a clip cannot be undone from inside the new block.
3997        let cases: [(&str, &[u8]); 5] = [
3998            (
3999                "clip",
4000                b"0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Existing) Tj ET",
4001            ),
4002            ("alpha", b"/GS0 gs BT /F1 12 Tf 72 700 Td (Existing) Tj ET"),
4003            (
4004                "open q",
4005                b"q q 0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Existing) Tj ET",
4006            ),
4007            ("stray Q", b"Q Q BT /F1 12 Tf 72 700 Td (Existing) Tj ET"),
4008            (
4009                "balanced",
4010                b"q 2 0 0 2 0 0 cm BT /F1 12 Tf 36 350 Td (Existing) Tj ET Q",
4011            ),
4012        ];
4013        for (label, content) in cases {
4014            let doc = build_pdf(content);
4015            let source = doc_bytes(&doc);
4016            let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
4017                .unwrap_or_else(|e| panic!("{label}: {e:?}"));
4018            let (out, graph) = reinspect(&candidate, 1);
4019            assert!(
4020                graph.islands.iter().any(|island| island.text == "Hi"),
4021                "{label}"
4022            );
4023            let (depth, lowest, streams) = nesting_before_last_stream(&out, 1);
4024            assert_eq!(depth, 0, "{label}: the added stream starts at depth zero");
4025            assert_eq!(lowest, 0, "{label}: the wrapper is never popped early");
4026            assert_eq!(streams, 4, "{label}: q wrapper, original, Q wrapper, added");
4027            // The added block itself is unchanged.
4028            let names: Vec<String> = appended_operations(&out, 1)
4029                .iter()
4030                .map(|op| op.operator.clone())
4031                .collect();
4032            assert_eq!(
4033                names,
4034                ["q", "BT", "Tf", "Tr", "Tc", "Tw", "Tz", "Ts", "rg", "Tm", "Tj", "ET", "Q"],
4035                "{label}"
4036            );
4037        }
4038    }
4039
4040    #[test]
4041    fn finding4_the_original_content_stream_is_not_modified() {
4042        let doc = build_pdf(b"0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
4043        let page_id = doc.get_pages()[&1];
4044        let original_ids = content_stream_ids(
4045            &doc,
4046            doc.get_object(page_id)
4047                .expect("page")
4048                .as_dict()
4049                .expect("dict")
4050                .get(b"Contents")
4051                .expect("contents"),
4052        )
4053        .expect("ids");
4054        let original = decode_stream_content(&doc, original_ids[0]).expect("content");
4055        let source = doc_bytes(&doc);
4056        let candidate =
4057            compose_candidate(&source, &[add_hi("a")], &test_resolver()).expect("composes");
4058        let (out, _) = reinspect(&candidate, 1);
4059        let kept = decode_stream_content(&out, original_ids[0]).expect("content");
4060        assert_eq!(
4061            kept.encode().expect("encode"),
4062            original.encode().expect("encode")
4063        );
4064    }
4065
4066    #[test]
4067    fn finding4_a_replacement_in_a_new_font_is_isolated_from_the_streams_clip() {
4068        let doc = build_pdf(b"0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
4069        let source = doc_bytes(&doc);
4070        let sha = document_sha256(&source);
4071        let island = locate_island(&doc, &sha, 1, "Hello");
4072        let command = replace_cmd(
4073            "new-font",
4074            &island,
4075            "Hi",
4076            appearance("test-font", 24.0),
4077            PdfRect {
4078                x: 72.0,
4079                y: 690.0,
4080                width: 400.0,
4081                height: 40.0,
4082            },
4083        );
4084        let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4085        let (out, graph) = reinspect(&candidate, 1);
4086        assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
4087        let (depth, lowest, _) = nesting_before_last_stream(&out, 1);
4088        assert_eq!((depth, lowest), (0, 0));
4089    }
4090
4091    #[test]
4092    fn finding5_a_locator_missing_one_of_the_islands_spans_is_refused() {
4093        let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (AB) Tj (CD) Tj ET");
4094        let source = doc_bytes(&doc);
4095        let sha = document_sha256(&source);
4096        let island = locate_island(&doc, &sha, 1, "ABCD");
4097        assert_eq!(island.locator.spans.len(), 2);
4098        let bounds = PdfRect {
4099            x: 72.0,
4100            y: 690.0,
4101            width: 400.0,
4102            height: 40.0,
4103        };
4104        let changed_font = appearance("test-font", 24.0);
4105
4106        // The island digest is retained; only the second span is removed.
4107        let mut partial = replace_cmd("partial", &island, "Hi", changed_font.clone(), bounds);
4108        if let PdfEditCommand::ReplaceSourceText { locator, .. } = &mut partial {
4109            locator.spans.truncate(1);
4110        }
4111        assert!(matches!(
4112            compose_candidate(&source, &[partial], &test_resolver()),
4113            Err(PdfEditError::SourceDigestMismatch)
4114        ));
4115
4116        // The same holds for a reordered locator and for one with a repeated span.
4117        let mut reordered = replace_cmd("reordered", &island, "Hi", changed_font.clone(), bounds);
4118        if let PdfEditCommand::ReplaceSourceText { locator, .. } = &mut reordered {
4119            locator.spans.reverse();
4120        }
4121        assert!(matches!(
4122            compose_candidate(&source, &[reordered], &test_resolver()),
4123            Err(PdfEditError::SourceDigestMismatch)
4124        ));
4125        let mut repeated = replace_cmd("repeated", &island, "Hi", changed_font.clone(), bounds);
4126        if let PdfEditCommand::ReplaceSourceText { locator, .. } = &mut repeated {
4127            let first = locator.spans[0].clone();
4128            locator.spans = vec![first.clone(), first];
4129        }
4130        assert!(matches!(
4131            compose_candidate(&source, &[repeated], &test_resolver()),
4132            Err(PdfEditError::SourceDigestMismatch)
4133        ));
4134
4135        // The complete locator still composes, and removes the whole island.
4136        let whole = replace_cmd("whole", &island, "Hi", changed_font, bounds);
4137        let candidate =
4138            compose_candidate(&source, &[whole], &test_resolver()).expect("complete locator");
4139        let (_, graph) = reinspect(&candidate, 1);
4140        assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
4141        assert!(!graph
4142            .islands
4143            .iter()
4144            .any(|isl| isl.text.contains("AB") || isl.text.contains("CD")));
4145    }
4146
4147    #[test]
4148    fn finding4_replacing_with_code_32_pays_the_word_spacing_the_page_will_draw() {
4149        // /Differences [32 /x 97 /a /b]: writing x writes byte 32, and byte 32 takes Tw.
4150        let doc = custom_page_pdf(
4151            |d| vec![("F1", code_32_letter_font(d))],
4152            &[b"BT /F1 10 Tf 10 Tw 72 700 Td (aab) Tj ET"],
4153            false,
4154        );
4155        let source = doc_bytes(&doc);
4156        let sha = document_sha256(&source);
4157        let island = locate_island(&doc, &sha, 1, "aab");
4158        assert!(island.editable, "{:?}", island.edit_blocker);
4159        let own = island_box(&island);
4160        assert!((own.width - 15.0).abs() < 0.01, "{}", own.width);
4161
4162        // In its own 15 pt the edit would draw 5 + (5 + 10) + 5 = 25 pt: refused before writing.
4163        let command = replace_cmd("tw", &island, "axb", island.appearance.clone(), own);
4164        assert!(matches!(
4165            compose_candidate(&source, &[command], &FailingFontResolver),
4166            Err(PdfEditError::TextOverflow)
4167        ));
4168
4169        // Given 25 pt it is written, and the page reads back exactly that wide.
4170        let command = replace_cmd("tw", &island, "axb", island.appearance.clone(), rect(25.0));
4171        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
4172            .expect("fits the width it needs");
4173        let (written, width) = island_after(&candidate, "axb");
4174        assert!((width - 25.0).abs() < 0.01, "{width}");
4175        assert!(
4176            written.gaps.is_empty(),
4177            "the Tw jump is not read back as a gap"
4178        );
4179    }
4180
4181    #[test]
4182    fn finding4_removing_code_32_gives_back_its_word_spacing() {
4183        let doc = custom_page_pdf(
4184            |d| vec![("F1", code_32_letter_font(d))],
4185            &[b"BT /F1 10 Tf 10 Tw 72 700 Td (a b) Tj ET"],
4186            false,
4187        );
4188        let source = doc_bytes(&doc);
4189        let sha = document_sha256(&source);
4190        let island = locate_island(&doc, &sha, 1, "axb");
4191        assert!((island_box(&island).width - 25.0).abs() < 0.01);
4192        let command = replace_cmd("tw", &island, "aab", island.appearance.clone(), rect(15.0));
4193        let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
4194            .expect("15 pt is enough once the code-32 glyph is gone");
4195        let (_, width) = island_after(&candidate, "aab");
4196        assert!((width - 15.0).abs() < 0.01, "{width}");
4197    }
4198
4199    #[test]
4200    fn finding4_a_space_through_another_code_is_not_charged_word_spacing_in_the_fit() {
4201        // The space is code 5, so Tw never applies to it: "a b" is 12.5 pt and "aab" is 15 pt.
4202        let doc = custom_page_pdf(
4203            |d| vec![("F1", code_5_space_font(d))],
4204            &[b"BT /F1 10 Tf 10 Tw 72 700 Td <610562> Tj ET"],
4205            false,
4206        );
4207        let source = doc_bytes(&doc);
4208        let sha = document_sha256(&source);
4209        let island = locate_island(&doc, &sha, 1, "a b");
4210        let own = island_box(&island);
4211        assert!((own.width - 12.5).abs() < 0.01, "{}", own.width);
4212
4213        let command = replace_cmd("tw", &island, "aab", island.appearance.clone(), own);
4214        assert!(
4215            matches!(
4216                compose_candidate(&source, &[command], &FailingFontResolver),
4217                Err(PdfEditError::TextOverflow)
4218            ),
4219            "the letter is 2.5 pt wider than the space it replaces, with no Tw to give back"
4220        );
4221        let command = replace_cmd("tw", &island, "aab", island.appearance.clone(), rect(15.0));
4222        let candidate =
4223            compose_candidate(&source, &[command], &FailingFontResolver).expect("fits 15 pt");
4224        let (_, width) = island_after(&candidate, "aab");
4225        assert!((width - 15.0).abs() < 0.01, "{width}");
4226    }
4227    #[test]
4228    fn finding2_a_composed_add_text_passes_staging_against_its_inheriting_base() {
4229        // The save-side check agrees with the composer: Add Text keeps F1 and Im1 and adds a font.
4230        let source = doc_bytes(&inherited_resources_pdf(
4231            b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET /Im1 Do",
4232        ));
4233        let sha = document_sha256(&source);
4234        let command = add_hi("a");
4235        let candidate =
4236            compose_candidate(&source, std::slice::from_ref(&command), &test_resolver())
4237                .expect("composes");
4238        let store = crate::save::PdfSaveTokenStore::default();
4239        crate::save::stage_prepared_save_against_base(
4240            &store,
4241            "/doc.pdf",
4242            &sha,
4243            &sha,
4244            &source,
4245            candidate.bytes,
4246            candidate.affected_pages,
4247            crate::save::expected_text_checks(std::slice::from_ref(&command)),
4248        )
4249        .expect("an Add Text hides nothing the page resolved");
4250    }
4251
4252    // -----------------------------------------------------------------------------------
4253    // Fourth Astra read, 2026-10-11: findings 9 and 12 (batches against one page)
4254    // -----------------------------------------------------------------------------------
4255
4256    /// The islands of `bytes`' page 1, in reading order.
4257    fn page_islands(bytes: &[u8]) -> Vec<crate::model::PdfTextIsland> {
4258        let doc = Document::load_mem(bytes).expect("reopen");
4259        walk_page_graph(&doc, &document_sha256(bytes), 1)
4260            .expect("inspect")
4261            .islands
4262    }
4263
4264    fn changed_font_cmd(
4265        id: &str,
4266        island: &crate::model::PdfTextIsland,
4267        text: &str,
4268        x: f64,
4269    ) -> PdfEditCommand {
4270        replace_cmd(
4271            id,
4272            island,
4273            text,
4274            appearance("test-font", 24.0),
4275            PdfRect {
4276                x,
4277                y: 650.0,
4278                width: 200.0,
4279                height: 40.0,
4280            },
4281        )
4282    }
4283
4284    fn add_text_cmd(id: &str, text: &str) -> PdfEditCommand {
4285        PdfEditCommand::AddText {
4286            id: id.to_string(),
4287            page: 1,
4288            anchor: PdfPoint { x: 100.0, y: 100.0 },
4289            text: text.to_string(),
4290            appearance: appearance("test-font", 18.0),
4291        }
4292    }
4293
4294    /// Three islands on one baseline, told apart by colour: `AB` black, `CD` red, `AB` blue.
4295    const THREE_COLOURS: &[u8] =
4296        b"BT /F1 12 Tf 72 700 Td (AB) Tj 1 0 0 rg (CD) Tj 0 0 1 rg (AB) Tj ET";
4297
4298    #[test]
4299    fn finding9_a_changed_font_replacement_keeps_the_neighbouring_quads_bit_for_bit() {
4300        let source = doc_bytes(&build_pdf(THREE_COLOURS));
4301        let before = page_islands(&source);
4302        assert_eq!(
4303            before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
4304            ["AB", "CD", "AB"]
4305        );
4306
4307        let command = changed_font_cmd("first", &before[0], "Hi", 72.0);
4308        let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4309        let after = page_islands(&candidate.bytes);
4310
4311        // Stream reference, contents index, operator, operand and glyph range of one span.
4312        type SpanPlace = ((u32, u16), u32, u32, u32, u32, u32);
4313        // The red island's quads and where its glyphs sit in the source.
4314        let red = |islands: &[crate::model::PdfTextIsland]| {
4315            islands
4316                .iter()
4317                .find(|i| i.text == "CD")
4318                .map(|i| {
4319                    let spans: Vec<SpanPlace> = i
4320                        .locator
4321                        .spans
4322                        .iter()
4323                        .map(|s| {
4324                            (
4325                                s.stream_ref,
4326                                s.contents_index,
4327                                s.operator_index,
4328                                s.operand_index,
4329                                s.glyph_start,
4330                                s.glyph_end,
4331                            )
4332                        })
4333                        .collect();
4334                    (i.quads.clone(), spans)
4335                })
4336                .expect("the red island survives")
4337        };
4338        let ((quads_before, spans_before), (quads_after, spans_after)) =
4339            (red(&before), red(&after));
4340        // What the replacement guarantees about the text after the edited run: its quads are the
4341        // same, bit for bit, not close. The old glyphs' advance was deleted with them, which slid
4342        // CD left by the width of AB.
4343        assert_eq!(quads_after, quads_before);
4344        // What it does not keep is the island digest. The digest binds every span's
4345        // `contents_index`, and the isolation wrapper puts one stream in front of the original, so
4346        // every island of the page is addressed one stream later (the same stream object, operator
4347        // and operand). A locator taken before the edit is stale by design; inspect the page again.
4348        let moved_one_stream: Vec<_> = spans_before
4349            .iter()
4350            .map(|&(stream, index, operator, operand, start, end)| {
4351                (stream, index + 1, operator, operand, start, end)
4352            })
4353            .collect();
4354        assert_eq!(spans_after, moved_one_stream);
4355        let blue = after
4356            .iter()
4357            .filter(|i| i.text == "AB")
4358            .map(|i| i.quads.clone())
4359            .collect::<Vec<_>>();
4360        assert_eq!(blue, [before[2].quads.clone()]);
4361        assert!(after.iter().any(|i| i.text == "Hi"));
4362        assert!(!after
4363            .iter()
4364            .any(|i| i.text == "AB" && i.quads == before[0].quads));
4365    }
4366
4367    #[test]
4368    fn finding9_a_removed_string_inside_a_tj_keeps_the_pen_through_kerns_and_following_runs() {
4369        // CD's own island begins after a -300 kern; EF follows in another colour.
4370        let source = doc_bytes(&build_pdf(
4371            b"BT /F1 12 Tf 72 700 Td [(AB) -300 (CD)] TJ 1 0 0 rg (EF) Tj ET",
4372        ));
4373        let before = page_islands(&source);
4374        let first = before
4375            .iter()
4376            .find(|i| i.text.contains("AB"))
4377            .expect("first island");
4378        let command = changed_font_cmd("tj", first, "Hi", 72.0);
4379        let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4380        let after = page_islands(&candidate.bytes);
4381        let ef = |islands: &[crate::model::PdfTextIsland]| {
4382            islands
4383                .iter()
4384                .find(|i| i.text == "EF")
4385                .map(|i| i.quads.clone())
4386                .expect("EF survives")
4387        };
4388        assert_eq!(ef(&before), ef(&after));
4389    }
4390
4391    // A replacement is drawn on the source run's own baseline, so a replacement beside a neighbour
4392    // of the same colour is grouped with it by the walker ("Hi" and "CA" 3 pt apart read as
4393    // "HiCA"), as a reader of the page sees them. The finding12 tests are about the batch: every
4394    // command resolves and is applied, in whatever order, and the page reads right. They therefore
4395    // look at the glyphs' origins and at the page's text, not at how many islands the walker forms.
4396
4397    /// Device-space origin of every shown glyph reading `glyph`, across all islands, in order.
4398    fn glyph_origins(islands: &[crate::model::PdfTextIsland], glyph: &str) -> Vec<(f64, f64)> {
4399        islands
4400            .iter()
4401            .flat_map(|island| island.run.iter())
4402            .filter(|item| item.glyph.is_some() && item.text == glyph)
4403            .filter_map(|item| item.placement)
4404            .map(|(matrix, _)| (matrix.e, matrix.f))
4405            .collect()
4406    }
4407
4408    /// Whether some origin in `origins` is within 0.5 pt of the first two numbers of `at`.
4409    fn has_origin(origins: &[(f64, f64)], at: [f64; 4]) -> bool {
4410        origins
4411            .iter()
4412            .any(|origin| (origin.0 - at[0]).hypot(origin.1 - at[1]) <= 0.5)
4413    }
4414
4415    /// The text of every island of the page, one string, with the walker's synthesised spaces and
4416    /// island boundaries left out.
4417    fn page_text(islands: &[crate::model::PdfTextIsland]) -> String {
4418        islands
4419            .iter()
4420            .flat_map(|island| island.text.chars())
4421            .filter(|c| *c != ' ')
4422            .collect()
4423    }
4424
4425    #[test]
4426    fn finding12_add_text_and_two_replacements_on_one_page_all_resolve_in_one_batch() {
4427        let source = doc_bytes(&build_pdf(THREE_COLOURS));
4428        let before = page_islands(&source);
4429        // The add goes first: its isolation wrapper used to be installed on the spot, shifting every
4430        // original `contents_index` and with it every later island digest.
4431        let commands = [
4432            add_text_cmd("add", "HH"),
4433            changed_font_cmd("one", &before[0], "Hi", 72.0),
4434            changed_font_cmd("two", &before[2], "CA", 300.0),
4435        ];
4436        let candidate = compose_candidate(&source, &commands, &test_resolver())
4437            .expect("every command resolves");
4438        assert_eq!(candidate.affected_pages, [1]);
4439        let after = page_islands(&candidate.bytes);
4440        let texts: Vec<&str> = after.iter().map(|i| i.text.as_str()).collect();
4441        // The untouched and the added islands are islands of their own; the two replacements sit
4442        // on the page's baseline 3 pt apart in one colour and read as one island, "HiCA".
4443        for wanted in ["CD", "HH"] {
4444            assert!(texts.contains(&wanted), "{wanted}: {texts:?}");
4445        }
4446        let text = page_text(&after);
4447        assert!(
4448            text.contains("Hi") && text.contains("CA") && !text.contains("AB"),
4449            "{texts:?}"
4450        );
4451        // Each replacement is at the origin of the run it replaced (the H of "Hi", the C of "CA").
4452        assert!(has_origin(&glyph_origins(&after, "H"), before[0].baseline));
4453        assert!(has_origin(&glyph_origins(&after, "C"), before[2].baseline));
4454        assert!(has_origin(
4455            &glyph_origins(&after, "H"),
4456            [100.0, 100.0, 0.0, 0.0]
4457        ));
4458
4459        // The untouched middle island did not move.
4460        let red = after.iter().find(|i| i.text == "CD").expect("CD");
4461        assert_eq!(red.quads, before[1].quads);
4462
4463        // One wrapper pair around the original, then the three new streams in command order.
4464        let (out, _) = reinspect(&candidate, 1);
4465        let page_id = out.get_pages()[&1];
4466        let contents = out
4467            .get_object(page_id)
4468            .expect("page")
4469            .as_dict()
4470            .expect("dict")
4471            .get(b"Contents")
4472            .expect("contents");
4473        assert_eq!(content_stream_ids(&out, contents).expect("ids").len(), 6);
4474    }
4475
4476    #[test]
4477    fn finding12_replacements_apply_back_to_front_whatever_order_they_are_given_in() {
4478        let source = doc_bytes(&build_pdf(THREE_COLOURS));
4479        let before = page_islands(&source);
4480        for order in [[0_usize, 2], [2, 0]] {
4481            let commands: Vec<PdfEditCommand> = order
4482                .iter()
4483                .map(|&i| changed_font_cmd(&format!("c{i}"), &before[i], "Hi", 72.0 + i as f64))
4484                .collect();
4485            let candidate = compose_candidate(&source, &commands, &test_resolver())
4486                .unwrap_or_else(|e| panic!("{order:?}: {e:?}"));
4487            let after = page_islands(&candidate.bytes);
4488            // Both commands were applied: two "Hi", no "AB" left, each at its run's own origin.
4489            // Whether they read as one island ("HiHi", applied 0 then 2) or two (2 then 0: the
4490            // second starts left of where the first ends) depends on the order of the appended
4491            // streams, which is the order of the commands, so the island count is not asserted.
4492            let text = page_text(&after);
4493            assert_eq!(text.matches("Hi").count(), 2, "{order:?}: {text}");
4494            assert!(!text.contains("AB"), "{order:?}: {text}");
4495            let origins = glyph_origins(&after, "H");
4496            assert_eq!(origins.len(), 2, "{order:?}");
4497            assert!(has_origin(&origins, before[0].baseline), "{order:?}");
4498            assert!(has_origin(&origins, before[2].baseline), "{order:?}");
4499            let red = after.iter().find(|i| i.text == "CD").expect("CD");
4500            assert_eq!(red.quads, before[1].quads, "{order:?}");
4501        }
4502    }
4503
4504    #[test]
4505    fn finding12_two_islands_in_one_tj_array_both_resolve_when_the_first_is_split() {
4506        // A 24 pt gap is wider than 1.5 em: AB (item 0) and CD (item 2) are two islands of one TJ.
4507        // Splitting item 0 first would move CD to item 4 and break its locator.
4508        let source = doc_bytes(&build_pdf(
4509            b"BT /F1 12 Tf 72 700 Td [(AB) -2000 (CD)] TJ 1 0 0 rg (AB) Tj ET",
4510        ));
4511        let before = page_islands(&source);
4512        assert_eq!(
4513            before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
4514            ["AB", "CD", "AB"]
4515        );
4516        let commands = [
4517            changed_font_cmd("ab", &before[0], "Hi", 72.0),
4518            changed_font_cmd("cd", &before[1], "CA", 300.0),
4519        ];
4520        let candidate = compose_candidate(&source, &commands, &test_resolver())
4521            .expect("both islands of the array resolve");
4522        let after = page_islands(&candidate.bytes);
4523        let texts: Vec<&str> = after.iter().map(|i| i.text.as_str()).collect();
4524        // The replacements are on the baseline 11 pt apart in one colour: "Hi CA" is one island.
4525        // What matters is that both resolved and sit where their runs were.
4526        let text = page_text(&after);
4527        assert!(text.contains("Hi") && text.contains("CA"), "{texts:?}");
4528        assert!(has_origin(&glyph_origins(&after, "H"), before[0].baseline));
4529        assert!(has_origin(&glyph_origins(&after, "C"), before[1].baseline));
4530        // The run after the array kept its place through both removals.
4531        let tail = after
4532            .iter()
4533            .find(|i| i.text == "AB")
4534            .map(|i| i.quads.clone())
4535            .expect("the trailing AB");
4536        assert_eq!(tail, before[2].quads);
4537    }
4538
4539    #[test]
4540    fn finding9_glyphs_shown_by_a_line_moving_operator_are_refused_not_left_with_the_pen_short() {
4541        let source = doc_bytes(&build_pdf(
4542            b"BT /F1 12 Tf 72 700 Td 14 TL (AB) ' 1 0 0 rg (CD) Tj ET",
4543        ));
4544        let before = page_islands(&source);
4545        let first = before.iter().find(|i| i.text == "AB").expect("AB");
4546        let command = changed_font_cmd("quote", first, "Hi", 72.0);
4547        assert!(matches!(
4548            compose_candidate(&source, &[command], &test_resolver()),
4549            Err(PdfEditError::UnsupportedEncoding)
4550        ));
4551    }
4552
4553    #[test]
4554    fn finding16_inspection_advertises_a_substitution_exactly_when_composition_admits_it() {
4555        // The same two letters shown by each of the four show operators. The island may say
4556        // `substitution_editable` only where the composer will remove its glyphs, and the composer
4557        // must succeed exactly there: one predicate, read by both.
4558        let cases: [(&[u8], bool); 4] = [
4559            (b"BT /F1 12 Tf 72 700 Td (AB) Tj ET", true),
4560            (b"BT /F1 12 Tf 72 700 Td [(AB)] TJ ET", true),
4561            (b"BT /F1 12 Tf 72 700 Td 14 TL (AB) ' ET", false),
4562            (b"BT /F1 12 Tf 72 700 Td 14 TL 0 0 (AB) \" ET", false),
4563        ];
4564        for (content, advertised) in cases {
4565            let label = String::from_utf8_lossy(content).into_owned();
4566            let source = doc_bytes(&build_pdf(content));
4567            let before = page_islands(&source);
4568            let island = before
4569                .iter()
4570                .find(|i| i.text == "AB")
4571                .unwrap_or_else(|| panic!("{label}: no AB island"));
4572            assert_eq!(island.substitution_editable, advertised, "{label}");
4573
4574            let command = changed_font_cmd("substitute", island, "Hi", 72.0);
4575            let composed = compose_candidate(&source, &[command], &test_resolver());
4576            assert_eq!(
4577                composed.is_ok(),
4578                advertised,
4579                "{label}: composition said {:?}",
4580                composed.as_ref().err()
4581            );
4582            if !advertised {
4583                assert!(
4584                    matches!(composed, Err(PdfEditError::UnsupportedEncoding)),
4585                    "{label}"
4586                );
4587            }
4588        }
4589    }
4590
4591    #[test]
4592    fn finding12_an_island_listed_twice_in_a_batch_is_still_refused_the_second_time() {
4593        let source = doc_bytes(&build_pdf(THREE_COLOURS));
4594        let before = page_islands(&source);
4595        let commands = [
4596            changed_font_cmd("a", &before[0], "Hi", 72.0),
4597            changed_font_cmd("b", &before[0], "CA", 300.0),
4598        ];
4599        assert!(matches!(
4600            compose_candidate(&source, &commands, &test_resolver()),
4601            Err(PdfEditError::SourceDigestMismatch)
4602        ));
4603    }
4604
4605    #[test]
4606    fn finding10_a_page_sharing_a_stream_with_many_pages_costs_one_copy() {
4607        // Four pages share one content stream; editing page 1 copies it once for the other three.
4608        let mut doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (AB) Tj ET");
4609        let page_id = doc.get_pages()[&1];
4610        let (contents, parent, resources) = {
4611            let page = doc
4612                .get_object(page_id)
4613                .expect("page")
4614                .as_dict()
4615                .expect("dict");
4616            (
4617                page.get(b"Contents").expect("contents").clone(),
4618                page.get(b"Parent").expect("parent").clone(),
4619                page.get(b"Resources").expect("resources").clone(),
4620            )
4621        };
4622        let Object::Reference(pages_id) = parent.clone() else {
4623            panic!("parent reference");
4624        };
4625        let mut kids = vec![Object::Reference(page_id)];
4626        for _ in 0..3 {
4627            let extra = doc.add_object(dictionary! {
4628                "Type" => "Page",
4629                "Parent" => parent.clone(),
4630                "Contents" => contents.clone(),
4631                "Resources" => resources.clone(),
4632                "MediaBox" => Object::Array(vec![
4633                    Object::Integer(0),
4634                    Object::Integer(0),
4635                    Object::Integer(612),
4636                    Object::Integer(792),
4637                ]),
4638            });
4639            kids.push(Object::Reference(extra));
4640        }
4641        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
4642            pages.set("Kids", Object::Array(kids));
4643            pages.set("Count", 4);
4644        }
4645        let source = doc_bytes(&doc);
4646        let before = page_islands(&source);
4647        let before_objects = Document::load_mem(&source).expect("reopen").objects.len();
4648        let command = changed_font_cmd("shared", &before[0], "Hi", 72.0);
4649        let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4650        let out = Document::load_mem(&candidate.bytes).expect("reopen candidate");
4651        let mut streams: std::collections::BTreeSet<ObjectId> = Default::default();
4652        for page in 2..=4 {
4653            let id = out.get_pages()[&page];
4654            let contents = out
4655                .get_object(id)
4656                .expect("page")
4657                .as_dict()
4658                .expect("dict")
4659                .get(b"Contents")
4660                .expect("contents");
4661            streams.extend(content_stream_ids(&out, contents).expect("ids"));
4662        }
4663        assert_eq!(streams.len(), 1, "the three other pages share one copy");
4664        assert!(out.objects.len() > before_objects);
4665    }
4666
4667    #[test]
4668    fn finding10_unclosed_q_operators_are_refused_not_expanded_into_a_wrapper() {
4669        // The wrapper closes every `q` the original leaves open, one `Q` operation each; the count
4670        // comes from the document, so it is bounded before the wrapper is built.
4671        let content = format!(
4672            "{}BT /F1 12 Tf 72 700 Td (Existing) Tj ET",
4673            "q ".repeat(70_000)
4674        );
4675        let source = doc_bytes(&build_pdf(content.as_bytes()));
4676        assert!(matches!(
4677            compose_candidate(&source, &[add_hi("a")], &test_resolver()),
4678            Err(PdfEditError::MalformedContent)
4679        ));
4680    }
4681
4682    // -----------------------------------------------------------------------------------
4683    // Corpus probe of 0.2.1, 2026-10-12: Add Text and changed-font replacement returned
4684    // `malformed_content` on irs-1040.pdf, irs-p17.pdf and irs-w9.pdf (0.2.0 composed them)
4685    // -----------------------------------------------------------------------------------
4686
4687    /// The first page of those forms: `/Contents` is cut inside the operation `/Artifact
4688    /// <</MCID 0>> BDC`, between its operands. ISO 32000-1 7.8.2 lets a producer cut at any
4689    /// boundary between tokens, so the page is well formed, yet the middle stream alone ends in an
4690    /// operand with no operator.
4691    const CUT_INSIDE_AN_OPERATION: [&[u8]; 3] = [
4692        b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET ",
4693        b"q 1 0 0 1 0 0 cm Q /Artifact ",
4694        b"<< /MCID 0 >> BDC EMC ",
4695    ];
4696
4697    fn page_cut_inside_an_operation() -> Document {
4698        // The fixture is what it claims: refused alone, well formed as the one stream it is.
4699        assert!(Content::decode_strict(CUT_INSIDE_AN_OPERATION[1]).is_err());
4700        assert!(Content::decode_strict(&CUT_INSIDE_AN_OPERATION.concat()).is_ok());
4701        custom_page_pdf(
4702            |d| vec![("F1", helvetica_font(d))],
4703            &CUT_INSIDE_AN_OPERATION,
4704            false,
4705        )
4706    }
4707
4708    /// Page 1's `/Contents` streams, in order.
4709    fn page_one_streams(doc: &Document) -> Vec<ObjectId> {
4710        let page_id = doc.get_pages()[&1];
4711        let contents = doc
4712            .get_object(page_id)
4713            .expect("page")
4714            .as_dict()
4715            .expect("dict")
4716            .get(b"Contents")
4717            .expect("contents");
4718        content_stream_ids(doc, contents).expect("ids")
4719    }
4720
4721    fn stream_bytes(doc: &Document, id: ObjectId) -> Vec<u8> {
4722        doc.get_object(id)
4723            .expect("object")
4724            .as_stream()
4725            .expect("a stream")
4726            .decompressed_content()
4727            .expect("decodes")
4728    }
4729
4730    /// The nesting depth reached just before the last stream and the lowest depth seen, counted
4731    /// with the lenient parser (a stream here may end in operands, which the strict one refuses).
4732    fn nesting_before_the_last_stream_leniently(doc: &Document) -> (i64, i64) {
4733        let ids = page_one_streams(doc);
4734        let (mut depth, mut lowest) = (0_i64, 0_i64);
4735        for id in &ids[..ids.len() - 1] {
4736            let content = Content::decode(&stream_bytes(doc, *id)).expect("parses leniently");
4737            for op in content.operations {
4738                match op.operator.as_str() {
4739                    "q" => depth += 1,
4740                    "Q" => {
4741                        depth -= 1;
4742                        lowest = lowest.min(depth);
4743                    }
4744                    _ => {}
4745                }
4746            }
4747        }
4748        (depth, lowest)
4749    }
4750
4751    #[test]
4752    fn add_text_on_a_page_cut_inside_an_operation_composes_and_leaves_every_stream_alone() {
4753        let doc = page_cut_inside_an_operation();
4754        let original = page_one_streams(&doc);
4755        let source = doc_bytes(&doc);
4756        let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
4757            .expect("a page is not malformed because its streams are cut inside an operation");
4758        let (out, graph) = reinspect(&candidate, 1);
4759        assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
4760        assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
4761
4762        // q wrapper, the three originals, Q wrapper, the added stream.
4763        let ids = page_one_streams(&out);
4764        assert_eq!(ids.len(), 6);
4765        assert_eq!(&ids[1..4], &original[..]);
4766        for (id, bytes) in ids[1..4].iter().zip(CUT_INSIDE_AN_OPERATION) {
4767            assert_eq!(stream_bytes(&out, *id), bytes);
4768        }
4769        assert_eq!(nesting_before_the_last_stream_leniently(&out), (0, 0));
4770    }
4771
4772    #[test]
4773    fn a_changed_font_replacement_on_a_page_cut_inside_an_operation_composes() {
4774        let doc = page_cut_inside_an_operation();
4775        let source = doc_bytes(&doc);
4776        let before = page_islands(&source);
4777        assert_eq!(
4778            before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
4779            ["Existing"]
4780        );
4781        let command = changed_font_cmd("redraw", &before[0], "Hi", 72.0);
4782        let candidate = compose_candidate(&source, &[command], &test_resolver())
4783            .expect("a page is not malformed because its streams are cut inside an operation");
4784        let (out, graph) = reinspect(&candidate, 1);
4785        assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
4786        assert!(!graph.islands.iter().any(|isl| isl.text == "Existing"));
4787
4788        // The edit rewrote the first stream. The cut ones were not edited and are as they were.
4789        let ids = page_one_streams(&out);
4790        assert_eq!(ids.len(), 6);
4791        assert_eq!(stream_bytes(&out, ids[2]), CUT_INSIDE_AN_OPERATION[1]);
4792        assert_eq!(stream_bytes(&out, ids[3]), CUT_INSIDE_AN_OPERATION[2]);
4793        assert_eq!(nesting_before_the_last_stream_leniently(&out), (0, 0));
4794    }
4795
4796    #[test]
4797    fn add_text_still_refuses_a_stream_that_ends_in_anything_but_operands() {
4798        // The allowance for a cut between operands and operator is not a way past the strict parse:
4799        // a string left open, or a byte no tokenizer reads, is still malformed content.
4800        for (label, tail) in [
4801            ("open string", &b"q (open"[..]),
4802            ("stray byte", &b"q \x01 Q"[..]),
4803        ] {
4804            let streams: [&[u8]; 2] = [b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET ", tail];
4805            let doc = custom_page_pdf(|d| vec![("F1", helvetica_font(d))], &streams, false);
4806            let source = doc_bytes(&doc);
4807            assert!(
4808                matches!(
4809                    compose_candidate(&source, &[add_hi("a")], &test_resolver()),
4810                    Err(PdfEditError::MalformedContent)
4811                ),
4812                "{label}"
4813            );
4814        }
4815    }
4816
4817    // -----------------------------------------------------------------------------------
4818    // A block in the default graphics state carries the source run's whole placement
4819    // -----------------------------------------------------------------------------------
4820
4821    /// Page content like a producer that places every run with `cm` outside the text object: a
4822    /// leading `0.1` scale and a `7920` shift (a 792 pt page in tenths), then one
4823    /// `q <cm> cm BT /F1 100 Tf <colour> rg 0 0 Td (<text>) Tj ET Q` per `(cm, rg, text)`. The run
4824    /// colours differ, so neighbouring runs stay separate islands.
4825    fn cm_positioned_page(runs: &[(&str, &str, &str)]) -> Vec<u8> {
4826        let mut content = String::from("0.1 0 0 0.1 0 0 cm\n1 0 0 1 0 7920 cm\n");
4827        for (cm, rg, text) in runs {
4828            content.push_str(&format!(
4829                "q {cm} cm BT /F1 100 Tf {rg} rg 0 0 Td ({text}) Tj ET Q\n"
4830            ));
4831        }
4832        content.into_bytes()
4833    }
4834
4835    /// The three-run page with the middle run (`middle_text`) placed by `middle_cm`.
4836    fn three_cm_runs(middle_cm: &str, middle_text: &str) -> Vec<u8> {
4837        cm_positioned_page(&[
4838            ("1 0 0 1 1000 -1000", "0 0 0", "AB"),
4839            (middle_cm, "1 0 0", middle_text),
4840            ("1 0 0 1 3000 -1000", "0 0 1", "CA"),
4841        ])
4842    }
4843
4844    struct ReplacedMiddle {
4845        target: String,
4846        replacement: String,
4847        before: Vec<crate::model::PdfTextIsland>,
4848        after: Vec<crate::model::PdfTextIsland>,
4849        appended: Vec<Operation>,
4850        bytes: Vec<u8>,
4851    }
4852
4853    /// Replaces `BC` of `content` with `Hi` in the catalog font (`Tf` 100, the source run's own
4854    /// text-space size) and reads the saved bytes back.
4855    fn replace_middle_run_in_a_new_font(content: &[u8]) -> ReplacedMiddle {
4856        replace_run_in_a_new_font(content, "BC", "Hi")
4857    }
4858
4859    /// The same for any island of `content` reading `target`, replaced by `replacement`. Text
4860    /// turned a quarter is grouped by page axes (see the README's limits): it falls apart into one
4861    /// island per glyph, so the rotated fixtures use single-glyph runs.
4862    fn replace_run_in_a_new_font(
4863        content: &[u8],
4864        target: &str,
4865        replacement: &str,
4866    ) -> ReplacedMiddle {
4867        let source = doc_bytes(&build_pdf(content));
4868        let before = page_islands(&source);
4869        let found = before
4870            .iter()
4871            .find(|island| island.text == target)
4872            .expect("the middle run is an island of its own");
4873        let command = replace_cmd(
4874            "middle",
4875            found,
4876            replacement,
4877            appearance("test-font", 100.0),
4878            PdfRect {
4879                x: 190.0,
4880                y: 680.0,
4881                width: 200.0,
4882                height: 40.0,
4883            },
4884        );
4885        let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4886        let (out, graph) = reinspect(&candidate, 1);
4887        ReplacedMiddle {
4888            target: target.to_string(),
4889            replacement: replacement.to_string(),
4890            before,
4891            after: graph.islands,
4892            appended: appended_operations(&out, 1),
4893            bytes: candidate.bytes,
4894        }
4895    }
4896
4897    fn operands_of(ops: &[Operation], operator: &str) -> Vec<f64> {
4898        ops.iter()
4899            .find(|op| op.operator == operator)
4900            .unwrap_or_else(|| panic!("no {operator} in {ops:?}"))
4901            .operands
4902            .iter()
4903            .filter_map(object_number)
4904            .collect()
4905    }
4906
4907    fn assert_numbers(label: &str, got: &[f64], want: &[f64]) {
4908        assert_eq!(got.len(), want.len(), "{label}: {got:?} vs {want:?}");
4909        for (got_value, want_value) in got.iter().zip(want) {
4910            // PDF reals are f32.
4911            assert!(
4912                (got_value - want_value).abs() <= 1e-4,
4913                "{label}: {got:?} vs {want:?}"
4914            );
4915        }
4916    }
4917
4918    /// The font size of an island's first glyph on the page, in device space: the `Tf` size times
4919    /// the length of one text-space unit up the page.
4920    fn device_size(island: &crate::model::PdfTextIsland) -> f64 {
4921        let layout = island.layout.expect("layout");
4922        layout.font_size * layout.placement.c.hypot(layout.placement.d)
4923    }
4924
4925    fn island_named<'a>(
4926        islands: &'a [crate::model::PdfTextIsland],
4927        text: &str,
4928    ) -> &'a crate::model::PdfTextIsland {
4929        islands
4930            .iter()
4931            .find(|island| island.text == text)
4932            .unwrap_or_else(|| panic!("no island {text:?}"))
4933    }
4934
4935    /// What both placement tests assert once the appended `Tm` and `Tf` are checked.
4936    fn assert_replacement_sits_on_the_source_run(run: &ReplacedMiddle) {
4937        let source = island_named(&run.before, &run.target);
4938        let written = island_named(&run.after, &run.replacement);
4939        // (c) The saved page, read back with the crate's own walker: same glyph origin, same
4940        // size in device space.
4941        let origin_error = (written.baseline[0] - source.baseline[0])
4942            .hypot(written.baseline[1] - source.baseline[1]);
4943        assert!(
4944            origin_error <= 0.5,
4945            "origin {:?} vs source {:?}",
4946            &written.baseline[..2],
4947            &source.baseline[..2]
4948        );
4949        let (have, want) = (device_size(written), device_size(source));
4950        assert!(
4951            (have - want).abs() <= 0.02 * want,
4952            "device size {have} vs source {want}"
4953        );
4954        assert!(
4955            (want - 10.0).abs() < 1e-6,
4956            "the fixture's run is 10 pt: {want}"
4957        );
4958        // (d) The neighbours are exactly where they were.
4959        for neighbour in ["AB", "CA"] {
4960            assert_eq!(
4961                island_named(&run.after, neighbour).quads,
4962                island_named(&run.before, neighbour).quads,
4963                "{neighbour}"
4964            );
4965        }
4966        assert!(!run.after.iter().any(|island| island.text == run.target));
4967    }
4968
4969    #[test]
4970    fn a_changed_font_replacement_on_a_cm_positioned_page_carries_the_runs_placement() {
4971        let run = replace_middle_run_in_a_new_font(&three_cm_runs("1 0 0 1 2000 -1000", "BC"));
4972        // (a) Tm_source (the identity: `0 0 Td`) x CTM_source, row vectors. The CTM of the middle
4973        // run is `[1 0 0 1 2000 -1000] x [1 0 0 1 0 7920] x [0.1 0 0 0.1 0 0]`: a text-space point
4974        // (x, y) lands at (0.1 x + 0.1 * 2000, 0.1 y + 0.1 * (7920 - 1000)) = (0.1 x + 200,
4975        // 0.1 y + 692).
4976        assert_numbers(
4977            "Tm",
4978            &operands_of(&run.appended, "Tm"),
4979            &[0.1, 0.0, 0.0, 0.1, 200.0, 692.0],
4980        );
4981        // (b) Tf stays the source's text-space size, not the 10 pt it is drawn at.
4982        assert_numbers("Tf", &operands_of(&run.appended, "Tf"), &[100.0]);
4983        assert_replacement_sits_on_the_source_run(&run);
4984    }
4985
4986    #[test]
4987    fn a_changed_font_replacement_keeps_the_rotation_of_a_cm_positioned_run() {
4988        // `0 1 -1 0 2000 -1000 cm` turns the run a quarter turn. Its CTM is
4989        // `[0 1 -1 0 2000 -1000] x [0.1 0 0 0.1 0 792]` = [0 0.1 -0.1 0 200 692].
4990        // One glyph in, one glyph out: a quarter-turned run of two or more glyphs is not one island.
4991        let run = replace_run_in_a_new_font(&three_cm_runs("0 1 -1 0 2000 -1000", "B"), "B", "H");
4992        assert_numbers(
4993            "Tm",
4994            &operands_of(&run.appended, "Tm"),
4995            &[0.0, 0.1, -0.1, 0.0, 200.0, 692.0],
4996        );
4997        assert_numbers("Tf", &operands_of(&run.appended, "Tf"), &[100.0]);
4998        assert_replacement_sits_on_the_source_run(&run);
4999        // The walker reads the new glyph's whole matrix back, rotation included.
5000        let placement = island_named(&run.after, "H")
5001            .layout
5002            .expect("layout")
5003            .placement;
5004        assert_numbers(
5005            "read back",
5006            &[placement.a, placement.b, placement.c, placement.d],
5007            &[0.0, 0.1, -0.1, 0.0],
5008        );
5009    }
5010
5011    #[test]
5012    fn a_changed_font_replacement_keeps_the_text_matrix_and_the_cm_together() {
5013        // Both carry placement: `Tm` turns and moves the run, the `cm` scales it.
5014        let content = b"0.1 0 0 0.1 0 0 cm\n1 0 0 1 0 7920 cm\n\
5015            q 1 0 0 1 1000 -1000 cm BT /F1 100 Tf 0 0 0 rg 0 0 Td (AB) Tj ET Q\n\
5016            q 1 0 0 1 2000 -1000 cm BT /F1 100 Tf 1 0 0 rg 0 1 -1 0 30 40 Tm (B) Tj ET Q\n\
5017            q 1 0 0 1 3000 -1000 cm BT /F1 100 Tf 0 0 1 rg 0 0 Td (CA) Tj ET Q\n";
5018        let run = replace_run_in_a_new_font(content, "B", "H");
5019        // Tm [0 1 -1 0 30 40] x CTM [0.1 0 0 0.1 200 692]: a = 0, b = 0.1, c = -0.1, d = 0,
5020        // e = 30 * 0.1 + 40 * 0 + 200 = 203, f = 30 * 0 + 40 * 0.1 + 692 = 696.
5021        assert_numbers(
5022            "Tm",
5023            &operands_of(&run.appended, "Tm"),
5024            &[0.0, 0.1, -0.1, 0.0, 203.0, 696.0],
5025        );
5026        assert_numbers("Tf", &operands_of(&run.appended, "Tf"), &[100.0]);
5027        assert_replacement_sits_on_the_source_run(&run);
5028    }
5029
5030    #[test]
5031    fn add_text_on_a_page_with_a_leading_scale_stays_an_upright_point_in_page_space() {
5032        let source = doc_bytes(&build_pdf(&three_cm_runs("1 0 0 1 2000 -1000", "BC")));
5033        let command = PdfEditCommand::AddText {
5034            id: "add".to_string(),
5035            page: 1,
5036            anchor: PdfPoint { x: 100.0, y: 100.0 },
5037            text: "Hi".to_string(),
5038            appearance: appearance("test-font", 18.0),
5039        };
5040        let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
5041        let (out, graph) = reinspect(&candidate, 1);
5042        let ops = appended_operations(&out, 1);
5043        assert_numbers(
5044            "Tm",
5045            &operands_of(&ops, "Tm"),
5046            &[1.0, 0.0, 0.0, 1.0, 100.0, 100.0],
5047        );
5048        assert_numbers("Tf", &operands_of(&ops, "Tf"), &[18.0]);
5049        // Read back: the text is at the requested page point, 18 pt, whatever the page's own CTM.
5050        let added = island_named(&graph.islands, "Hi");
5051        assert!(
5052            (added.baseline[0] - 100.0).abs() < 1e-3 && (added.baseline[1] - 100.0).abs() < 1e-3
5053        );
5054        assert!((device_size(added) - 18.0).abs() < 1e-6);
5055    }
5056
5057    #[test]
5058    fn the_composer_read_back_refuses_a_replacement_drawn_away_from_the_source_run() {
5059        let run = replace_middle_run_in_a_new_font(&three_cm_runs("1 0 0 1 2000 -1000", "BC"));
5060        let placement = island_named(&run.before, "BC")
5061            .layout
5062            .expect("layout")
5063            .placement;
5064        let mut doc = Document::load_mem(&run.bytes).expect("reopen");
5065        let stream = last_content_stream(&doc, 1);
5066        let expected = |placement: Matrix, size: f64| AppendedRun {
5067            page: 1,
5068            stream: Some(stream),
5069            placement,
5070            size,
5071        };
5072        let shifted = |dx: f64, dy: f64| Matrix {
5073            e: placement.e + dx,
5074            f: placement.f + dy,
5075            ..placement
5076        };
5077
5078        verify_appended_runs(&doc, &[expected(placement, 100.0)])
5079            .expect("where the source run was");
5080        // Origin: within 0.5 pt passes, 1 pt does not.
5081        verify_appended_runs(&doc, &[expected(shifted(0.3, -0.3), 100.0)]).expect("0.42 pt");
5082        for (dx, dy) in [(1.0, 0.0), (0.0, -1.0), (-200.0, -692.0)] {
5083            assert!(
5084                matches!(
5085                    verify_appended_runs(&doc, &[expected(shifted(dx, dy), 100.0)]),
5086                    Err(PdfEditError::VerificationFailed)
5087                ),
5088                "({dx}, {dy})"
5089            );
5090        }
5091        // Size: 1 % passes, 3 % and ten times do not.
5092        verify_appended_runs(&doc, &[expected(placement, 101.0)]).expect("1 %");
5093        for size in [103.0, 1000.0] {
5094            assert!(matches!(
5095                verify_appended_runs(&doc, &[expected(placement, size)]),
5096                Err(PdfEditError::VerificationFailed)
5097            ));
5098        }
5099        // Rotation counts as placement.
5100        let turned = Matrix {
5101            a: 0.0,
5102            b: 0.1,
5103            c: -0.1,
5104            d: 0.0,
5105            ..placement
5106        };
5107        assert!(matches!(
5108            verify_appended_runs(&doc, &[expected(turned, 100.0)]),
5109            Err(PdfEditError::VerificationFailed)
5110        ));
5111
5112        // The output of 0.2.0 and 0.2.1: the same block with `1 0 0 1 0 0 Tm`. Its text reads back
5113        // as "Hi"; only the geometry says it is wrong.
5114        let last = last_content_stream(&doc, 1);
5115        let mut content = decode_stream_content(&doc, last).expect("content");
5116        for op in content
5117            .operations
5118            .iter_mut()
5119            .filter(|op| op.operator == "Tm")
5120        {
5121            op.operands = [1_i64, 0, 0, 1, 0, 0].map(Object::Integer).to_vec();
5122        }
5123        write_stream_content(&mut doc, last, &content).expect("write");
5124        let graph = walk_page_graph(&doc, "", 1).expect("inspect");
5125        assert!(graph.islands.iter().any(|island| island.text == "Hi"));
5126        assert!(matches!(
5127            verify_appended_runs(&doc, &[expected(placement, 100.0)]),
5128            Err(PdfEditError::VerificationFailed)
5129        ));
5130    }
5131
5132    fn last_content_stream(doc: &Document, page: u32) -> ObjectId {
5133        let page_id = doc.get_pages()[&page];
5134        let contents = doc
5135            .get_object(page_id)
5136            .expect("page")
5137            .as_dict()
5138            .expect("dict")
5139            .get(b"Contents")
5140            .expect("contents");
5141        *content_stream_ids(doc, contents)
5142            .expect("ids")
5143            .last()
5144            .expect("a stream")
5145    }
5146
5147    // -----------------------------------------------------------------------------------
5148    // The appended run is found by its own stream, not by the island it was grouped into
5149    // -----------------------------------------------------------------------------------
5150
5151    #[test]
5152    fn a_replacement_that_joins_a_preceding_same_colour_run_is_still_read_back() {
5153        // "42.00" is shown first, then "Total:" ends about 10 pt before where the replacement
5154        // starts. The join rule does not compare fonts, so the appended "Hi" (same colour, same
5155        // baseline, inside the join window) is grouped into "Total:"'s island and is not its
5156        // first glyph.
5157        let source = doc_bytes(&build_pdf(
5158            b"BT /F1 12 Tf 0 g 120 700 Td (42.00) Tj 1 0 0 1 80 700 Tm (Total:) Tj ET",
5159        ));
5160        let before = page_islands(&source);
5161        let target = island_named(&before, "42.00");
5162        let command = replace_cmd(
5163            "price",
5164            target,
5165            "Hi",
5166            appearance("test-font", 12.0),
5167            PdfRect {
5168                x: 120.0,
5169                y: 690.0,
5170                width: 100.0,
5171                height: 30.0,
5172            },
5173        );
5174        let candidate = compose_candidate(&source, &[command], &test_resolver())
5175            .expect("a correct edit is not refused because it was grouped with its neighbour");
5176        let after = page_islands(&candidate.bytes);
5177        assert!(
5178            after
5179                .iter()
5180                .any(|island| island.text.contains("Total:") && island.text.contains("Hi")),
5181            "the fixture must put the replacement in the neighbour's island: {:?}",
5182            after.iter().map(|i| i.text.as_str()).collect::<Vec<_>>()
5183        );
5184        let hi = after
5185            .iter()
5186            .flat_map(|island| island.run.iter())
5187            .filter_map(|item| item.glyph.zip(item.placement))
5188            .find(|(glyph, _)| glyph.stream_ref == last_stream_ref(&candidate.bytes))
5189            .map(|(_, placement)| placement)
5190            .expect("the appended run's first glyph");
5191        assert_numbers(
5192            "origin of the replacement",
5193            &[hi.0.e, hi.0.f],
5194            &[120.0, 700.0],
5195        );
5196    }
5197
5198    #[test]
5199    fn two_adjacent_replacements_in_one_batch_are_both_read_back() {
5200        // "AB" and "CA" are separate islands at 12 pt (34 pt apart, join window 18). Replaced at
5201        // 24 pt, "Hi" is 28.8 pt wide, so the second appended run starts about 21 pt after the
5202        // first ends, inside the 36 pt window: both land in one island, the second mid-island.
5203        let source = doc_bytes(&build_pdf(
5204            b"BT /F1 12 Tf 0 g 100 700 Td (AB) Tj 1 0 0 1 150 700 Tm (CA) Tj ET",
5205        ));
5206        let before = page_islands(&source);
5207        assert_eq!(
5208            before.len(),
5209            2,
5210            "{:?}",
5211            before.iter().map(|i| &i.text).collect::<Vec<_>>()
5212        );
5213        let bounds = PdfRect {
5214            x: 100.0,
5215            y: 690.0,
5216            width: 100.0,
5217            height: 40.0,
5218        };
5219        let commands = [
5220            replace_cmd(
5221                "first",
5222                island_named(&before, "AB"),
5223                "Hi",
5224                appearance("test-font", 24.0),
5225                bounds,
5226            ),
5227            replace_cmd(
5228                "second",
5229                island_named(&before, "CA"),
5230                "Hi",
5231                appearance("test-font", 24.0),
5232                bounds,
5233            ),
5234        ];
5235        let candidate = compose_candidate(&source, &commands, &test_resolver())
5236            .expect("both replacements read back");
5237        let after = page_islands(&candidate.bytes);
5238        assert!(
5239            after
5240                .iter()
5241                .any(|island| island.text.matches("Hi").count() == 2),
5242            "the fixture must group the two replacements: {:?}",
5243            after.iter().map(|i| i.text.as_str()).collect::<Vec<_>>()
5244        );
5245    }
5246
5247    #[test]
5248    fn replacements_located_in_the_pristine_file_compose_in_one_call_in_either_order() {
5249        // The consumer's invariant: every locator of a page comes from one inspection of the
5250        // file on disk and all of them go to one call. "AB" and "CA" are separate islands (34 pt
5251        // apart at 12 pt, join window 18); replaced at 24 pt, the results sit in one island
5252        // (about 21 pt apart, window 36) - in the output only. Resolution happens before anything
5253        // is appended, so a merge in the output cannot touch a later command's locator, and the
5254        // order of the list changes only the order of the appended streams.
5255        let source = doc_bytes(&build_pdf(
5256            b"BT /F1 12 Tf 0 g 100 700 Td (AB) Tj 1 0 0 1 150 700 Tm (CA) Tj \
5257              1 0 0 rg 1 0 0 1 400 700 Tm (CD) Tj ET",
5258        ));
5259        let before = page_islands(&source);
5260        assert_eq!(
5261            before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
5262            ["AB", "CA", "CD"]
5263        );
5264        let bounds = PdfRect {
5265            x: 100.0,
5266            y: 690.0,
5267            width: 100.0,
5268            height: 40.0,
5269        };
5270        let command = |id: &str, text: &str| {
5271            replace_cmd(
5272                id,
5273                island_named(&before, text),
5274                "Hi",
5275                appearance("test-font", 24.0),
5276                bounds,
5277            )
5278        };
5279        for order in [["AB", "CA"], ["CA", "AB"]] {
5280            let commands = [command("one", order[0]), command("two", order[1])];
5281            let candidate = compose_candidate(&source, &commands, &test_resolver())
5282                .unwrap_or_else(|e| panic!("{order:?}: {e:?}"));
5283            let after = page_islands(&candidate.bytes);
5284            let texts: Vec<&str> = after.iter().map(|i| i.text.as_str()).collect();
5285
5286            // Both applied, each at the origin of its own source run, and the old text is gone.
5287            let text = page_text(&after);
5288            assert_eq!(text.matches("Hi").count(), 2, "{order:?}: {texts:?}");
5289            assert!(
5290                !text.contains("AB") && !text.contains("CA"),
5291                "{order:?}: {texts:?}"
5292            );
5293            let origins = glyph_origins(&after, "H");
5294            assert_eq!(origins.len(), 2, "{order:?}");
5295            assert!(has_origin(&origins, island_named(&before, "AB").baseline));
5296            assert!(has_origin(&origins, island_named(&before, "CA").baseline));
5297
5298            // The untouched run is intact, bit for bit.
5299            assert_eq!(
5300                island_named(&after, "CD").quads,
5301                island_named(&before, "CD").quads,
5302                "{order:?}"
5303            );
5304            // In the order of the page the two results are neighbours of one colour and read as
5305            // one island; given the other way round the second starts left of the first's end.
5306            if order[0] == "AB" {
5307                assert!(
5308                    after.iter().any(|i| i.text.matches("Hi").count() == 2),
5309                    "{texts:?}"
5310                );
5311            }
5312        }
5313    }
5314
5315    /// The object of the last `/Contents` stream of page 1 of `bytes`, as the walker names it.
5316    fn last_stream_ref(bytes: &[u8]) -> (u32, u16) {
5317        let doc = Document::load_mem(bytes).expect("reopen");
5318        let id = last_content_stream(&doc, 1);
5319        (id.0, id.1)
5320    }
5321}