mod apply;
mod scan;
mod signatures;
mod token;
use std::collections::HashMap;
use std::fmt;
use std::ops::{Bound, RangeBounds};
use lopdf::{Document, Object};
use crate::content_editor::multiply_matrix;
use crate::error::ManipError;
use crate::text_replace::inject_fallback_font;
pub use token::DocumentRevision;
use apply::{EditRequest, PreparedPage};
use scan::{ContainerScan, PageScan};
use token::TokenPayload;
const REGION_EPSILON: f64 = 1e-6;
const CONTEXT_WINDOW: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct MatchId(String);
impl MatchId {
pub fn from_token(token: impl Into<String>) -> Self {
Self(token.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for MatchId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum StaleReason {
RevisionChanged,
SourceBytesChanged,
ContextChanged,
ContainerMissing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum UnsupportedContainer {
FormXObject,
FusedPageStreams,
SharedPageStream,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TextEditError {
#[error("stale match id ({reason:?})")]
StaleMatch {
match_id: MatchId,
reason: StaleReason,
},
#[error("invalid match id: {reason}")]
InvalidMatchId {
reason: String,
},
#[error("invalid query: {reason}")]
InvalidQuery {
reason: String,
},
#[error("staged edits overlap")]
OverlappingEdits {
a: MatchId,
b: MatchId,
},
#[error("match already staged")]
DuplicateStage {
match_id: MatchId,
},
#[error("unsupported container: {kind:?}")]
UnsupportedContainer {
match_id: MatchId,
kind: UnsupportedContainer,
},
#[error("match spans multiple styles: {detail}")]
UnsupportedStyleSpan {
match_id: MatchId,
detail: String,
},
#[error("encoding failed in font '{font}': {detail}")]
EncodingFailed {
match_id: Option<MatchId>,
font: String,
detail: String,
},
#[error("font fallback denied for font '{font}': {detail}")]
FontFallbackDenied {
match_id: Option<MatchId>,
font: String,
detail: String,
},
#[error("match is covered by /ActualText")]
TaggedTextConflict {
match_id: MatchId,
visual_text: String,
actual_text: String,
},
#[error("document is digitally signed ({} signature(s))", signatures.len())]
SignedDocumentRejected {
signatures: Vec<SignatureSummary>,
},
#[error("document permissions forbid content modification")]
PermissionsDenied,
#[error("unsupported fit policy: {policy:?}")]
UnsupportedFitPolicy {
policy: FitPolicy,
},
#[error(transparent)]
Document(#[from] ManipError),
#[error("internal error: {detail}")]
Internal {
detail: String,
},
}
impl TextEditError {
fn with_match_id(self, id: &MatchId) -> Self {
match self {
TextEditError::EncodingFailed {
match_id: None,
font,
detail,
} => TextEditError::EncodingFailed {
match_id: Some(id.clone()),
font,
detail,
},
TextEditError::FontFallbackDenied {
match_id: None,
font,
detail,
} => TextEditError::FontFallbackDenied {
match_id: Some(id.clone()),
font,
detail,
},
other => other,
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("commit failed: {error}")]
pub struct CommitError {
#[source]
pub error: TextEditError,
pub results: Vec<TextReplacementResult>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum FitPolicy {
Exact,
AdjustSpacing,
ShrinkToFit,
ReflowInBounds,
ExpandBounds,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum FontFallback {
Deny,
Explicit(String),
InjectStandard,
#[cfg(feature = "font-subset")]
EmbedUnicode(crate::unicode_font::UnicodeFont),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum CommitPolicy {
AllOrNothing,
BestEffort,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum SignaturePolicy {
RejectSignedDocuments,
AllowPostSignatureChange,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum TaggedTextPolicy {
Reject,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum RegionRelation {
Intersects,
Contained,
}
#[derive(Debug, Clone)]
pub struct ReplaceOptions {
pub fit: FitPolicy,
pub font_fallback: FontFallback,
pub commit_policy: CommitPolicy,
pub signature_policy: SignaturePolicy,
pub tagged_text_policy: TaggedTextPolicy,
}
impl Default for ReplaceOptions {
fn default() -> Self {
Self {
fit: FitPolicy::Exact,
font_fallback: FontFallback::Deny,
commit_policy: CommitPolicy::AllOrNothing,
signature_policy: SignaturePolicy::RejectSignedDocuments,
tagged_text_policy: TaggedTextPolicy::Reject,
}
}
}
impl ReplaceOptions {
#[must_use]
pub fn fit(mut self, fit: FitPolicy) -> Self {
self.fit = fit;
self
}
#[must_use]
pub fn font_fallback(mut self, fallback: FontFallback) -> Self {
self.font_fallback = fallback;
self
}
#[must_use]
pub fn signature_policy(mut self, policy: SignaturePolicy) -> Self {
self.signature_policy = policy;
self
}
#[must_use]
pub fn commit_policy(mut self, policy: CommitPolicy) -> Self {
self.commit_policy = policy;
self
}
}
#[derive(Debug, Clone)]
enum Pattern {
Literal(String),
Regex(Box<regex::Regex>),
}
#[derive(Debug, Clone)]
pub struct TextQuery {
pattern: Pattern,
case_insensitive: bool,
pages: Option<(u32, u32)>,
region: Option<(u32, [f64; 4], RegionRelation)>,
limit: Option<usize>,
}
impl TextQuery {
pub fn exact(text: impl Into<String>) -> Self {
Self {
pattern: Pattern::Literal(text.into()),
case_insensitive: false,
pages: None,
region: None,
limit: None,
}
}
pub fn regex(pattern: &str) -> Result<Self, TextEditError> {
Self::regex_inner(pattern, false)
}
fn regex_inner(pattern: &str, case_insensitive: bool) -> Result<Self, TextEditError> {
let compiled = regex::RegexBuilder::new(pattern)
.case_insensitive(case_insensitive)
.build()
.map_err(|e| TextEditError::InvalidQuery {
reason: format!("regex does not compile: {e}"),
})?;
if compiled.is_match("") {
return Err(TextEditError::InvalidQuery {
reason: format!(
"regex {pattern:?} matches the empty string, so it would match at every \
position in the document; anchor it or require at least one character"
),
});
}
Ok(Self {
pattern: Pattern::Regex(Box::new(compiled)),
case_insensitive,
pages: None,
region: None,
limit: None,
})
}
#[must_use]
pub fn case_insensitive(mut self, yes: bool) -> Self {
self.case_insensitive = yes;
if let Pattern::Regex(rx) = &self.pattern {
if let Ok(rebuilt) = regex::RegexBuilder::new(rx.as_str())
.case_insensitive(yes)
.build()
{
self.pattern = Pattern::Regex(Box::new(rebuilt));
}
}
self
}
#[must_use]
pub fn pages(mut self, range: impl RangeBounds<u32>) -> Self {
let start = match range.start_bound() {
Bound::Included(&s) => s,
Bound::Excluded(&s) => s + 1,
Bound::Unbounded => 1,
};
let end = match range.end_bound() {
Bound::Included(&e) => e,
Bound::Excluded(&e) => e.saturating_sub(1),
Bound::Unbounded => u32::MAX,
};
self.pages = Some((start.max(1), end));
self
}
#[must_use]
pub fn region(self, page: u32, rect: [f64; 4]) -> Self {
self.region_with(page, rect, RegionRelation::Intersects)
}
#[must_use]
pub fn region_with(mut self, page: u32, rect: [f64; 4], relation: RegionRelation) -> Self {
self.region = Some((page, rect, relation));
self
}
#[must_use]
pub fn limit(mut self, n: usize) -> Self {
self.limit = Some(n);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum WritingDirection {
Ltr,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum ContainerKind {
PageStream {
index: u32,
},
FormXObject {
path: Vec<String>,
shared_by: u32,
},
FusedPageStreams,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct ContainerInfo {
pub page: u32,
pub stream_obj: (u32, u16),
pub kind: ContainerKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct MatchSpan {
pub op_index: usize,
pub char_start: usize,
pub char_end: usize,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct MatchStyle {
pub font_name: String,
pub font_size: f64,
pub fill_color: [f64; 3],
pub char_spacing: f64,
pub word_spacing: f64,
pub horiz_scaling: f64,
pub text_rise: f64,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TextMatch {
pub id: MatchId,
pub text: String,
pub page: u32,
pub bbox: [f64; 4],
pub spans: Vec<MatchSpan>,
pub style: MatchStyle,
pub transform: [f64; 6],
pub writing_direction: WritingDirection,
pub container: ContainerInfo,
pub actual_text: Option<String>,
pub editable: bool,
pub unsupported: Option<UnsupportedReason>,
pub warnings: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum UnsupportedReason {
Container(UnsupportedContainer),
StyleSpan {
detail: String,
},
TaggedText,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Diagnostic {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SignatureSummary {
pub field_name: String,
pub docmdp_permission: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum ReplacementStatus {
Applied,
Failed {
reason: String,
},
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TextReplacementResult {
pub match_id: MatchId,
pub status: ReplacementStatus,
pub old_bbox: [f64; 4],
pub new_bbox: Option<[f64; 4]>,
pub font_used: String,
pub font_substituted: bool,
pub old_font_size: f64,
pub new_font_size: f64,
pub fit_applied: FitPolicy,
pub new_line_count: u32,
pub overflow: Option<String>,
pub actual_text_updated: Option<bool>,
pub tags_affected: bool,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TextReplacementReport {
pub matches_found: usize,
pub replacements_applied: usize,
pub replacements_failed: usize,
pub pages_modified: Vec<u32>,
pub containers_modified: Vec<ContainerInfo>,
pub containers_fused: Vec<ContainerInfo>,
pub signatures_present: bool,
pub signatures_invalidated: bool,
pub results: Vec<TextReplacementResult>,
pub next_revision: DocumentRevision,
}
pub fn begin_text_edit(
doc: &mut Document,
revision: DocumentRevision,
) -> Result<TextEditSession<'_>, TextEditError> {
if signatures::modification_forbidden(doc) {
return Err(TextEditError::PermissionsDenied);
}
Ok(TextEditSession {
doc,
revision,
scans: HashMap::new(),
staged: Vec::new(),
})
}
pub fn replace_text(
doc: &mut Document,
revision: DocumentRevision,
query: TextQuery,
replacement: &str,
options: ReplaceOptions,
) -> Result<TextReplacementReport, CommitError> {
let wrap = |error: TextEditError| CommitError {
error,
results: Vec::new(),
};
let mut session = begin_text_edit(doc, revision).map_err(wrap)?;
let matches = session.find_text(query).map_err(wrap)?;
let mut skipped: Vec<TextReplacementResult> = Vec::new();
for m in &matches {
if m.editable {
if let Err(e) = session.stage_replace(&m.id, replacement, options.clone()) {
skipped.push(unstaged_result(m, &options, e.to_string()));
}
} else {
let reason = match &m.unsupported {
Some(UnsupportedReason::Container(kind)) => {
format!("unsupported container: {kind:?}")
}
Some(UnsupportedReason::StyleSpan { detail }) => {
format!("match spans multiple styles: {detail}")
}
Some(UnsupportedReason::TaggedText) => {
"match is covered by /ActualText".to_string()
}
None => "not editable".to_string(),
};
skipped.push(unstaged_result(m, &options, reason));
}
}
let mut report = match session.commit() {
Ok(r) => r,
Err(mut e) => {
e.results.extend(skipped);
return Err(e);
}
};
report.matches_found += skipped.len();
report.replacements_failed += skipped.len();
report.results.extend(skipped);
Ok(report)
}
fn unstaged_result(
m: &TextMatch,
options: &ReplaceOptions,
reason: String,
) -> TextReplacementResult {
TextReplacementResult {
match_id: m.id.clone(),
status: ReplacementStatus::Failed { reason },
old_bbox: m.bbox,
new_bbox: None,
font_used: m.style.font_name.clone(),
font_substituted: false,
old_font_size: m.style.font_size,
new_font_size: m.style.font_size,
fit_applied: options.fit,
new_line_count: m.spans.len() as u32,
overflow: None,
actual_text_updated: m.actual_text.as_ref().map(|_| false),
tags_affected: false,
diagnostics: m.warnings.clone(),
}
}
struct StagedEdit {
id: MatchId,
payload: TokenPayload,
snapshot: TextMatch,
replacement: String,
options: ReplaceOptions,
}
pub struct TextEditSession<'d> {
doc: &'d mut Document,
revision: DocumentRevision,
scans: HashMap<u32, PageScan>,
staged: Vec<StagedEdit>,
}
impl TextEditSession<'_> {
pub fn find_text(&mut self, query: TextQuery) -> Result<Vec<TextMatch>, TextEditError> {
if matches!(&query.pattern, Pattern::Literal(n) if n.is_empty()) {
return Err(TextEditError::InvalidQuery {
reason: "empty search text".to_string(),
});
}
let page_count = self.doc.get_pages().len() as u32;
let (lo, hi) = query.pages.unwrap_or((1, u32::MAX));
let hi = hi.min(page_count);
let mut matches = Vec::new();
for page in lo..=hi {
self.ensure_scan(page)?;
let scan = &self.scans[&page];
for (s, e) in find_all_pattern(
&scan.content.combined,
&query.pattern,
query.case_insensitive,
) {
matches.push(build_match(&self.revision, scan, ScanTarget::Page, (s, e)));
}
for (xi, xobj) in scan.xobjects.iter().enumerate() {
for (s, e) in
find_all_pattern(&xobj.scan.combined, &query.pattern, query.case_insensitive)
{
matches.push(build_match(
&self.revision,
scan,
ScanTarget::Xobject(xi),
(s, e),
));
}
}
}
if let Some((page, rect, relation)) = query.region {
matches.retain(|m| m.page == page && region_matches(&m.bbox, &rect, relation));
}
if let Some(n) = query.limit {
matches.truncate(n);
}
Ok(matches)
}
pub fn resolve(&mut self, id: &MatchId) -> Result<TextMatch, TextEditError> {
let payload = token::decode_token(id.as_str())?;
token::check_revision(&payload, &self.revision, id)?;
self.ensure_scan(payload.page)?;
let scan = &self.scans[&payload.page];
let target = if payload.ck == "p" {
ScanTarget::Page
} else {
let path = payload.ck.trim_start_matches("x:");
let found = scan
.xobjects
.iter()
.position(|x| x.name_path.join("/") == path);
match found {
Some(xi) => ScanTarget::Xobject(xi),
None => {
return Err(TextEditError::StaleMatch {
match_id: id.clone(),
reason: StaleReason::ContainerMissing,
})
}
}
};
let container = target.container(scan);
let (s, e) = (payload.chr[0] as usize, payload.chr[1] as usize);
let combined = &container.combined;
if e > combined.len() || !combined.is_char_boundary(s) || !combined.is_char_boundary(e) {
return Err(TextEditError::StaleMatch {
match_id: id.clone(),
reason: StaleReason::SourceBytesChanged,
});
}
if token::hash64_hex(&combined.as_bytes()[s..e]) != payload.sh {
return Err(TextEditError::StaleMatch {
match_id: id.clone(),
reason: StaleReason::SourceBytesChanged,
});
}
if context_hash(combined, (s, e)) != payload.ch {
return Err(TextEditError::StaleMatch {
match_id: id.clone(),
reason: StaleReason::ContextChanged,
});
}
Ok(build_match(&self.revision, scan, target, (s, e)))
}
pub fn stage_replace(
&mut self,
target: &MatchId,
replacement: &str,
options: ReplaceOptions,
) -> Result<(), TextEditError> {
if !matches!(
options.fit,
FitPolicy::Exact | FitPolicy::ShrinkToFit | FitPolicy::ReflowInBounds
) {
return Err(TextEditError::UnsupportedFitPolicy {
policy: options.fit,
});
}
if self.staged.iter().any(|s| &s.id == target) {
return Err(TextEditError::DuplicateStage {
match_id: target.clone(),
});
}
let snapshot = self.resolve(target)?;
if let Some(reason) = &snapshot.unsupported {
return Err(match reason {
UnsupportedReason::Container(kind) => TextEditError::UnsupportedContainer {
match_id: target.clone(),
kind: *kind,
},
UnsupportedReason::StyleSpan { detail } => TextEditError::UnsupportedStyleSpan {
match_id: target.clone(),
detail: detail.clone(),
},
UnsupportedReason::TaggedText => TextEditError::TaggedTextConflict {
match_id: target.clone(),
visual_text: snapshot.text.clone(),
actual_text: snapshot.actual_text.clone().unwrap_or_default(),
},
});
}
let payload = token::decode_token(target.as_str())?;
self.staged.push(StagedEdit {
id: target.clone(),
payload,
snapshot,
replacement: replacement.to_string(),
options,
});
Ok(())
}
pub fn staged(&self) -> Vec<&MatchId> {
self.staged.iter().map(|s| &s.id).collect()
}
pub fn unstage(&mut self, target: &MatchId) -> bool {
let before = self.staged.len();
self.staged.retain(|s| &s.id != target);
self.staged.len() != before
}
pub fn abort(self) {}
pub fn commit(mut self) -> Result<TextReplacementReport, CommitError> {
let staged_count = self.staged.len();
if staged_count == 0 {
return Ok(self.empty_report());
}
let best_effort = self
.staged
.iter()
.all(|s| s.options.commit_policy == CommitPolicy::BestEffort);
let found_signatures = signatures::detect_signatures(self.doc);
let signatures_present = !found_signatures.is_empty();
let any_reject = self
.staged
.iter()
.any(|s| s.options.signature_policy == SignaturePolicy::RejectSignedDocuments);
if signatures_present && any_reject {
let error = TextEditError::SignedDocumentRejected {
signatures: found_signatures,
};
let results = self.all_failed_results(&format!("{error}"));
return Err(CommitError { error, results });
}
let mut failed: HashMap<usize, TextEditError> = HashMap::new();
for i in 0..self.staged.len() {
if failed.contains_key(&i) {
continue;
}
for j in (i + 1)..self.staged.len() {
if failed.contains_key(&j) {
continue;
}
let (a, b) = (&self.staged[i], &self.staged[j]);
if a.payload.page == b.payload.page
&& a.payload.ck == b.payload.ck
&& ranges_overlap(a.payload.chr, b.payload.chr)
{
let error = TextEditError::OverlappingEdits {
a: a.id.clone(),
b: b.id.clone(),
};
if best_effort {
failed.insert(j, error);
} else {
let results = self.all_failed_results(&format!("{error}"));
return Err(CommitError { error, results });
}
}
}
}
let mut active: Vec<usize> = (0..staged_count)
.filter(|i| !failed.contains_key(i))
.collect();
let prepared: Vec<(u32, PreparedPage)>;
loop {
match self.prepare_active(&active) {
Ok((p, new_failures)) => {
if new_failures.is_empty() {
prepared = p;
break;
}
if !best_effort {
return Err(self.all_or_nothing_failure(new_failures, failed));
}
for (i, e) in new_failures {
failed.insert(i, e);
}
active.retain(|i| !failed.contains_key(i));
if active.is_empty() {
prepared = Vec::new();
break;
}
}
Err(e) => {
let results = self.all_failed_results(&format!("{e}"));
return Err(CommitError { error: e, results });
}
}
}
let mut prepared = prepared;
for (page, p) in &mut prepared {
#[cfg(feature = "font-subset")]
if let Some(encoder) = p.unicode_encoder.take() {
if let Err(e) = encoder.embed(self.doc, *page) {
let error = TextEditError::Internal {
detail: format!("embedding the Unicode font on page {page} failed: {e}"),
};
let results = self.all_failed_results(&format!("{error}"));
return Err(CommitError { error, results });
}
continue;
}
if p.inject_fallback && inject_fallback_font(self.doc, *page).is_none() {
let error = TextEditError::Internal {
detail: format!("fallback font injection failed on page {page}"),
};
let results = self.all_failed_results(&format!("{error}"));
return Err(CommitError { error, results });
}
}
let prepared = prepared;
let mut containers_modified = Vec::new();
for (page, p) in &prepared {
for (idx, (stream_id, bytes)) in p.touched_streams.iter().enumerate() {
write_stream_bytes(self.doc, *stream_id, bytes);
containers_modified.push(ContainerInfo {
page: *page,
stream_obj: *stream_id,
kind: ContainerKind::PageStream {
index: p.touched_stream_indices[idx] as u32,
},
});
}
}
let applied_count = active.len();
let mut results = Vec::with_capacity(staged_count);
for (i, staged) in self.staged.iter().enumerate() {
if let Some(e) = failed.get(&i) {
results.push(base_result(
staged,
ReplacementStatus::Failed {
reason: e.to_string(),
},
false,
None,
));
} else {
let info = prepared
.iter()
.flat_map(|(_, p)| p.outcomes.iter())
.find(|o| o.staged_index == i)
.and_then(|o| o.result.as_ref().ok());
results.push(base_result(
staged,
ReplacementStatus::Applied,
info.map(|i| i.font_substituted).unwrap_or(false),
info,
));
}
}
let mut pages_modified: Vec<u32> = prepared
.iter()
.filter(|(_, p)| !p.touched_streams.is_empty())
.map(|(page, _)| *page)
.collect();
pages_modified.sort_unstable();
pages_modified.dedup();
let containers_fused = self
.scans
.values()
.filter(|s| s.fused)
.flat_map(|s| {
s.stream_ids.iter().map(|&id| ContainerInfo {
page: s.page,
stream_obj: id,
kind: ContainerKind::FusedPageStreams,
})
})
.collect();
Ok(TextReplacementReport {
matches_found: staged_count,
replacements_applied: applied_count,
replacements_failed: staged_count - applied_count,
pages_modified,
containers_modified,
containers_fused,
signatures_present,
signatures_invalidated: signatures_present && applied_count > 0,
results,
next_revision: if applied_count > 0 {
self.revision.next()
} else {
self.revision
},
})
}
#[allow(clippy::type_complexity)]
fn prepare_active(
&mut self,
active: &[usize],
) -> Result<(Vec<(u32, PreparedPage)>, HashMap<usize, TextEditError>), TextEditError> {
let mut pages: Vec<u32> = active
.iter()
.map(|&i| self.staged[i].payload.page)
.collect();
pages.sort_unstable();
pages.dedup();
let mut prepared = Vec::new();
let mut failures: HashMap<usize, TextEditError> = HashMap::new();
for &page in &pages {
self.ensure_scan(page)?;
let scan = &self.scans[&page];
let requests: Vec<EditRequest> = active
.iter()
.map(|&i| (i, &self.staged[i]))
.filter(|(_, s)| s.payload.page == page)
.map(|(i, s)| EditRequest {
staged_index: i,
chr: (s.payload.chr[0] as usize, s.payload.chr[1] as usize),
replacement: s.replacement.clone(),
fallback: s.options.font_fallback.clone(),
fit: s.options.fit,
})
.collect();
let p = apply::prepare_page(scan, &requests)?;
for outcome in &p.outcomes {
if let Err(e) = &outcome.result {
let id = &self.staged[outcome.staged_index].id;
failures.insert(outcome.staged_index, clone_error(e).with_match_id(id));
}
}
prepared.push((page, p));
}
Ok((prepared, failures))
}
fn all_or_nothing_failure(
&self,
new_failures: HashMap<usize, TextEditError>,
mut failed: HashMap<usize, TextEditError>,
) -> CommitError {
for (i, e) in new_failures {
failed.insert(i, e);
}
let mut results = Vec::with_capacity(self.staged.len());
for (i, staged) in self.staged.iter().enumerate() {
let status = match failed.get(&i) {
Some(e) => ReplacementStatus::Failed {
reason: e.to_string(),
},
None => ReplacementStatus::Failed {
reason: "aborted: transaction is AllOrNothing and another edit failed"
.to_string(),
},
};
results.push(base_result(staged, status, false, None));
}
let first = failed
.into_iter()
.min_by_key(|(i, _)| *i)
.map(|(_, e)| e)
.expect("non-empty");
CommitError {
error: first,
results,
}
}
fn ensure_scan(&mut self, page: u32) -> Result<(), TextEditError> {
if !self.scans.contains_key(&page) {
let scan = scan::scan_page(self.doc, page)?;
self.scans.insert(page, scan);
}
Ok(())
}
fn empty_report(&self) -> TextReplacementReport {
TextReplacementReport {
matches_found: 0,
replacements_applied: 0,
replacements_failed: 0,
pages_modified: Vec::new(),
containers_modified: Vec::new(),
containers_fused: Vec::new(),
signatures_present: false,
signatures_invalidated: false,
results: Vec::new(),
next_revision: self.revision,
}
}
fn all_failed_results(&self, reason: &str) -> Vec<TextReplacementResult> {
self.staged
.iter()
.map(|s| {
base_result(
s,
ReplacementStatus::Failed {
reason: reason.to_string(),
},
false,
None,
)
})
.collect()
}
}
fn base_result(
staged: &StagedEdit,
status: ReplacementStatus,
font_substituted: bool,
info: Option<&apply::AppliedInfo>,
) -> TextReplacementResult {
TextReplacementResult {
match_id: staged.id.clone(),
status,
old_bbox: staged.snapshot.bbox,
new_bbox: None,
font_used: info
.map(|i| i.font_used.clone())
.unwrap_or_else(|| staged.snapshot.style.font_name.clone()),
font_substituted,
old_font_size: staged.snapshot.style.font_size,
new_font_size: staged.snapshot.style.font_size,
fit_applied: staged.options.fit,
new_line_count: staged.snapshot.spans.len() as u32,
overflow: None,
actual_text_updated: staged.snapshot.actual_text.as_ref().map(|_| false),
tags_affected: false,
diagnostics: info.map(|i| i.diagnostics.clone()).unwrap_or_default(),
}
}
fn clone_error(e: &TextEditError) -> TextEditError {
match e {
TextEditError::EncodingFailed {
match_id,
font,
detail,
} => TextEditError::EncodingFailed {
match_id: match_id.clone(),
font: font.clone(),
detail: detail.clone(),
},
TextEditError::FontFallbackDenied {
match_id,
font,
detail,
} => TextEditError::FontFallbackDenied {
match_id: match_id.clone(),
font: font.clone(),
detail: detail.clone(),
},
other => TextEditError::Internal {
detail: other.to_string(),
},
}
}
enum ScanTarget {
Page,
Xobject(usize),
}
impl ScanTarget {
fn container<'a>(&self, scan: &'a PageScan) -> &'a ContainerScan {
match self {
ScanTarget::Page => &scan.content,
ScanTarget::Xobject(i) => &scan.xobjects[*i].scan,
}
}
}
fn build_match(
revision: &DocumentRevision,
scan: &PageScan,
target: ScanTarget,
range: (usize, usize),
) -> TextMatch {
let container = target.container(scan);
let (s, e) = range;
let text = container.combined[s..e].to_string();
let bounds = &container.run_bounds;
let ri0 = run_index(bounds, s);
let ri1 = run_index(bounds, e.saturating_sub(1).max(s));
let runs = &container.runs[ri0..=ri1];
let mut spans = Vec::with_capacity(runs.len());
for (k, run) in runs.iter().enumerate() {
let run_start = bounds[ri0 + k];
spans.push(MatchSpan {
op_index: run.ops_range.start,
char_start: s.max(run_start) - run_start,
char_end: (e.min(bounds[ri0 + k + 1])) - run_start,
});
}
let first = &runs[0];
let snapshot = container.tracker.state_at(first.ops_range.start);
let (style, transform) = match snapshot {
Some(gs) => (
MatchStyle {
font_name: first.font_name.clone(),
font_size: first.font_size,
fill_color: gs.fill_color,
char_spacing: gs.char_spacing,
word_spacing: gs.word_spacing,
horiz_scaling: gs.horiz_scaling,
text_rise: gs.text_rise,
},
multiply_matrix(&gs.text_matrix, &gs.ctm),
),
None => (
MatchStyle {
font_name: first.font_name.clone(),
font_size: first.font_size,
fill_color: [0.0; 3],
char_spacing: 0.0,
word_spacing: 0.0,
horiz_scaling: 100.0,
text_rise: 0.0,
},
[1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
),
};
let mut bbox = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for run in runs {
bbox[0] = bbox[0].min(run.x);
bbox[1] = bbox[1].min(run.y);
bbox[2] = bbox[2].max(run.x + run.width);
bbox[3] = bbox[3].max(run.y + run.font_size);
}
let mut warnings = vec![Diagnostic {
code: "approximate-bbox".to_string(),
message: "bbox derived from estimated run metrics (exact metrics land in Phase 2)"
.to_string(),
}];
let (container_info, mut unsupported) = match &target {
ScanTarget::Page => {
let (stream_idx, _) = scan.source_of(first.ops_range.start).unwrap_or((0, 0));
let stream_obj = scan.stream_ids.get(stream_idx).copied().unwrap_or((0, 0));
if scan.fused {
(
ContainerInfo {
page: scan.page,
stream_obj,
kind: ContainerKind::FusedPageStreams,
},
Some(UnsupportedReason::Container(
UnsupportedContainer::FusedPageStreams,
)),
)
} else if scan.shared_stream {
(
ContainerInfo {
page: scan.page,
stream_obj,
kind: ContainerKind::PageStream {
index: stream_idx as u32,
},
},
Some(UnsupportedReason::Container(
UnsupportedContainer::SharedPageStream,
)),
)
} else {
(
ContainerInfo {
page: scan.page,
stream_obj,
kind: ContainerKind::PageStream {
index: stream_idx as u32,
},
},
None,
)
}
}
ScanTarget::Xobject(xi) => {
let x = &scan.xobjects[*xi];
(
ContainerInfo {
page: scan.page,
stream_obj: x.stream_id,
kind: ContainerKind::FormXObject {
path: x.name_path.clone(),
shared_by: x.shared_by,
},
},
Some(UnsupportedReason::Container(
UnsupportedContainer::FormXObject,
)),
)
}
};
if unsupported.is_none() {
let mixed = runs.iter().any(|r| {
r.font_name != first.font_name || (r.font_size - first.font_size).abs() > 1e-9
});
if mixed {
unsupported = Some(UnsupportedReason::StyleSpan {
detail: "match spans runs with differing font or size".to_string(),
});
}
}
let actual_text = runs
.iter()
.find_map(|r| container.actual.get(r.ops_range.start).cloned().flatten());
if unsupported.is_none() && actual_text.is_some() {
unsupported = Some(UnsupportedReason::TaggedText);
warnings.push(Diagnostic {
code: "actual-text-present".to_string(),
message: "match is covered by /ActualText; replacement is rejected in Phase 1"
.to_string(),
});
}
let ck = match &target {
ScanTarget::Page => "p".to_string(),
ScanTarget::Xobject(xi) => format!("x:{}", scan.xobjects[*xi].name_path.join("/")),
};
let payload = TokenPayload {
v: 1,
fp: revision.digest_hex(),
ctr: revision.counter(),
page: scan.page,
ck,
chr: [s as u64, e as u64],
sh: token::hash64_hex(text.as_bytes()),
ch: context_hash(&container.combined, (s, e)),
};
let id = MatchId(token::encode_token(&payload));
TextMatch {
editable: unsupported.is_none(),
id,
text,
page: scan.page,
bbox,
spans,
style,
transform,
writing_direction: WritingDirection::Ltr,
container: container_info,
actual_text,
unsupported,
warnings,
}
}
fn run_index(bounds: &[usize], offset: usize) -> usize {
match bounds.binary_search(&offset) {
Ok(i) => i.min(bounds.len().saturating_sub(2)),
Err(i) => i - 1,
}
}
fn context_hash(combined: &str, range: (usize, usize)) -> String {
let mut lo = range.0.saturating_sub(CONTEXT_WINDOW);
while lo > 0 && !combined.is_char_boundary(lo) {
lo -= 1;
}
let mut hi = (range.1 + CONTEXT_WINDOW).min(combined.len());
while hi < combined.len() && !combined.is_char_boundary(hi) {
hi += 1;
}
let mut data = Vec::new();
data.extend_from_slice(&combined.as_bytes()[lo..range.0]);
data.push(0);
data.extend_from_slice(&combined.as_bytes()[range.1..hi]);
token::hash64_hex(&data)
}
fn ranges_overlap(a: [u64; 2], b: [u64; 2]) -> bool {
a[0] < b[1] && b[0] < a[1]
}
fn region_matches(bbox: &[f64; 4], rect: &[f64; 4], relation: RegionRelation) -> bool {
match relation {
RegionRelation::Intersects => {
let w = bbox[2].min(rect[2]) - bbox[0].max(rect[0]);
let h = bbox[3].min(rect[3]) - bbox[1].max(rect[1]);
w > REGION_EPSILON && h > REGION_EPSILON
}
RegionRelation::Contained => {
bbox[0] >= rect[0] - REGION_EPSILON
&& bbox[1] >= rect[1] - REGION_EPSILON
&& bbox[2] <= rect[2] + REGION_EPSILON
&& bbox[3] <= rect[3] + REGION_EPSILON
}
}
}
fn find_all_pattern(
haystack: &str,
pattern: &Pattern,
case_insensitive: bool,
) -> Vec<(usize, usize)> {
match pattern {
Pattern::Literal(needle) => find_all(haystack, needle, case_insensitive),
Pattern::Regex(rx) => rx
.find_iter(haystack)
.map(|m| (m.start(), m.end()))
.collect(),
}
}
fn find_all(haystack: &str, needle: &str, case_insensitive: bool) -> Vec<(usize, usize)> {
if !case_insensitive {
return haystack
.match_indices(needle)
.map(|(s, m)| (s, s + m.len()))
.collect();
}
let mut out = Vec::new();
let needle_chars: Vec<char> = needle.chars().collect();
let mut iter = haystack.char_indices().peekable();
while let Some(&(start, _)) = iter.peek() {
let mut probe = haystack[start..].chars();
let mut end = start;
let mut ok = true;
for &nc in &needle_chars {
match probe.next() {
Some(hc) if chars_eq_fold(hc, nc) => end += hc.len_utf8(),
_ => {
ok = false;
break;
}
}
}
if ok {
out.push((start, end));
while let Some(&(pos, _)) = iter.peek() {
if pos < end {
iter.next();
} else {
break;
}
}
} else {
iter.next();
}
}
out
}
fn chars_eq_fold(a: char, b: char) -> bool {
a == b || a.to_lowercase().eq(b.to_lowercase())
}
fn write_stream_bytes(doc: &mut Document, stream_id: (u32, u16), bytes: &[u8]) {
use std::io::Write;
let compressed = {
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
if encoder.write_all(bytes).is_ok() {
encoder.finish().unwrap_or_else(|_| bytes.to_vec())
} else {
bytes.to_vec()
}
};
let (content, use_flate) = if compressed.len() < bytes.len() {
(compressed, true)
} else {
(bytes.to_vec(), false)
};
if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(stream_id) {
s.content = content;
if use_flate {
s.dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
} else {
s.dict.remove(b"Filter");
}
s.dict
.set("Length", Object::Integer(s.content.len() as i64));
}
}