1mod 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
47const REGION_EPSILON: f64 = 1e-6;
49const CONTEXT_WINDOW: usize = 32;
51
52#[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 pub fn from_token(token: impl Into<String>) -> Self {
69 Self(token.into())
70 }
71
72 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize))]
92pub enum StaleReason {
93 RevisionChanged,
95 SourceBytesChanged,
97 ContextChanged,
99 ContainerMissing,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize))]
106pub enum UnsupportedContainer {
107 FormXObject,
109 FusedPageStreams,
111 SharedPageStream,
113}
114
115#[derive(Debug, thiserror::Error)]
117#[non_exhaustive]
118pub enum TextEditError {
119 #[error("stale match id ({reason:?})")]
121 StaleMatch {
122 match_id: MatchId,
124 reason: StaleReason,
126 },
127 #[error("invalid match id: {reason}")]
129 InvalidMatchId {
130 reason: String,
132 },
133 #[error("invalid query: {reason}")]
135 InvalidQuery {
136 reason: String,
138 },
139 #[error("staged edits overlap")]
141 OverlappingEdits {
142 a: MatchId,
144 b: MatchId,
146 },
147 #[error("match already staged")]
149 DuplicateStage {
150 match_id: MatchId,
152 },
153 #[error("unsupported container: {kind:?}")]
155 UnsupportedContainer {
156 match_id: MatchId,
158 kind: UnsupportedContainer,
160 },
161 #[error("match spans multiple styles: {detail}")]
163 UnsupportedStyleSpan {
164 match_id: MatchId,
166 detail: String,
168 },
169 #[error("encoding failed in font '{font}': {detail}")]
171 EncodingFailed {
172 match_id: Option<MatchId>,
174 font: String,
176 detail: String,
178 },
179 #[error("font fallback denied for font '{font}': {detail}")]
182 FontFallbackDenied {
183 match_id: Option<MatchId>,
185 font: String,
187 detail: String,
189 },
190 #[error("match is covered by /ActualText")]
192 TaggedTextConflict {
193 match_id: MatchId,
195 visual_text: String,
197 actual_text: String,
199 },
200 #[error("document is digitally signed ({} signature(s))", signatures.len())]
203 SignedDocumentRejected {
204 signatures: Vec<SignatureSummary>,
206 },
207 #[error("document permissions forbid content modification")]
209 PermissionsDenied,
210 #[error("unsupported fit policy: {policy:?}")]
212 UnsupportedFitPolicy {
213 policy: FitPolicy,
215 },
216 #[error(transparent)]
218 Document(#[from] ManipError),
219 #[error("internal error: {detail}")]
221 Internal {
222 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#[derive(Debug, thiserror::Error)]
256#[error("commit failed: {error}")]
257pub struct CommitError {
258 #[source]
260 pub error: TextEditError,
261 pub results: Vec<TextReplacementResult>,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272#[cfg_attr(feature = "serde", derive(serde::Serialize))]
273#[non_exhaustive]
274pub enum FitPolicy {
275 Exact,
277 AdjustSpacing,
279 ShrinkToFit,
281 ReflowInBounds,
283 ExpandBounds,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
290#[cfg_attr(feature = "serde", derive(serde::Serialize))]
291pub enum FontFallback {
292 Deny,
294 Explicit(String),
296 InjectStandard,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304#[cfg_attr(feature = "serde", derive(serde::Serialize))]
305#[non_exhaustive]
306pub enum CommitPolicy {
307 AllOrNothing,
309 BestEffort,
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316#[cfg_attr(feature = "serde", derive(serde::Serialize))]
317pub enum SignaturePolicy {
318 RejectSignedDocuments,
320 AllowPostSignatureChange,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327#[cfg_attr(feature = "serde", derive(serde::Serialize))]
328#[non_exhaustive]
329pub enum TaggedTextPolicy {
330 Reject,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336#[cfg_attr(feature = "serde", derive(serde::Serialize))]
337pub enum RegionRelation {
338 Intersects,
340 Contained,
342}
343
344#[derive(Debug, Clone)]
346pub struct ReplaceOptions {
347 pub fit: FitPolicy,
349 pub font_fallback: FontFallback,
351 pub commit_policy: CommitPolicy,
353 pub signature_policy: SignaturePolicy,
355 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 #[must_use]
374 pub fn font_fallback(mut self, fallback: FontFallback) -> Self {
375 self.font_fallback = fallback;
376 self
377 }
378
379 #[must_use]
381 pub fn signature_policy(mut self, policy: SignaturePolicy) -> Self {
382 self.signature_policy = policy;
383 self
384 }
385
386 #[must_use]
388 pub fn commit_policy(mut self, policy: CommitPolicy) -> Self {
389 self.commit_policy = policy;
390 self
391 }
392}
393
394#[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 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 #[must_use]
423 pub fn case_insensitive(mut self, yes: bool) -> Self {
424 self.case_insensitive = yes;
425 self
426 }
427
428 #[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 #[must_use]
448 pub fn region(self, page: u32, rect: [f64; 4]) -> Self {
449 self.region_with(page, rect, RegionRelation::Intersects)
450 }
451
452 #[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 #[must_use]
461 pub fn limit(mut self, n: usize) -> Self {
462 self.limit = Some(n);
463 self
464 }
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473#[cfg_attr(feature = "serde", derive(serde::Serialize))]
474#[non_exhaustive]
475pub enum WritingDirection {
476 Ltr,
478}
479
480#[derive(Debug, Clone, PartialEq)]
482#[cfg_attr(feature = "serde", derive(serde::Serialize))]
483pub enum ContainerKind {
484 PageStream {
486 index: u32,
488 },
489 FormXObject {
491 path: Vec<String>,
493 shared_by: u32,
495 },
496 FusedPageStreams,
498}
499
500#[derive(Debug, Clone, PartialEq)]
502#[cfg_attr(feature = "serde", derive(serde::Serialize))]
503pub struct ContainerInfo {
504 pub page: u32,
506 pub stream_obj: (u32, u16),
508 pub kind: ContainerKind,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
514#[cfg_attr(feature = "serde", derive(serde::Serialize))]
515pub struct MatchSpan {
516 pub op_index: usize,
518 pub char_start: usize,
520 pub char_end: usize,
522}
523
524#[derive(Debug, Clone, PartialEq)]
526#[cfg_attr(feature = "serde", derive(serde::Serialize))]
527pub struct MatchStyle {
528 pub font_name: String,
530 pub font_size: f64,
532 pub fill_color: [f64; 3],
534 pub char_spacing: f64,
536 pub word_spacing: f64,
538 pub horiz_scaling: f64,
540 pub text_rise: f64,
542}
543
544#[derive(Debug, Clone)]
546#[cfg_attr(feature = "serde", derive(serde::Serialize))]
547pub struct TextMatch {
548 pub id: MatchId,
550 pub text: String,
552 pub page: u32,
554 pub bbox: [f64; 4],
556 pub spans: Vec<MatchSpan>,
558 pub style: MatchStyle,
560 pub transform: [f64; 6],
562 pub writing_direction: WritingDirection,
564 pub container: ContainerInfo,
566 pub actual_text: Option<String>,
568 pub editable: bool,
570 pub unsupported: Option<UnsupportedReason>,
572 pub warnings: Vec<Diagnostic>,
574}
575
576#[derive(Debug, Clone, PartialEq)]
578#[cfg_attr(feature = "serde", derive(serde::Serialize))]
579pub enum UnsupportedReason {
580 Container(UnsupportedContainer),
582 StyleSpan {
584 detail: String,
586 },
587 TaggedText,
589}
590
591#[derive(Debug, Clone, PartialEq, Eq)]
593#[cfg_attr(feature = "serde", derive(serde::Serialize))]
594pub struct Diagnostic {
595 pub code: String,
597 pub message: String,
599}
600
601#[derive(Debug, Clone, PartialEq, Eq)]
603#[cfg_attr(feature = "serde", derive(serde::Serialize))]
604pub struct SignatureSummary {
605 pub field_name: String,
607 pub docmdp_permission: Option<u32>,
610}
611
612#[derive(Debug, Clone, PartialEq, Eq)]
618#[cfg_attr(feature = "serde", derive(serde::Serialize))]
619pub enum ReplacementStatus {
620 Applied,
622 Failed {
624 reason: String,
626 },
627}
628
629#[derive(Debug, Clone)]
631#[cfg_attr(feature = "serde", derive(serde::Serialize))]
632pub struct TextReplacementResult {
633 pub match_id: MatchId,
635 pub status: ReplacementStatus,
637 pub old_bbox: [f64; 4],
639 pub new_bbox: Option<[f64; 4]>,
641 pub font_used: String,
643 pub font_substituted: bool,
645 pub old_font_size: f64,
647 pub new_font_size: f64,
649 pub fit_applied: FitPolicy,
651 pub new_line_count: u32,
653 pub overflow: Option<String>,
655 pub actual_text_updated: Option<bool>,
657 pub tags_affected: bool,
659 pub diagnostics: Vec<Diagnostic>,
661}
662
663#[derive(Debug, Clone)]
666#[cfg_attr(feature = "serde", derive(serde::Serialize))]
667pub struct TextReplacementReport {
668 pub matches_found: usize,
670 pub replacements_applied: usize,
672 pub replacements_failed: usize,
674 pub pages_modified: Vec<u32>,
676 pub containers_modified: Vec<ContainerInfo>,
678 pub containers_fused: Vec<ContainerInfo>,
680 pub signatures_present: bool,
682 pub signatures_invalidated: bool,
685 pub results: Vec<TextReplacementResult>,
687 pub next_revision: DocumentRevision,
689}
690
691pub 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
716pub 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
773fn 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
805pub 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 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 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 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 pub fn staged(&self) -> Vec<&MatchId> {
965 self.staged.iter().map(|s| &s.id).collect()
966 }
967
968 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 pub fn abort(self) {}
977
978 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 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 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 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 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 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 #[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 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
1316fn 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
1344enum 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 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 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 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 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 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 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
1557fn 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
1595fn 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 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
1640fn 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}