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