Skip to main content

rightkit_pdf/
model.rs

1//! Shared PDF-editor wire/model types (Dispatch A, lane A1).
2//!
3//! Owns every Rust type other PDF-editor lanes compile against, per
4//! `docs/PDF-EDITOR-NOW.md` §5.1 (wire models), §5.2 (inspection state types), §7.2 (font catalog
5//! API), and §11 (typed errors), plus the digest helpers those sections describe and the shared
6//! `PdfEditCommand`/`ComposedCandidate` types the frozen composer signature depends on
7//! (`docs/PDF-EDITOR-DISPATCH.md`, "Frozen cross-lane seams"):
8//!
9//! ```ignore
10//! pub fn compose_candidate(
11//!     source: &[u8],
12//!     commands: &[PdfEditCommand],
13//!     fonts: &dyn PdfFontResolver,
14//! ) -> Result<ComposedCandidate, PdfEditError>;
15//! ```
16//!
17//! That function itself is defined by `pdf_compose.rs` (Dispatch C, lane C1) — this module only
18//! provides the types its signature depends on, so downstream lanes can build and test against a
19//! stable contract before the real implementation lands. Likewise `inspect_pdf_page`'s stateful
20//! content walker (§5.2's `visit_text_show`, §5.3's `may_join`/painted-bounds inventory) is
21//! `pdf_inspect.rs` (lane B1); this module owns only the `Matrix`/`TextState`/`PositionedGlyph`
22//! family of types that walker is built from.
23//!
24//! This file makes no filesystem or Tauri-state assumptions and defines no `#[tauri::command]` —
25//! every command in this effort (`inspect_pdf_page`, `list_pdf_fonts`, `compose_pdf_preview`,
26//! `prepare_pdf_save`, `commit_pdf_save`) belongs to a later lane's file.
27
28use std::fmt;
29use std::rc::Rc;
30
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33
34// ---------------------------------------------------------------------------------------------
35// §11 — Typed failures
36// ---------------------------------------------------------------------------------------------
37
38/// Stable, typed rejection reasons for PDF-editor commands. Crosses Tauri IPC as a JSON tag +
39/// message; `thiserror::Error` gives each variant the exact user-facing string from §11. Kept
40/// verbatim against the contract — do not add fields or reorder variants without updating §11
41/// first, since every lane (B1 inspection, B2 fonts, C1 composition, C2 save) matches on these by
42/// name.
43///
44/// `Serialize` is hand-written, not derived: this codebase's existing typed-error convention
45/// (the host error type in `error.rs`) wraps every variant in a `{ code, message }` envelope via
46/// `#[serde(tag = "code", content = "message", rename_all = "snake_case")]`. That attribute only
47/// populates `content` for variants that carry data; every `PdfEditError` variant is a bare unit
48/// variant (the message comes from `thiserror`'s `Display`, not a field), so the derive would
49/// serialize as a bare string tag with no `message` key. `pdfEditModel.ts` (A2) already models the
50/// frontend side as `{ code: PdfEditErrorCode, message: string }` matching the host error type's
51/// envelope — this manual impl produces exactly that shape so the two sides agree without D2
52/// needing to reconcile a mismatch later.
53#[derive(Debug, thiserror::Error)]
54pub enum PdfEditError {
55    #[error("PDF changed since edit started")]
56    StaleSource,
57    #[error("source operand changed")]
58    SourceDigestMismatch,
59    #[error("visible text has no unique editable source")]
60    #[allow(dead_code)] // part of §11's complete error taxonomy; not yet raised by any lane.
61    NoUniqueMatch,
62    #[error("text transform is unsupported")]
63    UnsupportedTransform,
64    #[error("font encoding is unsupported")]
65    UnsupportedEncoding,
66    #[error("selected font lacks a required glyph")]
67    MissingGlyph,
68    /// A replacement needs characters the island's own (subset) font cannot encode. Distinct from
69    /// [`PdfEditError::UnsupportedEncoding`] ("this font can never be re-encoded"): the island is
70    /// editable, this particular text is not. Wire `code` is `missing_glyph`, the same as
71    /// [`PdfEditError::MissingGlyph`]; the `message` names the characters.
72    #[error("selected font lacks a required glyph for: {}", format_missing_chars(.0))]
73    MissingGlyphs(Vec<char>),
74    #[error("font license forbids embedding")]
75    FontEmbeddingDenied,
76    #[error("text exceeds safe bounds")]
77    TextOverflow,
78    #[error("PDF content stream is malformed")]
79    MalformedContent,
80    #[error("encrypted PDF cannot be edited")]
81    EncryptedDocument,
82    #[error("annotations could not be serialized")]
83    AnnotationSerializationFailed,
84    #[error("candidate verification failed")]
85    VerificationFailed,
86    #[error("target changed before save")]
87    WriteConflict,
88}
89
90/// Renders characters for [`PdfEditError::MissingGlyphs`]: quoted, `Debug`-escaped (so a control
91/// character is readable), comma separated.
92fn format_missing_chars(chars: &[char]) -> String {
93    chars
94        .iter()
95        .map(|c| format!("{c:?}"))
96        .collect::<Vec<_>>()
97        .join(", ")
98}
99
100impl PdfEditError {
101    /// The characters a [`PdfEditError::MissingGlyphs`] refusal names, in first-appearance order.
102    pub fn missing_chars(&self) -> Option<&[char]> {
103        match self {
104            PdfEditError::MissingGlyphs(chars) => Some(chars),
105            _ => None,
106        }
107    }
108
109    /// Stable snake_case wire code for this variant — the `code` half of the `{ code, message }`
110    /// envelope below, matching `pdfEditModel.ts`'s `PdfEditErrorCode` union 1:1 by name.
111    pub fn code(&self) -> &'static str {
112        match self {
113            PdfEditError::StaleSource => "stale_source",
114            PdfEditError::SourceDigestMismatch => "source_digest_mismatch",
115            PdfEditError::NoUniqueMatch => "no_unique_match",
116            PdfEditError::UnsupportedTransform => "unsupported_transform",
117            PdfEditError::UnsupportedEncoding => "unsupported_encoding",
118            PdfEditError::MissingGlyph | PdfEditError::MissingGlyphs(_) => "missing_glyph",
119            PdfEditError::FontEmbeddingDenied => "font_embedding_denied",
120            PdfEditError::TextOverflow => "text_overflow",
121            PdfEditError::MalformedContent => "malformed_content",
122            PdfEditError::EncryptedDocument => "encrypted_document",
123            PdfEditError::AnnotationSerializationFailed => "annotation_serialization_failed",
124            PdfEditError::VerificationFailed => "verification_failed",
125            PdfEditError::WriteConflict => "write_conflict",
126        }
127    }
128}
129
130impl Serialize for PdfEditError {
131    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
132    where
133        S: serde::Serializer,
134    {
135        use serde::ser::SerializeStruct;
136        let mut state = serializer.serialize_struct("PdfEditError", 2)?;
137        state.serialize_field("code", self.code())?;
138        state.serialize_field("message", &self.to_string())?;
139        state.end()
140    }
141}
142
143// ---------------------------------------------------------------------------------------------
144// §5.1 — Wire models
145// ---------------------------------------------------------------------------------------------
146
147/// One point in unrotated PDF user-space (or device space, depending on call site — callers
148/// document which). Mirrors frontend `PdfPoint` (§6, owned by `pdfEditModel.ts`).
149#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
150#[serde(rename_all = "camelCase")]
151pub struct PdfPoint {
152    pub x: f64,
153    pub y: f64,
154}
155
156/// Axis-aligned rectangle in PDF user-space points. Mirrors frontend `PdfRect` (§6/§8, owned by
157/// `pdfEditModel.ts`). Used for §8 layout validation (`available.contains(run.bounds)`) and §5.3's
158/// painted-bounds inventory.
159#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
160#[serde(rename_all = "camelCase")]
161pub struct PdfRect {
162    pub x: f64,
163    pub y: f64,
164    pub width: f64,
165    pub height: f64,
166}
167
168impl PdfRect {
169    /// True if `other` fits entirely inside `self`, both edges inclusive.
170    pub fn contains(&self, other: PdfRect) -> bool {
171        other.x >= self.x
172            && other.y >= self.y
173            && other.x + other.width <= self.x + self.width
174            && other.y + other.height <= self.y + self.height
175    }
176
177    /// True if `self` and `other` overlap with nonzero area (touching edges do not count).
178    pub fn intersects(&self, other: PdfRect) -> bool {
179        self.x < other.x + other.width
180            && other.x < self.x + self.width
181            && self.y < other.y + other.height
182            && other.y < self.y + self.height
183    }
184}
185
186/// RGBA color for text-fill appearance. Mirrors frontend `PdfTextAppearance["color"]` (§6).
187#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
188#[serde(rename_all = "camelCase")]
189pub struct PdfColor {
190    pub r: f64,
191    pub g: f64,
192    pub b: f64,
193    pub alpha: f64,
194}
195
196/// Requested/applied text appearance for an edit. Mirrors frontend `PdfTextAppearance` (§6)
197/// verbatim (field order/names), used by [`PdfTextIsland::appearance`] and by [`PdfEditCommand`].
198#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
199#[serde(rename_all = "camelCase")]
200pub struct PdfTextAppearance {
201    pub font_id: String,
202    pub family: String,
203    pub size_pt: f64,
204    pub color: PdfColor,
205}
206
207#[derive(Serialize)]
208#[serde(rename_all = "camelCase")]
209#[allow(dead_code)] // §6 wire shape; not yet constructed by any lane in this crate.
210pub struct PdfDocumentRevision {
211    pub document_sha256: String,
212    pub page_count: u32,
213    pub pages: Vec<PdfPageGraph>,
214}
215
216#[derive(Serialize)]
217#[serde(rename_all = "camelCase")]
218pub struct PdfPageGraph {
219    pub page: u32,
220    /// Revision identity for this page, including scanned pages that have no source text islands.
221    pub document_sha256: String,
222    pub media_box: [f64; 4],
223    pub crop_box: [f64; 4],
224    pub rotation: i32,
225    pub islands: Vec<PdfTextIsland>,
226}
227
228/// Why an island's own font cannot be used to write replacement text. Serialized as a snake_case
229/// string in [`PdfTextIsland::edit_blocker`]. `editable == false` with one of these means "no
230/// replacement can be written through this island's font at all"; a replacement that merely needs
231/// a glyph the font lacks is not a blocker (see [`PdfEditError::MissingGlyphs`]).
232#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
233#[serde(rename_all = "snake_case")]
234pub enum PdfEditBlocker {
235    /// Type3, an unrecognised font subtype, a Type0 font whose CMap is not `Identity-H`, or no
236    /// resolvable font resource at all.
237    UnsupportedFontType,
238    /// The font has no code-to-character table that can be inverted exactly from data in the
239    /// file: no `ToUnicode` and no `/Encoding` the engine can interpret exactly (a symbolic
240    /// embedded font with a built-in encoding, an unknown base encoding, an `/Encoding` dictionary
241    /// the parser cannot read), or none of its codes carries a positive explicit width.
242    NoReversibleEncoding,
243    /// A code has no width where the font demands authoritative metrics, so glyph geometry (and
244    /// therefore a safe fit check) is unavailable.
245    MissingMetrics,
246    /// The island mixes fonts, sizes, scales or spacing, so one replacement string cannot
247    /// reproduce it without changing its look.
248    MixedTextState,
249    /// At least one glyph has no Unicode value; replacing the island text would silently drop it.
250    UnmappedGlyphs,
251    /// The island's source spans live in more than one content stream.
252    SpansAcrossStreams,
253    /// The island contains a word gap made by moving the text position (`Td`, `Tm`, ...) rather
254    /// than by `TJ` adjustments, or a positioning/state operator between two of its show
255    /// operators, so an edit touching that seam could not be rebuilt faithfully.
256    PositionedTextRun,
257}
258
259/// A word gap the walker synthesised as a space in [`PdfTextIsland::text`]: PDF text from TeX and
260/// similar producers has no space glyphs, only `TJ` adjustments or position moves.
261#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
262#[serde(rename_all = "camelCase")]
263pub struct PdfTextGap {
264    /// Index (in `char`s) of the synthesised space inside `text`. Every other character of
265    /// `text` is a real glyph.
266    pub char_index: usize,
267    /// Device-space width of the gap in points: the pen distance between the neighbouring glyphs
268    /// beyond what `Tc` already adds.
269    pub width_pt: f64,
270}
271
272/// One element of an island's run, in reading order: a real glyph (with its source bytes) or a
273/// synthesised gap. Crate-private; the composer rebuilds a run from it.
274#[derive(Clone, Debug)]
275#[allow(dead_code)] // read by compose (feature `edit`) only
276pub(crate) struct RunItem {
277    /// `Some` for a real glyph.
278    pub glyph: Option<GlyphSourceAddress>,
279    /// The glyph's Unicode text, or `" "` for a gap.
280    pub text: String,
281    /// Baseline position (device points, along the island's unit axis) of the item's start/end.
282    pub start: f64,
283    pub end: f64,
284    /// For a gap: the `TJ` adjustments that made it, or `None` when the gap came from a position
285    /// move. Always `None` for a glyph.
286    pub gap_numbers: Option<Vec<GapNumber>>,
287    /// For a gap: the pen distance across it beyond what `Tc` already adds (device points) - the
288    /// number [`PdfTextGap::width_pt`] reports. `0.0` for a glyph.
289    pub beyond_tc: f64,
290    /// True for a glyph that word spacing (`Tw`) applies to: the single-byte character code 32,
291    /// whatever it decodes to. Never true for a gap or a multi-byte (CID) code.
292    pub word_space: bool,
293    /// For a glyph: the `TJ` number whose pen movement equals the glyph's own (so a glyph removed
294    /// from a string leaves the pen where it was). `None` for a gap, and for a glyph whose
295    /// advance cannot be expressed that way (a zero or non-finite font size).
296    pub pen_adjust: Option<f64>,
297    /// For a glyph: `(text matrix x CTM, Tf size)` at this glyph, the same pair
298    /// [`IslandLayout::placement`] and [`IslandLayout::font_size`] hold for the island's first
299    /// glyph. `None` for a gap. Lets the composer's read-back look at one glyph of an island, not
300    /// only at the island's first.
301    pub placement: Option<(Matrix, f64)>,
302}
303
304/// One numeric `TJ` item that contributes to a word gap.
305#[derive(Clone, Copy, Debug, PartialEq)]
306#[allow(dead_code)]
307pub(crate) struct GapNumber {
308    pub operator_index: u32,
309    /// Index of the number inside its `TJ` array.
310    pub item_index: u32,
311    pub value: f64,
312}
313
314#[derive(Serialize)]
315#[serde(rename_all = "camelCase")]
316pub struct PdfTextIsland {
317    pub id: String,
318    pub text: String,
319    pub quads: Vec<[f64; 8]>,
320    pub baseline: [f64; 4],
321    pub appearance: PdfTextAppearance,
322    pub locator: PdfSourceLocator,
323    /// True when a replacement can be written through the island's own font (same appearance).
324    /// This is the same decision `compose_candidate` makes; the two cannot disagree. It does not
325    /// promise that every text is encodable: a replacement needing a glyph the font lacks fails
326    /// with [`PdfEditError::MissingGlyphs`].
327    pub editable: bool,
328    /// `Some(UnsupportedEncoding)` whenever `editable` is false.
329    pub rejection: Option<PdfEditError>,
330    /// Typed reason when `editable` is false (additive; `null` when editable).
331    pub edit_blocker: Option<PdfEditBlocker>,
332    /// True when the island's source glyphs can be removed and the text redrawn in a catalog font
333    /// (a changed appearance), whatever the source font - and the composer will accept that edit.
334    /// False for a rejected island and for an island whose source glyphs live in more than one
335    /// content stream (`edit_blocker` is then `spans_across_streams`): the composer removes the
336    /// old glyphs from one stream only, so it refuses that edit.
337    pub substitution_editable: bool,
338    /// Geometry and spacing the composer needs to measure a replacement exactly. Not on the wire.
339    #[serde(skip)]
340    #[allow(dead_code)] // read by compose (feature `edit`) only
341    pub(crate) layout: Option<IslandLayout>,
342    /// Synthesised word gaps inside `text` (additive; empty when the run has real spaces only).
343    pub gaps: Vec<PdfTextGap>,
344    #[serde(skip)]
345    #[allow(dead_code)] // read by compose (feature `edit`) only
346    pub(crate) run: Vec<RunItem>,
347}
348
349/// Text-space to device-space measurement context for one island, captured by the walker.
350#[derive(Clone, Copy, Debug, PartialEq)]
351#[allow(dead_code)] // read by compose (feature `edit`) only
352pub(crate) struct IslandLayout {
353    /// Device-space vector of one text-space unit along the baseline: the `(a, b)` row of
354    /// [`IslandLayout::placement`].
355    pub axis: (f64, f64),
356    /// `text matrix x CTM` at the island's first glyph, all six numbers, in the row-vector
357    /// convention of [`Matrix`] (`Matrix::then` applies the left operand first, ISO 32000-1 9.4.2).
358    /// It carries the run's whole placement - translation, rotation, skew and scale - and excludes
359    /// `Tf` size, `Tz` and `Ts`, which the text rendering matrix applies in front of it. A block
360    /// that runs in the default graphics state (identity CTM) and sets this as its `Tm` with the
361    /// same `Tf` size draws the first glyph exactly where the source did.
362    pub placement: Matrix,
363    /// Font size in text-space units (`Tf` operand).
364    pub font_size: f64,
365    pub char_spacing: f64,
366    pub word_spacing: f64,
367    /// `Tz / 100`.
368    pub horizontal_scale: f64,
369}
370
371#[derive(Clone, Debug, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373pub struct PdfSourceLocator {
374    pub document_sha256: String,
375    pub page: u32,
376    pub spans: Vec<PdfSourceSpan>,
377    pub island_digest: String,
378}
379
380#[derive(Clone, Debug, Serialize, Deserialize)]
381#[serde(rename_all = "camelCase")]
382pub struct PdfSourceSpan {
383    pub stream_ref: (u32, u16),
384    pub contents_index: u32,
385    pub operator_index: u32,
386    pub operand_index: u32,
387    pub glyph_start: u32,
388    pub glyph_end: u32,
389    pub expected_text: String,
390    pub expected_digest: String,
391}
392
393// ---------------------------------------------------------------------------------------------
394// §5.2 — Inspection state: Matrix / TextState / PositionedGlyph family
395// ---------------------------------------------------------------------------------------------
396//
397// These types back `visit_text_show` and §5.3's island grouping, both implemented by
398// `pdf_inspect.rs` (lane B1). This module provides their concrete shapes plus generic matrix/text-
399// state algebra (matching PDF spec 9.4.2-9.4.4); font-specific decoding is abstracted behind
400// `TextStateFont` so this module stays independent of any particular font-parsing strategy.
401
402/// 2D affine transform in PDF row-vector convention: `(x' y' 1) = (x y 1) x M`. Used for both the
403/// graphics-state CTM (`cm`/`q`/`Q`) and the text matrix/text-line matrix (`Tm`/`Td`/`TD`/`T*`).
404#[derive(Clone, Copy, Debug, PartialEq)]
405pub struct Matrix {
406    pub a: f64,
407    pub b: f64,
408    pub c: f64,
409    pub d: f64,
410    pub e: f64,
411    pub f: f64,
412}
413
414impl Matrix {
415    pub const IDENTITY: Matrix = Matrix {
416        a: 1.0,
417        b: 0.0,
418        c: 0.0,
419        d: 1.0,
420        e: 0.0,
421        f: 0.0,
422    };
423
424    pub fn translation(tx: f64, ty: f64) -> Matrix {
425        Matrix {
426            a: 1.0,
427            b: 0.0,
428            c: 0.0,
429            d: 1.0,
430            e: tx,
431            f: ty,
432        }
433    }
434
435    /// `self` composed with (applied before) `other`: `self x other`.
436    pub fn then(self, other: Matrix) -> Matrix {
437        Matrix {
438            a: self.a * other.a + self.b * other.c,
439            b: self.a * other.b + self.b * other.d,
440            c: self.c * other.a + self.d * other.c,
441            d: self.c * other.b + self.d * other.d,
442            e: self.e * other.a + self.f * other.c + other.e,
443            f: self.e * other.b + self.f * other.d + other.f,
444        }
445    }
446
447    /// Transforms `point` (row vector) through this matrix.
448    pub fn transform(self, point: (f64, f64)) -> (f64, f64) {
449        (
450            point.0 * self.a + point.1 * self.c + self.e,
451            point.0 * self.b + point.1 * self.d + self.f,
452        )
453    }
454
455    /// This matrix's translation component — for a text matrix, the run's PDF-space origin.
456    pub fn origin(self) -> (f64, f64) {
457        (self.e, self.f)
458    }
459
460    /// Post-show horizontal advance (PDF spec 9.4.3): `self = [1 0 0 1 tx ty] x self`.
461    pub fn translate_text(&mut self, tx: f64, ty: f64) {
462        *self = Matrix::translation(tx, ty).then(*self);
463    }
464}
465
466/// One decoded glyph from a `Tj`/`TJ` show operand. `source_range` is the glyph's byte range
467/// within the *decoded* operand string (start inclusive, end exclusive) and feeds
468/// [`SourceAddress::with_glyph`] for later island-span grouping (§5.3). `bbox`, when the font
469/// decoder can supply it, is the glyph's font-unit bounding box (`x_min, y_min, x_max, y_max`,
470/// scaled to `/1000` em units) used by [`TextState::glyph_quad`] for exact geometry; when absent,
471/// `glyph_quad` falls back to an advance-width approximation.
472#[derive(Clone, Debug)]
473pub struct DecodedGlyph {
474    pub cid: u32,
475    pub unicode: String,
476    pub source_range: (u32, u32),
477    pub bbox: Option<[f64; 4]>,
478}
479
480/// Per-page active font handle used while walking a content stream (§5.2's `visit_text_show`).
481/// Implemented by whatever page-font resolver `pdf_inspect.rs` (lane B1) builds from a page's
482/// `/Resources/Font` — distinct from [`PdfFontResolver`] (§7.2), which resolves *catalog* fonts
483/// for composition/embedding, not content-stream decoding.
484pub trait TextStateFont: fmt::Debug {
485    /// Stable identifier for this font within its page's resource dictionary, threaded onto every
486    /// [`PositionedGlyph`] this font produces (`font_id`).
487    fn id(&self) -> &str;
488
489    /// Decode one `Tj`/`TJ` string operand into glyphs via this font's encoding/ToUnicode map.
490    fn decode_show_operand(
491        &self,
492        operand: &lopdf::Object,
493    ) -> Result<Vec<DecodedGlyph>, PdfEditError>;
494
495    /// Glyph advance width, in text-space units already scaled by `font_size` (i.e. `w0/1000 *
496    /// font_size`), for `cid`.
497    fn advance(&self, cid: u32, font_size: f64) -> f64;
498}
499
500/// Text-object render mode (`Tr`), per PDF spec 9.3.6. Only fill/stroke visibility matters for
501/// island grouping's `compatible_paint` (§5.3); modes 4-7 (clipping variants) are represented but
502/// current scope treats any clipping mode as unsupported at the call site, not here.
503#[derive(Clone, Copy, Debug, PartialEq, Eq)]
504pub enum TextRenderMode {
505    Fill,
506    Stroke,
507    FillStroke,
508    Invisible,
509    FillClip,
510    StrokeClip,
511    FillStrokeClip,
512    Clip,
513}
514
515impl TextRenderMode {
516    /// Decode a raw `Tr` operand (0-7); unknown values conservatively map to `Invisible` so callers
517    /// don't mistake an out-of-range mode for paintable text.
518    pub fn from_operand(value: i64) -> TextRenderMode {
519        match value {
520            0 => TextRenderMode::Fill,
521            1 => TextRenderMode::Stroke,
522            2 => TextRenderMode::FillStroke,
523            4 => TextRenderMode::FillClip,
524            5 => TextRenderMode::StrokeClip,
525            6 => TextRenderMode::FillStrokeClip,
526            7 => TextRenderMode::Clip,
527            _ => TextRenderMode::Invisible,
528        }
529    }
530}
531
532/// Paint identity of a run of text, used by §5.3's `compatible_paint` to decide whether two
533/// adjacent glyphs may join into the same island.
534#[derive(Clone, Debug, PartialEq)]
535pub struct PaintState {
536    pub render_mode: TextRenderMode,
537    pub fill_color: Option<[f64; 4]>,
538    pub stroke_color: Option<[f64; 4]>,
539}
540
541/// Text writing direction. Current scope supports left-to-right only (§7.3); the other variants
542/// exist so a walker can classify and then reject unsupported content with a typed error instead
543/// of silently misinterpreting it.
544#[derive(Clone, Copy, Debug, PartialEq, Eq)]
545pub enum WritingDirection {
546    LeftToRight,
547    #[allow(dead_code)]
548    // classification-only per the doc comment above; §7.3 rejects, never builds, one.
549    RightToLeft,
550    #[allow(dead_code)]
551    Vertical,
552}
553
554/// Address of one show-operand (`Tj`, or one string item inside a `TJ` array) before any glyph
555/// index is known — built once per operator, then narrowed per glyph via
556/// [`SourceAddress::with_glyph`].
557#[derive(Clone, Debug)]
558pub struct SourceAddress {
559    pub stream_ref: (u32, u16),
560    pub contents_index: u32,
561    pub operator_index: u32,
562    pub operand_index: u32,
563}
564
565impl SourceAddress {
566    /// Narrows this operand-level address to one glyph's byte range within it. Adjacent
567    /// [`GlyphSourceAddress`] values sharing the same operand fields are merged by §5.3's island
568    /// grouping into a single [`PdfSourceSpan`] (min `glyph_start`, max `glyph_end`).
569    pub fn with_glyph(&self, glyph_range: (u32, u32)) -> GlyphSourceAddress {
570        GlyphSourceAddress {
571            stream_ref: self.stream_ref,
572            contents_index: self.contents_index,
573            operator_index: self.operator_index,
574            operand_index: self.operand_index,
575            glyph_start: glyph_range.0,
576            glyph_end: glyph_range.1,
577        }
578    }
579}
580
581/// One glyph's source address — the same shape as [`PdfSourceSpan`] minus the fields
582/// (`expected_text`, `expected_digest`) that only become knowable once island grouping has
583/// concatenated a run of glyphs' decoded text.
584#[derive(Clone, Copy, Debug)]
585pub struct GlyphSourceAddress {
586    pub stream_ref: (u32, u16),
587    pub contents_index: u32,
588    pub operator_index: u32,
589    pub operand_index: u32,
590    pub glyph_start: u32,
591    pub glyph_end: u32,
592}
593
594/// One glyph positioned in both PDF user-space (via `text_matrix`/`ctm`) and device space (via
595/// `quad`), as produced by `visit_text_show` (§5.2) and consumed by §5.3's `may_join` grouping and
596/// painted-bounds inventory.
597#[derive(Clone, Debug)]
598pub struct PositionedGlyph {
599    pub page: u32,
600    pub unicode: String,
601    #[allow(dead_code)] // populated by the walker for a future CID-aware consumer; not yet read.
602    pub cid: u32,
603    /// Device-space origin: `ctm.transform(text_matrix.origin())`.
604    pub origin: (f64, f64),
605    /// Device-space glyph quad, four corners as `[x0,y0, x1,y1, x2,y2, x3,y3]`.
606    pub quad: [f64; 8],
607    pub source: GlyphSourceAddress,
608    pub font_id: String,
609    pub font_size: f64,
610    /// Text-space distance the pen moved after this glyph, before horizontal scaling: the font's
611    /// advance plus `Tc` (plus `Tw` for single-byte code 32). A removed glyph is replaced by the
612    /// `TJ` number that moves the pen by exactly this.
613    pub pen_advance: f64,
614    pub text_matrix: Matrix,
615    pub ctm: Matrix,
616    pub writing_direction: WritingDirection,
617    pub paint: PaintState,
618}
619
620/// Running text-object state for one page's content-stream scan: the CTM, text matrix/text-line
621/// matrix, and the spacing/scale/leading/rise/render-mode parameters §5.2 requires tracking across
622/// `q`/`Q`/`cm`; `BT`/`ET`; `Tm`/`Td`/`TD`/`T*`; `Tf`/`Tz`/`Tc`/`Tw`/`TL`/`Ts`/`Tr`.
623#[derive(Clone, Debug)]
624pub struct TextState {
625    pub ctm: Matrix,
626    pub text_matrix: Matrix,
627    pub text_line_matrix: Matrix,
628    pub font: Option<Rc<dyn TextStateFont>>,
629    /// Current font size (`Tf`'s second operand).
630    pub font_size: f64,
631    /// Character spacing (`Tc`).
632    pub char_spacing: f64,
633    /// Word spacing (`Tw`), applied only to single-byte code 32 by the caller.
634    pub word_spacing: f64,
635    /// Horizontal scaling (`Tz`), stored as a fraction (`Tz` operand / 100); PDF default `1.0`.
636    pub horizontal_scale: f64,
637    /// Leading (`TL`), used by `T*`/`TD`.
638    pub leading: f64,
639    /// Text rise (`Ts`).
640    pub text_rise: f64,
641    pub render_mode: TextRenderMode,
642}
643
644impl TextState {
645    pub fn new() -> TextState {
646        TextState {
647            ctm: Matrix::IDENTITY,
648            text_matrix: Matrix::IDENTITY,
649            text_line_matrix: Matrix::IDENTITY,
650            font: None,
651            font_size: 0.0,
652            char_spacing: 0.0,
653            word_spacing: 0.0,
654            horizontal_scale: 1.0,
655            leading: 0.0,
656            text_rise: 0.0,
657            render_mode: TextRenderMode::Fill,
658        }
659    }
660
661    /// `BT`: both text matrices reset to identity (PDF spec 9.4.1).
662    pub fn begin_text(&mut self) {
663        self.text_matrix = Matrix::IDENTITY;
664        self.text_line_matrix = Matrix::IDENTITY;
665    }
666
667    /// `Tm a b c d e f`: both matrices are set directly.
668    pub fn set_tm(&mut self, m: Matrix) {
669        self.text_matrix = m;
670        self.text_line_matrix = m;
671    }
672
673    /// `Td`/the translation half of `TD`: `Tlm = [1 0 0 1 tx ty] x Tlm_old`; `Tm = Tlm`.
674    pub fn move_line(&mut self, tx: f64, ty: f64) {
675        self.text_line_matrix = Matrix::translation(tx, ty).then(self.text_line_matrix);
676        self.text_matrix = self.text_line_matrix;
677    }
678
679    /// `T*`: equivalent to `0 -TL Td`.
680    pub fn next_line(&mut self) {
681        self.move_line(0.0, -self.leading);
682    }
683
684    /// Device-space origin a `Tj`/`TJ` at the current text matrix would render at — where
685    /// `pdf_inspect.rs`'s `visit_text_show` records each glyph's `origin`.
686    pub fn origin(&self) -> (f64, f64) {
687        self.ctm.transform(self.text_matrix.origin())
688    }
689
690    /// Device-space glyph quad for `glyph` shown at `text_matrix` under this state's `ctm`. Uses
691    /// `glyph.bbox` when the font decoder supplied one; otherwise approximates an em-box from this
692    /// state's font size and the font's advance width for `glyph.cid` — a placeholder good enough
693    /// for construction/serialization tests, refined by `pdf_inspect.rs` (B1) once real font
694    /// metrics (§7.2) are threaded through.
695    pub fn glyph_quad(
696        &self,
697        glyph: &DecodedGlyph,
698        text_matrix: Matrix,
699    ) -> Result<[f64; 8], PdfEditError> {
700        let font = self.font.as_ref().ok_or(PdfEditError::MalformedContent)?;
701        let (x_min, y_min, x_max, y_max) = match glyph.bbox {
702            Some([x0, y0, x1, y1]) => (
703                x0 / 1000.0 * self.font_size,
704                y0 / 1000.0 * self.font_size,
705                x1 / 1000.0 * self.font_size,
706                y1 / 1000.0 * self.font_size,
707            ),
708            None => {
709                let advance = font.advance(glyph.cid, self.font_size);
710                (0.0, self.font_size * -0.25, advance, self.font_size * 0.75)
711            }
712        };
713
714        let corners = [
715            (x_min, y_min),
716            (x_max, y_min),
717            (x_max, y_max),
718            (x_min, y_max),
719        ];
720        let mut quad = [0.0; 8];
721        for (i, (x, y)) in corners.iter().enumerate() {
722            let scaled = (x * self.horizontal_scale, y + self.text_rise);
723            let (tx, ty) = text_matrix.transform(scaled);
724            let (dx, dy) = self.ctm.transform((tx, ty));
725            quad[i * 2] = dx;
726            quad[i * 2 + 1] = dy;
727        }
728        Ok(quad)
729    }
730}
731
732impl Default for TextState {
733    fn default() -> Self {
734        TextState::new()
735    }
736}
737
738/// Conservative axis-aligned device-space bounding box for path paint (`S`, `s`, `f`, `F`, `f*`,
739/// `B`, `B*`, `b`, `b*`) or `XObject Do` placement, per §5.3's coarse painted-bounds inventory. `B1`
740/// records these while walking; `unrelated` flags an entry as belonging to content other than the
741/// island/run currently being evaluated, matching §8's `validate_layout` collision check
742/// (`item.unrelated && item.bounds.intersects(run.bounds)`).
743#[derive(Clone, Debug)]
744// pdf_inspect.rs (B1) ended up defining its own equivalent local `PaintedBoundsEntry` for its
745// walker's internal §8 collision bookkeeping rather than this frozen type — a lane deviation noted
746// for follow-up, not corrected here to avoid a late cross-module rework. Kept for its documented
747// §5.3/§8 role.
748#[allow(dead_code)]
749pub struct PaintedBounds {
750    pub page: u32,
751    pub bounds: PdfRect,
752    pub unrelated: bool,
753}
754
755// ---------------------------------------------------------------------------------------------
756// §6 — Frontend command mirror (Rust side) + composer types
757// ---------------------------------------------------------------------------------------------
758
759/// Rust mirror of frontend `PdfSourceEdit`/`PdfAddText`'s shared `{ text, appearance }` shape
760/// (§6). Used for both `before`/`after` on a replace-source-text command.
761#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
762#[serde(rename_all = "camelCase")]
763pub struct PdfTextEditState {
764    pub text: String,
765    pub appearance: PdfTextAppearance,
766}
767
768/// Proof supplied by OCR/UI inspection that a line's background is safe to cover with an opaque
769/// rectangle. Only near-uniform backgrounds are accepted by the sparse OCR correction composer.
770#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
771#[serde(rename_all = "camelCase")]
772pub enum PdfOcrBackgroundProof {
773    NearUniform,
774}
775
776/// Rust mirror of frontend `PdfEditCommand` (§6: `PdfSourceEdit | PdfAddText`). `#[serde(tag =
777/// "kind", rename_all = "camelCase")]` on a `PascalCase` variant name serializes/deserializes to
778/// exactly the same `"replaceSourceText"`/`"addText"` discriminant the frontend sends, so the two
779/// sides share one wire shape without a hand-written tag. This is the `commands: &[PdfEditCommand]`
780/// parameter of the frozen `compose_candidate` signature (`PDF-EDITOR-DISPATCH.md`) and the
781/// `source_commands` field of `prepare_pdf_save`/`compose_pdf_preview` requests (§9.4/§10.1).
782#[derive(Clone, Debug, Serialize, Deserialize)]
783#[serde(
784    tag = "kind",
785    rename_all = "camelCase",
786    rename_all_fields = "camelCase"
787)]
788// Boxing the larger variant's fields would change this frozen wire type's construction/matching
789// ergonomics for every consumer across dispatches — not worth it for a lint on a command list
790// that is never long-lived or copied in bulk.
791#[allow(clippy::large_enum_variant)]
792pub enum PdfEditCommand {
793    ReplaceSourceText {
794        id: String,
795        locator: PdfSourceLocator,
796        before: PdfTextEditState,
797        after: PdfTextEditState,
798        available_bounds: PdfRect,
799    },
800    AddText {
801        id: String,
802        page: u32,
803        anchor: PdfPoint,
804        text: String,
805        appearance: PdfTextAppearance,
806    },
807    #[serde(rename = "correctOcrText")]
808    CorrectOcrLine {
809        id: String,
810        page: u32,
811        line_id: String,
812        rect: PdfRect,
813        text: String,
814        appearance: PdfTextAppearance,
815        cover_color: PdfColor,
816        background_proof: PdfOcrBackgroundProof,
817    },
818}
819
820/// Returns commands that remain active after collapsing OCR corrections by line identity. The
821/// last correction for a line wins; all existing command kinds retain their original order.
822#[cfg(any(feature = "edit", test))]
823pub(crate) fn effective_pdf_edit_commands(commands: &[PdfEditCommand]) -> Vec<&PdfEditCommand> {
824    use std::collections::HashMap;
825
826    let mut last_correction: HashMap<&str, usize> = HashMap::new();
827    for (index, command) in commands.iter().enumerate() {
828        if let PdfEditCommand::CorrectOcrLine { line_id, .. } = command {
829            last_correction.insert(line_id.as_str(), index);
830        }
831    }
832
833    commands
834        .iter()
835        .enumerate()
836        .filter_map(|(index, command)| match command {
837            PdfEditCommand::CorrectOcrLine { line_id, .. }
838                if last_correction.get(line_id.as_str()) != Some(&index) =>
839            {
840                None
841            }
842            _ => Some(command),
843        })
844        .collect()
845}
846
847/// Result of composing a source-command batch against fresh source bytes — the frozen
848/// `compose_candidate`'s success type (`PDF-EDITOR-DISPATCH.md`). `pdf_compose.rs` (lane C1)
849/// produces this; `pdf_save.rs` (lane C2) and the `compose_pdf_preview` command (§9.4) consume it —
850/// `bytes` becomes candidate/preview bytes, `affected_pages` becomes `PreparedPdfSave.affected_pages`
851/// (§10.1) and the pages `compose_pdf_preview` tells the frontend to re-render.
852#[derive(Clone, Debug)]
853pub struct ComposedCandidate {
854    pub bytes: Vec<u8>,
855    pub affected_pages: Vec<u32>,
856}
857
858// ---------------------------------------------------------------------------------------------
859// §7.2 — Font catalog API
860// ---------------------------------------------------------------------------------------------
861
862/// Where a catalog font's data comes from — bundled with RightKit (e.g. Source Serif 4) or
863/// discovered on the host system via `fontdb` (§7.1).
864#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
865#[serde(rename_all = "camelCase")]
866pub enum PdfFontSource {
867    Bundled,
868    System,
869}
870
871#[derive(Clone, Debug, Serialize)]
872#[serde(rename_all = "camelCase")]
873pub struct PdfFontCatalogEntry {
874    pub id: String,
875    pub family: String,
876    pub postscript_name: Option<String>,
877    pub styles: Vec<String>,
878    pub embeddable: bool,
879    pub subsettable: bool,
880    pub source: PdfFontSource,
881}
882
883/// A catalog font resolved to its actual font-program bytes, ready for glyph mapping (§7.3) and
884/// subsetting/embedding (§7.4). `id` matches [`PdfFontCatalogEntry::id`] — the font ID, not the
885/// family label, is what crosses the Save boundary (§7.2).
886#[derive(Clone, Debug)]
887pub struct ResolvedPdfFont {
888    #[allow(dead_code)] // documented identity per §7.2; current callers key off `family` instead.
889    pub id: String,
890    pub family: String,
891    /// Raw font-program bytes (TrueType/OpenType), suitable for `ttf_parser::Face::parse`.
892    pub bytes: Vec<u8>,
893    /// Face index within `bytes`, for font collections (`.ttc`); `0` for a single-face file.
894    pub face_index: u32,
895    #[allow(dead_code)] // callers re-derive this via `face_embedding_permissions` before use.
896    pub embeddable: bool,
897    #[allow(dead_code)]
898    pub subsettable: bool,
899}
900
901/// One glyph identifier within a resolved font's own glyph index space, matching
902/// `ttf_parser::GlyphId`'s representation (a 16-bit glyph index) so `pdf_font.rs` (lane B2) and
903/// `pdf_compose.rs` (lane C1) can convert between the two without an extra newtype hop.
904#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
905pub struct GlyphId(pub u16);
906
907/// Resolves catalog font IDs to usable font data and maps text to glyphs (§7.2). Implemented by
908/// `pdf_font.rs` (lane B2); consumed by the frozen `compose_candidate` signature
909/// (`PDF-EDITOR-DISPATCH.md`) as `fonts: &dyn PdfFontResolver`, so `pdf_compose.rs` (lane C1) can
910/// build and test against this trait before B2's real implementation lands.
911pub trait PdfFontResolver {
912    fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError>;
913    fn glyphs_for_text(
914        &self,
915        font: &ResolvedPdfFont,
916        text: &str,
917    ) -> Result<Vec<GlyphId>, PdfEditError>;
918}
919
920// ---------------------------------------------------------------------------------------------
921// Digest helpers (§5.1: document_sha256 / expected_digest / island_digest)
922// ---------------------------------------------------------------------------------------------
923
924/// Lowercase-hex SHA-256 of `bytes`. Backs every digest helper below and is directly usable for
925/// `PdfDocumentRevision::document_sha256`/`PdfSourceLocator::document_sha256`.
926pub fn sha256_hex(bytes: &[u8]) -> String {
927    let digest: [u8; 32] = Sha256::digest(bytes).into();
928    let mut out = String::with_capacity(digest.len() * 2);
929    for byte in digest {
930        out.push_str(&format!("{byte:02x}"));
931    }
932    out
933}
934
935/// `document_sha256`: identifies the exact backing bytes a [`PdfDocumentRevision`]/
936/// [`PdfSourceLocator`] was computed against, so a command carrying a stale locator can be rejected
937/// with [`PdfEditError::StaleSource`] before it touches content streams.
938pub fn document_sha256(source_bytes: &[u8]) -> String {
939    sha256_hex(source_bytes)
940}
941
942/// `expected_digest`: verifies one [`PdfSourceSpan`]'s operand text hasn't changed since the
943/// locator was built, independent of the whole-document hash — lets a save distinguish "unrelated
944/// edit elsewhere in the document" (safe) from "this exact operand changed" (reject with
945/// [`PdfEditError::SourceDigestMismatch`]).
946pub fn expected_text_digest(expected_text: &str) -> String {
947    sha256_hex(expected_text.as_bytes())
948}
949
950/// `island_digest`: binds an island's ordered spans, decoded glyph sequence, font identity, active
951/// matrices, paint, and geometry into one digest (§5.1) — a stronger identity check than
952/// `expected_digest` alone, since two visibly-identical runs of text (e.g. repeated labels) can
953/// still be told apart by their distinct source spans and transforms. Field order below is
954/// significant and part of this function's contract: callers on either side of a save must hash the
955/// same canonicalization to agree on a digest.
956#[allow(clippy::too_many_arguments)]
957pub fn compute_island_digest(
958    spans: &[PdfSourceSpan],
959    decoded_text: &str,
960    font_id: &str,
961    text_matrix: &Matrix,
962    ctm: &Matrix,
963    paint: &PaintState,
964    quads: &[[f64; 8]],
965) -> String {
966    let mut buf = String::new();
967    for span in spans {
968        buf.push_str(&format!(
969            "span:{}.{}/{}/{}/{}-{}|",
970            span.stream_ref.0,
971            span.stream_ref.1,
972            span.contents_index,
973            span.operator_index,
974            span.operand_index,
975            span.glyph_start
976        ));
977        buf.push_str(&format!("{}\n", span.glyph_end));
978    }
979    buf.push_str("text:");
980    buf.push_str(decoded_text);
981    buf.push_str("\nfont:");
982    buf.push_str(font_id);
983    buf.push_str(&format!(
984        "\ntm:{},{},{},{},{},{}",
985        text_matrix.a, text_matrix.b, text_matrix.c, text_matrix.d, text_matrix.e, text_matrix.f
986    ));
987    buf.push_str(&format!(
988        "\nctm:{},{},{},{},{},{}",
989        ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f
990    ));
991    buf.push_str(&format!(
992        "\npaint:{:?}/{:?}/{:?}",
993        paint.render_mode, paint.fill_color, paint.stroke_color
994    ));
995    for quad in quads {
996        buf.push_str("\nquad:");
997        for value in quad {
998            buf.push_str(&format!("{value},"));
999        }
1000    }
1001    sha256_hex(buf.as_bytes())
1002}
1003
1004// ---------------------------------------------------------------------------------------------
1005// Tests
1006// ---------------------------------------------------------------------------------------------
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011
1012    #[test]
1013    fn sha256_hex_of_empty_input_is_64_hex_chars_and_stable() {
1014        // Don't hardcode the literal SHA-256("") digest here (easy to transcribe wrong by hand);
1015        // determinism + shape is what callers actually rely on, and is covered precisely.
1016        let a = sha256_hex(b"");
1017        let b = sha256_hex(b"");
1018        assert_eq!(a, b);
1019        assert_eq!(a.len(), 64);
1020        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
1021    }
1022
1023    #[test]
1024    fn sha256_hex_is_deterministic_and_64_hex_chars() {
1025        let a = sha256_hex(b"viewright pdf editor");
1026        let b = sha256_hex(b"viewright pdf editor");
1027        assert_eq!(a, b);
1028        assert_eq!(a.len(), 64);
1029        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
1030    }
1031
1032    #[test]
1033    fn sha256_hex_differs_for_different_input() {
1034        assert_ne!(
1035            sha256_hex(b"CAR YOU LIST."),
1036            sha256_hex(b"CAR YOU LIST, YOU WIN.")
1037        );
1038    }
1039
1040    #[test]
1041    fn document_sha256_matches_sha256_hex() {
1042        let bytes = b"%PDF-1.7 fixture bytes";
1043        assert_eq!(document_sha256(bytes), sha256_hex(bytes));
1044    }
1045
1046    #[test]
1047    fn expected_text_digest_is_stable_and_distinct() {
1048        let a = expected_text_digest("CAR YOU LIST.");
1049        let b = expected_text_digest("CAR YOU LIST.");
1050        let c = expected_text_digest("CAR YOU LIST, YOU WIN.");
1051        assert_eq!(a, b);
1052        assert_ne!(a, c);
1053    }
1054
1055    fn sample_span(operator_index: u32, glyph_start: u32, glyph_end: u32) -> PdfSourceSpan {
1056        PdfSourceSpan {
1057            stream_ref: (7, 0),
1058            contents_index: 0,
1059            operator_index,
1060            operand_index: 0,
1061            glyph_start,
1062            glyph_end,
1063            expected_text: "CAR".to_string(),
1064            expected_digest: expected_text_digest("CAR"),
1065        }
1066    }
1067
1068    #[test]
1069    fn island_digest_is_stable_for_identical_inputs() {
1070        let spans = vec![sample_span(0, 0, 3)];
1071        let paint = PaintState {
1072            render_mode: TextRenderMode::Fill,
1073            fill_color: Some([0.0, 0.0, 0.0, 1.0]),
1074            stroke_color: None,
1075        };
1076        let tm = Matrix::IDENTITY;
1077        let ctm = Matrix::IDENTITY;
1078        let quads = vec![[0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0]];
1079
1080        let a = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
1081        let b = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
1082        assert_eq!(a, b);
1083        assert_eq!(a.len(), 64);
1084    }
1085
1086    #[test]
1087    fn island_digest_distinguishes_repeated_text_by_span_position() {
1088        // Two visibly-identical runs of "CAR" at different operator indices must produce distinct
1089        // digests, matching §5.1's requirement that repeated visible text yield distinct locators.
1090        let paint = PaintState {
1091            render_mode: TextRenderMode::Fill,
1092            fill_color: None,
1093            stroke_color: None,
1094        };
1095        let tm = Matrix::IDENTITY;
1096        let ctm = Matrix::IDENTITY;
1097        let quads = vec![[0.0; 8]];
1098
1099        let first = compute_island_digest(
1100            &[sample_span(0, 0, 3)],
1101            "CAR",
1102            "F1",
1103            &tm,
1104            &ctm,
1105            &paint,
1106            &quads,
1107        );
1108        let second = compute_island_digest(
1109            &[sample_span(5, 0, 3)],
1110            "CAR",
1111            "F1",
1112            &tm,
1113            &ctm,
1114            &paint,
1115            &quads,
1116        );
1117        assert_ne!(first, second);
1118    }
1119
1120    #[test]
1121    fn island_digest_distinguishes_by_matrix() {
1122        let spans = vec![sample_span(0, 0, 3)];
1123        let paint = PaintState {
1124            render_mode: TextRenderMode::Fill,
1125            fill_color: None,
1126            stroke_color: None,
1127        };
1128        let ctm = Matrix::IDENTITY;
1129        let quads = vec![[0.0; 8]];
1130
1131        let a = compute_island_digest(&spans, "CAR", "F1", &Matrix::IDENTITY, &ctm, &paint, &quads);
1132        let b = compute_island_digest(
1133            &spans,
1134            "CAR",
1135            "F1",
1136            &Matrix::translation(12.0, 0.0),
1137            &ctm,
1138            &paint,
1139            &quads,
1140        );
1141        assert_ne!(a, b);
1142    }
1143
1144    #[test]
1145    fn matrix_identity_transform_is_noop() {
1146        let (x, y) = Matrix::IDENTITY.transform((3.0, 4.0));
1147        assert_eq!((x, y), (3.0, 4.0));
1148    }
1149
1150    #[test]
1151    fn matrix_translation_then_composes_in_order() {
1152        let m = Matrix::translation(10.0, 0.0).then(Matrix::translation(0.0, 5.0));
1153        assert_eq!(m.transform((0.0, 0.0)), (10.0, 5.0));
1154    }
1155
1156    #[test]
1157    fn text_state_move_line_updates_origin() {
1158        let mut state = TextState::new();
1159        state.begin_text();
1160        state.move_line(12.0, -20.0);
1161        assert_eq!(state.text_matrix.origin(), (12.0, -20.0));
1162        assert_eq!(state.origin(), (12.0, -20.0));
1163    }
1164
1165    #[test]
1166    fn text_state_next_line_uses_leading() {
1167        let mut state = TextState::new();
1168        state.leading = 14.0;
1169        state.next_line();
1170        assert_eq!(state.text_matrix.origin(), (0.0, -14.0));
1171    }
1172
1173    #[test]
1174    fn pdf_rect_contains_and_intersects() {
1175        let outer = PdfRect {
1176            x: 0.0,
1177            y: 0.0,
1178            width: 100.0,
1179            height: 50.0,
1180        };
1181        let inner = PdfRect {
1182            x: 10.0,
1183            y: 10.0,
1184            width: 20.0,
1185            height: 10.0,
1186        };
1187        let outside = PdfRect {
1188            x: 200.0,
1189            y: 200.0,
1190            width: 10.0,
1191            height: 10.0,
1192        };
1193        assert!(outer.contains(inner));
1194        assert!(!outer.contains(outside));
1195        assert!(outer.intersects(inner));
1196        assert!(!outer.intersects(outside));
1197    }
1198
1199    #[test]
1200    fn text_render_mode_from_operand_covers_spec_range() {
1201        assert_eq!(TextRenderMode::from_operand(0), TextRenderMode::Fill);
1202        assert_eq!(TextRenderMode::from_operand(1), TextRenderMode::Stroke);
1203        assert_eq!(TextRenderMode::from_operand(2), TextRenderMode::FillStroke);
1204        assert_eq!(TextRenderMode::from_operand(7), TextRenderMode::Clip);
1205        assert_eq!(TextRenderMode::from_operand(99), TextRenderMode::Invisible);
1206    }
1207
1208    #[test]
1209    fn pdf_source_locator_round_trips_through_json() {
1210        let locator = PdfSourceLocator {
1211            document_sha256: document_sha256(b"fixture"),
1212            page: 1,
1213            spans: vec![sample_span(0, 0, 3)],
1214            island_digest: sha256_hex(b"island"),
1215        };
1216        let json = serde_json::to_string(&locator).expect("serialize");
1217        assert!(json.contains("\"documentSha256\""));
1218        assert!(json.contains("\"islandDigest\""));
1219        let round_tripped: PdfSourceLocator = serde_json::from_str(&json).expect("deserialize");
1220        assert_eq!(round_tripped.page, locator.page);
1221        assert_eq!(round_tripped.spans.len(), locator.spans.len());
1222        assert_eq!(round_tripped.island_digest, locator.island_digest);
1223    }
1224
1225    fn sample_appearance() -> PdfTextAppearance {
1226        PdfTextAppearance {
1227            font_id: "source-serif-4".to_string(),
1228            family: "Source Serif 4".to_string(),
1229            size_pt: 34.0,
1230            color: PdfColor {
1231                r: 0.0,
1232                g: 0.0,
1233                b: 0.0,
1234                alpha: 1.0,
1235            },
1236        }
1237    }
1238
1239    #[test]
1240    fn pdf_edit_command_replace_source_text_round_trips() {
1241        let command = PdfEditCommand::ReplaceSourceText {
1242            id: "edit-1".to_string(),
1243            locator: PdfSourceLocator {
1244                document_sha256: document_sha256(b"fixture"),
1245                page: 1,
1246                spans: vec![sample_span(0, 0, 3)],
1247                island_digest: sha256_hex(b"island"),
1248            },
1249            before: PdfTextEditState {
1250                text: "CAR YOU LIST.".to_string(),
1251                appearance: sample_appearance(),
1252            },
1253            after: PdfTextEditState {
1254                text: "CAR YOU LIST, YOU WIN.".to_string(),
1255                appearance: sample_appearance(),
1256            },
1257            available_bounds: PdfRect {
1258                x: 0.0,
1259                y: 0.0,
1260                width: 200.0,
1261                height: 40.0,
1262            },
1263        };
1264
1265        let json = serde_json::to_string(&command).expect("serialize");
1266        assert!(json.contains("\"kind\":\"replaceSourceText\""));
1267        assert!(json.contains("\"availableBounds\""));
1268
1269        let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1270        match round_tripped {
1271            PdfEditCommand::ReplaceSourceText { id, after, .. } => {
1272                assert_eq!(id, "edit-1");
1273                assert_eq!(after.text, "CAR YOU LIST, YOU WIN.");
1274            }
1275            PdfEditCommand::AddText { .. } => panic!("expected ReplaceSourceText"),
1276            PdfEditCommand::CorrectOcrLine { .. } => panic!("expected ReplaceSourceText"),
1277        }
1278    }
1279
1280    #[test]
1281    fn pdf_edit_command_add_text_round_trips() {
1282        let command = PdfEditCommand::AddText {
1283            id: "add-1".to_string(),
1284            page: 2,
1285            anchor: PdfPoint { x: 72.0, y: 144.0 },
1286            text: "New line".to_string(),
1287            appearance: sample_appearance(),
1288        };
1289
1290        let json = serde_json::to_string(&command).expect("serialize");
1291        assert!(json.contains("\"kind\":\"addText\""));
1292
1293        let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1294        match round_tripped {
1295            PdfEditCommand::AddText { id, page, text, .. } => {
1296                assert_eq!(id, "add-1");
1297                assert_eq!(page, 2);
1298                assert_eq!(text, "New line");
1299            }
1300            PdfEditCommand::ReplaceSourceText { .. } => panic!("expected AddText"),
1301            PdfEditCommand::CorrectOcrLine { .. } => panic!("expected AddText"),
1302        }
1303    }
1304
1305    #[test]
1306    fn pdf_edit_command_correct_ocr_text_round_trips() {
1307        let command = PdfEditCommand::CorrectOcrLine {
1308            id: "ocr-1".to_string(),
1309            page: 1,
1310            line_id: "line-7".to_string(),
1311            rect: PdfRect {
1312                x: 72.0,
1313                y: 700.0,
1314                width: 120.0,
1315                height: 18.0,
1316            },
1317            text: "Corrected".to_string(),
1318            appearance: sample_appearance(),
1319            cover_color: PdfColor {
1320                r: 1.0,
1321                g: 1.0,
1322                b: 1.0,
1323                alpha: 1.0,
1324            },
1325            background_proof: PdfOcrBackgroundProof::NearUniform,
1326        };
1327        let json = serde_json::to_string(&command).expect("serialize");
1328        assert!(json.contains("\"kind\":\"correctOcrText\""));
1329        assert!(json.contains("\"lineId\":\"line-7\""));
1330        assert!(json.contains("\"backgroundProof\":\"nearUniform\""));
1331        let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1332        assert!(matches!(
1333            round_tripped,
1334            PdfEditCommand::CorrectOcrLine {
1335                background_proof: PdfOcrBackgroundProof::NearUniform,
1336                ..
1337            }
1338        ));
1339        let invalid = json.replace("nearUniform", "gradient");
1340        assert!(serde_json::from_str::<PdfEditCommand>(&invalid).is_err());
1341    }
1342
1343    #[test]
1344    fn effective_commands_last_ocr_correction_wins() {
1345        let appearance = sample_appearance();
1346        let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
1347            id: id.to_string(),
1348            page: 1,
1349            line_id: "line-1".to_string(),
1350            rect: PdfRect {
1351                x: 0.0,
1352                y: 0.0,
1353                width: 100.0,
1354                height: 20.0,
1355            },
1356            text: text.to_string(),
1357            appearance: appearance.clone(),
1358            cover_color: PdfColor {
1359                r: 1.0,
1360                g: 1.0,
1361                b: 1.0,
1362                alpha: 1.0,
1363            },
1364            background_proof: PdfOcrBackgroundProof::NearUniform,
1365        };
1366        let commands = vec![correction("old", "old"), correction("new", "new")];
1367        let active = effective_pdf_edit_commands(&commands);
1368        assert_eq!(active.len(), 1);
1369        assert!(matches!(active[0], PdfEditCommand::CorrectOcrLine { text, .. } if text == "new"));
1370    }
1371
1372    #[test]
1373    fn pdf_font_source_round_trips() {
1374        for (source, wire) in [
1375            (PdfFontSource::Bundled, "\"bundled\""),
1376            (PdfFontSource::System, "\"system\""),
1377        ] {
1378            let json = serde_json::to_string(&source).expect("serialize");
1379            assert_eq!(json, wire, "frontend expects a string source discriminator");
1380            let round_tripped: PdfFontSource = serde_json::from_str(&json).expect("deserialize");
1381            assert_eq!(round_tripped, source);
1382        }
1383    }
1384
1385    #[test]
1386    fn pdf_font_catalog_entry_serializes_camel_case() {
1387        let entry = PdfFontCatalogEntry {
1388            id: "source-serif-4".to_string(),
1389            family: "Source Serif 4".to_string(),
1390            postscript_name: Some("SourceSerif4-Regular".to_string()),
1391            styles: vec!["Regular".to_string(), "Bold".to_string()],
1392            embeddable: true,
1393            subsettable: true,
1394            source: PdfFontSource::Bundled,
1395        };
1396        let json = serde_json::to_string(&entry).expect("serialize");
1397        assert!(json.contains("\"postscriptName\""));
1398        assert!(json.contains("\"subsettable\""));
1399        assert_eq!(
1400            serde_json::from_str::<serde_json::Value>(&json).unwrap()["source"],
1401            "bundled"
1402        );
1403    }
1404
1405    #[test]
1406    fn pdf_edit_error_serializes_as_code_message_envelope() {
1407        // Matches the host error type's existing `{ code, message }` wire convention (error.rs) and
1408        // `pdfEditModel.ts`'s `PdfEditErrorCode` union (A2) — not a bare variant-name string.
1409        let json = serde_json::to_string(&PdfEditError::StaleSource).expect("serialize");
1410        assert_eq!(
1411            json,
1412            r#"{"code":"stale_source","message":"PDF changed since edit started"}"#
1413        );
1414        assert_eq!(
1415            PdfEditError::TextOverflow.to_string(),
1416            "text exceeds safe bounds"
1417        );
1418        assert_eq!(PdfEditError::TextOverflow.code(), "text_overflow");
1419    }
1420
1421    /// A minimal `TextStateFont` used only to exercise `glyph_quad`'s fallback (bbox-less) path.
1422    #[derive(Debug)]
1423    struct FixedAdvanceFont {
1424        advance: f64,
1425    }
1426
1427    impl TextStateFont for FixedAdvanceFont {
1428        fn id(&self) -> &str {
1429            "fixed"
1430        }
1431
1432        fn decode_show_operand(
1433            &self,
1434            _operand: &lopdf::Object,
1435        ) -> Result<Vec<DecodedGlyph>, PdfEditError> {
1436            Ok(Vec::new())
1437        }
1438
1439        fn advance(&self, _cid: u32, _font_size: f64) -> f64 {
1440            self.advance
1441        }
1442    }
1443
1444    #[test]
1445    fn glyph_quad_without_bbox_uses_advance_fallback() {
1446        let mut state = TextState::new();
1447        state.font = Some(Rc::new(FixedAdvanceFont { advance: 10.0 }));
1448        state.font_size = 12.0;
1449
1450        let glyph = DecodedGlyph {
1451            cid: 1,
1452            unicode: "A".to_string(),
1453            source_range: (0, 1),
1454            bbox: None,
1455        };
1456
1457        let quad = state
1458            .glyph_quad(&glyph, Matrix::IDENTITY)
1459            .expect("font is set");
1460        // Bottom-left corner should sit at the origin (identity matrices, no rise/scale).
1461        assert_eq!((quad[0], quad[1]), (0.0, state.font_size * -0.25));
1462        // Bottom-right corner should sit at the fallback advance width.
1463        assert_eq!((quad[2], quad[3]), (10.0, state.font_size * -0.25));
1464    }
1465
1466    #[test]
1467    fn glyph_quad_without_font_is_malformed_content() {
1468        let state = TextState::new();
1469        let glyph = DecodedGlyph {
1470            cid: 1,
1471            unicode: "A".to_string(),
1472            source_range: (0, 1),
1473            bbox: None,
1474        };
1475        let err = state
1476            .glyph_quad(&glyph, Matrix::IDENTITY)
1477            .expect_err("no font set");
1478        assert!(matches!(err, PdfEditError::MalformedContent));
1479    }
1480
1481    #[test]
1482    fn source_address_with_glyph_narrows_correctly() {
1483        let address = SourceAddress {
1484            stream_ref: (7, 0),
1485            contents_index: 1,
1486            operator_index: 2,
1487            operand_index: 0,
1488        };
1489        let glyph_address = address.with_glyph((3, 6));
1490        assert_eq!(glyph_address.stream_ref, (7, 0));
1491        assert_eq!(glyph_address.contents_index, 1);
1492        assert_eq!(glyph_address.operator_index, 2);
1493        assert_eq!(glyph_address.glyph_start, 3);
1494        assert_eq!(glyph_address.glyph_end, 6);
1495    }
1496}