Skip to main content

xberg_native_pdf/document/
annotations.rs

1//! Annotation, widget, and form-field text.
2//!
3//! Split out of the parent's single 18,544-line `impl PdfDocument`, which made
4//! `document.rs` 1.2 MiB and tripped the 500 KiB file-safety limit. A child module's
5//! `impl` is the same inherent impl and sees the parent's private items unchanged. ~keep
6
7use super::*;
8
9impl PdfDocument {
10    /// Parse font size from a /DA (Default Appearance) string.
11    ///
12    /// DA strings follow the format: `"/FontName size Tf ..."` (e.g., `"/Helv 12 Tf 0 g"`).
13    /// Returns the font size preceding the `Tf` operator, or a default of 10.0 if not found.
14    fn parse_font_size_from_da(da: &str) -> f32 {
15        let tokens: Vec<&str> = da.split_whitespace().collect();
16        for i in 0..tokens.len() {
17            if tokens[i] == "Tf"
18                && i > 0
19                && let Ok(size) = tokens[i - 1].parse::<f32>()
20                && size > 0.0
21            {
22                return size;
23            }
24        }
25        10.0
26    }
27
28    /// Extract widget annotation values as TextSpans positioned at their /Rect locations.
29    ///
30    /// Converts each widget annotation's field value into a `TextSpan` with the annotation's
31    /// bounding box. These spans merge naturally with content stream spans and get positioned
32    /// correctly by existing layout algorithms.
33    pub(super) fn extract_widget_spans(&self, page_index: usize) -> Vec<TextSpan> {
34        use crate::extractors::forms::field_flags;
35        use crate::geometry::Rect;
36
37        let page_obj = match self.get_page(page_index) {
38            Ok(o) => o,
39            Err(_) => return Vec::new(),
40        };
41        let page_dict = match page_obj.as_dict() {
42            Some(d) => d,
43            None => return Vec::new(),
44        };
45
46        let annots_arr = match page_dict.get("Annots") {
47            Some(Object::Array(arr)) => arr.clone(),
48            Some(Object::Reference(r)) => match self.load_object(*r) {
49                Ok(Object::Array(arr)) => arr,
50                _ => return Vec::new(),
51            },
52            _ => return Vec::new(),
53        };
54
55        let mut spans = Vec::new();
56        let base_sequence = 1_000_000;
57        // ~keep
58
59        for (idx, annot_obj) in annots_arr.iter().enumerate() {
60            let annot_ref = match annot_obj {
61                Object::Reference(r) => *r,
62                _ => continue,
63            };
64            let dict = match self.load_object(annot_ref) {
65                Ok(obj) => match obj.as_dict() {
66                    Some(d) => d.clone(),
67                    None => continue,
68                },
69                Err(_) => continue,
70            };
71
72            let subtype = match dict.get("Subtype").and_then(|s| s.as_name()) {
73                Some(s) => s.to_string(),
74                None => continue,
75            };
76            if !subtype.eq_ignore_ascii_case("widget") {
77                continue;
78            }
79
80            // Check /F flags — skip invisible/hidden/noview annotations
81            // Bit 1 (0x1) = Invisible, Bit 2 (0x2) = Hidden, Bit 6 (0x20) = NoView ~keep
82            if let Some(Object::Integer(f)) = dict.get("F")
83                && *f & (0x1 | 0x2 | 0x20) != 0
84            {
85                continue;
86            }
87
88            let rect = match dict.get("Rect") {
89                Some(Object::Array(arr)) if arr.len() == 4 => {
90                    let mut coords = [0.0f32; 4];
91                    let mut ok = true;
92                    for (i, item) in arr.iter().enumerate() {
93                        match item {
94                            Object::Integer(n) => coords[i] = *n as f32,
95                            Object::Real(f) => coords[i] = *f as f32,
96                            _ => {
97                                ok = false;
98                                break;
99                            }
100                        }
101                    }
102                    if !ok {
103                        continue;
104                    }
105                    let x = coords[0].min(coords[2]);
106                    let y = coords[1].min(coords[3]);
107                    let w = (coords[2] - coords[0]).abs();
108                    let h = (coords[3] - coords[1]).abs();
109                    if w < 0.1 || h < 0.1 {
110                        continue;
111                    }
112                    Rect::new(x, y, w, h)
113                }
114                Some(Object::Reference(r)) => match self.load_object(*r) {
115                    Ok(Object::Array(arr)) if arr.len() == 4 => {
116                        let mut coords = [0.0f32; 4];
117                        let mut ok = true;
118                        for (i, item) in arr.iter().enumerate() {
119                            match item {
120                                Object::Integer(n) => coords[i] = *n as f32,
121                                Object::Real(f) => coords[i] = *f as f32,
122                                _ => {
123                                    ok = false;
124                                    break;
125                                }
126                            }
127                        }
128                        if !ok {
129                            continue;
130                        }
131                        let x = coords[0].min(coords[2]);
132                        let y = coords[1].min(coords[3]);
133                        let w = (coords[2] - coords[0]).abs();
134                        let h = (coords[3] - coords[1]).abs();
135                        if w < 0.1 || h < 0.1 {
136                            continue;
137                        }
138                        Rect::new(x, y, w, h)
139                    }
140                    _ => continue,
141                },
142                _ => continue,
143            };
144
145            let ft = dict
146                .get("FT")
147                .and_then(|o| o.as_name())
148                .map(|s| s.to_string())
149                .or_else(|| self.resolve_inherited_ft(&dict));
150
151            let ff = dict
152                .get("Ff")
153                .and_then(|o| match o {
154                    Object::Integer(i) => Some(*i as u32),
155                    _ => None,
156                })
157                .or_else(|| self.resolve_inherited_ff(&dict));
158            let ff = ff.unwrap_or(0);
159
160            let display_text = match ft.as_deref() {
161                Some("Tx") => {
162                    if ff & field_flags::PASSWORD != 0 {
163                        Some("********".to_string())
164                    } else {
165                        let value = Self::parse_string_value_static(dict.get("V"))
166                            .or_else(|| self.resolve_inherited_field_value(&dict));
167                        match value {
168                            Some(v) if !v.trim().is_empty() => {
169                                // Bound the value to the widget's visual
170                                // capacity. Multi-line text-area fields
171                                // can hold scrollable content far larger
172                                // than the bbox visually renders; per
173                                // spec §12.7.4.3 `/V` is the field's
174                                // data, but `extract_text` semantics
175                                // target what would be visible on the
176                                // page. Truncate keeps the rendered
177                                // portion and drops the overflow. ~keep
178                                Some(Self::truncate_to_widget_capacity(v.trim().to_string(), &rect))
179                            }
180                            _ => {
181                                // Fallback: try AP stream text. Truncate
182                                // to bbox capacity — some PDFs reuse a
183                                // single Form XObject for many widgets'
184                                // `/AP /N`, pointing every widget's
185                                // appearance at the page-background
186                                // content; without the cap each widget
187                                // would extract that content once. ~keep
188                                self.extract_text_from_ap_stream(&dict).and_then(|t| {
189                                    let t = t.trim().to_string();
190                                    if t.is_empty() {
191                                        return None;
192                                    }
193                                    Some(Self::truncate_to_widget_capacity(t, &rect))
194                                })
195                            }
196                        }
197                    }
198                }
199                Some("Btn") => {
200                    if ff & field_flags::PUSH_BUTTON != 0 {
201                        // Push button: caption is in /MK /CA per PDF Spec
202                        // ISO 32000-1:2008 §12.5.6.19 (Appearance Characteristics
203                        // Dictionary). Extracting it lets screen readers
204                        // text-extraction consumers see the button label. ~keep
205                        dict.get("MK")
206                            .and_then(|mk| mk.as_dict())
207                            .and_then(|mk| Self::parse_string_value_static(mk.get("CA")))
208                            .and_then(|s| {
209                                let t = s.trim().to_string();
210                                if t.is_empty() { None } else { Some(t) }
211                            })
212                    } else {
213                        let value = Self::parse_string_value_static(dict.get("V"))
214                            .or_else(|| self.resolve_inherited_field_value(&dict));
215                        let is_checked = match &value {
216                            Some(v) => {
217                                let v_lower = v.to_ascii_lowercase();
218                                v_lower != "off" && !v_lower.is_empty()
219                            }
220                            None => false,
221                        };
222                        if is_checked {
223                            Some("[x]".to_string())
224                        } else {
225                            // An UNCHECKED box carries no text. Emitting "[ ]"
226                            // here injected noise that pdftotext/PyMuPDF never
227                            // produce — the dominant cause of xberg-native-pdf being
228                            // the sole outlier on AcroForm-heavy PDFs in the
229                            // cross-corpus sweep (CORPUS-1). Emit nothing. ~keep
230                            None
231                        }
232                    }
233                }
234                Some("Ch") => {
235                    let value = dict.get("V");
236                    match value {
237                        Some(Object::Array(arr)) => {
238                            let items: Vec<String> = arr
239                                .iter()
240                                .filter_map(|item| Self::parse_string_value_static(Some(item)))
241                                .collect();
242                            if items.is_empty() { None } else { Some(items.join(", ")) }
243                        }
244                        other => Self::parse_string_value_static(other)
245                            .or_else(|| self.resolve_inherited_field_value(&dict))
246                            .and_then(|v| {
247                                let t = v.trim().to_string();
248                                if t.is_empty() { None } else { Some(t) }
249                            }),
250                    }
251                }
252                Some("Sig") => None,
253                _ => Self::parse_string_value_static(dict.get("V"))
254                    .or_else(|| self.resolve_inherited_field_value(&dict))
255                    .and_then(|v| {
256                        let t = v.trim().to_string();
257                        if t.is_empty() { None } else { Some(t) }
258                    }),
259            };
260
261            let text = match display_text {
262                Some(t) if !t.is_empty() => t,
263                _ => {
264                    // CORPUS-5: a widget with no extractable /V value (notably a
265                    // signature field, /FT /Sig) often carries its VISIBLE text
266                    // in the /AP/N appearance stream (e.g. "Firmato
267                    // elettronicamente da ..."). pdftotext / PyMuPDF surface it;
268                    // fall back to the appearance stream so it isn't dropped.
269                    // Fields that DO yield a /V value take the arm above, so this
270                    // never double-extracts. ~keep
271                    match self.extract_text_from_ap_stream(&dict) {
272                        Some(ap) if !ap.trim().is_empty() => ap.trim().to_string(),
273                        _ => continue,
274                    }
275                }
276            };
277
278            let font_size = {
279                let da = dict
280                    .get("DA")
281                    .and_then(|o| match o {
282                        Object::String(s) => Some(Self::decode_pdf_text_string(s)),
283                        _ => None,
284                    })
285                    .or_else(|| self.resolve_inherited_da(&dict));
286
287                match da {
288                    Some(da_str) => {
289                        let size = Self::parse_font_size_from_da(&da_str);
290                        if size <= 0.0 {
291                            (rect.height * 0.7).clamp(6.0, 24.0)
292                        } else {
293                            size
294                        }
295                    }
296                    None => (rect.height * 0.7).clamp(6.0, 24.0),
297                }
298            };
299
300            spans.push(TextSpan {
301                provenance: None,
302                artifact_type: None,
303                text,
304                bbox: rect,
305                font_name: String::new(),
306                font_size,
307                font_weight: crate::layout::text_block::FontWeight::Normal,
308                is_italic: false,
309                is_monospace: false,
310                color: crate::layout::text_block::Color { r: 0.0, g: 0.0, b: 0.0 },
311                mcid: None,
312                mcid_scope: None,
313                sequence: base_sequence + idx,
314                split_boundary_before: false,
315                offset_semantic: false,
316                char_spacing: 0.0,
317                word_spacing: 0.0,
318                horizontal_scaling: 100.0,
319                primary_detected: false,
320                char_widths: vec![],
321                char_x_offsets: Vec::new(),
322                heading_level: None,
323                rotation_degrees: 0.0,
324                wmode: 0,
325                text_rise: 0.0,
326                rtl_draw_logical: false,
327                mirrored: false,
328                page_rotation_applied: 0,
329            });
330        }
331
332        spans
333    }
334
335    /// Build TextSpan objects from the /Contents field of content-bearing annotations.
336    ///
337    /// Sticky note (/Subtype/Text), FreeText, Stamp, and markup annotations carry
338    /// human-readable text in their /Contents field. Widget annotations are already
339    /// handled by `extract_widget_spans`; Popup annotations hold no independent
340    /// content (their text belongs to the parent annotation).
341    pub(super) fn annotation_content_spans(&self, page_index: usize) -> Vec<TextSpan> {
342        use crate::geometry::Rect;
343
344        let page_obj = match self.get_page(page_index) {
345            Ok(o) => o,
346            Err(_) => return Vec::new(),
347        };
348        let page_dict = match page_obj.as_dict() {
349            Some(d) => d,
350            None => return Vec::new(),
351        };
352
353        let annots_arr = match page_dict.get("Annots") {
354            Some(Object::Array(arr)) => arr.clone(),
355            Some(Object::Reference(r)) => match self.load_object(*r) {
356                Ok(Object::Array(arr)) => arr,
357                _ => return Vec::new(),
358            },
359            _ => return Vec::new(),
360        };
361
362        let mut spans: Vec<TextSpan> = Vec::new();
363        let base_sequence = 2_000_000usize; // sort after widget spans ~keep
364
365        for (idx, annot_obj) in annots_arr.iter().enumerate() {
366            let annot_ref = match annot_obj {
367                Object::Reference(r) => *r,
368                _ => continue,
369            };
370            let dict = match self.load_object(annot_ref) {
371                Ok(obj) => match obj.as_dict() {
372                    Some(d) => d.clone(),
373                    None => continue,
374                },
375                Err(_) => continue,
376            };
377
378            let subtype = match dict.get("Subtype").and_then(|s| s.as_name()) {
379                Some(s) => s.to_string(),
380                None => continue,
381            };
382            let subtype_lc = subtype.to_ascii_lowercase();
383
384            // Skip Widget (handled by extract_widget_spans) and Popup (no independent content).
385            // ~keep
386            if subtype_lc == "widget" || subtype_lc == "popup" {
387                continue;
388            }
389
390            if let Some(Object::Integer(f)) = dict.get("F")
391                && *f & (0x1 | 0x2 | 0x20) != 0
392            {
393                continue;
394            }
395
396            // Only FreeText and Stamp have /Contents representing visible page text.
397            // Text (sticky-note) /Contents is reviewer comment text shown in a pop-up
398            // window, not rendered on the page — exclude it to avoid injecting popup
399            // notes into the body text stream.
400            // For FreeText/Stamp: try /Contents first; fall back to AP stream so that
401            // Stamp annotations with empty /Contents but a rendered AP stream are included. ~keep
402            let is_visible = matches!(subtype_lc.as_str(), "freetext" | "stamp");
403            if !is_visible {
404                continue;
405            }
406
407            let text = {
408                let from_contents = if let Some(Object::String(s)) = dict.get("Contents") {
409                    let decoded = Self::decode_pdf_text_string(s).trim().to_string();
410                    if decoded.is_empty() { None } else { Some(decoded) }
411                } else {
412                    None
413                };
414                if let Some(t) = from_contents {
415                    t
416                } else {
417                    match self.extract_text_from_ap_stream(&dict) {
418                        Some(ap_text) if !ap_text.trim().is_empty() => ap_text.trim().to_string(),
419                        _ => continue,
420                    }
421                }
422            };
423
424            let rect_obj = match dict.get("Rect") {
425                Some(Object::Reference(r)) => match self.load_object(*r) {
426                    Ok(o) => o,
427                    Err(_) => continue,
428                },
429                Some(o) => o.clone(),
430                None => continue,
431            };
432            let rect = match rect_obj.as_array() {
433                Some(arr) if arr.len() == 4 => {
434                    let mut coords = [0.0f32; 4];
435                    let mut ok = true;
436                    for (i, item) in arr.iter().enumerate() {
437                        match item {
438                            Object::Integer(n) => coords[i] = *n as f32,
439                            Object::Real(f) => coords[i] = *f as f32,
440                            _ => {
441                                ok = false;
442                                break;
443                            }
444                        }
445                    }
446                    if !ok {
447                        continue;
448                    }
449                    let x = coords[0].min(coords[2]);
450                    let y = coords[1].min(coords[3]);
451                    let w = (coords[2] - coords[0]).abs();
452                    let h = (coords[3] - coords[1]).abs();
453                    Rect {
454                        x,
455                        y,
456                        width: w.max(1.0),
457                        height: h.max(1.0),
458                    }
459                }
460                _ => continue,
461            };
462
463            spans.push(TextSpan {
464                provenance: None,
465                artifact_type: None,
466                text,
467                bbox: rect,
468                font_name: String::new(),
469                font_size: 12.0,
470                font_weight: crate::layout::text_block::FontWeight::Normal,
471                is_italic: false,
472                is_monospace: false,
473                color: crate::layout::text_block::Color { r: 0.0, g: 0.0, b: 0.0 },
474                mcid: None,
475                mcid_scope: None,
476                sequence: base_sequence + idx,
477                split_boundary_before: false,
478                offset_semantic: false,
479                char_spacing: 0.0,
480                word_spacing: 0.0,
481                horizontal_scaling: 100.0,
482                primary_detected: false,
483                char_widths: vec![],
484                char_x_offsets: Vec::new(),
485                heading_level: None,
486                rotation_degrees: 0.0,
487                wmode: 0,
488                text_rise: 0.0,
489                rtl_draw_logical: false,
490                mirrored: false,
491                page_rotation_applied: 0,
492            });
493        }
494
495        spans
496    }
497
498    /// Walk /Parent chain to find inherited /Ff (field flags) value.
499    fn resolve_inherited_ff(&self, dict: &std::collections::HashMap<String, Object>) -> Option<u32> {
500        let mut parent_ref = match dict.get("Parent") {
501            Some(Object::Reference(r)) => Some(*r),
502            _ => return None,
503        };
504        let mut depth = 0;
505        while let Some(pref) = parent_ref {
506            if depth >= 10 {
507                break;
508            }
509            depth += 1;
510            match self.load_object(pref) {
511                Ok(parent_obj) => {
512                    if let Some(parent_dict) = parent_obj.as_dict() {
513                        if let Some(Object::Integer(ff)) = parent_dict.get("Ff") {
514                            return Some(*ff as u32);
515                        }
516                        parent_ref = match parent_dict.get("Parent") {
517                            Some(Object::Reference(r)) => Some(*r),
518                            _ => None,
519                        };
520                    } else {
521                        break;
522                    }
523                }
524                _ => {
525                    break;
526                }
527            }
528        }
529        None
530    }
531
532    /// Walk /Parent chain (and AcroForm) to find inherited /DA (Default Appearance) string.
533    fn resolve_inherited_da(&self, dict: &std::collections::HashMap<String, Object>) -> Option<String> {
534        let mut parent_ref = match dict.get("Parent") {
535            Some(Object::Reference(r)) => Some(*r),
536            _ => None,
537        };
538        let mut depth = 0;
539        while let Some(pref) = parent_ref {
540            if depth >= 10 {
541                break;
542            }
543            depth += 1;
544            match self.load_object(pref) {
545                Ok(parent_obj) => {
546                    if let Some(parent_dict) = parent_obj.as_dict() {
547                        if let Some(Object::String(da)) = parent_dict.get("DA") {
548                            return Some(Self::decode_pdf_text_string(da));
549                        }
550                        parent_ref = match parent_dict.get("Parent") {
551                            Some(Object::Reference(r)) => Some(*r),
552                            _ => None,
553                        };
554                    } else {
555                        break;
556                    }
557                }
558                _ => {
559                    break;
560                }
561            }
562        }
563
564        if let Some(trailer_dict) = self.trailer.as_dict()
565            && let Some(root_ref) = trailer_dict.get("Root").and_then(|o| o.as_reference())
566            && let Ok(root_obj) = self.load_object(root_ref)
567            && let Some(root_dict) = root_obj.as_dict()
568        {
569            let acroform = match root_dict.get("AcroForm") {
570                Some(Object::Reference(r)) => self.load_object(*r).ok(),
571                Some(obj) => Some(obj.clone()),
572                None => None,
573            };
574            if let Some(acroform_obj) = acroform
575                && let Some(af_dict) = acroform_obj.as_dict()
576                && let Some(Object::String(da)) = af_dict.get("DA")
577            {
578                return Some(Self::decode_pdf_text_string(da));
579            }
580        }
581
582        None
583    }
584
585    /// Append text from non-widget annotations on a page.
586    ///
587    /// Extracts text from FreeText annotations (text box contents), Stamp annotations
588    /// (appearance stream text), and other non-widget annotation types.
589    /// Widget annotations are handled separately via `extract_widget_spans()`.
590    /// Skips hidden and invisible annotations per PDF spec flags.
591    pub(super) fn append_non_widget_annotation_text(&self, page_index: usize, text: &mut String) {
592        // Lightweight annotation text extraction — avoids full get_annotations() overhead.
593        // Only reads /Subtype, /V, /Contents, /F, and /Parent (for field value inheritance).
594        // Uses get_page() which is cached after first access. ~keep
595        let page_obj = match self.get_page(page_index) {
596            Ok(o) => o,
597            Err(_) => return,
598        };
599        let page_dict = match page_obj.as_dict() {
600            Some(d) => d,
601            None => return,
602        };
603
604        let annots_arr = match page_dict.get("Annots") {
605            Some(Object::Array(arr)) => arr.clone(),
606            Some(Object::Reference(r)) => match self.load_object(*r) {
607                Ok(Object::Array(arr)) => arr,
608                _ => return,
609            },
610            _ => return,
611        };
612
613        let mut annot_texts: Vec<String> = Vec::new();
614
615        for annot_obj in &annots_arr {
616            let len_before_annot = annot_texts.len();
617            let annot_ref = match annot_obj {
618                Object::Reference(r) => *r,
619                _ => continue,
620            };
621            let dict = match self.load_object(annot_ref) {
622                Ok(obj) => match obj.as_dict() {
623                    Some(d) => d.clone(),
624                    None => continue,
625                },
626                Err(_) => continue,
627            };
628
629            // Check /F flags — skip invisible/hidden annotations
630            // Bit 1 (0x1) = Invisible, Bit 2 (0x2) = Hidden, Bit 6 (0x20) = NoView ~keep
631            if let Some(Object::Integer(f)) = dict.get("F")
632                && *f & (0x1 | 0x2 | 0x20) != 0
633            {
634                continue;
635            }
636
637            let subtype = match dict.get("Subtype").and_then(|s| s.as_name()) {
638                Some(s) => s.to_string(),
639                None => continue,
640            };
641            let subtype_lower = subtype.to_ascii_lowercase();
642
643            match subtype_lower.as_str() {
644                "widget" => {
645                    // Widgets are now handled by extract_widget_spans() as inline TextSpans.
646                    // Skip them here to avoid duplicate text at the end of output. ~keep
647                    continue;
648                }
649                "freetext" | "stamp" => {
650                    if let Some(Object::String(s)) = dict.get("Contents") {
651                        let decoded = Self::decode_pdf_text_string(s);
652                        let trimmed = decoded.trim().to_string();
653                        if !trimmed.is_empty() {
654                            annot_texts.push(trimmed);
655                        }
656                    }
657                }
658                // Text (sticky-note) /Contents is reviewer popup comment text, not
659                // visible page content — skip to avoid injecting popup notes. ~keep
660                "text" => {}
661                // Geometric shape annotations — per §12.5.6.2, their /Contents is
662                // also popup/comment text, same as the markup group below. ~keep
663                "line" | "circle" | "square" | "polygon" | "polyline" => {}
664                // Markup/comment annotations — per ISO 32000-1 §12.5.6.2 (Table 166),
665                // the /Contents of all these subtypes is popup/comment text written
666                // by a reviewer, NOT text displayed on the page. Exclude to avoid
667                // injecting user annotation notes into the body text stream.
668                // Per §12.5.6.2, all of these annotations' /Contents is popup/comment
669                // text (displayed in a pop-up window), not rendered page content.
670                // FileAttachment is explicitly in this category per §12.5.6.2 even
671                // though §12.5.6.15 calls it "descriptive text" — the pop-up semantics
672                // take precedence. ~keep
673                "highlight" | "underline" | "strikeout" | "squiggly" | "caret" | "fileattachment" | "redact"
674                | "ink" => {}
675                // Link /Contents is an accessibility alternate description (§12.5.6.5).
676                // Treated as supplementary text on pages with no body content. ~keep
677                "link" => {
678                    if let Some(Object::String(s)) = dict.get("Contents") {
679                        let decoded = Self::decode_pdf_text_string(s);
680                        let trimmed = decoded.trim().to_string();
681                        if !trimmed.is_empty() {
682                            annot_texts.push(trimmed);
683                        }
684                    }
685                }
686                // Popup annotations — per §12.5.6.14 Table 183, the parent
687                // annotation's /Contents overrides the popup's own /Contents. ~keep
688                "popup" => {
689                    let mut got_text = false;
690                    if let Some(parent_ref) = dict.get("Parent").and_then(|o| o.as_reference())
691                        && let Ok(parent_obj) = self.load_object(parent_ref)
692                        && let Some(parent_dict) = parent_obj.as_dict()
693                        && let Some(Object::String(s)) = parent_dict.get("Contents")
694                    {
695                        let decoded = Self::decode_pdf_text_string(s);
696                        let trimmed = decoded.trim().to_string();
697                        if !trimmed.is_empty() {
698                            annot_texts.push(trimmed);
699                            got_text = true;
700                        }
701                    }
702                    if !got_text && let Some(Object::String(s)) = dict.get("Contents") {
703                        let decoded = Self::decode_pdf_text_string(s);
704                        let trimmed = decoded.trim().to_string();
705                        if !trimmed.is_empty() {
706                            annot_texts.push(trimmed);
707                        }
708                    }
709                }
710                _ => {
711                    if let Some(Object::String(s)) = dict.get("Contents") {
712                        let decoded = Self::decode_pdf_text_string(s);
713                        let trimmed = decoded.trim().to_string();
714                        if !trimmed.is_empty() {
715                            annot_texts.push(trimmed);
716                        }
717                    }
718                }
719            }
720
721            let text_before = annot_texts.len();
722            if text_before == len_before_annot
723                && let Some(ap_text) = self.extract_text_from_ap_stream(&dict)
724            {
725                let trimmed = ap_text.trim().to_string();
726                if !trimmed.is_empty() {
727                    annot_texts.push(trimmed);
728                }
729            }
730        }
731
732        if !annot_texts.is_empty() {
733            if !text.is_empty() && !text.ends_with('\n') {
734                text.push('\n');
735            }
736            text.push_str(&annot_texts.join("\n"));
737        }
738    }
739
740    /// Extract text from an annotation's Normal Appearance stream (/AP/N).
741    ///
742    /// AP streams are content streams with their own /Resources. This creates
743    /// a temporary TextExtractor, loads fonts from the AP stream resources,
744    /// and extracts text spans from the decoded stream data.
745    pub fn extract_annotation_appearance_text(
746        &self,
747        annotation: &crate::annotations::Annotation,
748        excluded_layers: &std::collections::HashSet<String>,
749    ) -> Option<String> {
750        self.extract_text_from_ap_stream_filtered(annotation.raw_dict.as_ref()?, excluded_layers)
751    }
752
753    fn extract_text_from_ap_stream(&self, annot_dict: &std::collections::HashMap<String, Object>) -> Option<String> {
754        self.extract_text_from_ap_stream_filtered(annot_dict, &std::collections::HashSet::new())
755    }
756
757    fn extract_text_from_ap_stream_filtered(
758        &self,
759        annot_dict: &std::collections::HashMap<String, Object>,
760        excluded_layers: &std::collections::HashSet<String>,
761    ) -> Option<String> {
762        use crate::extractors::TextExtractor;
763
764        let ap_obj = annot_dict.get("AP")?;
765        let ap = if let Some(r) = ap_obj.as_reference() {
766            self.load_object(r).ok()?
767        } else {
768            ap_obj.clone()
769        };
770        let ap_dict = ap.as_dict()?;
771
772        let n_obj = ap_dict.get("N")?;
773        let (n_stream, n_ref) = match n_obj {
774            Object::Reference(r) => (self.load_object(*r).ok()?, *r),
775            _ => return None,
776        };
777
778        let n_dict = n_stream.as_dict()?;
779
780        let stream_data = match self.decode_stream_with_encryption(&n_stream, n_ref) {
781            Ok(data) => data,
782            Err(_) => return None,
783        };
784
785        if !Self::may_contain_text(&stream_data) {
786            return None;
787        }
788
789        let mut extractor = TextExtractor::new();
790        if !excluded_layers.is_empty() {
791            extractor.set_excluded_layers(excluded_layers.clone());
792        }
793
794        // Load fonts from the AP/N stream's own /Resources. No resources on
795        // the AP stream — try the annotation's /DR or parent page resources
796        // — means we can't decode fonts, so bail. ~keep
797        {
798            let resources = n_dict.get("Resources")?;
799            let res_obj = if let Some(r) = resources.as_reference() {
800                self.load_object(r).ok().unwrap_or_else(|| resources.clone())
801            } else {
802                resources.clone()
803            };
804            extractor.set_resources(res_obj.clone());
805            extractor.set_document(self);
806            let _ = self.load_fonts(&res_obj, &mut extractor);
807        }
808
809        let spans = extractor.extract_text_spans(&stream_data).ok()?;
810        if spans.is_empty() {
811            return None;
812        }
813
814        let text: String = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join(" ");
815        if text.trim().is_empty() {
816            return None;
817        }
818        Some(text)
819    }
820
821    /// Char-count capacity for what physically fits inside a widget
822    /// bbox at body font sizes. Per PDF spec §12.7.4.3 the field's
823    /// value is `/V`; the appearance stream is visual rendering
824    /// only. When we fall back to AP extraction the result must be
825    /// bounded by what the widget could visually show — PDFs that
826    /// reuse a single Form XObject for many widgets' `/AP /N` would
827    /// otherwise dump the shared content once per widget, and
828    /// scrollable multi-line text fields hold far more characters
829    /// in `/V` than ever render at once.
830    ///
831    /// Heuristic: ~14 chars per cm² at body font sizes. At PDF
832    /// 72 dpi (1 pt = 0.0353 cm), the formula
833    /// `capacity = 0.0175 * w_pt * h_pt + 64` applies; the constant
834    /// term absorbs short labels where the area estimate alone is
835    /// too tight to even hold the field's name.
836    fn widget_text_capacity(bbox: &crate::geometry::Rect) -> usize {
837        let area = bbox.width.max(0.0) * bbox.height.max(0.0);
838        (0.0175 * area) as usize + 64
839    }
840
841    /// Truncate `text` to the widget's visual capacity. If `text`
842    /// already fits, returns it unchanged. Used to bound AP-fallback
843    /// extraction (and other content paths) so a single widget can't
844    /// dump page-background prose or scrollable field internals into
845    /// the page text.
846    fn truncate_to_widget_capacity(text: String, bbox: &crate::geometry::Rect) -> String {
847        let cap = Self::widget_text_capacity(bbox);
848        let n = text.chars().count();
849        if n <= cap {
850            return text;
851        }
852        text.chars().take(cap).collect()
853    }
854
855    /// Walk /Parent chain to find inherited /FT (field type) value.
856    fn resolve_inherited_ft(&self, dict: &std::collections::HashMap<String, Object>) -> Option<String> {
857        let mut parent_ref = match dict.get("Parent") {
858            Some(Object::Reference(r)) => Some(*r),
859            _ => return None,
860        };
861        let mut depth = 0;
862        while let Some(pref) = parent_ref {
863            if depth >= 10 {
864                break;
865            }
866            depth += 1;
867            match self.load_object(pref) {
868                Ok(parent_obj) => {
869                    if let Some(parent_dict) = parent_obj.as_dict() {
870                        if let Some(ft) = parent_dict.get("FT").and_then(|o| o.as_name()) {
871                            return Some(ft.to_string());
872                        }
873                        parent_ref = match parent_dict.get("Parent") {
874                            Some(Object::Reference(r)) => Some(*r),
875                            _ => None,
876                        };
877                    } else {
878                        break;
879                    }
880                }
881                _ => {
882                    break;
883                }
884            }
885        }
886        None
887    }
888
889    /// Walk /Parent chain to find inherited /V value (PDF spec 12.7.3.1).
890    fn resolve_inherited_field_value(&self, dict: &std::collections::HashMap<String, Object>) -> Option<String> {
891        let mut parent_ref = match dict.get("Parent") {
892            Some(Object::Reference(r)) => Some(*r),
893            _ => return None,
894        };
895        let mut depth = 0;
896        while let Some(pref) = parent_ref {
897            if depth >= 10 {
898                break;
899            }
900            depth += 1;
901            match self.load_object(pref) {
902                Ok(parent_obj) => {
903                    if let Some(parent_dict) = parent_obj.as_dict() {
904                        if let Some(v) = Self::parse_string_value_static(parent_dict.get("V")) {
905                            return Some(v);
906                        }
907                        parent_ref = match parent_dict.get("Parent") {
908                            Some(Object::Reference(r)) => Some(*r),
909                            _ => None,
910                        };
911                    } else {
912                        break;
913                    }
914                }
915                _ => {
916                    break;
917                }
918            }
919        }
920        None
921    }
922
923    /// Parse a string value from a PDF object with proper PDF string decoding.
924    /// Handles UTF-16BE (BOM \xFE\xFF) and PDFDocEncoding per ISO 32000-1 §7.9.2.2.
925    pub(super) fn parse_string_value_static(obj: Option<&Object>) -> Option<String> {
926        match obj {
927            Some(Object::String(s)) => Some(Self::decode_pdf_text_string(s)),
928            Some(Object::Name(n)) => Some(n.clone()),
929            Some(Object::Integer(i)) => Some(i.to_string()),
930            Some(Object::Real(f)) => Some(f.to_string()),
931            _ => None,
932        }
933    }
934
935    /// Decode a PDF text string that may be UTF-16BE/LE (with BOM) or PDFDocEncoding.
936    pub(super) fn decode_pdf_text_string(bytes: &[u8]) -> String {
937        if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
938            let utf16_bytes = &bytes[2..];
939            let utf16_pairs: Vec<u16> = utf16_bytes
940                .as_chunks::<2>()
941                .0
942                .iter()
943                .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
944                .collect();
945            String::from_utf16(&utf16_pairs).unwrap_or_else(|_| String::from_utf8_lossy(bytes).to_string())
946        } else if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
947            let utf16_bytes = &bytes[2..];
948            let utf16_pairs: Vec<u16> = utf16_bytes
949                .as_chunks::<2>()
950                .0
951                .iter()
952                .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
953                .collect();
954            String::from_utf16(&utf16_pairs).unwrap_or_else(|_| String::from_utf8_lossy(bytes).to_string())
955        } else {
956            bytes
957                .iter()
958                .filter_map(|&b| crate::fonts::font_dict::pdfdoc_encoding_lookup(b))
959                .collect()
960        }
961    }
962
963    /// Strip XHTML tags from rich content (/RC) to extract plain text.
964    ///
965    /// Per PDF Spec ISO 32000-1:2008 Section 12.7.3.4, /RC entries contain
966    /// XHTML-formatted rich text. This method strips tags to produce plain text.
967    #[cfg(test)]
968    pub(super) fn strip_xhtml_tags(xhtml: &str) -> String {
969        let mut result = String::with_capacity(xhtml.len());
970        let mut inside_tag = false;
971        for ch in xhtml.chars() {
972            match ch {
973                '<' => inside_tag = true,
974                '>' => inside_tag = false,
975                _ if !inside_tag => result.push(ch),
976                _ => {}
977            }
978        }
979        result
980    }
981}