use crate::error::{ManipError, Result};
use crate::text_edit::{
self, CommitPolicy, DocumentRevision, FontFallback, ReplaceOptions, SignaturePolicy,
TextEditError, TextQuery,
};
use crate::text_run::FontMap;
use lopdf::{Document, Object};
pub fn replace_text(
doc: &mut Document,
page_num: u32,
search: &str,
replacement: &str,
_fonts: &FontMap,
) -> Result<usize> {
let total = doc.get_pages().len();
if page_num == 0 || page_num as usize > total {
return Err(ManipError::PageOutOfRange(page_num as usize, total));
}
legacy_replace(
doc,
TextQuery::exact(search).pages(page_num..=page_num),
replacement,
)
}
pub fn replace_text_all_pages(
doc: &mut Document,
search: &str,
replacement: &str,
) -> Result<usize> {
legacy_replace(doc, TextQuery::exact(search), replacement)
}
fn legacy_replace(doc: &mut Document, query: TextQuery, replacement: &str) -> Result<usize> {
let revision = DocumentRevision::from_source_bytes(&[]);
let options = ReplaceOptions::default()
.font_fallback(FontFallback::InjectStandard)
.signature_policy(SignaturePolicy::AllowPostSignatureChange)
.commit_policy(CommitPolicy::BestEffort);
match text_edit::replace_text(doc, revision, query, replacement, options) {
Ok(report) => Ok(report.replacements_applied),
Err(e) => match e.error {
TextEditError::Document(m) => Err(m),
other => Err(ManipError::Other(format!(
"text replacement failed: {other}"
))),
},
}
}
pub(crate) fn encode_text_for_font(
font_name: &str,
text: &str,
fonts: &FontMap,
) -> Result<Vec<u8>> {
if fonts.is_cid_font(font_name) {
return encode_cid_text(font_name, text, fonts);
}
let reverse = fonts.build_reverse_map(font_name);
if !reverse.is_empty() {
let mut bytes = Vec::with_capacity(text.len());
for ch in text.chars() {
if let Some(&code) = reverse.get(&ch) {
if code <= 0xFF {
bytes.push(code as u8);
} else {
return Err(ManipError::Other(format!(
"character '{}' (U+{:04X}) maps to code {} which exceeds single-byte range in font '{}'",
ch, ch as u32, code, font_name
)));
}
} else {
return Err(ManipError::Other(format!(
"character '{}' (U+{:04X}) not available in font '{}' (subset or custom encoding)",
ch, ch as u32, font_name
)));
}
}
let decoded_back = fonts.decode_string(font_name, &bytes);
if decoded_back != text {
return Err(ManipError::Other(format!(
"encoding round-trip mismatch for font '{}': '{}' re-decodes as '{}'",
font_name, text, decoded_back
)));
}
return Ok(bytes);
}
if fonts.is_subset_font(font_name) {
return Err(ManipError::Other(format!(
"font '{}' is a subset with no known encoding — cannot safely encode replacement text",
font_name
)));
}
if fonts.is_symbolic_font(font_name) {
return Err(ManipError::Other(format!(
"font '{}' is symbolic with no known encoding — Latin-1 fallback unsafe",
font_name
)));
}
let mut bytes = Vec::with_capacity(text.len());
for ch in text.chars() {
let code = ch as u32;
if code <= 0xFF {
bytes.push(code as u8);
} else {
return Err(ManipError::Other(format!(
"character '{}' (U+{:04X}) cannot be encoded in font '{}'",
ch, code, font_name
)));
}
}
Ok(bytes)
}
fn encode_cid_text(font_name: &str, text: &str, fonts: &FontMap) -> Result<Vec<u8>> {
let reverse = fonts.build_reverse_map(font_name);
let mut bytes = Vec::with_capacity(text.len() * 2);
for ch in text.chars() {
if let Some(code) = reverse.get(&ch) {
bytes.push((*code >> 8) as u8);
bytes.push((*code & 0xFF) as u8);
} else {
return Err(ManipError::Other(format!(
"character '{}' (U+{:04X}) not in font '{}' CMap",
ch, ch as u32, font_name
)));
}
}
Ok(bytes)
}
pub(crate) fn encode_latin1(text: &str) -> Result<Vec<u8>> {
let mut bytes = Vec::with_capacity(text.len());
for ch in text.chars() {
let code = ch as u32;
if code <= 0xFF {
bytes.push(code as u8);
} else {
return Err(ManipError::Other(format!(
"character '{}' (U+{:04X}) cannot be encoded as Latin-1",
ch, code
)));
}
}
Ok(bytes)
}
pub(crate) fn inject_fallback_font(doc: &mut Document, page_num: u32) -> Option<String> {
const FALLBACK: &str = "F__Helv";
let pages = doc.get_pages();
let &page_id = pages.get(&page_num)?;
let mut helv_dict = lopdf::Dictionary::new();
helv_dict.set("Type", Object::Name(b"Font".to_vec()));
helv_dict.set("Subtype", Object::Name(b"Type1".to_vec()));
helv_dict.set("BaseFont", Object::Name(b"Helvetica".to_vec()));
helv_dict.set("Encoding", Object::Name(b"WinAnsiEncoding".to_vec()));
let helv_id = doc.add_object(Object::Dictionary(helv_dict));
let resources_entry = doc.get_object(page_id).ok().and_then(|obj| {
if let Object::Dictionary(ref d) = obj {
d.get(b"Resources").ok().cloned()
} else {
None
}
});
let inserted = match resources_entry {
Some(Object::Reference(res_id)) => {
let font_entry = doc.get_object(res_id).ok().and_then(|obj| {
if let Object::Dictionary(ref d) = obj {
d.get(b"Font").ok().cloned()
} else {
None
}
});
match font_entry {
Some(Object::Reference(fd_id)) => {
if let Ok(Object::Dictionary(ref mut fd)) = doc.get_object_mut(fd_id) {
fd.set(FALLBACK, Object::Reference(helv_id));
true
} else {
false
}
}
font_val => {
let mut new_font = match font_val {
Some(Object::Dictionary(fd)) => fd,
_ => lopdf::Dictionary::new(),
};
new_font.set(FALLBACK, Object::Reference(helv_id));
if let Ok(Object::Dictionary(ref mut rd)) = doc.get_object_mut(res_id) {
rd.set("Font", Object::Dictionary(new_font));
true
} else {
false
}
}
}
}
Some(Object::Dictionary(res_dict)) => {
let font_entry = res_dict.get(b"Font").ok().cloned();
match font_entry {
Some(Object::Reference(fd_id)) => {
if let Ok(Object::Dictionary(ref mut fd)) = doc.get_object_mut(fd_id) {
fd.set(FALLBACK, Object::Reference(helv_id));
true
} else {
false
}
}
font_val => {
let mut new_font = match font_val {
Some(Object::Dictionary(fd)) => fd,
_ => lopdf::Dictionary::new(),
};
new_font.set(FALLBACK, Object::Reference(helv_id));
let mut new_res = res_dict;
new_res.set("Font", Object::Dictionary(new_font));
if let Ok(Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
pd.set("Resources", Object::Dictionary(new_res));
true
} else {
false
}
}
}
}
_ => {
let mut new_font = lopdf::Dictionary::new();
new_font.set(FALLBACK, Object::Reference(helv_id));
let mut new_res = lopdf::Dictionary::new();
new_res.set("Font", Object::Dictionary(new_font));
if let Ok(Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
pd.set("Resources", Object::Dictionary(new_res));
true
} else {
false
}
}
};
if inserted {
Some(FALLBACK.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content_editor::editor_for_page;
use crate::text_run::extract_text_runs;
use lopdf::content::{Content, Operation};
use lopdf::{dictionary, Document, Object, Stream};
fn make_doc_with_cid_font(content_bytes: Vec<u8>) -> Document {
let mut doc = Document::with_version("1.7");
let to_unicode_data = b"/CIDInit /ProcSet findresource begin\n\
12 dict begin\n\
begincmap\n\
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
/CMapName /Adobe-Identity-UCS def\n\
/CMapType 2 def\n\
1 begincodespacerange\n\
<0000> <FFFF>\n\
endcodespacerange\n\
1 beginbfrange\n\
<0020> <007E> <0020>\n\
endbfrange\n\
endcmap\n\
CMapName currentdict /CMap defineresource pop\n\
end\n\
end\n";
let to_unicode_stream = Stream::new(dictionary! {}, to_unicode_data.to_vec());
let to_unicode_id = doc.add_object(Object::Stream(to_unicode_stream));
let cid_font = dictionary! {
"Type" => Object::Name(b"Font".to_vec()),
"Subtype" => Object::Name(b"CIDFontType2".to_vec()),
"BaseFont" => Object::Name(b"TestCIDFont".to_vec()),
"CIDSystemInfo" => Object::Dictionary(dictionary! {
"Registry" => Object::String(b"Adobe".to_vec(), lopdf::StringFormat::Literal),
"Ordering" => Object::String(b"Identity".to_vec(), lopdf::StringFormat::Literal),
"Supplement" => Object::Integer(0),
}),
"DW" => Object::Integer(1000),
};
let cid_font_id = doc.add_object(Object::Dictionary(cid_font));
let type0_font = dictionary! {
"Type" => Object::Name(b"Font".to_vec()),
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"TestCIDFont".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
"DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
"ToUnicode" => Object::Reference(to_unicode_id),
};
let type0_font_id = doc.add_object(Object::Dictionary(type0_font));
let font_resources = dictionary! {
"F1" => Object::Reference(type0_font_id),
};
let resources = dictionary! {
"Font" => Object::Dictionary(font_resources),
};
let content_stream = Stream::new(dictionary! {}, content_bytes);
let content_id = doc.add_object(Object::Stream(content_stream));
let page_dict = dictionary! {
"Type" => Object::Name(b"Page".to_vec()),
"MediaBox" => Object::Array(vec![
Object::Integer(0), Object::Integer(0),
Object::Integer(612), Object::Integer(792),
]),
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(resources),
};
let page_id = doc.add_object(Object::Dictionary(page_dict));
let pages_dict = dictionary! {
"Type" => Object::Name(b"Pages".to_vec()),
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
"Count" => Object::Integer(1),
};
let pages_id = doc.add_object(Object::Dictionary(pages_dict));
if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
d.set("Parent", Object::Reference(pages_id));
}
let catalog = dictionary! {
"Type" => Object::Name(b"Catalog".to_vec()),
"Pages" => Object::Reference(pages_id),
};
let catalog_id = doc.add_object(Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference(catalog_id));
doc
}
fn hello_cid_content() -> Vec<u8> {
let cid_bytes = vec![0x00u8, 0x48, 0x00, 0x65, 0x00, 0x6C, 0x00, 0x6C, 0x00, 0x6F];
Content {
operations: vec![
Operation::new("BT", vec![]),
Operation::new("Tf", vec![Object::Name(b"F1".to_vec()), Object::Real(12.0)]),
Operation::new("Td", vec![Object::Real(100.0), Object::Real(700.0)]),
Operation::new(
"Tj",
vec![Object::String(cid_bytes, lopdf::StringFormat::Hexadecimal)],
),
Operation::new("ET", vec![]),
],
}
.encode()
.unwrap()
}
fn make_doc_with_text(content: &[u8]) -> Document {
let mut doc = Document::with_version("1.7");
let font = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
};
let font_id = doc.add_object(Object::Dictionary(font));
let font_resources = dictionary! {
"F1" => Object::Reference(font_id),
};
let resources = dictionary! {
"Font" => Object::Dictionary(font_resources),
};
let content_stream = Stream::new(dictionary! {}, content.to_vec());
let content_id = doc.add_object(Object::Stream(content_stream));
let page_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(resources),
};
let page_id = doc.add_object(Object::Dictionary(page_dict));
let pages_dict = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages_dict));
if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
d.set("Parent", Object::Reference(pages_id));
}
let catalog = dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
let catalog_id = doc.add_object(Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference(catalog_id));
doc
}
#[test]
fn replace_simple_same_length() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
assert_eq!(count, 1);
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "Hallo World");
}
#[test]
fn replace_different_length() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "World", "Earth!", &fonts).unwrap();
assert_eq!(count, 1);
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "Hello Earth!");
}
#[test]
fn replace_no_match() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello) Tj ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "Missing", "Replacement", &fonts).unwrap();
assert_eq!(count, 0);
}
#[test]
fn replace_in_tj_array() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td [(Hel) -100 (lo)] TJ ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
assert_eq!(count, 1);
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts);
assert_eq!(runs[0].text, "Hallo");
}
#[test]
fn replace_error_on_unencodable_char() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello) Tj ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let result = replace_text(&mut doc, 1, "Hello", "\u{4e16}\u{754c}", &fonts);
assert_eq!(result.unwrap(), 0);
}
#[test]
fn replace_all_pages() {
let mut doc = Document::with_version("1.7");
let font = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
};
let font_id = doc.add_object(Object::Dictionary(font));
let font_resources = dictionary! {
"F1" => Object::Reference(font_id),
};
let resources = dictionary! {
"Font" => Object::Dictionary(font_resources),
};
let mut page_ids = Vec::new();
for _ in 0..2 {
let content = b"BT /F1 12 Tf (Hello) Tj ET";
let content_stream = Stream::new(dictionary! {}, content.to_vec());
let content_id = doc.add_object(Object::Stream(content_stream));
let page_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(resources.clone()),
};
let page_id = doc.add_object(Object::Dictionary(page_dict));
page_ids.push(page_id);
}
let pages_dict = dictionary! {
"Type" => "Pages",
"Kids" => page_ids.iter().map(|id| Object::Reference(*id)).collect::<Vec<_>>(),
"Count" => page_ids.len() as i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages_dict));
for &page_id in &page_ids {
if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
d.set("Parent", Object::Reference(pages_id));
}
}
let catalog = dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
let catalog_id = doc.add_object(Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference(catalog_id));
let count = replace_text_all_pages(&mut doc, "Hello", "Hallo").unwrap();
assert_eq!(count, 2);
}
#[test]
fn replace_cross_run_split_tj() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Jan) Tj (u) Tj (ary) Tj ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "January", "Februar", &fonts).unwrap();
assert_eq!(count, 1);
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts);
let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
assert!(
combined.contains("Februar"),
"expected 'Februar' in '{combined}'"
);
}
#[test]
fn replace_cross_run_with_positioning() {
let mut doc =
make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hel) Tj 0.5 0 Td (lo World) Tj ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
assert_eq!(count, 1);
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts);
let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
assert!(
combined.contains("Hallo"),
"expected 'Hallo' in '{combined}'"
);
}
#[test]
fn replace_cross_run_single_run_takes_priority() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
assert_eq!(count, 1);
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts);
assert_eq!(runs[0].text, "Hallo World");
}
#[test]
fn replace_cross_run_multiline_preserves_other_lines() {
let mut doc = make_doc_with_text(
b"BT /F1 12 Tf 72 700 Td (First line here.) Tj 0 -14 Td (Some words to ) Tj (edit now.) Tj 0 -14 Td (Third line stays.) Tj ET",
);
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(
&mut doc,
1,
"Some words to edit now.",
"Some words to edit.",
&fonts,
)
.unwrap();
assert_eq!(count, 1);
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts);
assert_eq!(
runs.first().map(|r| r.text.as_str()),
Some("First line here."),
"first line must keep only its own text"
);
assert_eq!(
runs.last().map(|r| r.text.as_str()),
Some("Third line stays."),
"third line must not be emptied"
);
let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(
combined,
"First line here.Some words to edit.Third line stays."
);
let first_y = runs.first().unwrap().y;
let last_y = runs.last().unwrap().y;
assert!(
(first_y - last_y).abs() > 20.0,
"lines collapsed onto one baseline: first_y={first_y} last_y={last_y}"
);
}
#[test]
fn replace_cross_run_keeps_prefix_and_suffix_in_their_runs() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 72 700 Td (AAA xx) Tj (yy BBB) Tj ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "xxyy", "Q", &fonts).unwrap();
assert_eq!(count, 1);
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts);
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].text, "AAA Q");
assert_eq!(runs[1].text, " BBB");
}
#[test]
fn replace_cross_run_multiple_occurrences_in_group() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 72 700 Td (ab) Tj (cab) Tj (c) Tj ET");
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "abc", "X", &fonts).unwrap();
assert_eq!(count, 2);
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts);
let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(combined, "XX");
}
#[test]
fn replace_cid_font_chars_in_cmap() {
let mut doc = make_doc_with_cid_font(hello_cid_content());
let fonts = FontMap::from_page(&doc, 1).unwrap();
assert!(fonts.is_cid_font("F1"), "F1 must be recognized as CID font");
let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
assert_eq!(count, 1);
let fonts_after = FontMap::from_page(&doc, 1).unwrap();
let editor = editor_for_page(&doc, 1).unwrap();
let runs = extract_text_runs(&editor, &fonts_after);
let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
assert!(
combined.contains("Hallo"),
"expected 'Hallo' in '{combined}'"
);
assert!(
!combined.contains("Hello"),
"old text 'Hello' should be gone"
);
}
#[test]
fn replace_cid_font_no_match() {
let mut doc = make_doc_with_cid_font(hello_cid_content());
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "World", "Earth", &fonts).unwrap();
assert_eq!(count, 0);
}
#[test]
fn replace_cid_font_fallback_chars_not_in_cmap() {
let mut doc = make_doc_with_cid_font(hello_cid_content());
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "Hello", "Hal\u{3053}", &fonts).unwrap();
assert_eq!(count, 0);
}
#[test]
fn replace_cid_font_latin_fallback() {
let mut doc = make_doc_with_cid_font(hello_cid_content());
let fonts = FontMap::from_page(&doc, 1).unwrap();
let count = replace_text(&mut doc, 1, "Hello", "Hêllo", &fonts).unwrap();
assert_eq!(count, 1);
}
}