1mod 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
53const REGION_EPSILON: f64 = 1e-6;
55const CONTEXT_WINDOW: usize = 32;
57
58#[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 pub fn from_token(token: impl Into<String>) -> Self {
75 Self(token.into())
76 }
77
78 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize))]
98pub enum StaleReason {
99 RevisionChanged,
101 SourceBytesChanged,
103 ContextChanged,
105 ContainerMissing,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111#[cfg_attr(feature = "serde", derive(serde::Serialize))]
112pub enum UnsupportedContainer {
113 FormXObject,
115 FusedPageStreams,
117 SharedPageStream,
119}
120
121#[derive(Debug, thiserror::Error)]
123#[non_exhaustive]
124pub enum TextEditError {
125 #[error("stale match id ({reason:?})")]
127 StaleMatch {
128 match_id: MatchId,
130 reason: StaleReason,
132 },
133 #[error("invalid match id: {reason}")]
135 InvalidMatchId {
136 reason: String,
138 },
139 #[error("invalid query: {reason}")]
141 InvalidQuery {
142 reason: String,
144 },
145 #[error("staged edits overlap")]
147 OverlappingEdits {
148 a: MatchId,
150 b: MatchId,
152 },
153 #[error("match already staged")]
155 DuplicateStage {
156 match_id: MatchId,
158 },
159 #[error("unsupported container: {kind:?}")]
161 UnsupportedContainer {
162 match_id: MatchId,
164 kind: UnsupportedContainer,
166 },
167 #[error("match spans multiple styles: {detail}")]
169 UnsupportedStyleSpan {
170 match_id: MatchId,
172 detail: String,
174 },
175 #[error("encoding failed in font '{font}': {detail}")]
177 EncodingFailed {
178 match_id: Option<MatchId>,
180 font: String,
182 detail: String,
184 },
185 #[error("font fallback denied for font '{font}': {detail}")]
188 FontFallbackDenied {
189 match_id: Option<MatchId>,
191 font: String,
193 detail: String,
195 },
196 #[error("match is covered by /ActualText")]
198 TaggedTextConflict {
199 match_id: MatchId,
201 visual_text: String,
203 actual_text: String,
205 },
206 #[error("document is digitally signed ({} signature(s))", signatures.len())]
209 SignedDocumentRejected {
210 signatures: Vec<SignatureSummary>,
212 },
213 #[error("document permissions forbid content modification")]
215 PermissionsDenied,
216 #[error("unsupported fit policy: {policy:?}")]
218 UnsupportedFitPolicy {
219 policy: FitPolicy,
221 },
222 #[error(transparent)]
224 Document(#[from] ManipError),
225 #[error("internal error: {detail}")]
227 Internal {
228 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#[derive(Debug, thiserror::Error)]
262#[error("commit failed: {error}")]
263pub struct CommitError {
264 #[source]
266 pub error: TextEditError,
267 pub results: Vec<TextReplacementResult>,
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280#[cfg_attr(feature = "serde", derive(serde::Serialize))]
281#[non_exhaustive]
282pub enum FitPolicy {
283 Exact,
285 AdjustSpacing,
287 ShrinkToFit,
295 ReflowInBounds,
302 ExpandBounds,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
309#[cfg_attr(feature = "serde", derive(serde::Serialize))]
310pub enum FontFallback {
311 Deny,
313 Explicit(String),
315 InjectStandard,
319 #[cfg(feature = "font-subset")]
330 EmbedUnicode(crate::unicode_font::UnicodeFont),
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337#[cfg_attr(feature = "serde", derive(serde::Serialize))]
338#[non_exhaustive]
339pub enum CommitPolicy {
340 AllOrNothing,
342 BestEffort,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349#[cfg_attr(feature = "serde", derive(serde::Serialize))]
350pub enum SignaturePolicy {
351 RejectSignedDocuments,
353 AllowPostSignatureChange,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360#[cfg_attr(feature = "serde", derive(serde::Serialize))]
361#[non_exhaustive]
362pub enum TaggedTextPolicy {
363 Reject,
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369#[cfg_attr(feature = "serde", derive(serde::Serialize))]
370pub enum RegionRelation {
371 Intersects,
373 Contained,
375}
376
377#[derive(Debug, Clone)]
379pub struct ReplaceOptions {
380 pub fit: FitPolicy,
383 pub font_fallback: FontFallback,
385 pub commit_policy: CommitPolicy,
387 pub signature_policy: SignaturePolicy,
389 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 #[must_use]
408 pub fn fit(mut self, fit: FitPolicy) -> Self {
409 self.fit = fit;
410 self
411 }
412
413 #[must_use]
415 pub fn font_fallback(mut self, fallback: FontFallback) -> Self {
416 self.font_fallback = fallback;
417 self
418 }
419
420 #[must_use]
422 pub fn signature_policy(mut self, policy: SignaturePolicy) -> Self {
423 self.signature_policy = policy;
424 self
425 }
426
427 #[must_use]
429 pub fn commit_policy(mut self, policy: CommitPolicy) -> Self {
430 self.commit_policy = policy;
431 self
432 }
433}
434
435#[derive(Debug, Clone)]
446enum Pattern {
447 Literal(String),
448 Regex(Box<regex::Regex>),
449}
450
451#[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 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 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 #[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 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 #[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 #[must_use]
566 pub fn region(self, page: u32, rect: [f64; 4]) -> Self {
567 self.region_with(page, rect, RegionRelation::Intersects)
568 }
569
570 #[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 #[must_use]
579 pub fn limit(mut self, n: usize) -> Self {
580 self.limit = Some(n);
581 self
582 }
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
591#[cfg_attr(feature = "serde", derive(serde::Serialize))]
592#[non_exhaustive]
593pub enum WritingDirection {
594 Ltr,
596}
597
598#[derive(Debug, Clone, PartialEq)]
600#[cfg_attr(feature = "serde", derive(serde::Serialize))]
601pub enum ContainerKind {
602 PageStream {
604 index: u32,
606 },
607 FormXObject {
609 path: Vec<String>,
611 shared_by: u32,
613 },
614 FusedPageStreams,
616}
617
618#[derive(Debug, Clone, PartialEq)]
620#[cfg_attr(feature = "serde", derive(serde::Serialize))]
621pub struct ContainerInfo {
622 pub page: u32,
624 pub stream_obj: (u32, u16),
626 pub kind: ContainerKind,
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
632#[cfg_attr(feature = "serde", derive(serde::Serialize))]
633pub struct MatchSpan {
634 pub op_index: usize,
636 pub char_start: usize,
638 pub char_end: usize,
640}
641
642#[derive(Debug, Clone, PartialEq)]
644#[cfg_attr(feature = "serde", derive(serde::Serialize))]
645pub struct MatchStyle {
646 pub font_name: String,
648 pub font_size: f64,
650 pub fill_color: [f64; 3],
652 pub char_spacing: f64,
654 pub word_spacing: f64,
656 pub horiz_scaling: f64,
658 pub text_rise: f64,
660}
661
662#[derive(Debug, Clone)]
664#[cfg_attr(feature = "serde", derive(serde::Serialize))]
665pub struct TextMatch {
666 pub id: MatchId,
668 pub text: String,
670 pub page: u32,
672 pub bbox: [f64; 4],
674 pub spans: Vec<MatchSpan>,
676 pub style: MatchStyle,
678 pub transform: [f64; 6],
680 pub writing_direction: WritingDirection,
682 pub container: ContainerInfo,
684 pub actual_text: Option<String>,
686 pub editable: bool,
688 pub unsupported: Option<UnsupportedReason>,
690 pub warnings: Vec<Diagnostic>,
692}
693
694#[derive(Debug, Clone, PartialEq)]
696#[cfg_attr(feature = "serde", derive(serde::Serialize))]
697pub enum UnsupportedReason {
698 Container(UnsupportedContainer),
700 StyleSpan {
702 detail: String,
704 },
705 TaggedText,
707}
708
709#[derive(Debug, Clone, PartialEq, Eq)]
711#[cfg_attr(feature = "serde", derive(serde::Serialize))]
712pub struct Diagnostic {
713 pub code: String,
715 pub message: String,
717}
718
719#[derive(Debug, Clone, PartialEq, Eq)]
721#[cfg_attr(feature = "serde", derive(serde::Serialize))]
722pub struct SignatureSummary {
723 pub field_name: String,
725 pub docmdp_permission: Option<u32>,
728}
729
730#[derive(Debug, Clone, PartialEq, Eq)]
736#[cfg_attr(feature = "serde", derive(serde::Serialize))]
737pub enum ReplacementStatus {
738 Applied,
740 Failed {
742 reason: String,
744 },
745}
746
747#[derive(Debug, Clone)]
749#[cfg_attr(feature = "serde", derive(serde::Serialize))]
750pub struct TextReplacementResult {
751 pub match_id: MatchId,
753 pub status: ReplacementStatus,
755 pub old_bbox: [f64; 4],
757 pub new_bbox: Option<[f64; 4]>,
759 pub font_used: String,
761 pub font_substituted: bool,
763 pub old_font_size: f64,
765 pub new_font_size: f64,
767 pub fit_applied: FitPolicy,
769 pub new_line_count: u32,
771 pub overflow: Option<String>,
773 pub actual_text_updated: Option<bool>,
775 pub tags_affected: bool,
777 pub diagnostics: Vec<Diagnostic>,
779}
780
781#[derive(Debug, Clone)]
784#[cfg_attr(feature = "serde", derive(serde::Serialize))]
785pub struct TextReplacementReport {
786 pub matches_found: usize,
788 pub replacements_applied: usize,
790 pub replacements_failed: usize,
792 pub pages_modified: Vec<u32>,
794 pub containers_modified: Vec<ContainerInfo>,
796 pub containers_fused: Vec<ContainerInfo>,
798 pub signatures_present: bool,
800 pub signatures_invalidated: bool,
803 pub results: Vec<TextReplacementResult>,
805 pub next_revision: DocumentRevision,
807}
808
809pub 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
834pub 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
891fn 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
923pub 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 pub fn find_text(&mut self, query: TextQuery) -> Result<Vec<TextMatch>, TextEditError> {
939 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 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 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 pub fn staged(&self) -> Vec<&MatchId> {
1090 self.staged.iter().map(|s| &s.id).collect()
1091 }
1092
1093 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 pub fn abort(self) {}
1102
1103 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 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 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 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 let mut prepared = prepared;
1213 for (page, p) in &mut prepared {
1214 #[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 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 #[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 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
1458fn 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
1486enum 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 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 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 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 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 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 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
1699fn 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
1737fn 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
1755fn 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 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
1800fn 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}