Skip to main content

pdf_manip/
text_replace.rs

1//! Text replacement in PDF content streams (legacy convenience API).
2//!
3//! Since Phase 1C these functions are thin wrappers over the
4//! [`crate::text_edit`] engine (see `docs/TEXT_REPLACE_ENGINE_DESIGN.md` §9).
5//! They keep the historical count-only contract: per-occurrence failures are
6//! not errors, they simply do not count. Callers that need per-edit
7//! diagnostics, single-occurrence selection, signature protection or stream
8//! preservation guarantees should use [`crate::text_edit`] directly.
9//!
10//! Behaviour changes relative to the pre-1C implementation (deliberate,
11//! each covered by a flipped characterization test):
12//! - multiple `/Contents` streams are preserved instead of collapsed;
13//! - text covered by `/ActualText` is left untouched instead of desynced;
14//! - cross-run matches whose replacement needs a fallback font now succeed;
15//! - TJ kerning outside the edited region is preserved;
16//! - encrypted documents whose permissions forbid modification are refused.
17
18// Copyright (c) 2026 Innovation Trigger B.V.
19//
20// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
21// the PDFluent Commercial Licence. See the LICENSE file in this repository --
22// that file travels with the copy you received, which a URL does not.
23
24use crate::error::{ManipError, Result};
25use crate::text_edit::{
26    self, CommitPolicy, DocumentRevision, FontFallback, ReplaceOptions, SignaturePolicy,
27    TextEditError, TextQuery,
28};
29use crate::text_run::FontMap;
30use lopdf::{Document, Object};
31
32/// Replace all occurrences of `search` with `replacement` in a page's content.
33///
34/// Returns the number of occurrences replaced. Occurrences that cannot be
35/// replaced (unencodable characters, unsupported containers such as Form
36/// XObjects, `/ActualText`-covered spans) do not count and are not errors.
37/// The replacement is encoded in the original font when possible; otherwise a
38/// Helvetica/WinAnsiEncoding fallback font is injected and reported through
39/// the [`crate::text_edit`] API (this wrapper only returns the count).
40///
41/// The `fonts` parameter is retained for signature compatibility; the engine
42/// builds its own font map.
43pub fn replace_text(
44    doc: &mut Document,
45    page_num: u32,
46    search: &str,
47    replacement: &str,
48    _fonts: &FontMap,
49) -> Result<usize> {
50    let total = doc.get_pages().len();
51    if page_num == 0 || page_num as usize > total {
52        return Err(ManipError::PageOutOfRange(page_num as usize, total));
53    }
54    legacy_replace(
55        doc,
56        TextQuery::exact(search).pages(page_num..=page_num),
57        replacement,
58    )
59}
60
61/// Replace text across all pages in a document.
62///
63/// Same contract as [`replace_text`]: returns the total count; occurrences
64/// that cannot be replaced simply do not count.
65pub fn replace_text_all_pages(
66    doc: &mut Document,
67    search: &str,
68    replacement: &str,
69) -> Result<usize> {
70    legacy_replace(doc, TextQuery::exact(search), replacement)
71}
72
73fn legacy_replace(doc: &mut Document, query: TextQuery, replacement: &str) -> Result<usize> {
74    // Match tokens never leave this call, so an internal-only revision
75    // suffices; persistence workflows go through `text_edit` directly.
76    let revision = DocumentRevision::from_source_bytes(&[]);
77    let options = ReplaceOptions::default()
78        .font_fallback(FontFallback::InjectStandard)
79        .signature_policy(SignaturePolicy::AllowPostSignatureChange)
80        .commit_policy(CommitPolicy::BestEffort);
81    match text_edit::replace_text(doc, revision, query, replacement, options) {
82        Ok(report) => Ok(report.replacements_applied),
83        Err(e) => match e.error {
84            TextEditError::Document(m) => Err(m),
85            other => Err(ManipError::Other(format!(
86                "text replacement failed: {other}"
87            ))),
88        },
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Text encoding
94// ---------------------------------------------------------------------------
95
96/// Encode Unicode text back to PDF string bytes for the given font.
97///
98/// Priority:
99/// 1. ToUnicode CMap reverse map (authoritative).
100/// 2. Differences/Encoding-based reverse map (for fonts without ToUnicode
101///    but with explicit Encoding in the PDF dict, e.g. WinAnsiEncoding).
102/// 3. Latin-1 fallback for full (non-subset) fonts only.  Subset fonts
103///    (BaseFont prefix like "ABCDEF+") have unknown glyph inventories,
104///    so we return an error to avoid silently writing unrenderable bytes.
105pub(crate) fn encode_text_for_font(
106    font_name: &str,
107    text: &str,
108    fonts: &FontMap,
109) -> Result<Vec<u8>> {
110    if fonts.is_cid_font(font_name) {
111        return encode_cid_text(font_name, text, fonts);
112    }
113
114    // Reverse map covers both ToUnicode CMap entries and Differences-based
115    // encoding entries (built in FontMap::build_reverse_map).
116    let reverse = fonts.build_reverse_map(font_name);
117    if !reverse.is_empty() {
118        let mut bytes = Vec::with_capacity(text.len());
119        for ch in text.chars() {
120            if let Some(&code) = reverse.get(&ch) {
121                if code <= 0xFF {
122                    bytes.push(code as u8);
123                } else {
124                    return Err(ManipError::Other(format!(
125                        "character '{}' (U+{:04X}) maps to code {} which exceeds single-byte range in font '{}'",
126                        ch, ch as u32, code, font_name
127                    )));
128                }
129            } else {
130                return Err(ManipError::Other(format!(
131                    "character '{}' (U+{:04X}) not available in font '{}' (subset or custom encoding)",
132                    ch, ch as u32, font_name
133                )));
134            }
135        }
136        // Round-trip verification: decode back and confirm we get the original text.
137        // Catches ToUnicode CMaps where the reverse map maps a char to a code that
138        // the CMap decodes to a different character (e.g. partial CMap with overlap).
139        // Fixes #468.
140        let decoded_back = fonts.decode_string(font_name, &bytes);
141        if decoded_back != text {
142            return Err(ManipError::Other(format!(
143                "encoding round-trip mismatch for font '{}': '{}' re-decodes as '{}'",
144                font_name, text, decoded_back
145            )));
146        }
147        return Ok(bytes);
148    }
149
150    // No reverse map available (font has neither ToUnicode nor an explicit
151    // Encoding dict).  Refuse for two classes of fonts where Latin-1 is unsafe:
152    //
153    // 1. Subset fonts ("ABCDEF+" prefix): only the glyphs used in the source
154    //    document are embedded; writing arbitrary bytes may produce unrenderable
155    //    glyphs and a FAIL on round-trip verification.
156    //
157    // 2. Symbolic fonts (FontDescriptor.Flags bit 3): these use the font's
158    //    built-in encoding, which may differ arbitrarily from StandardEncoding
159    //    (the PDF default for fonts without an Encoding entry).  A Latin-1
160    //    encoding of, e.g., '_' as 0x5F would fail round-trip verification if
161    //    the font maps 0x5F to a different glyph. Fixes #455.
162    if fonts.is_subset_font(font_name) {
163        return Err(ManipError::Other(format!(
164            "font '{}' is a subset with no known encoding — cannot safely encode replacement text",
165            font_name
166        )));
167    }
168    if fonts.is_symbolic_font(font_name) {
169        return Err(ManipError::Other(format!(
170            "font '{}' is symbolic with no known encoding — Latin-1 fallback unsafe",
171            font_name
172        )));
173    }
174
175    // Latin-1 fallback for full fonts without explicit Encoding.
176    let mut bytes = Vec::with_capacity(text.len());
177    for ch in text.chars() {
178        let code = ch as u32;
179        if code <= 0xFF {
180            bytes.push(code as u8);
181        } else {
182            return Err(ManipError::Other(format!(
183                "character '{}' (U+{:04X}) cannot be encoded in font '{}'",
184                ch, code, font_name
185            )));
186        }
187    }
188    Ok(bytes)
189}
190
191fn encode_cid_text(font_name: &str, text: &str, fonts: &FontMap) -> Result<Vec<u8>> {
192    // For CID fonts with Identity-H encoding, try to build reverse map
193    // from the ToUnicode CMap. If no reverse map is available, attempt
194    // direct Unicode → CID mapping.
195    let reverse = fonts.build_reverse_map(font_name);
196    let mut bytes = Vec::with_capacity(text.len() * 2);
197
198    for ch in text.chars() {
199        if let Some(code) = reverse.get(&ch) {
200            bytes.push((*code >> 8) as u8);
201            bytes.push((*code & 0xFF) as u8);
202        } else {
203            return Err(ManipError::Other(format!(
204                "character '{}' (U+{:04X}) not in font '{}' CMap",
205                ch, ch as u32, font_name
206            )));
207        }
208    }
209
210    Ok(bytes)
211}
212
213// ---------------------------------------------------------------------------
214// Font fallback for replacement encoding (Fixes #466 bugs 1–4)
215// ---------------------------------------------------------------------------
216
217/// Encode text as Latin-1 (ISO 8859-1) bytes.
218pub(crate) fn encode_latin1(text: &str) -> Result<Vec<u8>> {
219    let mut bytes = Vec::with_capacity(text.len());
220    for ch in text.chars() {
221        let code = ch as u32;
222        if code <= 0xFF {
223            bytes.push(code as u8);
224        } else {
225            return Err(ManipError::Other(format!(
226                "character '{}' (U+{:04X}) cannot be encoded as Latin-1",
227                ch, code
228            )));
229        }
230    }
231    Ok(bytes)
232}
233
234/// Inject a Helvetica/WinAnsiEncoding font resource named `"F__Helv"` into
235/// the page's Resources/Font dictionary, creating sub-dictionaries as needed.
236///
237/// Returns `None` if the injection could not be confirmed (e.g. Resources dict
238/// is not writable), so callers can avoid referencing an unknown font.
239pub(crate) fn inject_fallback_font(doc: &mut Document, page_num: u32) -> Option<String> {
240    const FALLBACK: &str = "F__Helv";
241
242    let pages = doc.get_pages();
243    let &page_id = pages.get(&page_num)?;
244
245    // Add the Helvetica font object first (no borrow of doc held after this).
246    let mut helv_dict = lopdf::Dictionary::new();
247    helv_dict.set("Type", Object::Name(b"Font".to_vec()));
248    helv_dict.set("Subtype", Object::Name(b"Type1".to_vec()));
249    helv_dict.set("BaseFont", Object::Name(b"Helvetica".to_vec()));
250    helv_dict.set("Encoding", Object::Name(b"WinAnsiEncoding".to_vec()));
251    let helv_id = doc.add_object(Object::Dictionary(helv_dict));
252
253    // Read the current Resources entry before any mutation.
254    let resources_entry = doc.get_object(page_id).ok().and_then(|obj| {
255        if let Object::Dictionary(ref d) = obj {
256            d.get(b"Resources").ok().cloned()
257        } else {
258            None
259        }
260    });
261
262    // Track whether F__Helv was actually inserted into the Resources/Font dict.
263    // If insertion fails (e.g. because the target object isn't a mutable dict),
264    // we return None so callers don't emit Tf operators for an unknown font.
265    // Fixes: fallback injection was silently failing on some PDFs, leaving the
266    // content stream referencing F__Helv that wasn't in Resources — causing
267    // PDFium to fail text extraction ("cannot extract text after roundtrip").
268    let inserted = match resources_entry {
269        Some(Object::Reference(res_id)) => {
270            let font_entry = doc.get_object(res_id).ok().and_then(|obj| {
271                if let Object::Dictionary(ref d) = obj {
272                    d.get(b"Font").ok().cloned()
273                } else {
274                    None
275                }
276            });
277            match font_entry {
278                Some(Object::Reference(fd_id)) => {
279                    if let Ok(Object::Dictionary(ref mut fd)) = doc.get_object_mut(fd_id) {
280                        fd.set(FALLBACK, Object::Reference(helv_id));
281                        true
282                    } else {
283                        false
284                    }
285                }
286                font_val => {
287                    let mut new_font = match font_val {
288                        Some(Object::Dictionary(fd)) => fd,
289                        _ => lopdf::Dictionary::new(),
290                    };
291                    new_font.set(FALLBACK, Object::Reference(helv_id));
292                    if let Ok(Object::Dictionary(ref mut rd)) = doc.get_object_mut(res_id) {
293                        rd.set("Font", Object::Dictionary(new_font));
294                        true
295                    } else {
296                        false
297                    }
298                }
299            }
300        }
301        Some(Object::Dictionary(res_dict)) => {
302            let font_entry = res_dict.get(b"Font").ok().cloned();
303            match font_entry {
304                Some(Object::Reference(fd_id)) => {
305                    if let Ok(Object::Dictionary(ref mut fd)) = doc.get_object_mut(fd_id) {
306                        fd.set(FALLBACK, Object::Reference(helv_id));
307                        true
308                    } else {
309                        false
310                    }
311                }
312                font_val => {
313                    let mut new_font = match font_val {
314                        Some(Object::Dictionary(fd)) => fd,
315                        _ => lopdf::Dictionary::new(),
316                    };
317                    new_font.set(FALLBACK, Object::Reference(helv_id));
318                    let mut new_res = res_dict;
319                    new_res.set("Font", Object::Dictionary(new_font));
320                    if let Ok(Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
321                        pd.set("Resources", Object::Dictionary(new_res));
322                        true
323                    } else {
324                        false
325                    }
326                }
327            }
328        }
329        _ => {
330            // Page has no direct Resources — create a minimal dict with only
331            // F__Helv. Note: any fonts from an inherited parent Resources are
332            // not copied here, but since get_page_font_dict also doesn't walk
333            // the parent chain, the FontMap would be empty for this page and
334            // no replacement would have been attempted in the first place.
335            let mut new_font = lopdf::Dictionary::new();
336            new_font.set(FALLBACK, Object::Reference(helv_id));
337            let mut new_res = lopdf::Dictionary::new();
338            new_res.set("Font", Object::Dictionary(new_font));
339            if let Ok(Object::Dictionary(ref mut pd)) = doc.get_object_mut(page_id) {
340                pd.set("Resources", Object::Dictionary(new_res));
341                true
342            } else {
343                false
344            }
345        }
346    };
347
348    if inserted {
349        Some(FALLBACK.to_string())
350    } else {
351        None
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::content_editor::editor_for_page;
359    use crate::text_run::extract_text_runs;
360    use lopdf::content::{Content, Operation};
361    use lopdf::{dictionary, Document, Object, Stream};
362
363    /// Build a minimal document with a Type0 (CID/Identity-H) font whose
364    /// ToUnicode CMap covers the printable ASCII range (0x0020–0x007E).
365    /// The character codes equal the Unicode codepoints, so e.g. 'H' is
366    /// encoded as the 2-byte CID code [0x00, 0x48].
367    fn make_doc_with_cid_font(content_bytes: Vec<u8>) -> Document {
368        let mut doc = Document::with_version("1.7");
369
370        // ToUnicode CMap: maps CID = Unicode for printable ASCII.
371        let to_unicode_data = b"/CIDInit /ProcSet findresource begin\n\
372            12 dict begin\n\
373            begincmap\n\
374            /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
375            /CMapName /Adobe-Identity-UCS def\n\
376            /CMapType 2 def\n\
377            1 begincodespacerange\n\
378            <0000> <FFFF>\n\
379            endcodespacerange\n\
380            1 beginbfrange\n\
381            <0020> <007E> <0020>\n\
382            endbfrange\n\
383            endcmap\n\
384            CMapName currentdict /CMap defineresource pop\n\
385            end\n\
386            end\n";
387        let to_unicode_stream = Stream::new(dictionary! {}, to_unicode_data.to_vec());
388        let to_unicode_id = doc.add_object(Object::Stream(to_unicode_stream));
389
390        let cid_font = dictionary! {
391            "Type" => Object::Name(b"Font".to_vec()),
392            "Subtype" => Object::Name(b"CIDFontType2".to_vec()),
393            "BaseFont" => Object::Name(b"TestCIDFont".to_vec()),
394            "CIDSystemInfo" => Object::Dictionary(dictionary! {
395                "Registry" => Object::String(b"Adobe".to_vec(), lopdf::StringFormat::Literal),
396                "Ordering" => Object::String(b"Identity".to_vec(), lopdf::StringFormat::Literal),
397                "Supplement" => Object::Integer(0),
398            }),
399            "DW" => Object::Integer(1000),
400        };
401        let cid_font_id = doc.add_object(Object::Dictionary(cid_font));
402
403        let type0_font = dictionary! {
404            "Type" => Object::Name(b"Font".to_vec()),
405            "Subtype" => Object::Name(b"Type0".to_vec()),
406            "BaseFont" => Object::Name(b"TestCIDFont".to_vec()),
407            "Encoding" => Object::Name(b"Identity-H".to_vec()),
408            "DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
409            "ToUnicode" => Object::Reference(to_unicode_id),
410        };
411        let type0_font_id = doc.add_object(Object::Dictionary(type0_font));
412
413        let font_resources = dictionary! {
414            "F1" => Object::Reference(type0_font_id),
415        };
416        let resources = dictionary! {
417            "Font" => Object::Dictionary(font_resources),
418        };
419
420        let content_stream = Stream::new(dictionary! {}, content_bytes);
421        let content_id = doc.add_object(Object::Stream(content_stream));
422
423        let page_dict = dictionary! {
424            "Type" => Object::Name(b"Page".to_vec()),
425            "MediaBox" => Object::Array(vec![
426                Object::Integer(0), Object::Integer(0),
427                Object::Integer(612), Object::Integer(792),
428            ]),
429            "Contents" => Object::Reference(content_id),
430            "Resources" => Object::Dictionary(resources),
431        };
432        let page_id = doc.add_object(Object::Dictionary(page_dict));
433
434        let pages_dict = dictionary! {
435            "Type" => Object::Name(b"Pages".to_vec()),
436            "Kids" => Object::Array(vec![Object::Reference(page_id)]),
437            "Count" => Object::Integer(1),
438        };
439        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
440
441        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
442            d.set("Parent", Object::Reference(pages_id));
443        }
444
445        let catalog = dictionary! {
446            "Type" => Object::Name(b"Catalog".to_vec()),
447            "Pages" => Object::Reference(pages_id),
448        };
449        let catalog_id = doc.add_object(Object::Dictionary(catalog));
450        doc.trailer.set("Root", Object::Reference(catalog_id));
451
452        doc
453    }
454
455    /// Encode "Hello" as Identity-H CID bytes: each char's code = Unicode codepoint.
456    fn hello_cid_content() -> Vec<u8> {
457        // CID codes: H=0x0048, e=0x0065, l=0x006C, l=0x006C, o=0x006F
458        let cid_bytes = vec![0x00u8, 0x48, 0x00, 0x65, 0x00, 0x6C, 0x00, 0x6C, 0x00, 0x6F];
459        Content {
460            operations: vec![
461                Operation::new("BT", vec![]),
462                Operation::new("Tf", vec![Object::Name(b"F1".to_vec()), Object::Real(12.0)]),
463                Operation::new("Td", vec![Object::Real(100.0), Object::Real(700.0)]),
464                Operation::new(
465                    "Tj",
466                    vec![Object::String(cid_bytes, lopdf::StringFormat::Hexadecimal)],
467                ),
468                Operation::new("ET", vec![]),
469            ],
470        }
471        .encode()
472        .unwrap()
473    }
474
475    fn make_doc_with_text(content: &[u8]) -> Document {
476        let mut doc = Document::with_version("1.7");
477
478        let font = dictionary! {
479            "Type" => "Font",
480            "Subtype" => "Type1",
481            "BaseFont" => "Helvetica",
482        };
483        let font_id = doc.add_object(Object::Dictionary(font));
484
485        let font_resources = dictionary! {
486            "F1" => Object::Reference(font_id),
487        };
488        let resources = dictionary! {
489            "Font" => Object::Dictionary(font_resources),
490        };
491
492        let content_stream = Stream::new(dictionary! {}, content.to_vec());
493        let content_id = doc.add_object(Object::Stream(content_stream));
494
495        let page_dict = dictionary! {
496            "Type" => "Page",
497            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
498            "Contents" => Object::Reference(content_id),
499            "Resources" => Object::Dictionary(resources),
500        };
501        let page_id = doc.add_object(Object::Dictionary(page_dict));
502
503        let pages_dict = dictionary! {
504            "Type" => "Pages",
505            "Kids" => vec![Object::Reference(page_id)],
506            "Count" => 1_i64,
507        };
508        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
509
510        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
511            d.set("Parent", Object::Reference(pages_id));
512        }
513
514        let catalog = dictionary! {
515            "Type" => "Catalog",
516            "Pages" => Object::Reference(pages_id),
517        };
518        let catalog_id = doc.add_object(Object::Dictionary(catalog));
519        doc.trailer.set("Root", Object::Reference(catalog_id));
520
521        doc
522    }
523
524    #[test]
525    fn replace_simple_same_length() {
526        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET");
527        let fonts = FontMap::from_page(&doc, 1).unwrap();
528        let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
529        assert_eq!(count, 1);
530
531        // Verify the replacement.
532        let editor = editor_for_page(&doc, 1).unwrap();
533        let runs = extract_text_runs(&editor, &fonts);
534        assert_eq!(runs.len(), 1);
535        assert_eq!(runs[0].text, "Hallo World");
536    }
537
538    #[test]
539    fn replace_different_length() {
540        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET");
541        let fonts = FontMap::from_page(&doc, 1).unwrap();
542        let count = replace_text(&mut doc, 1, "World", "Earth!", &fonts).unwrap();
543        assert_eq!(count, 1);
544
545        let editor = editor_for_page(&doc, 1).unwrap();
546        let runs = extract_text_runs(&editor, &fonts);
547        assert_eq!(runs.len(), 1);
548        assert_eq!(runs[0].text, "Hello Earth!");
549    }
550
551    #[test]
552    fn replace_no_match() {
553        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello) Tj ET");
554        let fonts = FontMap::from_page(&doc, 1).unwrap();
555        let count = replace_text(&mut doc, 1, "Missing", "Replacement", &fonts).unwrap();
556        assert_eq!(count, 0);
557    }
558
559    #[test]
560    fn replace_in_tj_array() {
561        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td [(Hel) -100 (lo)] TJ ET");
562        let fonts = FontMap::from_page(&doc, 1).unwrap();
563        let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
564        assert_eq!(count, 1);
565
566        let editor = editor_for_page(&doc, 1).unwrap();
567        let runs = extract_text_runs(&editor, &fonts);
568        assert_eq!(runs[0].text, "Hallo");
569    }
570
571    #[test]
572    fn replace_error_on_unencodable_char() {
573        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello) Tj ET");
574        let fonts = FontMap::from_page(&doc, 1).unwrap();
575        // Characters outside Latin-1 cannot be encoded by the original font or
576        // the Latin-1 fallback, so the replacement is silently skipped (Ok(0)).
577        let result = replace_text(&mut doc, 1, "Hello", "\u{4e16}\u{754c}", &fonts);
578        assert_eq!(result.unwrap(), 0);
579    }
580
581    #[test]
582    fn replace_all_pages() {
583        let mut doc = Document::with_version("1.7");
584
585        let font = dictionary! {
586            "Type" => "Font",
587            "Subtype" => "Type1",
588            "BaseFont" => "Helvetica",
589        };
590        let font_id = doc.add_object(Object::Dictionary(font));
591
592        let font_resources = dictionary! {
593            "F1" => Object::Reference(font_id),
594        };
595        let resources = dictionary! {
596            "Font" => Object::Dictionary(font_resources),
597        };
598
599        // Two pages with same text.
600        let mut page_ids = Vec::new();
601        for _ in 0..2 {
602            let content = b"BT /F1 12 Tf (Hello) Tj ET";
603            let content_stream = Stream::new(dictionary! {}, content.to_vec());
604            let content_id = doc.add_object(Object::Stream(content_stream));
605
606            let page_dict = dictionary! {
607                "Type" => "Page",
608                "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
609                "Contents" => Object::Reference(content_id),
610                "Resources" => Object::Dictionary(resources.clone()),
611            };
612            let page_id = doc.add_object(Object::Dictionary(page_dict));
613            page_ids.push(page_id);
614        }
615
616        let pages_dict = dictionary! {
617            "Type" => "Pages",
618            "Kids" => page_ids.iter().map(|id| Object::Reference(*id)).collect::<Vec<_>>(),
619            "Count" => page_ids.len() as i64,
620        };
621        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
622
623        for &page_id in &page_ids {
624            if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
625                d.set("Parent", Object::Reference(pages_id));
626            }
627        }
628
629        let catalog = dictionary! {
630            "Type" => "Catalog",
631            "Pages" => Object::Reference(pages_id),
632        };
633        let catalog_id = doc.add_object(Object::Dictionary(catalog));
634        doc.trailer.set("Root", Object::Reference(catalog_id));
635
636        let count = replace_text_all_pages(&mut doc, "Hello", "Hallo").unwrap();
637        assert_eq!(count, 2);
638    }
639
640    #[test]
641    fn replace_cross_run_split_tj() {
642        // Text "January" split across three Tj operators.
643        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Jan) Tj (u) Tj (ary) Tj ET");
644        let fonts = FontMap::from_page(&doc, 1).unwrap();
645        let count = replace_text(&mut doc, 1, "January", "Februar", &fonts).unwrap();
646        assert_eq!(count, 1);
647
648        // Verify: the combined text should now contain "Februar".
649        let editor = editor_for_page(&doc, 1).unwrap();
650        let runs = extract_text_runs(&editor, &fonts);
651        let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
652        assert!(
653            combined.contains("Februar"),
654            "expected 'Februar' in '{combined}'"
655        );
656    }
657
658    #[test]
659    fn replace_cross_run_with_positioning() {
660        // Text "Hello" split across two Tj ops with a Td positioning op in between.
661        let mut doc =
662            make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hel) Tj 0.5 0 Td (lo World) Tj ET");
663        let fonts = FontMap::from_page(&doc, 1).unwrap();
664        let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
665        assert_eq!(count, 1);
666
667        let editor = editor_for_page(&doc, 1).unwrap();
668        let runs = extract_text_runs(&editor, &fonts);
669        let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
670        assert!(
671            combined.contains("Hallo"),
672            "expected 'Hallo' in '{combined}'"
673        );
674    }
675
676    #[test]
677    fn replace_cross_run_single_run_takes_priority() {
678        // When the match is within a single run, use the fast path.
679        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET");
680        let fonts = FontMap::from_page(&doc, 1).unwrap();
681        let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
682        assert_eq!(count, 1);
683
684        let editor = editor_for_page(&doc, 1).unwrap();
685        let runs = extract_text_runs(&editor, &fonts);
686        assert_eq!(runs[0].text, "Hallo World");
687    }
688
689    #[test]
690    fn replace_cross_run_multiline_preserves_other_lines() {
691        // Regression (PDFluent multiline collapse): a text block of three lines,
692        // each positioned with its own Td, all in the same font. The middle line
693        // is split across two Tj operators, so editing it must go through the
694        // cross-run path. Runs of the OTHER lines must keep their own text —
695        // the rewrite must not concatenate the whole same-font group into the
696        // first run (which draws the entire block on line 1, off-screen).
697        let mut doc = make_doc_with_text(
698            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",
699        );
700        let fonts = FontMap::from_page(&doc, 1).unwrap();
701        let count = replace_text(
702            &mut doc,
703            1,
704            "Some words to edit now.",
705            "Some words to edit.",
706            &fonts,
707        )
708        .unwrap();
709        assert_eq!(count, 1);
710
711        let editor = editor_for_page(&doc, 1).unwrap();
712        let runs = extract_text_runs(&editor, &fonts);
713
714        // Untouched lines keep their own operators and text.
715        assert_eq!(
716            runs.first().map(|r| r.text.as_str()),
717            Some("First line here."),
718            "first line must keep only its own text"
719        );
720        assert_eq!(
721            runs.last().map(|r| r.text.as_str()),
722            Some("Third line stays."),
723            "third line must not be emptied"
724        );
725
726        // The edited line carries the replacement; nothing else changed.
727        let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
728        assert_eq!(
729            combined,
730            "First line here.Some words to edit.Third line stays."
731        );
732
733        // Lines stay on distinct baselines (positioning ops untouched).
734        let first_y = runs.first().unwrap().y;
735        let last_y = runs.last().unwrap().y;
736        assert!(
737            (first_y - last_y).abs() > 20.0,
738            "lines collapsed onto one baseline: first_y={first_y} last_y={last_y}"
739        );
740    }
741
742    #[test]
743    fn replace_cross_run_keeps_prefix_and_suffix_in_their_runs() {
744        // Match starts mid-run-0 and ends mid-run-1: the prefix stays in run 0,
745        // the suffix stays in run 1, and the replacement lands in run 0.
746        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 72 700 Td (AAA xx) Tj (yy BBB) Tj ET");
747        let fonts = FontMap::from_page(&doc, 1).unwrap();
748        let count = replace_text(&mut doc, 1, "xxyy", "Q", &fonts).unwrap();
749        assert_eq!(count, 1);
750
751        let editor = editor_for_page(&doc, 1).unwrap();
752        let runs = extract_text_runs(&editor, &fonts);
753        assert_eq!(runs.len(), 2);
754        assert_eq!(runs[0].text, "AAA Q");
755        assert_eq!(runs[1].text, " BBB");
756    }
757
758    #[test]
759    fn replace_cross_run_multiple_occurrences_in_group() {
760        // Two occurrences inside one same-font group, each straddling a run
761        // boundary. Both are replaced; each replacement lands in the run where
762        // its match starts.
763        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 72 700 Td (ab) Tj (cab) Tj (c) Tj ET");
764        let fonts = FontMap::from_page(&doc, 1).unwrap();
765        let count = replace_text(&mut doc, 1, "abc", "X", &fonts).unwrap();
766        assert_eq!(count, 2);
767
768        let editor = editor_for_page(&doc, 1).unwrap();
769        let runs = extract_text_runs(&editor, &fonts);
770        let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
771        assert_eq!(combined, "XX");
772    }
773
774    // ── CID font tests ────────────────────────────────────────────────────────
775
776    #[test]
777    fn replace_cid_font_chars_in_cmap() {
778        // CID/Identity-H font whose ToUnicode CMap covers printable ASCII.
779        // "Hallo" uses the same chars as "Hello" — all in the CMap — so
780        // encode_cid_text should succeed without Helvetica injection.
781        let mut doc = make_doc_with_cid_font(hello_cid_content());
782        let fonts = FontMap::from_page(&doc, 1).unwrap();
783
784        assert!(fonts.is_cid_font("F1"), "F1 must be recognized as CID font");
785
786        let count = replace_text(&mut doc, 1, "Hello", "Hallo", &fonts).unwrap();
787        assert_eq!(count, 1);
788
789        // Decode the result and verify the replacement.
790        let fonts_after = FontMap::from_page(&doc, 1).unwrap();
791        let editor = editor_for_page(&doc, 1).unwrap();
792        let runs = extract_text_runs(&editor, &fonts_after);
793        let combined: String = runs.iter().map(|r| r.text.as_str()).collect();
794        assert!(
795            combined.contains("Hallo"),
796            "expected 'Hallo' in '{combined}'"
797        );
798        assert!(
799            !combined.contains("Hello"),
800            "old text 'Hello' should be gone"
801        );
802    }
803
804    #[test]
805    fn replace_cid_font_no_match() {
806        let mut doc = make_doc_with_cid_font(hello_cid_content());
807        let fonts = FontMap::from_page(&doc, 1).unwrap();
808        let count = replace_text(&mut doc, 1, "World", "Earth", &fonts).unwrap();
809        assert_eq!(count, 0);
810    }
811
812    #[test]
813    fn replace_cid_font_fallback_chars_not_in_cmap() {
814        // Replacement text contains chars outside the CID font's CMap range
815        // (e.g. Japanese kana).  encode_cid_text will fail and the Helvetica
816        // fallback path is used instead.  Verify that a replacement still
817        // happens (count == 1) — the exact font used is secondary.
818        let mut doc = make_doc_with_cid_font(hello_cid_content());
819        let fonts = FontMap::from_page(&doc, 1).unwrap();
820        // '\u{3053}' = 'こ', not in the ASCII-only CMap → fallback needed.
821        let count = replace_text(&mut doc, 1, "Hello", "Hal\u{3053}", &fonts).unwrap();
822        // Non-Latin replacement cannot be encoded in either CID font or Helvetica
823        // (Helvetica/WinAnsi only covers Latin-1).  Expect 0 replacements.
824        assert_eq!(count, 0);
825    }
826
827    #[test]
828    fn replace_cid_font_latin_fallback() {
829        // Replacement is ASCII but NOT in the CID font's CMap (the CMap only
830        // covers 0x0020-0x007E and 'ê' is U+00EA = outside that range).
831        // encode_cid_text fails, but the Helvetica fallback should handle
832        // pure-Latin-1 chars.  Verify count == 1.
833        let mut doc = make_doc_with_cid_font(hello_cid_content());
834        let fonts = FontMap::from_page(&doc, 1).unwrap();
835        // Replace prefix + out-of-CMap char + suffix so fallback is exercised.
836        // "Hêllo" → 'ê' (U+00EA) is Latin-1 but outside the bfrange 0x0020-0x007E.
837        let count = replace_text(&mut doc, 1, "Hello", "Hêllo", &fonts).unwrap();
838        assert_eq!(count, 1);
839    }
840}