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