Skip to main content

pdf_manip/text_edit/
mod.rs

1//! Layout-aware text editing: find → stage → commit (Phase 1B engine).
2//!
3//! This is the match-based replacement engine designed in
4//! `docs/TEXT_REPLACE_ENGINE_DESIGN.md`. Unlike [`crate::text_replace`] it
5//! never skips silently, preserves the page's `/Contents` stream structure,
6//! detects text in Form XObjects, refuses signed documents by default and
7//! reports every staged edit.
8//!
9//! ```no_run
10//! use lopdf::Document;
11//! use pdf_manip::text_edit::{begin_text_edit, DocumentRevision, ReplaceOptions, TextQuery};
12//!
13//! let bytes = std::fs::read("in.pdf").unwrap();
14//! let mut doc = Document::load_mem(&bytes).unwrap();
15//! let revision = DocumentRevision::from_source_bytes(&bytes);
16//!
17//! let mut session = begin_text_edit(&mut doc, revision).unwrap();
18//! let matches = session.find_text(TextQuery::exact("Acme B.V.")).unwrap();
19//! session
20//!     .stage_replace(&matches[0].id, "Example B.V.", ReplaceOptions::default())
21//!     .unwrap();
22//! let report = session.commit().unwrap();
23//! assert_eq!(report.replacements_applied, 1);
24//! ```
25
26mod apply;
27mod scan;
28mod signatures;
29mod token;
30
31use std::collections::HashMap;
32use std::fmt;
33use std::ops::{Bound, RangeBounds};
34
35use lopdf::{Document, Object};
36
37use crate::content_editor::multiply_matrix;
38use crate::error::ManipError;
39use crate::text_replace::inject_fallback_font;
40
41pub use token::DocumentRevision;
42
43use apply::{EditRequest, PreparedPage};
44use scan::{ContainerScan, PageScan};
45use token::TokenPayload;
46
47/// Geometry tolerance for region matching (design §10.3).
48const REGION_EPSILON: f64 = 1e-6;
49/// Text-context window (bytes on each side) hashed into a MatchId.
50const CONTEXT_WINDOW: usize = 32;
51
52// ===========================================================================
53// Identifiers
54// ===========================================================================
55
56/// Opaque, serializable locator for one text match.
57///
58/// Wire format: `pdfluent-match-v1.<base64url(payload)>` (design §10.2).
59/// Valid only against the exact [`DocumentRevision`] that produced it; every
60/// use fully revalidates the locator against the live document.
61#[derive(Debug, Clone, PartialEq, Eq, Hash)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize))]
63#[cfg_attr(feature = "serde", serde(transparent))]
64pub struct MatchId(String);
65
66impl MatchId {
67    /// Wrap a previously serialized token (validated on first use).
68    pub fn from_token(token: impl Into<String>) -> Self {
69        Self(token.into())
70    }
71
72    /// The serialized token.
73    pub fn as_str(&self) -> &str {
74        &self.0
75    }
76}
77
78impl fmt::Display for MatchId {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.write_str(&self.0)
81    }
82}
83
84// ===========================================================================
85// Errors
86// ===========================================================================
87
88/// Why a [`MatchId`] no longer resolves (design §3). No fuzzy relocation is
89/// ever attempted.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize))]
92pub enum StaleReason {
93    /// The document revision differs from the one that produced the id.
94    RevisionChanged,
95    /// The matched source bytes changed under the locator.
96    SourceBytesChanged,
97    /// The surrounding context changed under the locator.
98    ContextChanged,
99    /// The container (page/stream/XObject) no longer resolves.
100    ContainerMissing,
101}
102
103/// Container kinds the Phase 1B engine detects but cannot edit.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize))]
106pub enum UnsupportedContainer {
107    /// Text lives in a Form XObject (editing lands with clone-on-write).
108    FormXObject,
109    /// Operators straddle stream boundaries; the page was scanned fused.
110    FusedPageStreams,
111    /// A content stream of this page is shared with another page.
112    SharedPageStream,
113}
114
115/// Typed errors of the text-edit engine.
116#[derive(Debug, thiserror::Error)]
117#[non_exhaustive]
118pub enum TextEditError {
119    /// The locator does not resolve against the current document revision.
120    #[error("stale match id ({reason:?})")]
121    StaleMatch {
122        /// The stale locator.
123        match_id: MatchId,
124        /// Why it is stale.
125        reason: StaleReason,
126    },
127    /// The token could not be decoded or failed strict validation.
128    #[error("invalid match id: {reason}")]
129    InvalidMatchId {
130        /// Decoder diagnostics.
131        reason: String,
132    },
133    /// The query is malformed (e.g. empty needle).
134    #[error("invalid query: {reason}")]
135    InvalidQuery {
136        /// What was wrong.
137        reason: String,
138    },
139    /// Two staged edits overlap in the same container.
140    #[error("staged edits overlap")]
141    OverlappingEdits {
142        /// First edit.
143        a: MatchId,
144        /// Second edit.
145        b: MatchId,
146    },
147    /// The same match was staged twice.
148    #[error("match already staged")]
149    DuplicateStage {
150        /// The duplicated locator.
151        match_id: MatchId,
152    },
153    /// The match lives in a container the engine cannot edit yet.
154    #[error("unsupported container: {kind:?}")]
155    UnsupportedContainer {
156        /// The match.
157        match_id: MatchId,
158        /// Container kind.
159        kind: UnsupportedContainer,
160    },
161    /// The match spans text with differing font/size/style.
162    #[error("match spans multiple styles: {detail}")]
163    UnsupportedStyleSpan {
164        /// The match.
165        match_id: MatchId,
166        /// Which styles differ.
167        detail: String,
168    },
169    /// Replacement (or retained) text could not be encoded.
170    #[error("encoding failed in font '{font}': {detail}")]
171    EncodingFailed {
172        /// The edit, when attributable.
173        match_id: Option<MatchId>,
174        /// Font that could not encode the text.
175        font: String,
176        /// Encoder diagnostics.
177        detail: String,
178    },
179    /// The original font cannot encode the replacement and the policy is
180    /// [`FontFallback::Deny`].
181    #[error("font fallback denied for font '{font}': {detail}")]
182    FontFallbackDenied {
183        /// The edit, when attributable.
184        match_id: Option<MatchId>,
185        /// The original font.
186        font: String,
187        /// Why the original font failed.
188        detail: String,
189    },
190    /// The match is covered by `/ActualText` (design §7).
191    #[error("match is covered by /ActualText")]
192    TaggedTextConflict {
193        /// The match.
194        match_id: MatchId,
195        /// Decoded glyph text.
196        visual_text: String,
197        /// The enclosing /ActualText value.
198        actual_text: String,
199    },
200    /// The document carries digital signatures and the policy is
201    /// [`SignaturePolicy::RejectSignedDocuments`].
202    #[error("document is digitally signed ({} signature(s))", signatures.len())]
203    SignedDocumentRejected {
204        /// The signatures found.
205        signatures: Vec<SignatureSummary>,
206    },
207    /// Document permissions forbid content modification.
208    #[error("document permissions forbid content modification")]
209    PermissionsDenied,
210    /// The requested fit policy is not implemented in this phase.
211    #[error("unsupported fit policy: {policy:?}")]
212    UnsupportedFitPolicy {
213        /// The requested policy.
214        policy: FitPolicy,
215    },
216    /// Underlying document error.
217    #[error(transparent)]
218    Document(#[from] ManipError),
219    /// Invariant violation — please report.
220    #[error("internal error: {detail}")]
221    Internal {
222        /// Diagnostics.
223        detail: String,
224    },
225}
226
227impl TextEditError {
228    fn with_match_id(self, id: &MatchId) -> Self {
229        match self {
230            TextEditError::EncodingFailed {
231                match_id: None,
232                font,
233                detail,
234            } => TextEditError::EncodingFailed {
235                match_id: Some(id.clone()),
236                font,
237                detail,
238            },
239            TextEditError::FontFallbackDenied {
240                match_id: None,
241                font,
242                detail,
243            } => TextEditError::FontFallbackDenied {
244                match_id: Some(id.clone()),
245                font,
246                detail,
247            },
248            other => other,
249        }
250    }
251}
252
253/// A failed commit: the first fatal cause plus the validation outcome of
254/// every staged edit. The document was not modified.
255#[derive(Debug, thiserror::Error)]
256#[error("commit failed: {error}")]
257pub struct CommitError {
258    /// First fatal cause.
259    #[source]
260    pub error: TextEditError,
261    /// Per-edit outcomes for all staged edits.
262    pub results: Vec<TextReplacementResult>,
263}
264
265// ===========================================================================
266// Policies & options
267// ===========================================================================
268
269/// What the engine may do when replacement text does not fit (design §4.3).
270/// Phase 1B implements only [`FitPolicy::Exact`].
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272#[cfg_attr(feature = "serde", derive(serde::Serialize))]
273#[non_exhaustive]
274pub enum FitPolicy {
275    /// Same position, font size and spacing; no measurement-based fitting.
276    Exact,
277    /// Adjust character/word spacing within limits (Phase 2).
278    AdjustSpacing,
279    /// Shrink font size down to a minimum (Phase 2).
280    ShrinkToFit,
281    /// Re-break lines within the original rectangle (Phase 2).
282    ReflowInBounds,
283    /// Expand the text box within caller-supplied bounds (Phase 2).
284    ExpandBounds,
285}
286
287/// Font fallback policy. Fallback use is never silent: it is always visible
288/// in the per-edit result (`font_substituted`).
289#[derive(Debug, Clone, PartialEq, Eq)]
290#[cfg_attr(feature = "serde", derive(serde::Serialize))]
291pub enum FontFallback {
292    /// Fail with [`TextEditError::FontFallbackDenied`] (default).
293    Deny,
294    /// Use the named font (must exist in the page resources).
295    Explicit(String),
296    /// Inject a Helvetica/WinAnsiEncoding resource and use it.
297    InjectStandard,
298}
299
300/// Transaction policy (design §10.4). Strictest-wins across the staged
301/// edits: the commit runs `BestEffort` only when **every** staged edit
302/// requested it.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304#[cfg_attr(feature = "serde", derive(serde::Serialize))]
305#[non_exhaustive]
306pub enum CommitPolicy {
307    /// Validate everything, then apply everything — or nothing (default).
308    AllOrNothing,
309    /// Apply the valid subset deterministically (earlier-staged edits win
310    /// conflicts) and report every failure per edit. Never silent.
311    BestEffort,
312}
313
314/// Digital-signature policy (design §6).
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316#[cfg_attr(feature = "serde", derive(serde::Serialize))]
317pub enum SignaturePolicy {
318    /// Refuse to commit into a signed document (default).
319    RejectSignedDocuments,
320    /// Proceed; existing signatures will fail validation afterwards and the
321    /// report sets `signatures_invalidated`.
322    AllowPostSignatureChange,
323}
324
325/// Tagged-text policy (design §7). Phase 1: reject `/ActualText` conflicts.
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327#[cfg_attr(feature = "serde", derive(serde::Serialize))]
328#[non_exhaustive]
329pub enum TaggedTextPolicy {
330    /// Fail with [`TextEditError::TaggedTextConflict`].
331    Reject,
332}
333
334/// How a region rectangle selects matches (design §10.3).
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336#[cfg_attr(feature = "serde", derive(serde::Serialize))]
337pub enum RegionRelation {
338    /// Positive-area bounding-box intersection (default).
339    Intersects,
340    /// The complete match bounding box inside the region.
341    Contained,
342}
343
344/// Options for one staged replacement.
345#[derive(Debug, Clone)]
346pub struct ReplaceOptions {
347    /// Fit policy (Phase 1B: [`FitPolicy::Exact`] only).
348    pub fit: FitPolicy,
349    /// Font fallback policy.
350    pub font_fallback: FontFallback,
351    /// Transaction policy (Phase 1B: [`CommitPolicy::AllOrNothing`] only).
352    pub commit_policy: CommitPolicy,
353    /// Signature policy; the strictest policy among staged edits wins.
354    pub signature_policy: SignaturePolicy,
355    /// Tagged-text policy.
356    pub tagged_text_policy: TaggedTextPolicy,
357}
358
359impl Default for ReplaceOptions {
360    fn default() -> Self {
361        Self {
362            fit: FitPolicy::Exact,
363            font_fallback: FontFallback::Deny,
364            commit_policy: CommitPolicy::AllOrNothing,
365            signature_policy: SignaturePolicy::RejectSignedDocuments,
366            tagged_text_policy: TaggedTextPolicy::Reject,
367        }
368    }
369}
370
371impl ReplaceOptions {
372    /// Set the font fallback policy.
373    #[must_use]
374    pub fn font_fallback(mut self, fallback: FontFallback) -> Self {
375        self.font_fallback = fallback;
376        self
377    }
378
379    /// Set the signature policy.
380    #[must_use]
381    pub fn signature_policy(mut self, policy: SignaturePolicy) -> Self {
382        self.signature_policy = policy;
383        self
384    }
385
386    /// Set the transaction policy.
387    #[must_use]
388    pub fn commit_policy(mut self, policy: CommitPolicy) -> Self {
389        self.commit_policy = policy;
390        self
391    }
392}
393
394// ===========================================================================
395// Query
396// ===========================================================================
397
398/// A text search query. Matching operates on the decoded visual text of the
399/// logical reading sequence per container (design §10 / Phase 1B narrowing).
400#[derive(Debug, Clone)]
401pub struct TextQuery {
402    needle: String,
403    case_insensitive: bool,
404    pages: Option<(u32, u32)>,
405    region: Option<(u32, [f64; 4], RegionRelation)>,
406    limit: Option<usize>,
407}
408
409impl TextQuery {
410    /// Search for this exact text.
411    pub fn exact(text: impl Into<String>) -> Self {
412        Self {
413            needle: text.into(),
414            case_insensitive: false,
415            pages: None,
416            region: None,
417            limit: None,
418        }
419    }
420
421    /// Unicode-simple case-insensitive matching.
422    #[must_use]
423    pub fn case_insensitive(mut self, yes: bool) -> Self {
424        self.case_insensitive = yes;
425        self
426    }
427
428    /// Restrict to a 1-based page range.
429    #[must_use]
430    pub fn pages(mut self, range: impl RangeBounds<u32>) -> Self {
431        let start = match range.start_bound() {
432            Bound::Included(&s) => s,
433            Bound::Excluded(&s) => s + 1,
434            Bound::Unbounded => 1,
435        };
436        let end = match range.end_bound() {
437            Bound::Included(&e) => e,
438            Bound::Excluded(&e) => e.saturating_sub(1),
439            Bound::Unbounded => u32::MAX,
440        };
441        self.pages = Some((start.max(1), end));
442        self
443    }
444
445    /// Restrict to matches whose bbox intersects `rect` on `page`
446    /// (positive-area intersection; see [`RegionRelation`]).
447    #[must_use]
448    pub fn region(self, page: u32, rect: [f64; 4]) -> Self {
449        self.region_with(page, rect, RegionRelation::Intersects)
450    }
451
452    /// Region restriction with an explicit relation.
453    #[must_use]
454    pub fn region_with(mut self, page: u32, rect: [f64; 4], relation: RegionRelation) -> Self {
455        self.region = Some((page, rect, relation));
456        self
457    }
458
459    /// Return at most `n` matches (document order).
460    #[must_use]
461    pub fn limit(mut self, n: usize) -> Self {
462        self.limit = Some(n);
463        self
464    }
465}
466
467// ===========================================================================
468// Matches
469// ===========================================================================
470
471/// Writing direction of matched text. Phase 1 supports LTR only.
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473#[cfg_attr(feature = "serde", derive(serde::Serialize))]
474#[non_exhaustive]
475pub enum WritingDirection {
476    /// Left to right.
477    Ltr,
478}
479
480/// Where a match lives.
481#[derive(Debug, Clone, PartialEq)]
482#[cfg_attr(feature = "serde", derive(serde::Serialize))]
483pub enum ContainerKind {
484    /// One stream of the page's `/Contents` (index within the array).
485    PageStream {
486        /// 0-based index in `/Contents` order.
487        index: u32,
488    },
489    /// A Form XObject reached from the page.
490    FormXObject {
491        /// Resource-name path from the page (e.g. `["Fm0"]`).
492        path: Vec<String>,
493        /// Number of pages referencing this XObject.
494        shared_by: u32,
495    },
496    /// The page had to be scanned fused (see [`UnsupportedContainer`]).
497    FusedPageStreams,
498}
499
500/// Container identity of a match or a modified stream.
501#[derive(Debug, Clone, PartialEq)]
502#[cfg_attr(feature = "serde", derive(serde::Serialize))]
503pub struct ContainerInfo {
504    /// 1-based page number.
505    pub page: u32,
506    /// lopdf object id of the stream, `(number, generation)`.
507    pub stream_obj: (u32, u16),
508    /// Container kind.
509    pub kind: ContainerKind,
510}
511
512/// One text-showing operator touched by a match.
513#[derive(Debug, Clone, PartialEq, Eq)]
514#[cfg_attr(feature = "serde", derive(serde::Serialize))]
515pub struct MatchSpan {
516    /// Operator index in the container's logical operator sequence.
517    pub op_index: usize,
518    /// Byte range of the match within the operator's decoded text.
519    pub char_start: usize,
520    /// End of the byte range.
521    pub char_end: usize,
522}
523
524/// Visual style at the start of a match.
525#[derive(Debug, Clone, PartialEq)]
526#[cfg_attr(feature = "serde", derive(serde::Serialize))]
527pub struct MatchStyle {
528    /// Font resource name.
529    pub font_name: String,
530    /// Font size.
531    pub font_size: f64,
532    /// Fill color (RGB).
533    pub fill_color: [f64; 3],
534    /// Character spacing (Tc).
535    pub char_spacing: f64,
536    /// Word spacing (Tw).
537    pub word_spacing: f64,
538    /// Horizontal scaling (Tz, percent).
539    pub horiz_scaling: f64,
540    /// Text rise (Ts).
541    pub text_rise: f64,
542}
543
544/// One found occurrence.
545#[derive(Debug, Clone)]
546#[cfg_attr(feature = "serde", derive(serde::Serialize))]
547pub struct TextMatch {
548    /// Opaque serializable locator.
549    pub id: MatchId,
550    /// The matched visual text.
551    pub text: String,
552    /// 1-based page number.
553    pub page: u32,
554    /// Approximate device-space bounding box `[x0, y0, x1, y1]`.
555    pub bbox: [f64; 4],
556    /// Touched text-showing operators.
557    pub spans: Vec<MatchSpan>,
558    /// Style at the start of the match.
559    pub style: MatchStyle,
560    /// Text matrix × CTM at the start of the match.
561    pub transform: [f64; 6],
562    /// Writing direction (Phase 1: LTR).
563    pub writing_direction: WritingDirection,
564    /// Container the match lives in.
565    pub container: ContainerInfo,
566    /// Enclosing `/ActualText`, when present.
567    pub actual_text: Option<String>,
568    /// Whether this match can be edited by the current engine phase.
569    pub editable: bool,
570    /// Why the match is not editable, when it is not.
571    pub unsupported: Option<UnsupportedReason>,
572    /// Non-fatal observations (approximate bbox, style notes, …).
573    pub warnings: Vec<Diagnostic>,
574}
575
576/// Why a found match cannot be edited in this phase.
577#[derive(Debug, Clone, PartialEq)]
578#[cfg_attr(feature = "serde", derive(serde::Serialize))]
579pub enum UnsupportedReason {
580    /// Unsupported container kind.
581    Container(UnsupportedContainer),
582    /// The match spans differing fonts/sizes.
583    StyleSpan {
584        /// Which styles differ.
585        detail: String,
586    },
587    /// The match is covered by `/ActualText`.
588    TaggedText,
589}
590
591/// A coded, human-readable observation attached to matches and results.
592#[derive(Debug, Clone, PartialEq, Eq)]
593#[cfg_attr(feature = "serde", derive(serde::Serialize))]
594pub struct Diagnostic {
595    /// Stable machine-readable code.
596    pub code: String,
597    /// Human-readable message.
598    pub message: String,
599}
600
601/// A digital signature found in the document.
602#[derive(Debug, Clone, PartialEq, Eq)]
603#[cfg_attr(feature = "serde", derive(serde::Serialize))]
604pub struct SignatureSummary {
605    /// Signature field name (`/T`).
606    pub field_name: String,
607    /// DocMDP certification level (`/P`), when this is a certification
608    /// signature: 1 = no changes, 2 = form fill, 3 = annotations too.
609    pub docmdp_permission: Option<u32>,
610}
611
612// ===========================================================================
613// Report
614// ===========================================================================
615
616/// Outcome of one staged edit.
617#[derive(Debug, Clone, PartialEq, Eq)]
618#[cfg_attr(feature = "serde", derive(serde::Serialize))]
619pub enum ReplacementStatus {
620    /// The edit was applied.
621    Applied,
622    /// The edit failed validation or encoding.
623    Failed {
624        /// Human-readable failure description.
625        reason: String,
626    },
627}
628
629/// Per-edit result (design §4.4).
630#[derive(Debug, Clone)]
631#[cfg_attr(feature = "serde", derive(serde::Serialize))]
632pub struct TextReplacementResult {
633    /// The staged match.
634    pub match_id: MatchId,
635    /// What happened.
636    pub status: ReplacementStatus,
637    /// Bounding box of the original text.
638    pub old_bbox: [f64; 4],
639    /// Bounding box of the new text (Phase 2: measured; Phase 1B: `None`).
640    pub new_bbox: Option<[f64; 4]>,
641    /// Font used for the replacement text.
642    pub font_used: String,
643    /// Whether a fallback font was used (never silent).
644    pub font_substituted: bool,
645    /// Original font size.
646    pub old_font_size: f64,
647    /// New font size (Phase 1B: unchanged).
648    pub new_font_size: f64,
649    /// Fit policy that was applied.
650    pub fit_applied: FitPolicy,
651    /// Number of text lines after the edit (Phase 1B: touched operators).
652    pub new_line_count: u32,
653    /// Overflow information (Phase 2).
654    pub overflow: Option<String>,
655    /// Whether `/ActualText` was updated (`None` = none present).
656    pub actual_text_updated: Option<bool>,
657    /// Whether the page participates in a structure tree.
658    pub tags_affected: bool,
659    /// Diagnostics with remediation hints.
660    pub diagnostics: Vec<Diagnostic>,
661}
662
663/// Exhaustive commit report: `matches_found == replacements_applied +
664/// replacements_failed` always holds (no silent skips).
665#[derive(Debug, Clone)]
666#[cfg_attr(feature = "serde", derive(serde::Serialize))]
667pub struct TextReplacementReport {
668    /// Number of staged edits in this transaction.
669    pub matches_found: usize,
670    /// Edits applied.
671    pub replacements_applied: usize,
672    /// Edits failed (0 for a successful AllOrNothing commit).
673    pub replacements_failed: usize,
674    /// Pages whose content changed.
675    pub pages_modified: Vec<u32>,
676    /// Streams that were rewritten (only touched containers).
677    pub containers_modified: Vec<ContainerInfo>,
678    /// Containers that had to be fused during scanning.
679    pub containers_fused: Vec<ContainerInfo>,
680    /// Whether the document carries digital signatures.
681    pub signatures_present: bool,
682    /// Whether this commit invalidated them (only under
683    /// [`SignaturePolicy::AllowPostSignatureChange`]).
684    pub signatures_invalidated: bool,
685    /// Per-edit results, one per staged edit.
686    pub results: Vec<TextReplacementResult>,
687    /// Revision to pass into the next [`begin_text_edit`] on this document.
688    pub next_revision: DocumentRevision,
689}
690
691// ===========================================================================
692// Session
693// ===========================================================================
694
695/// Start a text-edit session on a document.
696///
697/// `revision` must be [`DocumentRevision::from_source_bytes`] of the exact
698/// bytes the document was loaded from, or the `next_revision` of the previous
699/// commit's report. Fails with [`TextEditError::PermissionsDenied`] when the
700/// document's encryption permissions forbid content modification.
701pub fn begin_text_edit(
702    doc: &mut Document,
703    revision: DocumentRevision,
704) -> Result<TextEditSession<'_>, TextEditError> {
705    if signatures::modification_forbidden(doc) {
706        return Err(TextEditError::PermissionsDenied);
707    }
708    Ok(TextEditSession {
709        doc,
710        revision,
711        scans: HashMap::new(),
712        staged: Vec::new(),
713    })
714}
715
716/// Convenience: find + stage + commit in one call (design §4).
717///
718/// Every found occurrence is accounted for in the report: editable matches
719/// are staged and committed under `options`; matches the engine cannot edit
720/// (Form XObjects, style spans, `/ActualText`, …) appear as `Failed` results
721/// with their typed reason — never as silent skips. The editable set commits
722/// under the transaction policy in `options` (default `AllOrNothing`).
723pub fn replace_text(
724    doc: &mut Document,
725    revision: DocumentRevision,
726    query: TextQuery,
727    replacement: &str,
728    options: ReplaceOptions,
729) -> Result<TextReplacementReport, CommitError> {
730    let wrap = |error: TextEditError| CommitError {
731        error,
732        results: Vec::new(),
733    };
734    let mut session = begin_text_edit(doc, revision).map_err(wrap)?;
735    let matches = session.find_text(query).map_err(wrap)?;
736
737    let mut skipped: Vec<TextReplacementResult> = Vec::new();
738    for m in &matches {
739        if m.editable {
740            if let Err(e) = session.stage_replace(&m.id, replacement, options.clone()) {
741                skipped.push(unstaged_result(m, &options, e.to_string()));
742            }
743        } else {
744            let reason = match &m.unsupported {
745                Some(UnsupportedReason::Container(kind)) => {
746                    format!("unsupported container: {kind:?}")
747                }
748                Some(UnsupportedReason::StyleSpan { detail }) => {
749                    format!("match spans multiple styles: {detail}")
750                }
751                Some(UnsupportedReason::TaggedText) => {
752                    "match is covered by /ActualText".to_string()
753                }
754                None => "not editable".to_string(),
755            };
756            skipped.push(unstaged_result(m, &options, reason));
757        }
758    }
759
760    let mut report = match session.commit() {
761        Ok(r) => r,
762        Err(mut e) => {
763            e.results.extend(skipped);
764            return Err(e);
765        }
766    };
767    report.matches_found += skipped.len();
768    report.replacements_failed += skipped.len();
769    report.results.extend(skipped);
770    Ok(report)
771}
772
773/// Failed-result row for a match that was never staged.
774fn unstaged_result(
775    m: &TextMatch,
776    options: &ReplaceOptions,
777    reason: String,
778) -> TextReplacementResult {
779    TextReplacementResult {
780        match_id: m.id.clone(),
781        status: ReplacementStatus::Failed { reason },
782        old_bbox: m.bbox,
783        new_bbox: None,
784        font_used: m.style.font_name.clone(),
785        font_substituted: false,
786        old_font_size: m.style.font_size,
787        new_font_size: m.style.font_size,
788        fit_applied: options.fit,
789        new_line_count: m.spans.len() as u32,
790        overflow: None,
791        actual_text_updated: m.actual_text.as_ref().map(|_| false),
792        tags_affected: false,
793        diagnostics: m.warnings.clone(),
794    }
795}
796
797struct StagedEdit {
798    id: MatchId,
799    payload: TokenPayload,
800    snapshot: TextMatch,
801    replacement: String,
802    options: ReplaceOptions,
803}
804
805/// An in-progress text-edit transaction (design §2).
806///
807/// Edits are staged without touching the document; [`TextEditSession::commit`]
808/// validates every staged edit, rebuilds only the touched streams, and swaps
809/// them in atomically (`AllOrNothing`).
810pub struct TextEditSession<'d> {
811    doc: &'d mut Document,
812    revision: DocumentRevision,
813    scans: HashMap<u32, PageScan>,
814    staged: Vec<StagedEdit>,
815}
816
817impl TextEditSession<'_> {
818    /// Find matches for `query` across the requested pages, including inside
819    /// Form XObjects (reported non-editable in Phase 1B).
820    pub fn find_text(&mut self, query: TextQuery) -> Result<Vec<TextMatch>, TextEditError> {
821        if query.needle.is_empty() {
822            return Err(TextEditError::InvalidQuery {
823                reason: "empty search text".to_string(),
824            });
825        }
826        let page_count = self.doc.get_pages().len() as u32;
827        let (lo, hi) = query.pages.unwrap_or((1, u32::MAX));
828        let hi = hi.min(page_count);
829
830        let mut matches = Vec::new();
831        for page in lo..=hi {
832            self.ensure_scan(page)?;
833            let scan = &self.scans[&page];
834
835            for (s, e) in find_all(
836                &scan.content.combined,
837                &query.needle,
838                query.case_insensitive,
839            ) {
840                matches.push(build_match(&self.revision, scan, ScanTarget::Page, (s, e)));
841            }
842            for (xi, xobj) in scan.xobjects.iter().enumerate() {
843                for (s, e) in find_all(&xobj.scan.combined, &query.needle, query.case_insensitive) {
844                    matches.push(build_match(
845                        &self.revision,
846                        scan,
847                        ScanTarget::Xobject(xi),
848                        (s, e),
849                    ));
850                }
851            }
852        }
853
854        if let Some((page, rect, relation)) = query.region {
855            matches.retain(|m| m.page == page && region_matches(&m.bbox, &rect, relation));
856        }
857        if let Some(n) = query.limit {
858            matches.truncate(n);
859        }
860        Ok(matches)
861    }
862
863    /// Re-hydrate a serialized [`MatchId`] (asynchronous workflows). The
864    /// locator is fully revalidated: revision, container, range and content
865    /// hashes must all still match.
866    pub fn resolve(&mut self, id: &MatchId) -> Result<TextMatch, TextEditError> {
867        let payload = token::decode_token(id.as_str())?;
868        token::check_revision(&payload, &self.revision, id)?;
869        self.ensure_scan(payload.page)?;
870        let scan = &self.scans[&payload.page];
871
872        let target = if payload.ck == "p" {
873            ScanTarget::Page
874        } else {
875            let path = payload.ck.trim_start_matches("x:");
876            let found = scan
877                .xobjects
878                .iter()
879                .position(|x| x.name_path.join("/") == path);
880            match found {
881                Some(xi) => ScanTarget::Xobject(xi),
882                None => {
883                    return Err(TextEditError::StaleMatch {
884                        match_id: id.clone(),
885                        reason: StaleReason::ContainerMissing,
886                    })
887                }
888            }
889        };
890        let container = target.container(scan);
891        let (s, e) = (payload.chr[0] as usize, payload.chr[1] as usize);
892        let combined = &container.combined;
893        if e > combined.len() || !combined.is_char_boundary(s) || !combined.is_char_boundary(e) {
894            return Err(TextEditError::StaleMatch {
895                match_id: id.clone(),
896                reason: StaleReason::SourceBytesChanged,
897            });
898        }
899        if token::hash64_hex(&combined.as_bytes()[s..e]) != payload.sh {
900            return Err(TextEditError::StaleMatch {
901                match_id: id.clone(),
902                reason: StaleReason::SourceBytesChanged,
903            });
904        }
905        if context_hash(combined, (s, e)) != payload.ch {
906            return Err(TextEditError::StaleMatch {
907                match_id: id.clone(),
908                reason: StaleReason::ContextChanged,
909            });
910        }
911        Ok(build_match(&self.revision, scan, target, (s, e)))
912    }
913
914    /// Stage a replacement for one match. The document is not modified until
915    /// [`TextEditSession::commit`].
916    pub fn stage_replace(
917        &mut self,
918        target: &MatchId,
919        replacement: &str,
920        options: ReplaceOptions,
921    ) -> Result<(), TextEditError> {
922        if options.fit != FitPolicy::Exact {
923            return Err(TextEditError::UnsupportedFitPolicy {
924                policy: options.fit,
925            });
926        }
927        if self.staged.iter().any(|s| &s.id == target) {
928            return Err(TextEditError::DuplicateStage {
929                match_id: target.clone(),
930            });
931        }
932
933        let snapshot = self.resolve(target)?;
934        if let Some(reason) = &snapshot.unsupported {
935            return Err(match reason {
936                UnsupportedReason::Container(kind) => TextEditError::UnsupportedContainer {
937                    match_id: target.clone(),
938                    kind: *kind,
939                },
940                UnsupportedReason::StyleSpan { detail } => TextEditError::UnsupportedStyleSpan {
941                    match_id: target.clone(),
942                    detail: detail.clone(),
943                },
944                UnsupportedReason::TaggedText => TextEditError::TaggedTextConflict {
945                    match_id: target.clone(),
946                    visual_text: snapshot.text.clone(),
947                    actual_text: snapshot.actual_text.clone().unwrap_or_default(),
948                },
949            });
950        }
951
952        let payload = token::decode_token(target.as_str())?;
953        self.staged.push(StagedEdit {
954            id: target.clone(),
955            payload,
956            snapshot,
957            replacement: replacement.to_string(),
958            options,
959        });
960        Ok(())
961    }
962
963    /// The currently staged match ids, in staging order.
964    pub fn staged(&self) -> Vec<&MatchId> {
965        self.staged.iter().map(|s| &s.id).collect()
966    }
967
968    /// Remove a staged edit. Returns whether it was present.
969    pub fn unstage(&mut self, target: &MatchId) -> bool {
970        let before = self.staged.len();
971        self.staged.retain(|s| &s.id != target);
972        self.staged.len() != before
973    }
974
975    /// Drop all staged edits without touching the document.
976    pub fn abort(self) {}
977
978    /// Validate every staged edit, then apply the transaction.
979    ///
980    /// Under `AllOrNothing` (the default, strictest-wins: it applies unless
981    /// **every** staged edit requested `BestEffort`) any failure aborts
982    /// before the first byte changes and returns [`CommitError`] with all
983    /// per-edit results.
984    ///
985    /// Under `BestEffort` (design §10.4, Phase 1C) the valid subset is
986    /// applied and every failure is reported in the returned report — never
987    /// silently: `matches_found == replacements_applied +
988    /// replacements_failed` always holds. Subset selection is deterministic:
989    /// on overlap the earlier-staged edit wins; encoding/validation failures
990    /// remove only the failing edit and planning is repeated with the rest.
991    /// Document-level rejections (signatures, permissions) still fail the
992    /// whole commit.
993    ///
994    /// In both modes all touched containers are rebuilt into temporary
995    /// buffers first; the document is only mutated after the surviving edit
996    /// set and every rebuilt container validated (prepare-then-swap).
997    pub fn commit(mut self) -> Result<TextReplacementReport, CommitError> {
998        let staged_count = self.staged.len();
999        if staged_count == 0 {
1000            return Ok(self.empty_report());
1001        }
1002        let best_effort = self
1003            .staged
1004            .iter()
1005            .all(|s| s.options.commit_policy == CommitPolicy::BestEffort);
1006
1007        // Signature policy (document-level, before any mutation).
1008        let found_signatures = signatures::detect_signatures(self.doc);
1009        let signatures_present = !found_signatures.is_empty();
1010        let any_reject = self
1011            .staged
1012            .iter()
1013            .any(|s| s.options.signature_policy == SignaturePolicy::RejectSignedDocuments);
1014        if signatures_present && any_reject {
1015            let error = TextEditError::SignedDocumentRejected {
1016                signatures: found_signatures,
1017            };
1018            let results = self.all_failed_results(&format!("{error}"));
1019            return Err(CommitError { error, results });
1020        }
1021
1022        // Overlap detection (design §5). AllOrNothing: first conflict aborts.
1023        // BestEffort: the earlier-staged edit wins deterministically.
1024        let mut failed: HashMap<usize, TextEditError> = HashMap::new();
1025        for i in 0..self.staged.len() {
1026            if failed.contains_key(&i) {
1027                continue;
1028            }
1029            for j in (i + 1)..self.staged.len() {
1030                if failed.contains_key(&j) {
1031                    continue;
1032                }
1033                let (a, b) = (&self.staged[i], &self.staged[j]);
1034                if a.payload.page == b.payload.page
1035                    && a.payload.ck == b.payload.ck
1036                    && ranges_overlap(a.payload.chr, b.payload.chr)
1037                {
1038                    let error = TextEditError::OverlappingEdits {
1039                        a: a.id.clone(),
1040                        b: b.id.clone(),
1041                    };
1042                    if best_effort {
1043                        failed.insert(j, error);
1044                    } else {
1045                        let results = self.all_failed_results(&format!("{error}"));
1046                        return Err(CommitError { error, results });
1047                    }
1048                }
1049            }
1050        }
1051
1052        // Prepare loop: plan the active subset; under BestEffort remove
1053        // failing edits and re-plan until the subset is stable.
1054        let mut active: Vec<usize> = (0..staged_count)
1055            .filter(|i| !failed.contains_key(i))
1056            .collect();
1057        let prepared: Vec<(u32, PreparedPage)>;
1058        loop {
1059            match self.prepare_active(&active) {
1060                Ok((p, new_failures)) => {
1061                    if new_failures.is_empty() {
1062                        prepared = p;
1063                        break;
1064                    }
1065                    if !best_effort {
1066                        return Err(self.all_or_nothing_failure(new_failures, failed));
1067                    }
1068                    for (i, e) in new_failures {
1069                        failed.insert(i, e);
1070                    }
1071                    active.retain(|i| !failed.contains_key(i));
1072                    if active.is_empty() {
1073                        prepared = Vec::new();
1074                        break;
1075                    }
1076                }
1077                Err(e) => {
1078                    let results = self.all_failed_results(&format!("{e}"));
1079                    return Err(CommitError { error: e, results });
1080                }
1081            }
1082        }
1083
1084        // Swap phase: inject fallback fonts first, then rewrite the touched
1085        // streams. Only stream objects are mutated; /Contents structure and
1086        // untouched streams keep their exact bytes.
1087        for (page, p) in &prepared {
1088            if p.inject_fallback && inject_fallback_font(self.doc, *page).is_none() {
1089                let error = TextEditError::Internal {
1090                    detail: format!("fallback font injection failed on page {page}"),
1091                };
1092                let results = self.all_failed_results(&format!("{error}"));
1093                return Err(CommitError { error, results });
1094            }
1095        }
1096        let mut containers_modified = Vec::new();
1097        for (page, p) in &prepared {
1098            for (idx, (stream_id, bytes)) in p.touched_streams.iter().enumerate() {
1099                write_stream_bytes(self.doc, *stream_id, bytes);
1100                containers_modified.push(ContainerInfo {
1101                    page: *page,
1102                    stream_obj: *stream_id,
1103                    kind: ContainerKind::PageStream {
1104                        index: p.touched_stream_indices[idx] as u32,
1105                    },
1106                });
1107            }
1108        }
1109
1110        // Report.
1111        let applied_count = active.len();
1112        let mut results = Vec::with_capacity(staged_count);
1113        for (i, staged) in self.staged.iter().enumerate() {
1114            if let Some(e) = failed.get(&i) {
1115                results.push(base_result(
1116                    staged,
1117                    ReplacementStatus::Failed {
1118                        reason: e.to_string(),
1119                    },
1120                    false,
1121                    None,
1122                ));
1123            } else {
1124                let info = prepared
1125                    .iter()
1126                    .flat_map(|(_, p)| p.outcomes.iter())
1127                    .find(|o| o.staged_index == i)
1128                    .and_then(|o| o.result.as_ref().ok());
1129                results.push(base_result(
1130                    staged,
1131                    ReplacementStatus::Applied,
1132                    info.map(|i| i.font_substituted).unwrap_or(false),
1133                    info,
1134                ));
1135            }
1136        }
1137        let mut pages_modified: Vec<u32> = prepared
1138            .iter()
1139            .filter(|(_, p)| !p.touched_streams.is_empty())
1140            .map(|(page, _)| *page)
1141            .collect();
1142        pages_modified.sort_unstable();
1143        pages_modified.dedup();
1144        let containers_fused = self
1145            .scans
1146            .values()
1147            .filter(|s| s.fused)
1148            .flat_map(|s| {
1149                s.stream_ids.iter().map(|&id| ContainerInfo {
1150                    page: s.page,
1151                    stream_obj: id,
1152                    kind: ContainerKind::FusedPageStreams,
1153                })
1154            })
1155            .collect();
1156
1157        Ok(TextReplacementReport {
1158            matches_found: staged_count,
1159            replacements_applied: applied_count,
1160            replacements_failed: staged_count - applied_count,
1161            pages_modified,
1162            containers_modified,
1163            containers_fused,
1164            signatures_present,
1165            signatures_invalidated: signatures_present && applied_count > 0,
1166            results,
1167            next_revision: if applied_count > 0 {
1168                self.revision.next()
1169            } else {
1170                self.revision
1171            },
1172        })
1173    }
1174
1175    /// Plan the given staged-edit subset. Returns the prepared pages plus
1176    /// per-edit failures found during planning. The document is not mutated.
1177    #[allow(clippy::type_complexity)]
1178    fn prepare_active(
1179        &mut self,
1180        active: &[usize],
1181    ) -> Result<(Vec<(u32, PreparedPage)>, HashMap<usize, TextEditError>), TextEditError> {
1182        let mut pages: Vec<u32> = active
1183            .iter()
1184            .map(|&i| self.staged[i].payload.page)
1185            .collect();
1186        pages.sort_unstable();
1187        pages.dedup();
1188
1189        let mut prepared = Vec::new();
1190        let mut failures: HashMap<usize, TextEditError> = HashMap::new();
1191        for &page in &pages {
1192            self.ensure_scan(page)?;
1193            let scan = &self.scans[&page];
1194            let requests: Vec<EditRequest> = active
1195                .iter()
1196                .map(|&i| (i, &self.staged[i]))
1197                .filter(|(_, s)| s.payload.page == page)
1198                .map(|(i, s)| EditRequest {
1199                    staged_index: i,
1200                    chr: (s.payload.chr[0] as usize, s.payload.chr[1] as usize),
1201                    replacement: s.replacement.clone(),
1202                    fallback: s.options.font_fallback.clone(),
1203                })
1204                .collect();
1205            let p = apply::prepare_page(scan, &requests)?;
1206            for outcome in &p.outcomes {
1207                if let Err(e) = &outcome.result {
1208                    let id = &self.staged[outcome.staged_index].id;
1209                    failures.insert(outcome.staged_index, clone_error(e).with_match_id(id));
1210                }
1211            }
1212            prepared.push((page, p));
1213        }
1214        Ok((prepared, failures))
1215    }
1216
1217    /// Build the AllOrNothing abort error from the first planning failure.
1218    fn all_or_nothing_failure(
1219        &self,
1220        new_failures: HashMap<usize, TextEditError>,
1221        mut failed: HashMap<usize, TextEditError>,
1222    ) -> CommitError {
1223        for (i, e) in new_failures {
1224            failed.insert(i, e);
1225        }
1226        let mut results = Vec::with_capacity(self.staged.len());
1227        for (i, staged) in self.staged.iter().enumerate() {
1228            let status = match failed.get(&i) {
1229                Some(e) => ReplacementStatus::Failed {
1230                    reason: e.to_string(),
1231                },
1232                None => ReplacementStatus::Failed {
1233                    reason: "aborted: transaction is AllOrNothing and another edit failed"
1234                        .to_string(),
1235                },
1236            };
1237            results.push(base_result(staged, status, false, None));
1238        }
1239        let first = failed
1240            .into_iter()
1241            .min_by_key(|(i, _)| *i)
1242            .map(|(_, e)| e)
1243            .expect("non-empty");
1244        CommitError {
1245            error: first,
1246            results,
1247        }
1248    }
1249
1250    fn ensure_scan(&mut self, page: u32) -> Result<(), TextEditError> {
1251        if !self.scans.contains_key(&page) {
1252            let scan = scan::scan_page(self.doc, page)?;
1253            self.scans.insert(page, scan);
1254        }
1255        Ok(())
1256    }
1257
1258    fn empty_report(&self) -> TextReplacementReport {
1259        TextReplacementReport {
1260            matches_found: 0,
1261            replacements_applied: 0,
1262            replacements_failed: 0,
1263            pages_modified: Vec::new(),
1264            containers_modified: Vec::new(),
1265            containers_fused: Vec::new(),
1266            signatures_present: false,
1267            signatures_invalidated: false,
1268            results: Vec::new(),
1269            next_revision: self.revision,
1270        }
1271    }
1272
1273    fn all_failed_results(&self, reason: &str) -> Vec<TextReplacementResult> {
1274        self.staged
1275            .iter()
1276            .map(|s| {
1277                base_result(
1278                    s,
1279                    ReplacementStatus::Failed {
1280                        reason: reason.to_string(),
1281                    },
1282                    false,
1283                    None,
1284                )
1285            })
1286            .collect()
1287    }
1288}
1289
1290fn base_result(
1291    staged: &StagedEdit,
1292    status: ReplacementStatus,
1293    font_substituted: bool,
1294    info: Option<&apply::AppliedInfo>,
1295) -> TextReplacementResult {
1296    TextReplacementResult {
1297        match_id: staged.id.clone(),
1298        status,
1299        old_bbox: staged.snapshot.bbox,
1300        new_bbox: None,
1301        font_used: info
1302            .map(|i| i.font_used.clone())
1303            .unwrap_or_else(|| staged.snapshot.style.font_name.clone()),
1304        font_substituted,
1305        old_font_size: staged.snapshot.style.font_size,
1306        new_font_size: staged.snapshot.style.font_size,
1307        fit_applied: staged.options.fit,
1308        new_line_count: staged.snapshot.spans.len() as u32,
1309        overflow: None,
1310        actual_text_updated: staged.snapshot.actual_text.as_ref().map(|_| false),
1311        tags_affected: false,
1312        diagnostics: info.map(|i| i.diagnostics.clone()).unwrap_or_default(),
1313    }
1314}
1315
1316/// Clone a TextEditError for per-edit attribution (errors are not `Clone`
1317/// because of the `ManipError` source; degrade that case to a message).
1318fn clone_error(e: &TextEditError) -> TextEditError {
1319    match e {
1320        TextEditError::EncodingFailed {
1321            match_id,
1322            font,
1323            detail,
1324        } => TextEditError::EncodingFailed {
1325            match_id: match_id.clone(),
1326            font: font.clone(),
1327            detail: detail.clone(),
1328        },
1329        TextEditError::FontFallbackDenied {
1330            match_id,
1331            font,
1332            detail,
1333        } => TextEditError::FontFallbackDenied {
1334            match_id: match_id.clone(),
1335            font: font.clone(),
1336            detail: detail.clone(),
1337        },
1338        other => TextEditError::Internal {
1339            detail: other.to_string(),
1340        },
1341    }
1342}
1343
1344// ===========================================================================
1345// Match building
1346// ===========================================================================
1347
1348enum ScanTarget {
1349    Page,
1350    Xobject(usize),
1351}
1352
1353impl ScanTarget {
1354    fn container<'a>(&self, scan: &'a PageScan) -> &'a ContainerScan {
1355        match self {
1356            ScanTarget::Page => &scan.content,
1357            ScanTarget::Xobject(i) => &scan.xobjects[*i].scan,
1358        }
1359    }
1360}
1361
1362fn build_match(
1363    revision: &DocumentRevision,
1364    scan: &PageScan,
1365    target: ScanTarget,
1366    range: (usize, usize),
1367) -> TextMatch {
1368    let container = target.container(scan);
1369    let (s, e) = range;
1370    let text = container.combined[s..e].to_string();
1371
1372    // Involved runs.
1373    let bounds = &container.run_bounds;
1374    let ri0 = run_index(bounds, s);
1375    let ri1 = run_index(bounds, e.saturating_sub(1).max(s));
1376    let runs = &container.runs[ri0..=ri1];
1377
1378    let mut spans = Vec::with_capacity(runs.len());
1379    for (k, run) in runs.iter().enumerate() {
1380        let run_start = bounds[ri0 + k];
1381        spans.push(MatchSpan {
1382            op_index: run.ops_range.start,
1383            char_start: s.max(run_start) - run_start,
1384            char_end: (e.min(bounds[ri0 + k + 1])) - run_start,
1385        });
1386    }
1387
1388    // Style + transform from the graphics state before the first op.
1389    let first = &runs[0];
1390    let snapshot = container.tracker.state_at(first.ops_range.start);
1391    let (style, transform) = match snapshot {
1392        Some(gs) => (
1393            MatchStyle {
1394                font_name: first.font_name.clone(),
1395                font_size: first.font_size,
1396                fill_color: gs.fill_color,
1397                char_spacing: gs.char_spacing,
1398                word_spacing: gs.word_spacing,
1399                horiz_scaling: gs.horiz_scaling,
1400                text_rise: gs.text_rise,
1401            },
1402            multiply_matrix(&gs.text_matrix, &gs.ctm),
1403        ),
1404        None => (
1405            MatchStyle {
1406                font_name: first.font_name.clone(),
1407                font_size: first.font_size,
1408                fill_color: [0.0; 3],
1409                char_spacing: 0.0,
1410                word_spacing: 0.0,
1411                horiz_scaling: 100.0,
1412                text_rise: 0.0,
1413            },
1414            [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1415        ),
1416    };
1417
1418    // Approximate bbox: union of the involved runs' boxes.
1419    let mut bbox = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
1420    for run in runs {
1421        bbox[0] = bbox[0].min(run.x);
1422        bbox[1] = bbox[1].min(run.y);
1423        bbox[2] = bbox[2].max(run.x + run.width);
1424        bbox[3] = bbox[3].max(run.y + run.font_size);
1425    }
1426    let mut warnings = vec![Diagnostic {
1427        code: "approximate-bbox".to_string(),
1428        message: "bbox derived from estimated run metrics (exact metrics land in Phase 2)"
1429            .to_string(),
1430    }];
1431
1432    // Container info + editability.
1433    let (container_info, mut unsupported) = match &target {
1434        ScanTarget::Page => {
1435            let (stream_idx, _) = scan.source_of(first.ops_range.start).unwrap_or((0, 0));
1436            let stream_obj = scan.stream_ids.get(stream_idx).copied().unwrap_or((0, 0));
1437            if scan.fused {
1438                (
1439                    ContainerInfo {
1440                        page: scan.page,
1441                        stream_obj,
1442                        kind: ContainerKind::FusedPageStreams,
1443                    },
1444                    Some(UnsupportedReason::Container(
1445                        UnsupportedContainer::FusedPageStreams,
1446                    )),
1447                )
1448            } else if scan.shared_stream {
1449                (
1450                    ContainerInfo {
1451                        page: scan.page,
1452                        stream_obj,
1453                        kind: ContainerKind::PageStream {
1454                            index: stream_idx as u32,
1455                        },
1456                    },
1457                    Some(UnsupportedReason::Container(
1458                        UnsupportedContainer::SharedPageStream,
1459                    )),
1460                )
1461            } else {
1462                (
1463                    ContainerInfo {
1464                        page: scan.page,
1465                        stream_obj,
1466                        kind: ContainerKind::PageStream {
1467                            index: stream_idx as u32,
1468                        },
1469                    },
1470                    None,
1471                )
1472            }
1473        }
1474        ScanTarget::Xobject(xi) => {
1475            let x = &scan.xobjects[*xi];
1476            (
1477                ContainerInfo {
1478                    page: scan.page,
1479                    stream_obj: x.stream_id,
1480                    kind: ContainerKind::FormXObject {
1481                        path: x.name_path.clone(),
1482                        shared_by: x.shared_by,
1483                    },
1484                },
1485                Some(UnsupportedReason::Container(
1486                    UnsupportedContainer::FormXObject,
1487                )),
1488            )
1489        }
1490    };
1491
1492    // Style-span homogeneity (design §8).
1493    if unsupported.is_none() {
1494        let mixed = runs.iter().any(|r| {
1495            r.font_name != first.font_name || (r.font_size - first.font_size).abs() > 1e-9
1496        });
1497        if mixed {
1498            unsupported = Some(UnsupportedReason::StyleSpan {
1499                detail: "match spans runs with differing font or size".to_string(),
1500            });
1501        }
1502    }
1503
1504    // /ActualText coverage (design §7).
1505    let actual_text = runs
1506        .iter()
1507        .find_map(|r| container.actual.get(r.ops_range.start).cloned().flatten());
1508    if unsupported.is_none() && actual_text.is_some() {
1509        unsupported = Some(UnsupportedReason::TaggedText);
1510        warnings.push(Diagnostic {
1511            code: "actual-text-present".to_string(),
1512            message: "match is covered by /ActualText; replacement is rejected in Phase 1"
1513                .to_string(),
1514        });
1515    }
1516
1517    let ck = match &target {
1518        ScanTarget::Page => "p".to_string(),
1519        ScanTarget::Xobject(xi) => format!("x:{}", scan.xobjects[*xi].name_path.join("/")),
1520    };
1521    let payload = TokenPayload {
1522        v: 1,
1523        fp: revision.digest_hex(),
1524        ctr: revision.counter(),
1525        page: scan.page,
1526        ck,
1527        chr: [s as u64, e as u64],
1528        sh: token::hash64_hex(text.as_bytes()),
1529        ch: context_hash(&container.combined, (s, e)),
1530    };
1531    let id = MatchId(token::encode_token(&payload));
1532
1533    TextMatch {
1534        editable: unsupported.is_none(),
1535        id,
1536        text,
1537        page: scan.page,
1538        bbox,
1539        spans,
1540        style,
1541        transform,
1542        writing_direction: WritingDirection::Ltr,
1543        container: container_info,
1544        actual_text,
1545        unsupported,
1546        warnings,
1547    }
1548}
1549
1550fn run_index(bounds: &[usize], offset: usize) -> usize {
1551    match bounds.binary_search(&offset) {
1552        Ok(i) => i.min(bounds.len().saturating_sub(2)),
1553        Err(i) => i - 1,
1554    }
1555}
1556
1557/// Hash of the text surrounding a match (±32 bytes, clamped to char
1558/// boundaries), used for cheap stale detection.
1559fn context_hash(combined: &str, range: (usize, usize)) -> String {
1560    let mut lo = range.0.saturating_sub(CONTEXT_WINDOW);
1561    while lo > 0 && !combined.is_char_boundary(lo) {
1562        lo -= 1;
1563    }
1564    let mut hi = (range.1 + CONTEXT_WINDOW).min(combined.len());
1565    while hi < combined.len() && !combined.is_char_boundary(hi) {
1566        hi += 1;
1567    }
1568    let mut data = Vec::new();
1569    data.extend_from_slice(&combined.as_bytes()[lo..range.0]);
1570    data.push(0);
1571    data.extend_from_slice(&combined.as_bytes()[range.1..hi]);
1572    token::hash64_hex(&data)
1573}
1574
1575fn ranges_overlap(a: [u64; 2], b: [u64; 2]) -> bool {
1576    a[0] < b[1] && b[0] < a[1]
1577}
1578
1579fn region_matches(bbox: &[f64; 4], rect: &[f64; 4], relation: RegionRelation) -> bool {
1580    match relation {
1581        RegionRelation::Intersects => {
1582            let w = bbox[2].min(rect[2]) - bbox[0].max(rect[0]);
1583            let h = bbox[3].min(rect[3]) - bbox[1].max(rect[1]);
1584            w > REGION_EPSILON && h > REGION_EPSILON
1585        }
1586        RegionRelation::Contained => {
1587            bbox[0] >= rect[0] - REGION_EPSILON
1588                && bbox[1] >= rect[1] - REGION_EPSILON
1589                && bbox[2] <= rect[2] + REGION_EPSILON
1590                && bbox[3] <= rect[3] + REGION_EPSILON
1591        }
1592    }
1593}
1594
1595/// Non-overlapping occurrences of `needle` in `haystack` as byte ranges.
1596fn find_all(haystack: &str, needle: &str, case_insensitive: bool) -> Vec<(usize, usize)> {
1597    if !case_insensitive {
1598        return haystack
1599            .match_indices(needle)
1600            .map(|(s, m)| (s, s + m.len()))
1601            .collect();
1602    }
1603    let mut out = Vec::new();
1604    let needle_chars: Vec<char> = needle.chars().collect();
1605    let mut iter = haystack.char_indices().peekable();
1606    while let Some(&(start, _)) = iter.peek() {
1607        let mut probe = haystack[start..].chars();
1608        let mut end = start;
1609        let mut ok = true;
1610        for &nc in &needle_chars {
1611            match probe.next() {
1612                Some(hc) if chars_eq_fold(hc, nc) => end += hc.len_utf8(),
1613                _ => {
1614                    ok = false;
1615                    break;
1616                }
1617            }
1618        }
1619        if ok {
1620            out.push((start, end));
1621            // Skip past the match (non-overlapping).
1622            while let Some(&(pos, _)) = iter.peek() {
1623                if pos < end {
1624                    iter.next();
1625                } else {
1626                    break;
1627                }
1628            }
1629        } else {
1630            iter.next();
1631        }
1632    }
1633    out
1634}
1635
1636fn chars_eq_fold(a: char, b: char) -> bool {
1637    a == b || a.to_lowercase().eq(b.to_lowercase())
1638}
1639
1640// ===========================================================================
1641// Stream writing (swap phase)
1642// ===========================================================================
1643
1644/// Rewrite one content stream object in place (same object id, `/Contents`
1645/// untouched). Mirrors the compression behaviour of the legacy write path.
1646fn write_stream_bytes(doc: &mut Document, stream_id: (u32, u16), bytes: &[u8]) {
1647    use std::io::Write;
1648    let compressed = {
1649        let mut encoder =
1650            flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
1651        if encoder.write_all(bytes).is_ok() {
1652            encoder.finish().unwrap_or_else(|_| bytes.to_vec())
1653        } else {
1654            bytes.to_vec()
1655        }
1656    };
1657    let (content, use_flate) = if compressed.len() < bytes.len() {
1658        (compressed, true)
1659    } else {
1660        (bytes.to_vec(), false)
1661    };
1662    if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(stream_id) {
1663        s.content = content;
1664        if use_flate {
1665            s.dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
1666        } else {
1667            s.dict.remove(b"Filter");
1668        }
1669        s.dict
1670            .set("Length", Object::Integer(s.content.len() as i64));
1671    }
1672}