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.
664#[cfg(any(feature = "edit", test))]
665pub(crate) fn effective_pdf_edit_commands(commands: &[PdfEditCommand]) -> Vec<&PdfEditCommand> {
666    use std::collections::HashMap;
667
668    let mut last_correction: HashMap<&str, usize> = HashMap::new();
669    for (index, command) in commands.iter().enumerate() {
670        if let PdfEditCommand::CorrectOcrLine { line_id, .. } = command {
671            last_correction.insert(line_id.as_str(), index);
672        }
673    }
674
675    commands
676        .iter()
677        .enumerate()
678        .filter_map(|(index, command)| match command {
679            PdfEditCommand::CorrectOcrLine { line_id, .. }
680                if last_correction.get(line_id.as_str()) != Some(&index) =>
681            {
682                None
683            }
684            _ => Some(command),
685        })
686        .collect()
687}
688
689/// Result of composing a source-command batch against fresh source bytes — the frozen
690/// `compose_candidate`'s success type (`PDF-EDITOR-DISPATCH.md`). `pdf_compose.rs` (lane C1)
691/// produces this; `pdf_save.rs` (lane C2) and the `compose_pdf_preview` command (§9.4) consume it —
692/// `bytes` becomes candidate/preview bytes, `affected_pages` becomes `PreparedPdfSave.affected_pages`
693/// (§10.1) and the pages `compose_pdf_preview` tells the frontend to re-render.
694#[derive(Clone, Debug)]
695pub struct ComposedCandidate {
696    pub bytes: Vec<u8>,
697    pub affected_pages: Vec<u32>,
698}
699
700// ---------------------------------------------------------------------------------------------
701// §7.2 — Font catalog API
702// ---------------------------------------------------------------------------------------------
703
704/// Where a catalog font's data comes from — bundled with RightKit (e.g. Source Serif 4) or
705/// discovered on the host system via `fontdb` (§7.1).
706#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
707#[serde(rename_all = "camelCase")]
708pub enum PdfFontSource {
709    Bundled,
710    System,
711}
712
713#[derive(Clone, Debug, Serialize)]
714#[serde(rename_all = "camelCase")]
715pub struct PdfFontCatalogEntry {
716    pub id: String,
717    pub family: String,
718    pub postscript_name: Option<String>,
719    pub styles: Vec<String>,
720    pub embeddable: bool,
721    pub subsettable: bool,
722    pub source: PdfFontSource,
723}
724
725/// A catalog font resolved to its actual font-program bytes, ready for glyph mapping (§7.3) and
726/// subsetting/embedding (§7.4). `id` matches [`PdfFontCatalogEntry::id`] — the font ID, not the
727/// family label, is what crosses the Save boundary (§7.2).
728#[derive(Clone, Debug)]
729pub struct ResolvedPdfFont {
730    #[allow(dead_code)] // documented identity per §7.2; current callers key off `family` instead.
731    pub id: String,
732    pub family: String,
733    /// Raw font-program bytes (TrueType/OpenType), suitable for `ttf_parser::Face::parse`.
734    pub bytes: Vec<u8>,
735    /// Face index within `bytes`, for font collections (`.ttc`); `0` for a single-face file.
736    pub face_index: u32,
737    #[allow(dead_code)] // callers re-derive this via `face_embedding_permissions` before use.
738    pub embeddable: bool,
739    #[allow(dead_code)]
740    pub subsettable: bool,
741}
742
743/// One glyph identifier within a resolved font's own glyph index space, matching
744/// `ttf_parser::GlyphId`'s representation (a 16-bit glyph index) so `pdf_font.rs` (lane B2) and
745/// `pdf_compose.rs` (lane C1) can convert between the two without an extra newtype hop.
746#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
747pub struct GlyphId(pub u16);
748
749/// Resolves catalog font IDs to usable font data and maps text to glyphs (§7.2). Implemented by
750/// `pdf_font.rs` (lane B2); consumed by the frozen `compose_candidate` signature
751/// (`PDF-EDITOR-DISPATCH.md`) as `fonts: &dyn PdfFontResolver`, so `pdf_compose.rs` (lane C1) can
752/// build and test against this trait before B2's real implementation lands.
753pub trait PdfFontResolver {
754    fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError>;
755    fn glyphs_for_text(
756        &self,
757        font: &ResolvedPdfFont,
758        text: &str,
759    ) -> Result<Vec<GlyphId>, PdfEditError>;
760}
761
762// ---------------------------------------------------------------------------------------------
763// Digest helpers (§5.1: document_sha256 / expected_digest / island_digest)
764// ---------------------------------------------------------------------------------------------
765
766/// Lowercase-hex SHA-256 of `bytes`. Backs every digest helper below and is directly usable for
767/// `PdfDocumentRevision::document_sha256`/`PdfSourceLocator::document_sha256`.
768pub fn sha256_hex(bytes: &[u8]) -> String {
769    let digest: [u8; 32] = Sha256::digest(bytes).into();
770    let mut out = String::with_capacity(digest.len() * 2);
771    for byte in digest {
772        out.push_str(&format!("{byte:02x}"));
773    }
774    out
775}
776
777/// `document_sha256`: identifies the exact backing bytes a [`PdfDocumentRevision`]/
778/// [`PdfSourceLocator`] was computed against, so a command carrying a stale locator can be rejected
779/// with [`PdfEditError::StaleSource`] before it touches content streams.
780pub fn document_sha256(source_bytes: &[u8]) -> String {
781    sha256_hex(source_bytes)
782}
783
784/// `expected_digest`: verifies one [`PdfSourceSpan`]'s operand text hasn't changed since the
785/// locator was built, independent of the whole-document hash — lets a save distinguish "unrelated
786/// edit elsewhere in the document" (safe) from "this exact operand changed" (reject with
787/// [`PdfEditError::SourceDigestMismatch`]).
788pub fn expected_text_digest(expected_text: &str) -> String {
789    sha256_hex(expected_text.as_bytes())
790}
791
792/// `island_digest`: binds an island's ordered spans, decoded glyph sequence, font identity, active
793/// matrices, paint, and geometry into one digest (§5.1) — a stronger identity check than
794/// `expected_digest` alone, since two visibly-identical runs of text (e.g. repeated labels) can
795/// still be told apart by their distinct source spans and transforms. Field order below is
796/// significant and part of this function's contract: callers on either side of a save must hash the
797/// same canonicalization to agree on a digest.
798#[allow(clippy::too_many_arguments)]
799pub fn compute_island_digest(
800    spans: &[PdfSourceSpan],
801    decoded_text: &str,
802    font_id: &str,
803    text_matrix: &Matrix,
804    ctm: &Matrix,
805    paint: &PaintState,
806    quads: &[[f64; 8]],
807) -> String {
808    let mut buf = String::new();
809    for span in spans {
810        buf.push_str(&format!(
811            "span:{}.{}/{}/{}/{}-{}|",
812            span.stream_ref.0,
813            span.stream_ref.1,
814            span.contents_index,
815            span.operator_index,
816            span.operand_index,
817            span.glyph_start
818        ));
819        buf.push_str(&format!("{}\n", span.glyph_end));
820    }
821    buf.push_str("text:");
822    buf.push_str(decoded_text);
823    buf.push_str("\nfont:");
824    buf.push_str(font_id);
825    buf.push_str(&format!(
826        "\ntm:{},{},{},{},{},{}",
827        text_matrix.a, text_matrix.b, text_matrix.c, text_matrix.d, text_matrix.e, text_matrix.f
828    ));
829    buf.push_str(&format!(
830        "\nctm:{},{},{},{},{},{}",
831        ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f
832    ));
833    buf.push_str(&format!(
834        "\npaint:{:?}/{:?}/{:?}",
835        paint.render_mode, paint.fill_color, paint.stroke_color
836    ));
837    for quad in quads {
838        buf.push_str("\nquad:");
839        for value in quad {
840            buf.push_str(&format!("{value},"));
841        }
842    }
843    sha256_hex(buf.as_bytes())
844}
845
846// ---------------------------------------------------------------------------------------------
847// Tests
848// ---------------------------------------------------------------------------------------------
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853
854    #[test]
855    fn sha256_hex_of_empty_input_is_64_hex_chars_and_stable() {
856        // Don't hardcode the literal SHA-256("") digest here (easy to transcribe wrong by hand);
857        // determinism + shape is what callers actually rely on, and is covered precisely.
858        let a = sha256_hex(b"");
859        let b = sha256_hex(b"");
860        assert_eq!(a, b);
861        assert_eq!(a.len(), 64);
862        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
863    }
864
865    #[test]
866    fn sha256_hex_is_deterministic_and_64_hex_chars() {
867        let a = sha256_hex(b"viewright pdf editor");
868        let b = sha256_hex(b"viewright pdf editor");
869        assert_eq!(a, b);
870        assert_eq!(a.len(), 64);
871        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
872    }
873
874    #[test]
875    fn sha256_hex_differs_for_different_input() {
876        assert_ne!(
877            sha256_hex(b"CAR YOU LIST."),
878            sha256_hex(b"CAR YOU LIST, YOU WIN.")
879        );
880    }
881
882    #[test]
883    fn document_sha256_matches_sha256_hex() {
884        let bytes = b"%PDF-1.7 fixture bytes";
885        assert_eq!(document_sha256(bytes), sha256_hex(bytes));
886    }
887
888    #[test]
889    fn expected_text_digest_is_stable_and_distinct() {
890        let a = expected_text_digest("CAR YOU LIST.");
891        let b = expected_text_digest("CAR YOU LIST.");
892        let c = expected_text_digest("CAR YOU LIST, YOU WIN.");
893        assert_eq!(a, b);
894        assert_ne!(a, c);
895    }
896
897    fn sample_span(operator_index: u32, glyph_start: u32, glyph_end: u32) -> PdfSourceSpan {
898        PdfSourceSpan {
899            stream_ref: (7, 0),
900            contents_index: 0,
901            operator_index,
902            operand_index: 0,
903            glyph_start,
904            glyph_end,
905            expected_text: "CAR".to_string(),
906            expected_digest: expected_text_digest("CAR"),
907        }
908    }
909
910    #[test]
911    fn island_digest_is_stable_for_identical_inputs() {
912        let spans = vec![sample_span(0, 0, 3)];
913        let paint = PaintState {
914            render_mode: TextRenderMode::Fill,
915            fill_color: Some([0.0, 0.0, 0.0, 1.0]),
916            stroke_color: None,
917        };
918        let tm = Matrix::IDENTITY;
919        let ctm = Matrix::IDENTITY;
920        let quads = vec![[0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0]];
921
922        let a = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
923        let b = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
924        assert_eq!(a, b);
925        assert_eq!(a.len(), 64);
926    }
927
928    #[test]
929    fn island_digest_distinguishes_repeated_text_by_span_position() {
930        // Two visibly-identical runs of "CAR" at different operator indices must produce distinct
931        // digests, matching §5.1's requirement that repeated visible text yield distinct locators.
932        let paint = PaintState {
933            render_mode: TextRenderMode::Fill,
934            fill_color: None,
935            stroke_color: None,
936        };
937        let tm = Matrix::IDENTITY;
938        let ctm = Matrix::IDENTITY;
939        let quads = vec![[0.0; 8]];
940
941        let first = compute_island_digest(
942            &[sample_span(0, 0, 3)],
943            "CAR",
944            "F1",
945            &tm,
946            &ctm,
947            &paint,
948            &quads,
949        );
950        let second = compute_island_digest(
951            &[sample_span(5, 0, 3)],
952            "CAR",
953            "F1",
954            &tm,
955            &ctm,
956            &paint,
957            &quads,
958        );
959        assert_ne!(first, second);
960    }
961
962    #[test]
963    fn island_digest_distinguishes_by_matrix() {
964        let spans = vec![sample_span(0, 0, 3)];
965        let paint = PaintState {
966            render_mode: TextRenderMode::Fill,
967            fill_color: None,
968            stroke_color: None,
969        };
970        let ctm = Matrix::IDENTITY;
971        let quads = vec![[0.0; 8]];
972
973        let a = compute_island_digest(&spans, "CAR", "F1", &Matrix::IDENTITY, &ctm, &paint, &quads);
974        let b = compute_island_digest(
975            &spans,
976            "CAR",
977            "F1",
978            &Matrix::translation(12.0, 0.0),
979            &ctm,
980            &paint,
981            &quads,
982        );
983        assert_ne!(a, b);
984    }
985
986    #[test]
987    fn matrix_identity_transform_is_noop() {
988        let (x, y) = Matrix::IDENTITY.transform((3.0, 4.0));
989        assert_eq!((x, y), (3.0, 4.0));
990    }
991
992    #[test]
993    fn matrix_translation_then_composes_in_order() {
994        let m = Matrix::translation(10.0, 0.0).then(Matrix::translation(0.0, 5.0));
995        assert_eq!(m.transform((0.0, 0.0)), (10.0, 5.0));
996    }
997
998    #[test]
999    fn text_state_move_line_updates_origin() {
1000        let mut state = TextState::new();
1001        state.begin_text();
1002        state.move_line(12.0, -20.0);
1003        assert_eq!(state.text_matrix.origin(), (12.0, -20.0));
1004        assert_eq!(state.origin(), (12.0, -20.0));
1005    }
1006
1007    #[test]
1008    fn text_state_next_line_uses_leading() {
1009        let mut state = TextState::new();
1010        state.leading = 14.0;
1011        state.next_line();
1012        assert_eq!(state.text_matrix.origin(), (0.0, -14.0));
1013    }
1014
1015    #[test]
1016    fn pdf_rect_contains_and_intersects() {
1017        let outer = PdfRect {
1018            x: 0.0,
1019            y: 0.0,
1020            width: 100.0,
1021            height: 50.0,
1022        };
1023        let inner = PdfRect {
1024            x: 10.0,
1025            y: 10.0,
1026            width: 20.0,
1027            height: 10.0,
1028        };
1029        let outside = PdfRect {
1030            x: 200.0,
1031            y: 200.0,
1032            width: 10.0,
1033            height: 10.0,
1034        };
1035        assert!(outer.contains(inner));
1036        assert!(!outer.contains(outside));
1037        assert!(outer.intersects(inner));
1038        assert!(!outer.intersects(outside));
1039    }
1040
1041    #[test]
1042    fn text_render_mode_from_operand_covers_spec_range() {
1043        assert_eq!(TextRenderMode::from_operand(0), TextRenderMode::Fill);
1044        assert_eq!(TextRenderMode::from_operand(1), TextRenderMode::Stroke);
1045        assert_eq!(TextRenderMode::from_operand(2), TextRenderMode::FillStroke);
1046        assert_eq!(TextRenderMode::from_operand(7), TextRenderMode::Clip);
1047        assert_eq!(TextRenderMode::from_operand(99), TextRenderMode::Invisible);
1048    }
1049
1050    #[test]
1051    fn pdf_source_locator_round_trips_through_json() {
1052        let locator = PdfSourceLocator {
1053            document_sha256: document_sha256(b"fixture"),
1054            page: 1,
1055            spans: vec![sample_span(0, 0, 3)],
1056            island_digest: sha256_hex(b"island"),
1057        };
1058        let json = serde_json::to_string(&locator).expect("serialize");
1059        assert!(json.contains("\"documentSha256\""));
1060        assert!(json.contains("\"islandDigest\""));
1061        let round_tripped: PdfSourceLocator = serde_json::from_str(&json).expect("deserialize");
1062        assert_eq!(round_tripped.page, locator.page);
1063        assert_eq!(round_tripped.spans.len(), locator.spans.len());
1064        assert_eq!(round_tripped.island_digest, locator.island_digest);
1065    }
1066
1067    fn sample_appearance() -> PdfTextAppearance {
1068        PdfTextAppearance {
1069            font_id: "source-serif-4".to_string(),
1070            family: "Source Serif 4".to_string(),
1071            size_pt: 34.0,
1072            color: PdfColor {
1073                r: 0.0,
1074                g: 0.0,
1075                b: 0.0,
1076                alpha: 1.0,
1077            },
1078        }
1079    }
1080
1081    #[test]
1082    fn pdf_edit_command_replace_source_text_round_trips() {
1083        let command = PdfEditCommand::ReplaceSourceText {
1084            id: "edit-1".to_string(),
1085            locator: PdfSourceLocator {
1086                document_sha256: document_sha256(b"fixture"),
1087                page: 1,
1088                spans: vec![sample_span(0, 0, 3)],
1089                island_digest: sha256_hex(b"island"),
1090            },
1091            before: PdfTextEditState {
1092                text: "CAR YOU LIST.".to_string(),
1093                appearance: sample_appearance(),
1094            },
1095            after: PdfTextEditState {
1096                text: "CAR YOU LIST, YOU WIN.".to_string(),
1097                appearance: sample_appearance(),
1098            },
1099            available_bounds: PdfRect {
1100                x: 0.0,
1101                y: 0.0,
1102                width: 200.0,
1103                height: 40.0,
1104            },
1105        };
1106
1107        let json = serde_json::to_string(&command).expect("serialize");
1108        assert!(json.contains("\"kind\":\"replaceSourceText\""));
1109        assert!(json.contains("\"availableBounds\""));
1110
1111        let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1112        match round_tripped {
1113            PdfEditCommand::ReplaceSourceText { id, after, .. } => {
1114                assert_eq!(id, "edit-1");
1115                assert_eq!(after.text, "CAR YOU LIST, YOU WIN.");
1116            }
1117            PdfEditCommand::AddText { .. } => panic!("expected ReplaceSourceText"),
1118            PdfEditCommand::CorrectOcrLine { .. } => panic!("expected ReplaceSourceText"),
1119        }
1120    }
1121
1122    #[test]
1123    fn pdf_edit_command_add_text_round_trips() {
1124        let command = PdfEditCommand::AddText {
1125            id: "add-1".to_string(),
1126            page: 2,
1127            anchor: PdfPoint { x: 72.0, y: 144.0 },
1128            text: "New line".to_string(),
1129            appearance: sample_appearance(),
1130        };
1131
1132        let json = serde_json::to_string(&command).expect("serialize");
1133        assert!(json.contains("\"kind\":\"addText\""));
1134
1135        let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1136        match round_tripped {
1137            PdfEditCommand::AddText { id, page, text, .. } => {
1138                assert_eq!(id, "add-1");
1139                assert_eq!(page, 2);
1140                assert_eq!(text, "New line");
1141            }
1142            PdfEditCommand::ReplaceSourceText { .. } => panic!("expected AddText"),
1143            PdfEditCommand::CorrectOcrLine { .. } => panic!("expected AddText"),
1144        }
1145    }
1146
1147    #[test]
1148    fn pdf_edit_command_correct_ocr_text_round_trips() {
1149        let command = PdfEditCommand::CorrectOcrLine {
1150            id: "ocr-1".to_string(),
1151            page: 1,
1152            line_id: "line-7".to_string(),
1153            rect: PdfRect {
1154                x: 72.0,
1155                y: 700.0,
1156                width: 120.0,
1157                height: 18.0,
1158            },
1159            text: "Corrected".to_string(),
1160            appearance: sample_appearance(),
1161            cover_color: PdfColor {
1162                r: 1.0,
1163                g: 1.0,
1164                b: 1.0,
1165                alpha: 1.0,
1166            },
1167            background_proof: PdfOcrBackgroundProof::NearUniform,
1168        };
1169        let json = serde_json::to_string(&command).expect("serialize");
1170        assert!(json.contains("\"kind\":\"correctOcrText\""));
1171        assert!(json.contains("\"lineId\":\"line-7\""));
1172        assert!(json.contains("\"backgroundProof\":\"nearUniform\""));
1173        let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1174        assert!(matches!(
1175            round_tripped,
1176            PdfEditCommand::CorrectOcrLine {
1177                background_proof: PdfOcrBackgroundProof::NearUniform,
1178                ..
1179            }
1180        ));
1181        let invalid = json.replace("nearUniform", "gradient");
1182        assert!(serde_json::from_str::<PdfEditCommand>(&invalid).is_err());
1183    }
1184
1185    #[test]
1186    fn effective_commands_last_ocr_correction_wins() {
1187        let appearance = sample_appearance();
1188        let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
1189            id: id.to_string(),
1190            page: 1,
1191            line_id: "line-1".to_string(),
1192            rect: PdfRect {
1193                x: 0.0,
1194                y: 0.0,
1195                width: 100.0,
1196                height: 20.0,
1197            },
1198            text: text.to_string(),
1199            appearance: appearance.clone(),
1200            cover_color: PdfColor {
1201                r: 1.0,
1202                g: 1.0,
1203                b: 1.0,
1204                alpha: 1.0,
1205            },
1206            background_proof: PdfOcrBackgroundProof::NearUniform,
1207        };
1208        let commands = vec![correction("old", "old"), correction("new", "new")];
1209        let active = effective_pdf_edit_commands(&commands);
1210        assert_eq!(active.len(), 1);
1211        assert!(matches!(active[0], PdfEditCommand::CorrectOcrLine { text, .. } if text == "new"));
1212    }
1213
1214    #[test]
1215    fn pdf_font_source_round_trips() {
1216        for (source, wire) in [
1217            (PdfFontSource::Bundled, "\"bundled\""),
1218            (PdfFontSource::System, "\"system\""),
1219        ] {
1220            let json = serde_json::to_string(&source).expect("serialize");
1221            assert_eq!(json, wire, "frontend expects a string source discriminator");
1222            let round_tripped: PdfFontSource = serde_json::from_str(&json).expect("deserialize");
1223            assert_eq!(round_tripped, source);
1224        }
1225    }
1226
1227    #[test]
1228    fn pdf_font_catalog_entry_serializes_camel_case() {
1229        let entry = PdfFontCatalogEntry {
1230            id: "source-serif-4".to_string(),
1231            family: "Source Serif 4".to_string(),
1232            postscript_name: Some("SourceSerif4-Regular".to_string()),
1233            styles: vec!["Regular".to_string(), "Bold".to_string()],
1234            embeddable: true,
1235            subsettable: true,
1236            source: PdfFontSource::Bundled,
1237        };
1238        let json = serde_json::to_string(&entry).expect("serialize");
1239        assert!(json.contains("\"postscriptName\""));
1240        assert!(json.contains("\"subsettable\""));
1241        assert_eq!(
1242            serde_json::from_str::<serde_json::Value>(&json).unwrap()["source"],
1243            "bundled"
1244        );
1245    }
1246
1247    #[test]
1248    fn pdf_edit_error_serializes_as_code_message_envelope() {
1249        // Matches the host error type's existing `{ code, message }` wire convention (error.rs) and
1250        // `pdfEditModel.ts`'s `PdfEditErrorCode` union (A2) — not a bare variant-name string.
1251        let json = serde_json::to_string(&PdfEditError::StaleSource).expect("serialize");
1252        assert_eq!(
1253            json,
1254            r#"{"code":"stale_source","message":"PDF changed since edit started"}"#
1255        );
1256        assert_eq!(
1257            PdfEditError::TextOverflow.to_string(),
1258            "text exceeds safe bounds"
1259        );
1260        assert_eq!(PdfEditError::TextOverflow.code(), "text_overflow");
1261    }
1262
1263    /// A minimal `TextStateFont` used only to exercise `glyph_quad`'s fallback (bbox-less) path.
1264    #[derive(Debug)]
1265    struct FixedAdvanceFont {
1266        advance: f64,
1267    }
1268
1269    impl TextStateFont for FixedAdvanceFont {
1270        fn id(&self) -> &str {
1271            "fixed"
1272        }
1273
1274        fn decode_show_operand(
1275            &self,
1276            _operand: &lopdf::Object,
1277        ) -> Result<Vec<DecodedGlyph>, PdfEditError> {
1278            Ok(Vec::new())
1279        }
1280
1281        fn advance(&self, _cid: u32, _font_size: f64) -> f64 {
1282            self.advance
1283        }
1284    }
1285
1286    #[test]
1287    fn glyph_quad_without_bbox_uses_advance_fallback() {
1288        let mut state = TextState::new();
1289        state.font = Some(Rc::new(FixedAdvanceFont { advance: 10.0 }));
1290        state.font_size = 12.0;
1291
1292        let glyph = DecodedGlyph {
1293            cid: 1,
1294            unicode: "A".to_string(),
1295            source_range: (0, 1),
1296            bbox: None,
1297        };
1298
1299        let quad = state
1300            .glyph_quad(&glyph, Matrix::IDENTITY)
1301            .expect("font is set");
1302        // Bottom-left corner should sit at the origin (identity matrices, no rise/scale).
1303        assert_eq!((quad[0], quad[1]), (0.0, state.font_size * -0.25));
1304        // Bottom-right corner should sit at the fallback advance width.
1305        assert_eq!((quad[2], quad[3]), (10.0, state.font_size * -0.25));
1306    }
1307
1308    #[test]
1309    fn glyph_quad_without_font_is_malformed_content() {
1310        let state = TextState::new();
1311        let glyph = DecodedGlyph {
1312            cid: 1,
1313            unicode: "A".to_string(),
1314            source_range: (0, 1),
1315            bbox: None,
1316        };
1317        let err = state
1318            .glyph_quad(&glyph, Matrix::IDENTITY)
1319            .expect_err("no font set");
1320        assert!(matches!(err, PdfEditError::MalformedContent));
1321    }
1322
1323    #[test]
1324    fn source_address_with_glyph_narrows_correctly() {
1325        let address = SourceAddress {
1326            stream_ref: (7, 0),
1327            contents_index: 1,
1328            operator_index: 2,
1329            operand_index: 0,
1330        };
1331        let glyph_address = address.with_glyph((3, 6));
1332        assert_eq!(glyph_address.stream_ref, (7, 0));
1333        assert_eq!(glyph_address.contents_index, 1);
1334        assert_eq!(glyph_address.operator_index, 2);
1335        assert_eq!(glyph_address.glyph_start, 3);
1336        assert_eq!(glyph_address.glyph_end, 6);
1337    }
1338}