Skip to main content

oxidize_pdf/text/
extraction.rs

1//! Text extraction from PDF content streams
2//!
3//! This module provides functionality to extract text from PDF pages,
4//! handling text positioning, transformations, and basic encodings.
5
6use crate::graphics::Color;
7use crate::parser::content::{ContentOperation, ContentParser, TextElement};
8use crate::parser::document::PdfDocument;
9use crate::parser::objects::{PdfDictionary, PdfObject};
10use crate::parser::page_tree::ParsedPage;
11use crate::parser::ParseResult;
12use crate::text::extraction_cmap::{CMapTextExtractor, FontInfo};
13use crate::text::graphics_state_stack::GraphicsStateStack;
14use std::collections::HashMap;
15use std::io::{Read, Seek};
16
17/// Text extraction options
18#[derive(Debug, Clone)]
19pub struct ExtractionOptions {
20    /// Preserve the original layout (spacing and positioning)
21    pub preserve_layout: bool,
22    /// Minimum space width to insert space character (in text space units)
23    pub space_threshold: f64,
24    /// Threshold for synthesising an implicit `U+0020` from a `TJ` numeric
25    /// kerning offset, expressed as a fraction of the current font size.
26    /// A TJ kern advances the text matrix by `-adjustment/1000 * font_size`
27    /// without rendering any glyph; many PDFs (academic publishers, LaTeX,
28    /// kerned typography) encode inter-word gaps purely as wide negative
29    /// kerns rather than literal space bytes. When the synthesised advance
30    /// exceeds `tj_space_threshold * font_size`, the extractor inserts one
31    /// `U+0020`. Default `0.2` (200 milli-em) sits well between typical
32    /// intra-word kerning (10-50 milli-em) and the width of a `space`
33    /// glyph in most fonts (250-300 milli-em). Lower values catch tighter
34    /// spaces; higher values reduce false positives in fonts with unusually
35    /// wide kerning. Separate from `space_threshold` (which governs the
36    /// post-glyph gap between separate text-show operators) because the TJ
37    /// numeric kern is measured without any glyph advance baseline and
38    /// needs a more sensitive threshold (issue #272).
39    pub tj_space_threshold: f64,
40    /// Minimum vertical distance to insert newline (in text space units)
41    pub newline_threshold: f64,
42    /// Sort text fragments by position (useful for multi-column layouts)
43    pub sort_by_position: bool,
44    /// Detect and handle columns
45    pub detect_columns: bool,
46    /// Column separation threshold (in page units)
47    pub column_threshold: f64,
48    /// Merge hyphenated words at line ends
49    pub merge_hyphenated: bool,
50    /// Track space insertion decisions in each TextFragment (default: false).
51    /// When false: zero overhead. When true: populates `TextFragment::space_decisions`.
52    pub track_space_decisions: bool,
53    /// Reconstruct visual lines and paragraphs from the raw text fragments
54    /// produced by PDF text-show operators. When `true`, the extractor groups
55    /// fragments by baseline into single-line fragments, then groups
56    /// consecutive lines with normal leading into paragraph-level fragments.
57    /// This is what the partition pipeline needs to produce Element values at
58    /// paragraph granularity rather than at per-`Tj` granularity (see
59    /// [issue #261](https://github.com/bzsanti/oxidizePdf/issues/261)).
60    ///
61    /// Default `false` for backward compatibility with direct `extract_text`
62    /// callers. The `PdfDocument::partition*` entry points force this to
63    /// `true`.
64    pub reconstruct_paragraphs: bool,
65    /// Include content inside `/Artifact` marked-content scopes (page headers,
66    /// footers, watermarks, decorative content). Default `false` — Artifact
67    /// content is filtered out, as the PDF/UA conformance level recommends
68    /// for accessibility tooling and as RAG callers consistently want
69    /// (issue #269 Phase 1). Opt-in by setting `true` when extracting
70    /// page furniture matters (e.g. forensic auditing, redaction tools).
71    pub include_artifacts: bool,
72    /// Reorder flat-text output by column so per-column tokens stay adjacent in
73    /// multi-column layouts (issue #389). Only affects the flat path
74    /// (`preserve_layout = false`); in layout mode `detect_columns` already
75    /// reorders. Default `false` → the flat path is byte-identical to before.
76    /// When on, `.text` is produced by the fragment pipeline (its shape matches
77    /// the layout path's reconstruction, not stream order); `.fragments` stays
78    /// empty.
79    ///
80    /// Column reflow only triggers for column blocks whose rows are spaced at
81    /// least one line height apart. Layouts pitched tighter than that are
82    /// geometrically indistinguishable from tight-leading prose that merely
83    /// contains a wide gap, so they are intentionally left in reading order
84    /// rather than risk shredding prose (issue #417); text is never corrupted.
85    ///
86    /// Column blocks require gaps that align horizontally across rows: a set of
87    /// unrelated wide gaps at different X (e.g. a label/value form with varying
88    /// label lengths) is left in reading order, never reflowed (#422). A genuine
89    /// table whose column corridor drifts more than ~10pt between rows may also
90    /// be left un-reordered; text is never corrupted.
91    pub reorder_columns: bool,
92    /// Stop accumulating decoded text for a page once this many bytes have been
93    /// collected, bounding the per-page peak memory of extraction. The limit is
94    /// enforced *during* accumulation, not by truncating the finished string, so
95    /// a single page with a huge or adversarially inflated content stream cannot
96    /// materialise an unbounded `String` before the caller sees it (issue #382).
97    ///
98    /// Semantics are *undershoot*: extraction stops before the fragment that
99    /// would push the accumulated bytes past the limit, so the returned
100    /// `text.len() <= max_extracted_bytes` and a multi-byte UTF-8 character is
101    /// never split. When the limit cuts extraction short,
102    /// [`ExtractedText::truncated`] is set to `true`.
103    ///
104    /// `None` (default) means no limit — output is byte-identical to before.
105    /// The `text.len() <= max_extracted_bytes` invariant holds on **every** path
106    /// (flat, `reorder_columns`, `preserve_layout`): the layout paths rebuild
107    /// `.text` from the already-bounded fragment set and are then clamped to the
108    /// limit at a UTF-8 char boundary as a final safety net.
109    ///
110    /// Because whole decoded runs are the unit of truncation, a page whose text
111    /// is a single run larger than the whole budget (e.g. one huge `Tj`, or an
112    /// `/ActualText` override) comes back with `text == ""` and
113    /// `truncated == true` rather than a partial run — the limit is never
114    /// satisfied by splitting a run mid-character.
115    pub max_extracted_bytes: Option<usize>,
116}
117
118impl Default for ExtractionOptions {
119    fn default() -> Self {
120        Self {
121            preserve_layout: false,
122            space_threshold: 0.3,
123            tj_space_threshold: 0.2,
124            newline_threshold: 10.0,
125            sort_by_position: true,
126            detect_columns: false,
127            column_threshold: 50.0,
128            merge_hyphenated: true,
129            track_space_decisions: false,
130            reconstruct_paragraphs: false,
131            include_artifacts: false,
132            reorder_columns: false,
133            max_extracted_bytes: None,
134        }
135    }
136}
137
138/// Extracted text with position information.
139///
140/// Pipeline output: returned by the `extract_text*` entry points on
141/// [`Page`](crate::page::Page) / [`PdfDocument`](crate::parser::PdfDocument).
142/// `#[non_exhaustive]` so future fields (e.g. per-run diagnostics) can be added
143/// without a breaking change — construct one outside the crate via
144/// [`ExtractedText::new`].
145#[derive(Debug, Clone)]
146#[non_exhaustive]
147pub struct ExtractedText {
148    /// The extracted text content
149    pub text: String,
150    /// Text fragments with position information (if preserve_layout is true)
151    pub fragments: Vec<TextFragment>,
152    /// `true` when extraction stopped early because
153    /// [`ExtractionOptions::max_extracted_bytes`] was reached, so `text` is a
154    /// bounded prefix of the page's full text rather than the whole page
155    /// (issue #382). Always `false` when no limit is set.
156    pub truncated: bool,
157}
158
159impl ExtractedText {
160    /// Build an `ExtractedText` from its text and fragments, with `truncated`
161    /// set to `false`. Provided because `ExtractedText` is `#[non_exhaustive]`,
162    /// so external callers cannot use a struct literal. Set [`truncated`](Self::truncated)
163    /// afterwards if you are synthesizing a bounded result.
164    pub fn new(text: String, fragments: Vec<TextFragment>) -> Self {
165        Self {
166            text,
167            fragments,
168            truncated: false,
169        }
170    }
171}
172
173/// Metadata about a space insertion decision during text extraction.
174/// Only populated when [`ExtractionOptions::track_space_decisions`] is `true`.
175#[derive(Debug, Clone)]
176pub struct SpaceDecision {
177    /// Character offset in the extracted text.
178    pub offset: usize,
179    /// Actual horizontal gap (dx) in text space units.
180    pub dx: f64,
181    /// The threshold used at this point.
182    pub threshold: f64,
183    /// Confidence: `|dx - threshold| / threshold`, clamped to [0.0, 1.0].
184    pub confidence: f64,
185    /// Whether a space was inserted.
186    pub inserted: bool,
187}
188
189/// A fragment of text with position information
190#[derive(Debug, Clone)]
191pub struct TextFragment {
192    /// Text content
193    pub text: String,
194    /// X position in page coordinates
195    pub x: f64,
196    /// Y position in page coordinates
197    pub y: f64,
198    /// Width of the text
199    pub width: f64,
200    /// Height of the text
201    pub height: f64,
202    /// Font size
203    pub font_size: f64,
204    /// Font name (if known) - used for kerning-aware text spacing
205    pub font_name: Option<String>,
206    /// Whether the font is bold (detected from font name)
207    pub is_bold: bool,
208    /// Whether the font is italic (detected from font name)
209    pub is_italic: bool,
210    /// Fill color of the text (from graphics state)
211    pub color: Option<Color>,
212    /// Space insertion decisions (empty unless `track_space_decisions` is true).
213    pub space_decisions: Vec<SpaceDecision>,
214    /// Marked-content identifier from the innermost ancestor BDC with `/MCID`
215    /// (issue #269 Phase 1). `None` for non-tagged PDFs, which preserves the
216    /// pre-Phase-1 grouping behavior (`None == None` collapses to legacy keys).
217    pub mcid: Option<u32>,
218    /// Structural tag of the owning BDC (e.g. `"P"`, `"H1"`, `"Figure"`,
219    /// `"Artifact"`). Set on the same ancestor that supplied `mcid`. Phase 3
220    /// will consume this for partitioner classification; Phase 1 only carries it.
221    pub struct_tag: Option<String>,
222}
223
224/// One entry on the marked-content stack maintained by `TextState`.
225///
226/// PDF marked-content operators (BDC/BMC/EMC) form a balanced LIFO stack
227/// per content stream. Each entry remembers the tag (`"P"`, `"H1"`,
228/// `"Artifact"`, …), the optional `MCID` for fragment grouping, the
229/// optional `/ActualText` substitution string, and a computed
230/// `is_artifact` flag that inherits from any ancestor (so nested
231/// `/P` inside `/Artifact` is still filtered out).
232#[derive(Debug, Clone)]
233struct MarkedContentEntry {
234    /// The BDC/BMC tag (e.g. `"P"`, `"Figure"`, `"Artifact"`, `"Span"`).
235    tag: String,
236    /// MCID from `/MCID <int>` if present in the BDC props.
237    mcid: Option<u32>,
238    /// Decoded ActualText from `/ActualText (...)` if present. Decoded
239    /// once at BDC time (UTF-16BE BOM detection in `decode_pdf_string`)
240    /// rather than per-fragment.
241    #[allow(dead_code)] // Task 9 reads this via pending_actualtext flush path
242    actual_text: Option<String>,
243    /// True if this entry's tag == `"Artifact"` OR any ancestor on the
244    /// stack at push time had `is_artifact == true`. Inheritance lets the
245    /// emitter check only the innermost entry to decide filtering.
246    is_artifact: bool,
247}
248
249/// A pending ActualText run. Created when a BDC pushes an entry with
250/// `actual_text == Some(_)`; drained and emitted as a single synthetic
251/// `TextFragment` when the matching EMC pops the entry.
252///
253/// Spec §3a/§4 (collapse-on-EMC): per-`Tj` emission inside an ActualText
254/// scope is suppressed; on scope close we emit one fragment whose `text`
255/// is the substitution string, `x`/`y` is the first `Tj` origin, and
256/// `width` is the sum of suppressed text widths.
257#[derive(Debug, Clone)]
258struct PendingActualText {
259    /// Substitution text from the BDC's `/ActualText` (already decoded).
260    text: String,
261    /// Pen origin of the first suppressed `Tj` (page-space).
262    first_x: f64,
263    /// Same for Y.
264    first_y: f64,
265    /// Accumulated effective width of suppressed `Tj` runs.
266    width: f64,
267    /// Effective font size at the time the first `Tj` was suppressed.
268    font_size: f64,
269    /// Font name + style at first `Tj`. Set on first suppression.
270    font_name: Option<String>,
271    /// Bold/italic from the font name at first suppression.
272    is_bold: bool,
273    is_italic: bool,
274    /// Fill color at first suppression.
275    color: Option<Color>,
276    /// Depth in `mc_stack` at which this run was opened. When the entry at
277    /// this depth is popped, the pending run is flushed.
278    stack_depth: usize,
279    /// Whether a `Tj`/`TJ`/`'`/`"` has been observed yet inside the scope.
280    /// Until the first one fires, the run has no origin to record.
281    populated: bool,
282}
283
284/// Text extraction state
285struct TextState {
286    /// Current text matrix
287    text_matrix: [f64; 6],
288    /// Current text line matrix
289    text_line_matrix: [f64; 6],
290    /// Current transformation matrix (CTM)
291    ctm: [f64; 6],
292    /// Text leading (line spacing)
293    leading: f64,
294    /// Character spacing
295    char_space: f64,
296    /// Word spacing
297    word_space: f64,
298    /// Horizontal scaling
299    horizontal_scale: f64,
300    /// Text rise
301    text_rise: f64,
302    /// Current font size
303    font_size: f64,
304    /// Current font name
305    font_name: Option<String>,
306    /// Render mode (0 = fill, 1 = stroke, etc.)
307    render_mode: u8,
308    /// Fill color (for text rendering)
309    fill_color: Option<Color>,
310    /// Graphics state stack for `q`/`Q` operators. Each entry holds the CTM
311    /// and other graphics state items that the text extractor needs to restore.
312    /// Per PDF spec §8.4.4, `q` pushes the full graphics state and `Q` pops it;
313    /// here we save only the fields that influence text extraction.
314    ///
315    /// Bounded: see [`GraphicsStateStack`] for the depth cap and for why the
316    /// pushes it refuses have to be counted (issue #455).
317    saved_states: GraphicsStateStack<SavedGraphicsState>,
318    /// Marked-content stack (issue #269 Phase 1). Pushed on BMC/BDC,
319    /// popped on EMC. Empty on entry to each page.
320    mc_stack: Vec<MarkedContentEntry>,
321    /// Pending ActualText run if any BDC ancestor declared `/ActualText`.
322    /// At most one active run at a time — nested ActualText replaces the
323    /// outer (innermost wins, per spec §4).
324    pending_actualtext: Option<PendingActualText>,
325}
326
327impl TextState {
328    /// `q` (§8.4.4): snapshot the graphics state.
329    ///
330    /// The snapshot is built lazily so that past the depth cap it is not built
331    /// at all: a `q` flood must not pay for the font-name clone of an entry the
332    /// stack is about to refuse (issue #455).
333    ///
334    /// That laziness is what forces the stack out of the state and back: the
335    /// closure calls [`SavedGraphicsState::capture`], which borrows the WHOLE
336    /// `TextState` — the ten fields of the snapshot are defined in one place on
337    /// purpose, so the `q` path and the implicit save around `Do` cannot drift
338    /// apart — and that borrow overlaps the mutable borrow of `saved_states`.
339    /// Moving a four-word stack twice per `q` is the price of not duplicating
340    /// the snapshot definition. The plain extractor reads its three fields
341    /// inline instead, so its borrows are disjoint and it needs none of this.
342    fn save_graphics_state(&mut self) {
343        let mut stack = std::mem::take(&mut self.saved_states);
344        stack.push_with(|| SavedGraphicsState::capture(self));
345        self.saved_states = stack;
346    }
347}
348
349/// Graphics state saved by `q` and restored by `Q` (issues #262, #452).
350///
351/// Holds the CTM, the fill colour, and the TEXT STATE parameters. The text
352/// state is graphics state per ISO 32000-1 §9.3 and Table 52 — leading,
353/// character and word spacing, horizontal scaling, font and size, text rise
354/// and render mode all live there, so `Q` must put them back. Before #452 only
355/// the CTM and the colour were restored, and a leading set inside a `q … Q`
356/// block kept driving line breaks after the block closed.
357///
358/// `text_matrix` and `text_line_matrix` are deliberately NOT here: they are
359/// text OBJECT state, established by `BT` and discarded by `ET` (§9.4.1), not
360/// graphics state. Restoring them on `Q` would be a different bug.
361///
362/// Four of the text-state fields — `char_space`, `word_space`, `text_rise` and
363/// `render_mode` — are currently written by their operators but never read by
364/// the extractor, so restoring them changes no output today and no test can
365/// guard them. They are here because they are graphics state: whoever wires
366/// them into the pen advance, the y offset or invisible-text filtering should
367/// not have to rediscover this bug.
368struct SavedGraphicsState {
369    ctm: [f64; 6],
370    fill_color: Option<Color>,
371    leading: f64,
372    char_space: f64,
373    word_space: f64,
374    horizontal_scale: f64,
375    text_rise: f64,
376    font_size: f64,
377    font_name: Option<String>,
378    render_mode: u8,
379}
380
381impl SavedGraphicsState {
382    /// Snapshot the graphics state, for `q` and for the implicit save around
383    /// `Do` (§8.10.1). Both callers go through here so the two can never drift
384    /// into disagreeing about what the graphics state contains.
385    fn capture(state: &TextState) -> Self {
386        Self {
387            ctm: state.ctm,
388            fill_color: state.fill_color,
389            leading: state.leading,
390            char_space: state.char_space,
391            word_space: state.word_space,
392            horizontal_scale: state.horizontal_scale,
393            text_rise: state.text_rise,
394            font_size: state.font_size,
395            font_name: state.font_name.clone(),
396            render_mode: state.render_mode,
397        }
398    }
399
400    /// Put the snapshot back. Consumes it, so the `String` moves instead of
401    /// being cloned.
402    ///
403    /// Note the fields it does NOT touch: the text matrices (text object state,
404    /// §9.4.1), the marked-content stack (its nesting is independent of
405    /// `q`/`Q`, §14.6) and the saved-state stack itself.
406    fn restore_into(self, state: &mut TextState) {
407        state.ctm = self.ctm;
408        state.fill_color = self.fill_color;
409        state.leading = self.leading;
410        state.char_space = self.char_space;
411        state.word_space = self.word_space;
412        state.horizontal_scale = self.horizontal_scale;
413        state.text_rise = self.text_rise;
414        state.font_size = self.font_size;
415        state.font_name = self.font_name;
416        state.render_mode = self.render_mode;
417    }
418}
419
420/// Mutable accumulator threaded through `process_operations` so the op loop
421/// can be driven recursively (page content stream → Form XObjects) while
422/// carrying text state, position, and accumulated output. Bundled into one
423/// struct so the op match moves verbatim into the recursive method (#319).
424struct OpRunState {
425    state: TextState,
426    in_text_object: bool,
427    last_x: f64,
428    last_y: f64,
429    extracted_text: String,
430    fragments: Vec<TextFragment>,
431    /// Set once the per-page byte budget (`max_extracted_bytes`) has cut text
432    /// accumulation short. Propagates through Form XObject recursion and into
433    /// [`ExtractedText::truncated`] (issue #382).
434    truncated: bool,
435}
436
437impl Default for TextState {
438    fn default() -> Self {
439        Self {
440            text_matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
441            text_line_matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
442            ctm: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
443            leading: 0.0,
444            char_space: 0.0,
445            word_space: 0.0,
446            horizontal_scale: 100.0,
447            text_rise: 0.0,
448            font_size: 0.0,
449            font_name: None,
450            render_mode: 0,
451            fill_color: None,
452            saved_states: GraphicsStateStack::default(),
453            mc_stack: Vec::new(),
454            pending_actualtext: None,
455        }
456    }
457}
458
459/// Parse font style (bold/italic) from font name
460///
461/// Detects bold and italic styles from common font naming patterns.
462/// Works with PostScript font names (e.g., "Helvetica-Bold", "Times-BoldItalic")
463/// and TrueType names (e.g., "Arial Bold", "Courier Oblique").
464///
465/// # Examples
466///
467/// ```
468/// use oxidize_pdf::text::extraction::parse_font_style;
469///
470/// assert_eq!(parse_font_style("Helvetica-Bold"), (true, false));
471/// assert_eq!(parse_font_style("Times-BoldItalic"), (true, true));
472/// assert_eq!(parse_font_style("Courier"), (false, false));
473/// assert_eq!(parse_font_style("Arial-Italic"), (false, true));
474/// ```
475///
476/// # Returns
477///
478/// Tuple of (is_bold, is_italic)
479pub fn parse_font_style(font_name: &str) -> (bool, bool) {
480    let name_lower = font_name.to_lowercase();
481
482    // Detect bold from common patterns
483    let is_bold = name_lower.contains("bold")
484        || name_lower.contains("-b")
485        || name_lower.contains(" b ")
486        || name_lower.ends_with(" b");
487
488    // Detect italic/oblique from common patterns
489    let is_italic = name_lower.contains("italic")
490        || name_lower.contains("oblique")
491        || name_lower.contains("-i")
492        || name_lower.contains(" i ")
493        || name_lower.ends_with(" i");
494
495    (is_bold, is_italic)
496}
497
498/// Relative font-size difference below which two lines still count as the same
499/// typographic style. Absorbs the sub-point jitter a scaled text matrix
500/// produces (11.96 vs 12.0) without absorbing a real size step: the smallest
501/// step in common use is 12 → 13pt (8%).
502const PARAGRAPH_STYLE_SIZE_TOLERANCE: f64 = 0.05;
503
504/// Whether two consecutive lines share the typographic style that makes them
505/// one paragraph.
506///
507/// A paragraph is a run of lines set in the same face; a change of size or
508/// weight marks a new block. Vertical gap alone cannot tell a heading from its
509/// body — a title set 40pt above 10pt body text falls inside the same 1.5×
510/// median-line-height window as ordinary line spacing (issue #436).
511///
512/// The cost of the two errors is asymmetric, which is why this splits on a
513/// signal as weak as a weight change. An over-split leaves two adjacent
514/// fragments that downstream chunking can still group. An under-split is
515/// irreversible: the merged fragment inherits the heading's size and weight,
516/// so `partition` classifies the whole block as a `Title` and its text becomes
517/// the `heading_path` breadcrumb of everything that follows.
518fn same_paragraph_style(a: &TextFragment, b: &TextFragment) -> bool {
519    if a.is_bold != b.is_bold {
520        return false;
521    }
522    let scale = a.font_size.abs().max(b.font_size.abs());
523    if scale <= 0.0 {
524        return true; // no usable size on either line: gap decides
525    }
526    (a.font_size - b.font_size).abs() / scale <= PARAGRAPH_STYLE_SIZE_TOLERANCE
527}
528
529/// Text extractor for PDF pages with CMap support
530pub struct TextExtractor {
531    options: ExtractionOptions,
532    /// Font cache for the current page (name-keyed, rebuilt per page since names are page-local)
533    font_cache: HashMap<String, FontInfo>,
534    /// Persistent font cache keyed by PDF object reference — avoids re-parsing the same font
535    /// object across pages. Most multi-page PDFs reuse the same font objects.
536    font_object_cache: HashMap<(u32, u16), FontInfo>,
537}
538
539impl TextExtractor {
540    /// Create a new text extractor with default options
541    pub fn new() -> Self {
542        Self {
543            options: ExtractionOptions::default(),
544            font_cache: HashMap::new(),
545            font_object_cache: HashMap::new(),
546        }
547    }
548
549    /// Create a text extractor with custom options
550    pub fn with_options(options: ExtractionOptions) -> Self {
551        Self {
552            options,
553            font_cache: HashMap::new(),
554            font_object_cache: HashMap::new(),
555        }
556    }
557
558    /// Run the full fragment-merge chain used by the partition pipeline:
559    /// kerning fix → line reconstruction → paragraph reconstruction.
560    ///
561    /// Honors `ExtractionOptions::reconstruct_paragraphs`: when `false`, only
562    /// `merge_close_fragments` (the kerning fix) runs and the input is
563    /// returned at fragment granularity.
564    ///
565    /// This method is `pub` so the integration test in
566    /// `tests/paragraph_reconstruction_test.rs` can exercise it without going
567    /// through a PDF file. Production callers should prefer
568    /// `PdfDocument::partition()` and friends, which use this internally.
569    pub fn merge_fragments_for_partition(&self, fragments: &[TextFragment]) -> Vec<TextFragment> {
570        let kerning_fixed = self.merge_close_fragments(fragments);
571        if !self.options.reconstruct_paragraphs {
572            return kerning_fixed;
573        }
574        let lines = self.merge_into_lines(&kerning_fixed);
575        self.merge_into_paragraphs(&lines)
576    }
577
578    /// Group fragments by baseline into single-line fragments.
579    ///
580    /// Two fragments are on the same line when their Y centers differ by less
581    /// than `0.2 * min(head.height, frag.height)`. The 0.2 ratio absorbs
582    /// sub-point baseline jitter from text-matrix arithmetic while keeping
583    /// tightly-spaced visual rows (e.g. table cells whose baselines are
584    /// separated by ~2-3pt at 9pt font) on distinct logical lines — see
585    /// issue #265.
586    ///
587    /// Fragments are grouped by `(row_id, Y_bucket, mcid)`, where `row_id`
588    /// comes from `assign_row_ids` (increments on Y-up-jumps in emission
589    /// order). Within a line the tie-break is emission index for tagged PDFs
590    /// (any fragment carries an mcid — ISO 32000 mandates logical order) and
591    /// X coordinate for non-tagged PDFs. A space is inserted between adjacent
592    /// fragments when the X gap exceeds `space_threshold * font_size`.
593    ///
594    /// The output bounding box for each line is the axis-aligned union of the
595    /// input fragments' bounding boxes; `font_size` and `font_name` are
596    /// inherited from the line's first fragment.
597    fn merge_into_lines(&self, fragments: &[TextFragment]) -> Vec<TextFragment> {
598        if fragments.is_empty() {
599            return Vec::new();
600        }
601
602        // Pre-pass: assign row_id from Y-up-jumps in emission order. This
603        // disambiguates columns in multi-column layouts where a single outer
604        // BDC makes mcid uniform across visually distinct columns. See
605        // `docs/superpowers/specs/2026-05-23-issue-265-line-interleaving-design.md`.
606        let row_ids = assign_row_ids(fragments);
607
608        // Whether this page has at least one tagged (mcid-carrying) fragment.
609        // `.any()` returns true if even one fragment has mcid=Some; the within-line
610        // tie-break then uses emission index for the whole page rather than X.
611        // See `docs/superpowers/specs/2026-05-23-issue-265-line-interleaving-design.md`.
612        //
613        // For tagged PDFs (PDF/UA, ISO 32000-2 tagged), the content stream delivers
614        // text in logical reading order, so within a visual line we preserve emission
615        // order rather than sorting by X. Out-of-left-to-right glyph placement
616        // (common in typeset tagged PDFs where the PDF author lays out glyphs via
617        // non-monotone Td/Tm operators) is correctly rendered by keeping emission order.
618        //
619        // For non-tagged PDFs (all mcid=None), we retain the X-sort fallback
620        // because many generators emit glyphs in arbitrary (often right-to-left
621        // or random) order and only the X coordinate gives reading order.
622        let is_tagged = fragments.iter().any(|f| f.mcid.is_some());
623
624        // Sort for line GROUPING only: row_id, then Y descending, then X.
625        // row_id keeps fragments from different visual rows in separate
626        // Y-bucket groups; Y descending puts higher-on-page lines first. The
627        // X tie-break only makes same-line fragments adjacent for grouping —
628        // the authoritative reading order WITHIN each line is decided per line
629        // below (#302 symptom 1), so this grouping order is not the final order.
630        let mut indexed: Vec<(u32, usize, &TextFragment)> = row_ids
631            .iter()
632            .copied()
633            .zip(fragments.iter().enumerate())
634            .map(|(rid, (idx, f))| (rid, idx, f))
635            .collect();
636        indexed.sort_by(|a, b| {
637            a.0.cmp(&b.0)
638                .then(b.2.y.total_cmp(&a.2.y))
639                .then(a.2.x.total_cmp(&b.2.x))
640        });
641
642        // Group into visual lines, carrying each fragment's emission index so
643        // the per-line ordering decision below can restore emission order.
644        let mut lines: Vec<Vec<(usize, &TextFragment)>> = Vec::new();
645        let mut last_seen_row_id: Option<u32> = None;
646        for (rid, idx, frag) in indexed {
647            let same_batch = last_seen_row_id == Some(rid);
648            let placed = same_batch
649                && lines.last_mut().is_some_and(|line| {
650                    let head = line[0].1;
651                    let tol = (head.height.min(frag.height)) * 0.2;
652                    (head.y - frag.y).abs() < tol && head.mcid == frag.mcid
653                });
654            if placed {
655                lines.last_mut().unwrap().push((idx, frag));
656            } else {
657                lines.push(vec![(idx, frag)]);
658                last_seen_row_id = Some(rid);
659            }
660        }
661
662        // Decide reading order per visual line (#302 symptom 1).
663        //
664        // X-sort is wrong when one line mixes fonts whose glyph metrics differ
665        // (e.g. an italic particle symbol set in roman body text): the producer
666        // gives the font-switched run an x-origin that falls INSIDE the x-span
667        // of its neighbours, so sorting by x interleaves it
668        // ("to the Z boson" -> "tZboso theon"). The content stream still emits
669        // these runs in correct reading order, so when a line's emission order
670        // has no DISJOINT backward x-step (only span overlaps, or is already
671        // x-monotone) we keep emission order. A disjoint backward step signals
672        // a genuinely scrambled stream (right-to-left / random generators), for
673        // which x-order stays authoritative. Deciding per line — not per
674        // column — prevents one scrambled line from forcing x-sort on the rest.
675        lines
676            .into_iter()
677            .map(|mut line| {
678                if is_tagged || line_prefers_emission_order(&line) {
679                    line.sort_by_key(|&(idx, _)| idx);
680                } else {
681                    line.sort_by(|a, b| a.1.x.total_cmp(&b.1.x));
682                }
683                let frags: Vec<&TextFragment> = line.into_iter().map(|(_, f)| f).collect();
684                self.build_line_fragment(frags)
685            })
686            .collect()
687    }
688
689    /// Space-glyph advance for `font_name` in text space (point units at
690    /// `font_size`), or `None` when unknown. Prefers the font's embedded
691    /// `/Widths` entry for code 32; falls back to the Adobe Core-14 AFM space
692    /// width for the standard base fonts (Times/Helvetica/Courier/Symbol/
693    /// ZapfDingbats), which ship no `/Widths` array (#302 symptom 2).
694    fn font_space_advance(&self, font_name: Option<&str>, font_size: f64) -> Option<f64> {
695        let info = self.font_cache.get(font_name?)?;
696        if let Some(ref widths) = info.metrics.widths {
697            let first = info.metrics.first_char.unwrap_or(0);
698            if first <= 32 {
699                if let Some(&w) = widths.get((32 - first) as usize) {
700                    if w > 0.0 {
701                        return Some(w / 1000.0 * font_size);
702                    }
703                }
704            }
705        }
706        standard_14_space_width(&info.name).map(|em| em / 1000.0 * font_size)
707    }
708
709    /// Minimum inter-fragment x-gap that counts as a word space for `frag`.
710    /// Anchored to the font's real space-glyph advance when known — word gaps
711    /// scale with the font's space metric, not with a fixed fraction of font
712    /// size — falling back to `space_threshold * font_size` otherwise. Tightly
713    /// set justified text (e.g. Standard-14 Times body) has word gaps near
714    /// 0.2em, far below the legacy 0.3*font_size, which dropped spaces
715    /// ("thequadrupletis"); a font with a 250-unit space then gets a 0.125em
716    /// threshold instead (#302 symptom 2).
717    fn space_gap_threshold(&self, frag: &TextFragment) -> f64 {
718        match self.font_space_advance(frag.font_name.as_deref(), frag.font_size) {
719            Some(adv) if adv > 0.0 => 0.5 * adv,
720            _ => self.options.space_threshold * frag.font_size,
721        }
722    }
723
724    /// Assemble one visual line's fragments into a single line `TextFragment`,
725    /// inserting a space between consecutive fragments whose x-gap exceeds the
726    /// font-anchored [`space_gap_threshold`](Self::space_gap_threshold).
727    fn build_line_fragment(&self, line: Vec<&TextFragment>) -> TextFragment {
728        let head = line[0];
729        let mut text = String::new();
730        let mut x_min = head.x;
731        let mut x_max = head.x + head.width;
732        let mut y_min = head.y;
733        let mut y_max = head.y + head.height;
734
735        for (i, frag) in line.iter().enumerate() {
736            if i > 0 {
737                let prev = line[i - 1];
738                let gap = frag.x - (prev.x + prev.width);
739                if gap > self.space_gap_threshold(frag) {
740                    text.push(' ');
741                }
742            }
743            text.push_str(&frag.text);
744            x_min = x_min.min(frag.x);
745            x_max = x_max.max(frag.x + frag.width);
746            y_min = y_min.min(frag.y);
747            y_max = y_max.max(frag.y + frag.height);
748        }
749
750        TextFragment {
751            text,
752            x: x_min,
753            y: y_min,
754            width: x_max - x_min,
755            height: y_max - y_min,
756            font_size: head.font_size,
757            font_name: head.font_name.clone(),
758            is_bold: head.is_bold,
759            is_italic: head.is_italic,
760            color: head.color,
761            space_decisions: Vec::new(),
762            mcid: head.mcid,
763            struct_tag: head.struct_tag.clone(),
764        }
765    }
766
767    /// Group consecutive lines into paragraphs based on vertical gap and
768    /// typographic style.
769    ///
770    /// Two consecutive lines are part of the same paragraph when the vertical
771    /// gap between them is less than 1.5× the median line height in the input
772    /// **and** they share the same style — see [`same_paragraph_style`].
773    /// Hyphenated line breaks (previous line ends with `-` and
774    /// `merge_hyphenated` is set) join without a separator and drop the
775    /// hyphen; otherwise lines join with `'\n'`.
776    fn merge_into_paragraphs(&self, lines: &[TextFragment]) -> Vec<TextFragment> {
777        if lines.is_empty() {
778            return Vec::new();
779        }
780
781        // Median line height — robust to outliers
782        let mut heights: Vec<f64> = lines.iter().map(|l| l.height).collect();
783        heights.sort_by(f64::total_cmp);
784        let median_h = heights[heights.len() / 2];
785        let max_paragraph_gap = median_h * 1.5;
786
787        let mut paragraphs: Vec<TextFragment> = Vec::new();
788        let mut current = lines[0].clone();
789
790        for line in &lines[1..] {
791            let prev_bottom = current.y;
792            let line_top = line.y + line.height;
793            let gap = prev_bottom - line_top;
794
795            if gap < 0.0
796                || gap > max_paragraph_gap
797                || current.mcid != line.mcid
798                || !same_paragraph_style(&current, line)
799            {
800                paragraphs.push(current);
801                current = line.clone();
802                continue;
803            }
804
805            // Same paragraph — join
806            let joined_text = if self.options.merge_hyphenated && current.text.ends_with('-') {
807                let mut s = current.text.clone();
808                s.pop(); // drop trailing hyphen
809                s.push_str(&line.text);
810                s
811            } else {
812                format!("{}\n{}", current.text, line.text)
813            };
814
815            let x_min = current.x.min(line.x);
816            let x_max = (current.x + current.width).max(line.x + line.width);
817            let y_min = current.y.min(line.y);
818            let y_max = (current.y + current.height).max(line.y + line.height);
819
820            current = TextFragment {
821                text: joined_text,
822                x: x_min,
823                y: y_min,
824                width: x_max - x_min,
825                height: y_max - y_min,
826                font_size: current.font_size,
827                font_name: current.font_name.clone(),
828                is_bold: current.is_bold,
829                is_italic: current.is_italic,
830                color: current.color,
831                space_decisions: Vec::new(),
832                mcid: current.mcid,
833                struct_tag: current.struct_tag.clone(),
834            };
835        }
836        paragraphs.push(current);
837
838        paragraphs
839    }
840
841    /// Extract text from a PDF document
842    pub fn extract_from_document<R: Read + Seek>(
843        &mut self,
844        document: &PdfDocument<R>,
845    ) -> ParseResult<Vec<ExtractedText>> {
846        let page_count = document.page_count()?;
847        let mut results = Vec::new();
848
849        for i in 0..page_count {
850            let text = self.extract_from_page(document, i)?;
851            results.push(text);
852        }
853
854        Ok(results)
855    }
856
857    /// Extract text from a specific page
858    pub fn extract_from_page<R: Read + Seek>(
859        &mut self,
860        document: &PdfDocument<R>,
861        page_index: u32,
862    ) -> ParseResult<ExtractedText> {
863        // Get the page
864        let page = document.get_page(page_index)?;
865
866        // Extract font resources first
867        {
868            let _span = tracing::info_span!("font_resources").entered();
869            self.extract_font_resources(&page, document)?;
870        }
871
872        // Get content streams
873        let streams = {
874            let _span = tracing::info_span!("stream_decompress").entered();
875            page.content_streams_with_document(document)?
876        };
877
878        let extracted_text = String::new();
879        let fragments = Vec::new();
880        let state = TextState::default();
881        let in_text_object = false;
882        let last_x = 0.0;
883        let last_y = 0.0;
884
885        // Page resources (owned) for XObject + /Properties lookup during
886        // recursive Form XObject extraction (issue #319).
887        let page_resources: Option<crate::parser::objects::PdfDictionary> =
888            if let Some(rr) = page.dict.get("Resources").and_then(|o| o.as_reference()) {
889                document
890                    .get_object(rr.0, rr.1)
891                    .ok()
892                    .and_then(|o| o.as_dict().cloned())
893            } else {
894                page.get_resources().cloned()
895            };
896
897        let mut run = OpRunState {
898            state,
899            in_text_object,
900            last_x,
901            last_y,
902            extracted_text,
903            fragments,
904            truncated: false,
905        };
906
907        // Process each content stream
908        for (stream_idx, stream_data) in streams.iter().enumerate() {
909            let operations = match {
910                let _span = tracing::info_span!("content_parse").entered();
911                ContentParser::parse_content(stream_data)
912            } {
913                Ok(ops) => ops,
914                Err(e) => {
915                    // Enhanced diagnostic logging for content stream parsing failures
916                    tracing::debug!(
917                        "Warning: Failed to parse content stream on page {}, stream {}/{}",
918                        page_index + 1,
919                        stream_idx + 1,
920                        streams.len()
921                    );
922                    tracing::debug!("         Error: {}", e);
923                    tracing::debug!("         Stream size: {} bytes", stream_data.len());
924
925                    // Show first 100 bytes for diagnosis (or less if stream is smaller)
926                    let preview_len = stream_data.len().min(100);
927                    let preview = String::from_utf8_lossy(&stream_data[..preview_len]);
928                    tracing::debug!(
929                        "         Stream preview (first {} bytes): {:?}",
930                        preview_len,
931                        preview.chars().take(80).collect::<String>()
932                    );
933
934                    // Continue processing other streams
935                    continue;
936                }
937            };
938
939            run = self.process_operations(
940                operations,
941                document,
942                page_resources.as_ref(),
943                run,
944                page_index,
945                0,
946            )?;
947
948            // Per-page byte budget reached (issue #382): don't decode the
949            // remaining content streams — the text is already at the limit.
950            if run.truncated {
951                break;
952            }
953        }
954
955        let OpRunState {
956            mut extracted_text,
957            mut fragments,
958            mut truncated,
959            ..
960        } = run;
961        {
962            let _span = tracing::info_span!("layout_finalize").entered();
963
964            // Sort and process fragments if requested — but ONLY when we're not
965            // going to run merge_into_lines later. merge_into_lines does its
966            // own (row_id, y, x) sort that needs pre-sort emission order to
967            // detect Y-up-jumps for column splitting (issue #265). For the
968            // legacy path with reconstruct_paragraphs=false, the early sort is
969            // still required because nothing downstream reorders fragments.
970            if self.options.sort_by_position
971                && !self.options.reconstruct_paragraphs
972                && !fragments.is_empty()
973            {
974                self.sort_and_merge_fragments(&mut fragments);
975            }
976
977            // Merge close fragments to eliminate spacing artifacts (kerning fix)
978            if self.options.preserve_layout && !fragments.is_empty() {
979                fragments = self.merge_close_fragments(&fragments);
980            }
981
982            // Reconstruct visual lines and paragraphs from raw fragments.
983            // Required for the partition pipeline to produce Element values at
984            // paragraph granularity (issue #261).
985            if self.options.reconstruct_paragraphs && !fragments.is_empty() {
986                let lines = self.merge_into_lines(&fragments);
987                fragments = self.merge_into_paragraphs(&lines);
988            }
989
990            // Reconstruct text from sorted fragments if layout is preserved
991            if self.options.preserve_layout && !fragments.is_empty() {
992                extracted_text = self.reconstruct_text_from_fragments(&fragments);
993            }
994
995            // Flat path with column reordering (issue #389): fragments were
996            // collected only to reorder. `sort_and_merge_fragments` already ran
997            // at the top of this block (sort_by_position defaults true) and now
998            // applies column clustering via the gate above; call it here too so
999            // the behaviour is independent of `sort_by_position`, then rebuild
1000            // the flat text from the reordered fragments and drop them (the
1001            // `.fragments` contract only exposes fragments under preserve_layout).
1002            if self.options.reorder_columns
1003                && !self.options.preserve_layout
1004                && !fragments.is_empty()
1005            {
1006                self.sort_and_merge_fragments(&mut fragments);
1007                extracted_text = self.reconstruct_text_from_fragments(&fragments);
1008                fragments.clear();
1009            }
1010
1011            // Final safety net (issue #382): the layout/reorder reconstruction
1012            // above rebuilds `.text` with its own separators, so guarantee the
1013            // `text.len() <= max_extracted_bytes` invariant for every path here.
1014            // No-op for the flat path (already bounded) and when no limit is set.
1015            clamp_to_budget(
1016                &mut extracted_text,
1017                self.options.max_extracted_bytes,
1018                &mut truncated,
1019            );
1020        }
1021
1022        Ok(ExtractedText {
1023            text: extracted_text,
1024            fragments,
1025            truncated,
1026        })
1027    }
1028
1029    /// Run a content-stream operation list, recursing into Form XObjects so
1030    /// text drawn inside a `Do`-painted Form XObject is extracted (issue #319).
1031    #[allow(clippy::too_many_arguments)]
1032    fn process_operations<R: Read + Seek>(
1033        &mut self,
1034        operations: Vec<ContentOperation>,
1035        document: &PdfDocument<R>,
1036        resources: Option<&crate::parser::objects::PdfDictionary>,
1037        run: OpRunState,
1038        page_index: u32,
1039        depth: u8,
1040    ) -> ParseResult<OpRunState> {
1041        let OpRunState {
1042            mut state,
1043            mut in_text_object,
1044            mut last_x,
1045            mut last_y,
1046            mut extracted_text,
1047            mut fragments,
1048            mut truncated,
1049        } = run;
1050
1051        let page_properties: Option<&crate::parser::objects::PdfDictionary> =
1052            resources.and_then(|res| match res.get("Properties") {
1053                Some(crate::parser::objects::PdfObject::Dictionary(d)) => Some(d),
1054                _ => None,
1055            });
1056
1057        let _ops_span = tracing::info_span!("text_ops_loop").entered();
1058        for op in operations {
1059            // Per-page byte budget reached (issue #382): stop processing further
1060            // operators. Show-text arms also `break` mid-run, but a state-only
1061            // op between two show ops would otherwise keep the loop alive.
1062            if truncated {
1063                break;
1064            }
1065            match op {
1066                ContentOperation::BeginText => {
1067                    in_text_object = true;
1068                    // Reset text matrix to identity
1069                    state.text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
1070                    state.text_line_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
1071                }
1072
1073                ContentOperation::EndText => {
1074                    in_text_object = false;
1075                }
1076
1077                ContentOperation::SetTextMatrix(a, b, c, d, e, f) => {
1078                    state.text_matrix =
1079                        [a as f64, b as f64, c as f64, d as f64, e as f64, f as f64];
1080                    state.text_line_matrix =
1081                        [a as f64, b as f64, c as f64, d as f64, e as f64, f as f64];
1082                }
1083
1084                ContentOperation::MoveText(tx, ty) => {
1085                    // Update text matrix by translation
1086                    let new_matrix = multiply_matrix(
1087                        &[1.0, 0.0, 0.0, 1.0, tx as f64, ty as f64],
1088                        &state.text_line_matrix,
1089                    );
1090                    state.text_matrix = new_matrix;
1091                    state.text_line_matrix = new_matrix;
1092                }
1093
1094                // `tx ty TD` (ISO 32000-1 §9.4.2) is defined as `-ty TL`
1095                // followed by `tx ty Td`: it moves to the next line AND sets
1096                // the leading. The operator was parsed but never handled, so
1097                // the line break did not exist for the extractor (`dx = dy =
1098                // 0` at the boundary) and every later `T*` inherited a stale
1099                // leading (issue #451).
1100                ContentOperation::MoveTextSetLeading(tx, ty) => {
1101                    state.leading = -(ty as f64);
1102                    let new_matrix = multiply_matrix(
1103                        &[1.0, 0.0, 0.0, 1.0, tx as f64, ty as f64],
1104                        &state.text_line_matrix,
1105                    );
1106                    state.text_matrix = new_matrix;
1107                    state.text_line_matrix = new_matrix;
1108                }
1109
1110                ContentOperation::NextLine => {
1111                    // Move to next line using current leading
1112                    let new_matrix = multiply_matrix(
1113                        &[1.0, 0.0, 0.0, 1.0, 0.0, -state.leading],
1114                        &state.text_line_matrix,
1115                    );
1116                    state.text_matrix = new_matrix;
1117                    state.text_line_matrix = new_matrix;
1118                }
1119
1120                ContentOperation::ShowText(text) => {
1121                    if in_text_object {
1122                        let text_bytes = &text;
1123                        let decoded = self.decode_text(text_bytes, &state)?;
1124
1125                        // Pen origin in user space = (CTM × text_matrix)(0, 0).
1126                        let (x, y) = text_origin(&state);
1127
1128                        // Mirror the gate inside `emit_text_fragment` so that
1129                        // `.text` and `.fragments` stay consistent for pages
1130                        // wrapped in an `/Artifact` marked-content scope —
1131                        // issue #330.
1132                        let skip_text = skip_artifact_text(&state, self.options.include_artifacts);
1133
1134                        // Add spacing based on position change
1135                        if !skip_text {
1136                            let separator = if !extracted_text.is_empty() {
1137                                // Baseline-frame deltas (issue #443): identical
1138                                // to raw Δx/Δy for axis-aligned matrices,
1139                                // rotation-normalized otherwise.
1140                                let (dx, dy_signed) = pen_delta(&state, (last_x, last_y), (x, y));
1141                                let dy = dy_signed.abs();
1142
1143                                // A large backward jump in x is a line wrap: the
1144                                // pen returns to the left margin on a new line.
1145                                // When the line height is below `newline_threshold`
1146                                // the dy check alone misses it, so treat a backward
1147                                // dx beyond one line-height (2× the threshold,
1148                                // conservative) as a newline even when dy is small
1149                                // (issue #390). With a nonzero leading that gate is
1150                                // enough; but at dy == 0 the jump is ambiguous with
1151                                // a same-line reposition (issue #441). Resolve it by
1152                                // magnitude: a reposition is local, a same-Y wrap
1153                                // returns across the whole column, so a jump beyond
1154                                // `SAME_Y_WRAP_EM` font sizes is a wrap even at dy == 0
1155                                // (issue #447). dx/dy are baseline-relative (issue
1156                                // #443), so this holds under rotation; the epsilon
1157                                // absorbs projection rounding noise.
1158                                let same_y_wrap = dx < -(state.font_size.abs() * SAME_Y_WRAP_EM);
1159                                let line_wrap = dx < -(self.options.newline_threshold * 2.0)
1160                                    && (dy > SAME_LINE_EPS || same_y_wrap);
1161                                if dy > self.options.newline_threshold || line_wrap {
1162                                    Some('\n')
1163                                } else if dx > self.options.space_threshold * state.font_size {
1164                                    Some(' ')
1165                                } else {
1166                                    None
1167                                }
1168                            } else {
1169                                None
1170                            };
1171
1172                            // Per-page byte budget (issue #382): stop before the
1173                            // run that would overshoot; the outer loop guard ends
1174                            // extraction on the next iteration.
1175                            if !append_bounded(
1176                                &mut extracted_text,
1177                                separator,
1178                                &decoded,
1179                                self.options.max_extracted_bytes,
1180                                &mut truncated,
1181                            ) {
1182                                break;
1183                            }
1184                        }
1185
1186                        // Get font info for accurate width calculation.
1187                        // Width comes from the char codes (`text_bytes`), not
1188                        // the decoded Unicode: the Widths array is code-indexed
1189                        // (issue #302).
1190                        let text_width = {
1191                            let font_info = state
1192                                .font_name
1193                                .as_ref()
1194                                .and_then(|name| self.font_cache.get(name));
1195                            calculate_text_width_from_codes(
1196                                text_bytes,
1197                                &decoded,
1198                                state.font_size,
1199                                font_info,
1200                            )
1201                        };
1202
1203                        if self.options.preserve_layout || self.options.reorder_columns {
1204                            emit_text_fragment(
1205                                &mut fragments,
1206                                &decoded,
1207                                text_width,
1208                                x,
1209                                y,
1210                                &mut state,
1211                                self.options.include_artifacts,
1212                            );
1213                        }
1214
1215                        // Advance the text matrix and track the true post-advance
1216                        // pen point (folds in Tz and CTM scale, issue #386; a
1217                        // full point so rotated baselines advance y too, #443).
1218                        (last_x, last_y) = advance_pen(&mut state, text_width);
1219                    }
1220                }
1221
1222                ContentOperation::ShowTextArray(array) => {
1223                    if in_text_object {
1224                        for item in array {
1225                            match item {
1226                                TextElement::Text(text_bytes) => {
1227                                    let decoded = self.decode_text(&text_bytes, &state)?;
1228                                    // Mirror the gate inside `emit_text_fragment`
1229                                    // so `.text` and `.fragments` stay consistent
1230                                    // for Artifact scopes (issue #330).
1231                                    let skip_text =
1232                                        skip_artifact_text(&state, self.options.include_artifacts);
1233
1234                                    // Pen origin in user space = (CTM × text_matrix)(0, 0).
1235                                    let (x, y) = text_origin(&state);
1236
1237                                    // Insert a newline when this TJ piece starts on a
1238                                    // different visual line than the previously shown
1239                                    // text (issue #381), or when the pen jumps far back
1240                                    // to the left — a line wrap whose line height is
1241                                    // below `newline_threshold` (issue #390). Only the
1242                                    // newline case is handled here: horizontal word
1243                                    // spacing within a line is governed by the
1244                                    // `TextElement::Spacing` kern logic below, and a
1245                                    // forward dx-based space would wrongly split a single
1246                                    // word that a TJ array draws as several positioned
1247                                    // pieces. A *backward* dx beyond one line-height
1248                                    // (2× the threshold, conservative) is a wrap, not a
1249                                    // kern, so it is safe to break there — but only when
1250                                    // the pen also moved vertically: with a nonzero
1251                                    // leading that gate identifies the wrap. At dy == 0
1252                                    // the backward jump is ambiguous with a same-line
1253                                    // reposition (issue #441); resolve it by magnitude,
1254                                    // treating a jump beyond `SAME_Y_WRAP_EM` font sizes
1255                                    // as a same-Y wrap (issue #447). Deltas are
1256                                    // baseline-relative (issue #443), so both gates hold
1257                                    // under rotation; the epsilon absorbs projection
1258                                    // rounding noise.
1259                                    let (dx, dy_signed) =
1260                                        pen_delta(&state, (last_x, last_y), (x, y));
1261                                    let dy = dy_signed.abs();
1262                                    let same_y_wrap =
1263                                        dx < -(state.font_size.abs() * SAME_Y_WRAP_EM);
1264                                    let line_wrap = dx < -(self.options.newline_threshold * 2.0)
1265                                        && (dy > SAME_LINE_EPS || same_y_wrap);
1266                                    if !skip_text {
1267                                        let separator = if !extracted_text.is_empty()
1268                                            && (dy > self.options.newline_threshold || line_wrap)
1269                                        {
1270                                            Some('\n')
1271                                        } else {
1272                                            None
1273                                        };
1274
1275                                        // Per-page byte budget (issue #382).
1276                                        if !append_bounded(
1277                                            &mut extracted_text,
1278                                            separator,
1279                                            &decoded,
1280                                            self.options.max_extracted_bytes,
1281                                            &mut truncated,
1282                                        ) {
1283                                            break;
1284                                        }
1285                                    }
1286
1287                                    let text_width = {
1288                                        let font_info = state
1289                                            .font_name
1290                                            .as_ref()
1291                                            .and_then(|name| self.font_cache.get(name));
1292                                        calculate_text_width_from_codes(
1293                                            &text_bytes,
1294                                            &decoded,
1295                                            state.font_size,
1296                                            font_info,
1297                                        )
1298                                    };
1299
1300                                    if self.options.preserve_layout || self.options.reorder_columns
1301                                    {
1302                                        emit_text_fragment(
1303                                            &mut fragments,
1304                                            &decoded,
1305                                            text_width,
1306                                            x,
1307                                            y,
1308                                            &mut state,
1309                                            self.options.include_artifacts,
1310                                        );
1311                                    }
1312
1313                                    // Keep the pen position in sync so a following
1314                                    // `Tj`/`TJ` measures its gap from the right origin
1315                                    // (issue #381: a stale `last_y` dropped newlines;
1316                                    // issue #386: the pen must fold in Tz/CTM scale).
1317                                    (last_x, last_y) = advance_pen(&mut state, text_width);
1318                                }
1319                                TextElement::Spacing(adjustment) => {
1320                                    // Text position adjustment (negative = move left,
1321                                    // i.e. shifts the pen forward). When the synthesised
1322                                    // forward advance exceeds `tj_space_threshold * font_size`
1323                                    // we treat the kern as an implicit `U+0020` (issue #272):
1324                                    // many PDFs encode word breaks purely as wide negative
1325                                    // kerns and never emit a literal space byte.
1326                                    let tx = -(adjustment as f64) / 1000.0 * state.font_size;
1327
1328                                    let skip_tj_space =
1329                                        skip_artifact_text(&state, self.options.include_artifacts);
1330                                    if !skip_tj_space
1331                                        && tx > self.options.tj_space_threshold * state.font_size
1332                                        && !extracted_text.is_empty()
1333                                        && !extracted_text.ends_with(' ')
1334                                    {
1335                                        // Per-page byte budget (issue #382): even
1336                                        // the synthesised space counts, so the
1337                                        // `text.len() <= limit` invariant holds.
1338                                        if !append_bounded(
1339                                            &mut extracted_text,
1340                                            Some(' '),
1341                                            "",
1342                                            self.options.max_extracted_bytes,
1343                                            &mut truncated,
1344                                        ) {
1345                                            break;
1346                                        }
1347
1348                                        // Skip the fragment-level emission while an
1349                                        // ActualText scope is pending: the synthesised
1350                                        // space is a heuristic, not real content, and
1351                                        // emitting it would call `emit_text_fragment`
1352                                        // whose ActualText short-circuit would inflate
1353                                        // `pending.width` and set `pending.populated`
1354                                        // even though no real `Tj` has fired yet. The
1355                                        // EMC flush will supply the canonical fragment
1356                                        // text from the override (Phase 1 #269 contract).
1357                                        if (self.options.preserve_layout
1358                                            || self.options.reorder_columns)
1359                                            && state.pending_actualtext.is_none()
1360                                        {
1361                                            // Emit a synthetic single-space fragment at the
1362                                            // current pen origin so downstream layout merges
1363                                            // (e.g. `merge_close_fragments`) see the gap as
1364                                            // explicit content rather than as a sub-threshold
1365                                            // x-jump. Width = the kern advance so the next
1366                                            // text fragment begins flush against it.
1367                                            let (sx, sy) = text_origin(&state);
1368                                            emit_text_fragment(
1369                                                &mut fragments,
1370                                                " ",
1371                                                tx,
1372                                                sx,
1373                                                sy,
1374                                                &mut state,
1375                                                self.options.include_artifacts,
1376                                            );
1377                                        }
1378                                    }
1379
1380                                    state.text_matrix = multiply_matrix(
1381                                        &[1.0, 0.0, 0.0, 1.0, tx, 0.0],
1382                                        &state.text_matrix,
1383                                    );
1384                                }
1385                            }
1386                        }
1387                    }
1388                }
1389
1390                ContentOperation::NextLineShowText(text) => {
1391                    if in_text_object {
1392                        // ' = T* then Tj string. Advance line matrix by -leading.
1393                        let new_matrix = multiply_matrix(
1394                            &[1.0, 0.0, 0.0, 1.0, 0.0, -state.leading],
1395                            &state.text_line_matrix,
1396                        );
1397                        state.text_matrix = new_matrix;
1398                        state.text_line_matrix = new_matrix;
1399
1400                        let decoded = self.decode_text(&text, &state)?;
1401                        let (x, y) = text_origin(&state);
1402
1403                        // Mirror the artifact gate (issue #330).
1404                        let skip_text = skip_artifact_text(&state, self.options.include_artifacts);
1405                        if !skip_text {
1406                            let separator = if extracted_text.is_empty() {
1407                                None
1408                            } else {
1409                                Some('\n')
1410                            };
1411                            // Per-page byte budget (issue #382).
1412                            if !append_bounded(
1413                                &mut extracted_text,
1414                                separator,
1415                                &decoded,
1416                                self.options.max_extracted_bytes,
1417                                &mut truncated,
1418                            ) {
1419                                break;
1420                            }
1421                        }
1422
1423                        let text_width = {
1424                            let font_info = state
1425                                .font_name
1426                                .as_ref()
1427                                .and_then(|name| self.font_cache.get(name));
1428                            calculate_text_width_from_codes(
1429                                &text,
1430                                &decoded,
1431                                state.font_size,
1432                                font_info,
1433                            )
1434                        };
1435
1436                        if self.options.preserve_layout || self.options.reorder_columns {
1437                            emit_text_fragment(
1438                                &mut fragments,
1439                                &decoded,
1440                                text_width,
1441                                x,
1442                                y,
1443                                &mut state,
1444                                self.options.include_artifacts,
1445                            );
1446                        }
1447
1448                        (last_x, last_y) = advance_pen(&mut state, text_width);
1449                    }
1450                }
1451
1452                ContentOperation::SetSpacingNextLineShowText(word_space, char_space, text) => {
1453                    if in_text_object {
1454                        // " = aw Tw, ac Tc, then ' string. ISO 32000-1 §9.4.3.
1455                        // The variant fields mirror the spec field names:
1456                        // (word_spacing, char_spacing, text).
1457                        state.word_space = word_space as f64;
1458                        state.char_space = char_space as f64;
1459
1460                        let new_matrix = multiply_matrix(
1461                            &[1.0, 0.0, 0.0, 1.0, 0.0, -state.leading],
1462                            &state.text_line_matrix,
1463                        );
1464                        state.text_matrix = new_matrix;
1465                        state.text_line_matrix = new_matrix;
1466
1467                        let decoded = self.decode_text(&text, &state)?;
1468                        let (x, y) = text_origin(&state);
1469
1470                        // Mirror the artifact gate (issue #330).
1471                        let skip_text = skip_artifact_text(&state, self.options.include_artifacts);
1472                        if !skip_text {
1473                            let separator = if extracted_text.is_empty() {
1474                                None
1475                            } else {
1476                                Some('\n')
1477                            };
1478                            // Per-page byte budget (issue #382).
1479                            if !append_bounded(
1480                                &mut extracted_text,
1481                                separator,
1482                                &decoded,
1483                                self.options.max_extracted_bytes,
1484                                &mut truncated,
1485                            ) {
1486                                break;
1487                            }
1488                        }
1489
1490                        let text_width = {
1491                            let font_info = state
1492                                .font_name
1493                                .as_ref()
1494                                .and_then(|name| self.font_cache.get(name));
1495                            calculate_text_width_from_codes(
1496                                &text,
1497                                &decoded,
1498                                state.font_size,
1499                                font_info,
1500                            )
1501                        };
1502
1503                        if self.options.preserve_layout || self.options.reorder_columns {
1504                            emit_text_fragment(
1505                                &mut fragments,
1506                                &decoded,
1507                                text_width,
1508                                x,
1509                                y,
1510                                &mut state,
1511                                self.options.include_artifacts,
1512                            );
1513                        }
1514
1515                        (last_x, last_y) = advance_pen(&mut state, text_width);
1516                    }
1517                }
1518
1519                ContentOperation::SetFont(name, size) => {
1520                    state.font_name = Some(name);
1521                    state.font_size = size as f64;
1522                }
1523
1524                ContentOperation::SetLeading(leading) => {
1525                    state.leading = leading as f64;
1526                }
1527
1528                ContentOperation::SetCharSpacing(spacing) => {
1529                    state.char_space = spacing as f64;
1530                }
1531
1532                ContentOperation::SetWordSpacing(spacing) => {
1533                    state.word_space = spacing as f64;
1534                }
1535
1536                ContentOperation::SetHorizontalScaling(scale) => {
1537                    state.horizontal_scale = scale as f64;
1538                }
1539
1540                ContentOperation::SetTextRise(rise) => {
1541                    state.text_rise = rise as f64;
1542                }
1543
1544                ContentOperation::SetTextRenderMode(mode) => {
1545                    state.render_mode = mode as u8;
1546                }
1547
1548                ContentOperation::SetTransformMatrix(a, b, c, d, e, f) => {
1549                    // Update CTM: new_ctm = concat_matrix * current_ctm
1550                    let [a0, b0, c0, d0, e0, f0] = state.ctm;
1551                    let a = a as f64;
1552                    let b = b as f64;
1553                    let c = c as f64;
1554                    let d = d as f64;
1555                    let e = e as f64;
1556                    let f = f as f64;
1557                    state.ctm = [
1558                        a * a0 + b * c0,
1559                        a * b0 + b * d0,
1560                        c * a0 + d * c0,
1561                        c * b0 + d * d0,
1562                        e * a0 + f * c0 + e0,
1563                        e * b0 + f * d0 + f0,
1564                    ];
1565                }
1566
1567                // Graphics state stack (issue #262). `q` snapshots the
1568                // current CTM and fill_color; `Q` restores the most recent
1569                // snapshot. Without these, every `cm` accumulates onto the
1570                // CTM forever, producing absurd page-space coordinates and
1571                // wrong font_size scaling on PDFs that nest graphics state.
1572                ContentOperation::SaveGraphicsState => {
1573                    state.save_graphics_state();
1574                }
1575                ContentOperation::RestoreGraphicsState => {
1576                    // Text state is graphics state (§9.3, Table 52): a leading,
1577                    // font or scale set inside the block dies with it (issue
1578                    // #452). Unbalanced Q (pop on empty stack) is silently
1579                    // ignored to keep extraction robust to malformed PDFs.
1580                    if let Some(saved) = state.saved_states.pop() {
1581                        saved.restore_into(&mut state);
1582                    }
1583                }
1584
1585                // Color operations (Phase 4: Color extraction)
1586                ContentOperation::SetNonStrokingGray(gray) => {
1587                    state.fill_color = Some(Color::gray(gray as f64));
1588                }
1589
1590                ContentOperation::SetNonStrokingRGB(r, g, b) => {
1591                    state.fill_color = Some(Color::rgb(r as f64, g as f64, b as f64));
1592                }
1593
1594                ContentOperation::SetNonStrokingCMYK(c, m, y, k) => {
1595                    state.fill_color = Some(Color::cmyk(c as f64, m as f64, y as f64, k as f64));
1596                }
1597
1598                // Issue #269 Phase 1: marked-content operators
1599                ContentOperation::BeginMarkedContent(tag) => {
1600                    let parent_artifact = state.mc_stack.last().is_some_and(|e| e.is_artifact);
1601                    state.mc_stack.push(MarkedContentEntry {
1602                        is_artifact: tag == "Artifact" || parent_artifact,
1603                        tag,
1604                        mcid: None,
1605                        actual_text: None,
1606                    });
1607                }
1608
1609                ContentOperation::BeginMarkedContentWithProps(tag, props) => {
1610                    let parent_artifact = state.mc_stack.last().is_some_and(|e| e.is_artifact);
1611                    let (mcid, actual_text) = resolve_props(&props, page_properties);
1612
1613                    // If this scope declares ActualText, open a pending run that will be
1614                    // flushed on the matching EMC. Suppresses per-Tj emission inside the
1615                    // scope (innermost-ActualText-wins per spec §4).
1616                    if let Some(ref text) = actual_text {
1617                        state.pending_actualtext = Some(PendingActualText {
1618                            text: text.clone(),
1619                            first_x: 0.0,
1620                            first_y: 0.0,
1621                            width: 0.0,
1622                            font_size: state.font_size,
1623                            font_name: state.font_name.clone(),
1624                            is_bold: false, // overwritten on first Tj
1625                            is_italic: false,
1626                            color: state.fill_color,
1627                            stack_depth: state.mc_stack.len(), // BEFORE the push below
1628                            populated: false,
1629                        });
1630                    }
1631
1632                    state.mc_stack.push(MarkedContentEntry {
1633                        is_artifact: tag == "Artifact" || parent_artifact,
1634                        tag,
1635                        mcid,
1636                        actual_text,
1637                    });
1638                }
1639
1640                ContentOperation::EndMarkedContent => {
1641                    let popped_depth = state.mc_stack.len();
1642                    if state.mc_stack.pop().is_none() {
1643                        // Unbalanced EMC — log and ignore. Real PDFs occasionally emit
1644                        // dangling EMC (e.g. from incremental updates). We must not panic.
1645                        tracing::debug!(
1646                            "extraction: EMC with empty marked-content stack on page {}",
1647                            page_index + 1
1648                        );
1649                    } else if let Some(pending) = state.pending_actualtext.as_ref() {
1650                        // If we just closed the scope that opened the pending run, flush it.
1651                        if pending.stack_depth + 1 == popped_depth {
1652                            let run = state.pending_actualtext.take().unwrap();
1653                            if run.populated
1654                                && (self.options.preserve_layout || self.options.reorder_columns)
1655                            {
1656                                let (mcid, struct_tag) = innermost_mc_tag(&state.mc_stack);
1657                                let in_artifact = state.mc_stack.iter().any(|e| e.is_artifact);
1658                                if !in_artifact || self.options.include_artifacts {
1659                                    // Per-page byte budget (issue #382): the
1660                                    // `/ActualText` override is this scope's
1661                                    // canonical text and can be arbitrarily
1662                                    // large. It bypasses the per-`Tj`
1663                                    // `append_bounded` gate, so account it here
1664                                    // against the same ledger (`extracted_text`,
1665                                    // which these paths rebuild from `fragments`).
1666                                    // If it would overshoot, drop the fragment and
1667                                    // stop — a huge override must not escape the
1668                                    // cap while reporting `truncated = false`.
1669                                    if !append_bounded(
1670                                        &mut extracted_text,
1671                                        None,
1672                                        &run.text,
1673                                        self.options.max_extracted_bytes,
1674                                        &mut truncated,
1675                                    ) {
1676                                        break;
1677                                    }
1678                                    fragments.push(TextFragment {
1679                                        text: run.text,
1680                                        x: run.first_x,
1681                                        y: run.first_y,
1682                                        width: run.width,
1683                                        height: run.font_size,
1684                                        font_size: run.font_size,
1685                                        font_name: run.font_name,
1686                                        is_bold: run.is_bold,
1687                                        is_italic: run.is_italic,
1688                                        color: run.color,
1689                                        space_decisions: Vec::new(),
1690                                        mcid,
1691                                        struct_tag,
1692                                    });
1693                                }
1694                            }
1695                        }
1696                    }
1697                }
1698
1699                ContentOperation::PaintXObject(name) => {
1700                    // Issue #319: recurse into Form XObjects. `Do` paints a
1701                    // Form XObject in an implicit q/Q, with the XObject's
1702                    // /Matrix composed onto the CTM and its own /Resources
1703                    // fonts in scope. Without this, text drawn inside the
1704                    // XObject (the page body, for RML2PDF "inclPDF" output)
1705                    // is never extracted.
1706                    const MAX_XOBJECT_DEPTH: u8 = 12;
1707                    if depth < MAX_XOBJECT_DEPTH {
1708                        if let Some((xobj_ops, xobj_res, matrix)) =
1709                            self.load_form_xobject(resources, &name, document)
1710                        {
1711                            // `Do` paints inside an IMPLICIT q/Q (§8.10.1),
1712                            // so the whole graphics state — text state included
1713                            // (issue #452) — comes back afterwards. Same
1714                            // snapshot the `q` arm takes, so the two cannot
1715                            // disagree about what that state is.
1716                            let outer = SavedGraphicsState::capture(&state);
1717                            let saved_fonts = self.font_cache.clone();
1718                            // The form gets its own save-state stack: a stray
1719                            // `Q` inside it must not pop the page's snapshots.
1720                            // Truncating afterwards could not undo that — a
1721                            // popped entry is gone — and with the text state
1722                            // now in each snapshot, a mispaired restore
1723                            // corrupts font decoding, not just the CTM.
1724                            //
1725                            // The count of pushes the depth cap refused is part
1726                            // of the stack, so it changes hands here too: a
1727                            // form that inherited the page's count would let its
1728                            // own `Q` consume it, and the page would come back
1729                            // short (issue #455).
1730                            let outer_stack = std::mem::take(&mut state.saved_states);
1731
1732                            if let Some(m) = matrix {
1733                                let [a0, b0, c0, d0, e0, f0] = state.ctm;
1734                                let [a, b, c, d, e, f] = m;
1735                                state.ctm = [
1736                                    a * a0 + b * c0,
1737                                    a * b0 + b * d0,
1738                                    c * a0 + d * c0,
1739                                    c * b0 + d * d0,
1740                                    e * a0 + f * c0 + e0,
1741                                    e * b0 + f * d0 + f0,
1742                                ];
1743                            }
1744                            if let Some(ref xr) = xobj_res {
1745                                self.cache_fonts_from_resources::<R>(xr, document);
1746                            }
1747
1748                            let sub = OpRunState {
1749                                state,
1750                                in_text_object: false,
1751                                last_x,
1752                                last_y,
1753                                extracted_text,
1754                                fragments,
1755                                truncated,
1756                            };
1757                            let mut out = self.process_operations(
1758                                xobj_ops,
1759                                document,
1760                                xobj_res.as_ref(),
1761                                sub,
1762                                page_index,
1763                                depth + 1,
1764                            )?;
1765
1766                            outer.restore_into(&mut out.state);
1767                            out.state.saved_states = outer_stack;
1768                            self.font_cache = saved_fonts;
1769
1770                            state = out.state;
1771                            last_x = out.last_x;
1772                            last_y = out.last_y;
1773                            extracted_text = out.extracted_text;
1774                            fragments = out.fragments;
1775                            truncated = out.truncated;
1776                        }
1777                    }
1778                }
1779                _ => {
1780                    // Other operations don't affect text extraction
1781                }
1782            }
1783        }
1784
1785        Ok(OpRunState {
1786            state,
1787            in_text_object,
1788            last_x,
1789            last_y,
1790            extracted_text,
1791            fragments,
1792            truncated,
1793        })
1794    }
1795
1796    /// Load a Form XObject by name: parsed operations, resolved /Resources,
1797    /// and optional /Matrix. None for image XObjects or anything unparseable.
1798    fn load_form_xobject<R: Read + Seek>(
1799        &self,
1800        resources: Option<&crate::parser::objects::PdfDictionary>,
1801        name: &str,
1802        document: &PdfDocument<R>,
1803    ) -> Option<(
1804        Vec<ContentOperation>,
1805        Option<crate::parser::objects::PdfDictionary>,
1806        Option<[f64; 6]>,
1807    )> {
1808        use crate::parser::objects::PdfObject;
1809        let res = resources?;
1810        let xobjects = match res.get("XObject")? {
1811            PdfObject::Dictionary(d) => d.clone(),
1812            PdfObject::Reference(n, g) => match document.get_object(*n, *g).ok()? {
1813                PdfObject::Dictionary(d) => d,
1814                _ => return None,
1815            },
1816            _ => return None,
1817        };
1818        let (n, g) = xobjects.get(name)?.as_reference()?;
1819        let obj = document.get_object(n, g).ok()?;
1820        let stream = obj.as_stream()?;
1821        if stream
1822            .dict
1823            .get("Subtype")
1824            .and_then(|o| o.as_name())
1825            .map(|nm| nm.0.as_str())
1826            != Some("Form")
1827        {
1828            return None;
1829        }
1830        let data = stream.decode(&Default::default()).ok()?;
1831        let ops = ContentParser::parse_content(&data).ok()?;
1832        let xobj_res = match stream.dict.get("Resources") {
1833            Some(PdfObject::Dictionary(d)) => Some(d.clone()),
1834            Some(PdfObject::Reference(rn, rg)) => document
1835                .get_object(*rn, *rg)
1836                .ok()
1837                .and_then(|o| o.as_dict().cloned()),
1838            _ => None,
1839        };
1840        let matrix = stream
1841            .dict
1842            .get("Matrix")
1843            .and_then(|o| o.as_array())
1844            .and_then(|a| {
1845                if a.0.len() == 6 {
1846                    let mut m = [0.0f64; 6];
1847                    for (i, slot) in m.iter_mut().enumerate() {
1848                        *slot = a.0[i]
1849                            .as_real()
1850                            .or_else(|| a.0[i].as_integer().map(|x| x as f64))?;
1851                    }
1852                    Some(m)
1853                } else {
1854                    None
1855                }
1856            });
1857        Some((ops, xobj_res, matrix))
1858    }
1859
1860    /// Sort text fragments by position and merge them appropriately
1861    fn sort_and_merge_fragments(&self, fragments: &mut [TextFragment]) {
1862        // Establish reading order (top-to-bottom, left-to-right) without ever
1863        // collapsing two distinct visual lines into one.
1864        //
1865        // A single `sort_by` with a threshold-based "same line" comparator is not
1866        // transitive (A≈B, B≈C ⇏ A≈C), which Rust's sort requires. The previous
1867        // implementation restored transitivity by quantizing Y into fixed bands of
1868        // `newline_threshold` width — but fixed bands collide two lines that
1869        // straddle a band boundary while sitting closer than the band width. With
1870        // 8pt leading under the 10pt default, y=684 → band −68 and y=676 → band
1871        // −68 land in the same band; the secondary X sort then interleaved the two
1872        // lines glyph-by-glyph, shredding any token that straddled the corruption
1873        // (issue #408).
1874        //
1875        // Instead, sort in two transitive phases over an index permutation. First
1876        // by exact Y (top-to-bottom — a real total order). Then group consecutive
1877        // fragments into visual lines with a jitter tolerance anchored to the
1878        // line's head, matching `merge_into_lines` (`height * 0.2`, which tracks
1879        // font size, not the paragraph-break `newline_threshold`), and order each
1880        // line left-to-right by X. Ties broken by original index keep it stable.
1881        let n = fragments.len();
1882        let mut order: Vec<usize> = (0..n).collect();
1883        order.sort_by(|&i, &j| fragments[j].y.total_cmp(&fragments[i].y).then(i.cmp(&j)));
1884
1885        let mut line_start = 0usize;
1886        while line_start < n {
1887            let head_y = fragments[order[line_start]].y;
1888            let head_h = fragments[order[line_start]].height;
1889            let mut line_end = line_start + 1;
1890            while line_end < n {
1891                let frag = &fragments[order[line_end]];
1892                let tol = head_h.min(frag.height) * 0.2;
1893                // Negated `< tol` (not `>= tol`) so a non-finite Y from a
1894                // degenerate text matrix forces a line break instead of a
1895                // NaN comparison silently swallowing every remaining fragment.
1896                if !((head_y - frag.y).abs() < tol) {
1897                    break;
1898                }
1899                line_end += 1;
1900            }
1901            order[line_start..line_end].sort_by(|&i, &j| fragments[i].x.total_cmp(&fragments[j].x));
1902            line_start = line_end;
1903        }
1904
1905        // Apply the permutation in place (one clone per fragment, then move back).
1906        let reordered: Vec<TextFragment> = order.iter().map(|&i| fragments[i].clone()).collect();
1907        for (slot, frag) in fragments.iter_mut().zip(reordered) {
1908            *slot = frag;
1909        }
1910
1911        // Detect columns if requested. `reorder_columns` forces column detection
1912        // only on the flat path (`!preserve_layout`); in layout mode `detect_columns`
1913        // is the intended control, keeping the `reorder_columns` field flat-only as
1914        // documented (issue #389).
1915        if self.options.detect_columns
1916            || (self.options.reorder_columns && !self.options.preserve_layout)
1917        {
1918            self.detect_and_sort_columns(fragments);
1919        }
1920    }
1921
1922    /// Detect columns and re-sort fragments accordingly
1923    fn detect_and_sort_columns(&self, fragments: &mut [TextFragment]) {
1924        // `fragments` arrives pre-sorted by `sort_and_merge_fragments` in reading
1925        // order: top-to-bottom by Y band, left-to-right by X within a band.
1926        //
1927        // Column boundaries are scoped to the row-span of the block that produced
1928        // them (issue #403). A page that mixes a small table with unrelated
1929        // full-width prose must not apply the table's column gaps to the
1930        // paragraph: doing so bucketed the paragraph's per-glyph fragments into
1931        // different "columns" by x-position and shredded any token that straddled
1932        // a boundary. We therefore only reorder fragments inside a *columnar
1933        // block* — a maximal run of consecutive lines that each exhibit an
1934        // internal gap wider than `column_threshold` — and leave full-width
1935        // "flow" lines in their natural reading order.
1936
1937        // Group fragment indices into lines. Indices (not `&mut`) so we can later
1938        // reorder the slice by a computed permutation.
1939        //
1940        // The tolerance is anchored to the *line head* with a font-relative jitter
1941        // (`min(head, frag).height * 0.2`), matching `sort_and_merge_fragments` /
1942        // `merge_into_lines` (issue #408). A fixed `newline_threshold` band keyed
1943        // to the *previous* fragment accumulated drift on tight (sub-threshold)
1944        // leading and merged nearly a whole page into one pseudo-line, which the
1945        // block reorder below then reshuffled by X, shredding tokens (issue #417).
1946        let mut lines: Vec<Vec<usize>> = Vec::new();
1947        let mut current_line: Vec<usize> = Vec::new();
1948        let mut head_y = f64::INFINITY;
1949        let mut head_h = 0.0_f64;
1950        for (i, fragment) in fragments.iter().enumerate() {
1951            if !current_line.is_empty() {
1952                let tol = head_h.min(fragment.height) * 0.2;
1953                // Negated `< tol` (not `>= tol`) so a non-finite Y from a
1954                // degenerate text matrix forces a line break rather than swallowing
1955                // the whole page into one line.
1956                if !((head_y - fragment.y).abs() < tol) {
1957                    lines.push(std::mem::take(&mut current_line));
1958                }
1959            }
1960            if current_line.is_empty() {
1961                head_y = fragment.y;
1962                head_h = fragment.height;
1963            }
1964            current_line.push(i);
1965        }
1966        if !current_line.is_empty() {
1967            lines.push(current_line);
1968        }
1969
1970        // A line is "columnar" when it has at least one internal gap wider than
1971        // `column_threshold`.
1972        let line_is_columnar = |line: &[usize]| -> bool {
1973            line.windows(2).any(|w| {
1974                let (a, b) = (&fragments[w[0]], &fragments[w[1]]);
1975                b.x - (a.x + a.width) > self.options.column_threshold
1976            })
1977        };
1978
1979        // Two column boundaries within this many points are the same corridor.
1980        // Shared by the alignment gate (#422) and the boundary-dedup step below (#403).
1981        const COLUMN_ALIGN_TOL: f64 = 10.0;
1982
1983        // Wide-gap boundary X positions of a line: the midpoint of each internal gap
1984        // wider than `column_threshold`. Non-empty iff `line_is_columnar(line)`.
1985        let line_boundaries = |line: &[usize]| -> Vec<f64> {
1986            let mut bs = Vec::new();
1987            for w in line.windows(2) {
1988                let (a, b) = (&fragments[w[0]], &fragments[w[1]]);
1989                let gap = b.x - (a.x + a.width);
1990                if gap > self.options.column_threshold {
1991                    bs.push(a.x + a.width + gap / 2.0);
1992                }
1993            }
1994            bs
1995        };
1996
1997        // Segment lines into blocks: consecutive columnar lines share one segment
1998        // id (a multi-line column block); every other line is its own segment.
1999        // Segment ids are monotonic top-to-bottom, so a later stable sort keyed on
2000        // (segment, column) keeps regions in their original vertical order.
2001        let n = fragments.len();
2002        let mut segment_of = vec![0usize; n];
2003        let mut column_of = vec![0usize; n];
2004
2005        let mut has_columnar_block = false;
2006        let mut seg_id = 0usize;
2007        let mut prev_columnar = false;
2008        let mut prev_y = f64::INFINITY;
2009        let mut prev_h = 0.0_f64;
2010        // Anchor corridors of the CURRENT block: the wide-gap boundaries that
2011        // have recurred (within COLUMN_ALIGN_TOL) on *every* line of the block
2012        // so far, not just the immediately preceding line.
2013        let mut block_boundaries: Vec<f64> = Vec::new();
2014
2015        for (li, line) in lines.iter().enumerate() {
2016            let boundaries = line_boundaries(line);
2017            let columnar = !boundaries.is_empty();
2018            let head = &fragments[line[0]];
2019            // Two consecutive columnar lines share a multi-line column block only
2020            // when they are spaced like real table rows — at least a line height
2021            // apart. Tight-leading wrapped prose whose lines each happen to hold a
2022            // wide gap forms a common whitespace corridor and is geometrically
2023            // indistinguishable from a 2-column layout; merging it and reordering
2024            // column-major shredded the prose (#417).
2025            let row_spaced = (prev_y - head.y).abs() >= head.height.max(prev_h);
2026            // ...and only when a wide gap ALIGNS horizontally with the block's
2027            // running anchor. A real column is a whitespace corridor shared
2028            // across every row; several unrelated wide gaps at different X (a
2029            // label/value form with varying label lengths) are not a table.
2030            //
2031            // Alignment is checked against the whole block, not just the
2032            // previous line: a pairwise-only check let unrelated gaps chain
2033            // through accumulated drift (line N aligns with N-1, N-1 with N-2,
2034            // yet N shares no corridor with the anchor) into one giant block,
2035            // scattering a token embedded in that span across the page (#425).
2036            // Anchoring to the block — the way sort_and_merge_fragments anchors
2037            // line tolerance to the line head (#408) — removes the drift. The
2038            // pairwise `prev_boundaries` check that this replaces first landed
2039            // for #422; the anchor set subsumes it.
2040            let shared: Vec<f64> = block_boundaries
2041                .iter()
2042                .copied()
2043                .filter(|&p| boundaries.iter().any(|&c| (p - c).abs() < COLUMN_ALIGN_TOL))
2044                .collect();
2045            if li > 0 && columnar && prev_columnar && row_spaced && !shared.is_empty() {
2046                // Line joins the current block; tighten the anchor to the
2047                // corridors that persist, so a boundary must recur consistently
2048                // across the whole block to survive.
2049                block_boundaries = shared;
2050            } else {
2051                // Break the block: new segment, anchored to this line's own gaps.
2052                if li > 0 {
2053                    seg_id += 1;
2054                }
2055                block_boundaries = boundaries;
2056            }
2057            for &i in line {
2058                segment_of[i] = seg_id;
2059            }
2060            prev_columnar = columnar;
2061            prev_y = head.y;
2062            prev_h = head.height;
2063        }
2064
2065        // For each columnar block (a segment whose lines are columnar), derive
2066        // boundaries from that block's lines only and assign each fragment its
2067        // column. Flow segments keep column 0, so the stable sort preserves their
2068        // left-to-right reading order untouched.
2069        let mut block_start = 0usize;
2070        while block_start < lines.len() {
2071            if !line_is_columnar(&lines[block_start]) {
2072                block_start += 1;
2073                continue;
2074            }
2075            let seg = segment_of[lines[block_start][0]];
2076            let mut block_end = block_start;
2077            while block_end < lines.len() && segment_of[lines[block_end][0]] == seg {
2078                block_end += 1;
2079            }
2080
2081            // A real column boundary is a whitespace corridor that RECURS across
2082            // rows. Collect each line's wide-gap midpoints, then keep only
2083            // corridors seen on at least two distinct lines (within
2084            // COLUMN_ALIGN_TOL). A one-off gap — a single wide space inside
2085            // otherwise-flowing text, e.g. the space before a mid-page token —
2086            // is not a column; pooling it as a boundary bucketed the token into
2087            // a phantom column and relocated its pieces across the block (#425).
2088            let mut corridors: Vec<(f64, usize)> = Vec::new(); // (position, line count)
2089            for line in &lines[block_start..block_end] {
2090                // This line's wide-gap corridors, deduped within tolerance so a
2091                // line credits each corridor at most once.
2092                let mut line_bs: Vec<f64> = Vec::new();
2093                for w in line.windows(2) {
2094                    let (a, b) = (&fragments[w[0]], &fragments[w[1]]);
2095                    let gap = b.x - (a.x + a.width);
2096                    if gap > self.options.column_threshold {
2097                        let bpos = a.x + a.width + gap / 2.0;
2098                        if !line_bs.iter().any(|&c| (c - bpos).abs() < COLUMN_ALIGN_TOL) {
2099                            line_bs.push(bpos);
2100                        }
2101                    }
2102                }
2103                for bpos in line_bs {
2104                    if let Some(entry) = corridors
2105                        .iter_mut()
2106                        .find(|(c, _)| (*c - bpos).abs() < COLUMN_ALIGN_TOL)
2107                    {
2108                        entry.1 += 1;
2109                    } else {
2110                        corridors.push((bpos, 1));
2111                    }
2112                }
2113            }
2114            let mut boundaries = vec![0.0];
2115            for (pos, count) in &corridors {
2116                if *count >= 2 {
2117                    boundaries.push(*pos);
2118                }
2119            }
2120            boundaries.sort_by(|a, b| a.total_cmp(b));
2121
2122            if boundaries.len() > 1 {
2123                has_columnar_block = true;
2124                for line in &lines[block_start..block_end] {
2125                    for &i in line {
2126                        // Column = index of the last boundary not exceeding x.
2127                        // `boundaries[0]` is 0.0; a fragment drawn off-page-left
2128                        // (x < 0) saturates to column 0 rather than underflowing.
2129                        let col = boundaries
2130                            .iter()
2131                            .position(|&boundary| fragments[i].x < boundary)
2132                            .map_or(boundaries.len() - 1, |p| p.saturating_sub(1));
2133                        column_of[i] = col;
2134                    }
2135                }
2136            }
2137            block_start = block_end;
2138        }
2139
2140        // No columnar block → nothing to reorder; the reading-order sort stands.
2141        if !has_columnar_block {
2142            return;
2143        }
2144
2145        // Stable permutation by (segment, column), tie-broken by original index so
2146        // reading order is preserved within each (segment, column) — top-to-bottom
2147        // then left-to-right, i.e. column-major within a block.
2148        let mut order: Vec<usize> = (0..n).collect();
2149        order.sort_by(|&i, &j| {
2150            segment_of[i]
2151                .cmp(&segment_of[j])
2152                .then(column_of[i].cmp(&column_of[j]))
2153                .then(i.cmp(&j))
2154        });
2155
2156        // Materialize the permuted order once (one clone per fragment), then move
2157        // each element back into place — avoids a second full-slice clone.
2158        let reordered: Vec<TextFragment> = order.iter().map(|&i| fragments[i].clone()).collect();
2159        for (slot, frag) in fragments.iter_mut().zip(reordered) {
2160            *slot = frag;
2161        }
2162    }
2163
2164    /// Reconstruct text from sorted fragments
2165    fn reconstruct_text_from_fragments(&self, fragments: &[TextFragment]) -> String {
2166        // First, merge consecutive fragments that are very close together
2167        let merged_fragments = self.merge_close_fragments(fragments);
2168
2169        let mut result = String::new();
2170        let mut last_y = f64::INFINITY;
2171        let mut last_x = 0.0;
2172        let mut last_line_ended_with_hyphen = false;
2173
2174        for fragment in &merged_fragments {
2175            // Check if we need a newline
2176            let y_diff = (last_y - fragment.y).abs();
2177            if !result.is_empty() && y_diff > self.options.newline_threshold {
2178                // Handle hyphenation
2179                if self.options.merge_hyphenated && last_line_ended_with_hyphen {
2180                    // Remove the hyphen and don't add newline
2181                    if result.ends_with('-') {
2182                        result.pop();
2183                    }
2184                } else {
2185                    result.push('\n');
2186                }
2187            } else if !result.is_empty() {
2188                // Check if we need a space
2189                let x_gap = fragment.x - last_x;
2190                if x_gap > self.options.space_threshold * fragment.font_size {
2191                    result.push(' ');
2192                }
2193            }
2194
2195            result.push_str(&fragment.text);
2196            last_line_ended_with_hyphen = fragment.text.ends_with('-');
2197            last_y = fragment.y;
2198            last_x = fragment.x + fragment.width;
2199        }
2200
2201        result
2202    }
2203
2204    /// Merge fragments that are very close together on the same line
2205    /// This fixes artifacts like "IN VO ICE" -> "INVOICE"
2206    fn merge_close_fragments(&self, fragments: &[TextFragment]) -> Vec<TextFragment> {
2207        if fragments.is_empty() {
2208            return Vec::new();
2209        }
2210
2211        let mut merged = Vec::new();
2212        let mut current = fragments[0].clone();
2213
2214        for fragment in &fragments[1..] {
2215            // Check if this fragment is on the same line and very close
2216            let y_diff = (current.y - fragment.y).abs();
2217            let x_gap = fragment.x - (current.x + current.width);
2218
2219            // Y-tolerance for same-line merging.
2220            //
2221            // Legacy path (`reconstruct_paragraphs=false`): fragments arrive
2222            // after `sort_and_merge_fragments` which quantizes Y into 10pt bands.
2223            // All same-band fragments share nearly identical Y, so 1.0pt is enough.
2224            //
2225            // Reconstruct-paragraphs path (`reconstruct_paragraphs=true`): fragments
2226            // arrive in emission order. Inline superscripts (e.g. citation numbers
2227            // raised via `Td` operators) have Y deltas of 3-4pt for 10pt body text.
2228            // Without a wider tolerance, each superscript becomes its own fragment
2229            // → line proliferation (issue #265 follow-up). Use 0.5 * font_size,
2230            // which captures typical superscript/subscript offsets (typically
2231            // 0.33-0.4 * font_size from baseline) and stays below the row_id
2232            // threshold (also 0.5 * font_size) so adjacent rows are not collapsed.
2233            let y_tol = if self.options.reconstruct_paragraphs {
2234                // Defend against malformed PDFs that emit text before any `Tf` font
2235                // operator (font_size=0 in TextState initial). 0.5 * 0 = 0 would
2236                // prevent any merge, even at identical Y. Fall back to the legacy
2237                // 1.0pt threshold in that case so the path is at least as forgiving
2238                // as the non-reconstruct path.
2239                let base = 0.5 * current.font_size.min(fragment.font_size);
2240                if base > 0.0 {
2241                    base
2242                } else {
2243                    1.0
2244                }
2245            } else {
2246                1.0
2247            };
2248
2249            let should_merge = y_diff < y_tol
2250                && x_gap >= 0.0  // Fragment is to the right
2251                && x_gap < fragment.font_size * 0.5 // Gap less than 50% of font size
2252                && current.mcid == fragment.mcid;
2253
2254            if should_merge {
2255                // Merge this fragment into current, preserving word boundaries
2256                // when the gap exceeds the font-anchored space threshold.
2257                if x_gap > self.space_gap_threshold(fragment) {
2258                    current.text.push(' ');
2259                }
2260                current.text.push_str(&fragment.text);
2261                current.width = (fragment.x + fragment.width) - current.x;
2262            } else {
2263                // Start a new fragment
2264                merged.push(current);
2265                current = fragment.clone();
2266            }
2267        }
2268
2269        merged.push(current);
2270        merged
2271    }
2272
2273    /// Extract font resources from page
2274    ///
2275    /// Clears the per-page name cache (font names are page-local in PDF), but
2276    /// reuses previously parsed font objects via `font_object_cache` to avoid
2277    /// re-parsing the same font object across multiple pages.
2278    fn extract_font_resources<R: Read + Seek>(
2279        &mut self,
2280        page: &ParsedPage,
2281        document: &PdfDocument<R>,
2282    ) -> ParseResult<()> {
2283        // Clear per-page name mapping (font names like /F1 are page-local)
2284        self.font_cache.clear();
2285
2286        // Try to get resources manually from page dictionary first
2287        // This is necessary because ParsedPage.get_resources() may not always work
2288        if let Some(res_ref) = page.dict.get("Resources").and_then(|o| o.as_reference()) {
2289            if let Ok(PdfObject::Dictionary(resources)) = document.get_object(res_ref.0, res_ref.1)
2290            {
2291                self.cache_fonts_from_resources::<R>(&resources, document);
2292            }
2293        } else if let Some(resources) = page.get_resources() {
2294            // Fallback to get_resources() if Resources is not a reference
2295            self.cache_fonts_from_resources::<R>(resources, document);
2296        }
2297
2298        Ok(())
2299    }
2300
2301    /// Cache every font declared in a page's `/Resources` `/Font` dictionary.
2302    ///
2303    /// `/Font` itself may be either an inline dictionary or an indirect
2304    /// reference (`/Font 191 0 R`); both are common in real PDFs (e.g. the
2305    /// ATLAS Higgs paper references it). Resolving the reference is required —
2306    /// otherwise the font cache stays empty, decoding loses ToUnicode, and
2307    /// glyph widths fall back to a flat estimate that scrambles multi-column
2308    /// layout (issue #302).
2309    fn cache_fonts_from_resources<R: Read + Seek>(
2310        &mut self,
2311        resources: &PdfDictionary,
2312        document: &PdfDocument<R>,
2313    ) {
2314        for (font_name, entry) in
2315            crate::text::extraction_cmap::resolve_font_entries(resources, document)
2316        {
2317            match entry {
2318                crate::text::extraction_cmap::FontEntry::Indirect(num, gen) => {
2319                    self.cache_font_by_ref::<R>(&font_name, (num, gen), document);
2320                }
2321                crate::text::extraction_cmap::FontEntry::Inline(font_dict) => {
2322                    self.cache_inline_font::<R>(&font_name, &font_dict, document);
2323                }
2324            }
2325        }
2326    }
2327
2328    /// Cache a font written directly into the page's resources.
2329    ///
2330    /// Unlike [`Self::cache_font_by_ref`] this cannot touch the persistent
2331    /// cache: an inline dictionary has no object id to key on, and two pages
2332    /// may write different fonts under the same name. It is parsed per page.
2333    fn cache_inline_font<R: Read + Seek>(
2334        &mut self,
2335        font_name: &str,
2336        font_dict: &PdfDictionary,
2337        document: &PdfDocument<R>,
2338    ) {
2339        let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
2340        if let Ok(font_info) = cmap_extractor.extract_font_info(font_dict, document) {
2341            tracing::debug!(
2342                "Parsed inline font {} (ToUnicode: {})",
2343                font_name,
2344                font_info.to_unicode.is_some()
2345            );
2346            self.font_cache.insert(font_name.to_string(), font_info);
2347        }
2348    }
2349
2350    /// Cache a font, reusing the persistent object cache when possible.
2351    fn cache_font_by_ref<R: Read + Seek>(
2352        &mut self,
2353        font_name: &str,
2354        font_ref: (u32, u16),
2355        document: &PdfDocument<R>,
2356    ) {
2357        // Check persistent object cache first — avoids re-parsing across pages
2358        if let Some(cached) = self.font_object_cache.get(&font_ref) {
2359            self.font_cache
2360                .insert(font_name.to_string(), cached.clone());
2361            tracing::debug!(
2362                "Reused cached font object ({}, {}): {} (ToUnicode: {})",
2363                font_ref.0,
2364                font_ref.1,
2365                font_name,
2366                cached.to_unicode.is_some()
2367            );
2368            return;
2369        }
2370
2371        // Parse font object
2372        if let Ok(PdfObject::Dictionary(font_dict)) = document.get_object(font_ref.0, font_ref.1) {
2373            let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
2374            if let Ok(font_info) = cmap_extractor.extract_font_info(&font_dict, document) {
2375                let has_to_unicode = font_info.to_unicode.is_some();
2376                // Store in persistent cache
2377                self.font_object_cache.insert(font_ref, font_info.clone());
2378                // Store in per-page name cache
2379                self.font_cache.insert(font_name.to_string(), font_info);
2380                tracing::debug!(
2381                    "Parsed and cached font ({}, {}): {} (ToUnicode: {})",
2382                    font_ref.0,
2383                    font_ref.1,
2384                    font_name,
2385                    has_to_unicode
2386                );
2387            }
2388        }
2389    }
2390
2391    /// Decode text using the current font encoding and ToUnicode mapping
2392    fn decode_text(&self, text: &[u8], state: &TextState) -> ParseResult<String> {
2393        use crate::text::encoding::TextEncoding;
2394
2395        // First, try to use cached font information with ToUnicode CMap
2396        if let Some(ref font_name) = state.font_name {
2397            if let Some(font_info) = self.font_cache.get(font_name) {
2398                // Try CMap-based decoding first (free function — no allocation)
2399                if let Ok(decoded) =
2400                    crate::text::extraction_cmap::decode_text_with_font(text, font_info)
2401                {
2402                    // Only accept if we got meaningful text (not all null bytes
2403                    // or garbage). Whitespace counts as meaningful: a decode
2404                    // that is exactly a space is a space, not a failed decode
2405                    // (#438). See `decode_is_usable`.
2406                    if crate::text::extraction_cmap::decode_is_usable(&decoded) {
2407                        // Apply sanitization to remove control characters (Issue #116)
2408                        let sanitized = sanitize_extracted_text(&decoded);
2409                        tracing::debug!(
2410                            "Successfully decoded text using CMap for font {}: {:?} -> \"{}\"",
2411                            font_name,
2412                            text,
2413                            sanitized
2414                        );
2415                        return Ok(sanitized);
2416                    }
2417                }
2418
2419                tracing::debug!(
2420                    "CMap decoding failed or produced garbage for font {}, falling back to encoding",
2421                    font_name
2422                );
2423            }
2424        }
2425
2426        // Fall back to encoding-based decoding
2427        let encoding = if let Some(ref font_name) = state.font_name {
2428            match font_name.to_lowercase().as_str() {
2429                name if name.contains("macroman") => TextEncoding::MacRomanEncoding,
2430                name if name.contains("winansi") => TextEncoding::WinAnsiEncoding,
2431                name if name.contains("standard") => TextEncoding::StandardEncoding,
2432                name if name.contains("pdfdoc") => TextEncoding::PdfDocEncoding,
2433                _ => {
2434                    // Default based on common patterns
2435                    if font_name.starts_with("Times")
2436                        || font_name.starts_with("Helvetica")
2437                        || font_name.starts_with("Courier")
2438                    {
2439                        TextEncoding::WinAnsiEncoding // Most common for standard fonts
2440                    } else {
2441                        TextEncoding::PdfDocEncoding // Safe default
2442                    }
2443                }
2444            }
2445        } else {
2446            TextEncoding::WinAnsiEncoding // Default for most PDFs
2447        };
2448
2449        let fallback_result = encoding.decode(text);
2450        // Apply sanitization to remove control characters (Issue #116)
2451        let sanitized = sanitize_extracted_text(&fallback_result);
2452        tracing::debug!(
2453            "Fallback encoding decoding: {:?} -> \"{}\"",
2454            text,
2455            sanitized
2456        );
2457        Ok(sanitized)
2458    }
2459}
2460
2461impl Default for TextExtractor {
2462    fn default() -> Self {
2463        Self::new()
2464    }
2465}
2466
2467/// Emit a `TextFragment` for one decoded text-show event under `preserve_layout`.
2468///
2469/// Encapsulates the style-derivation + push sequence shared by every
2470/// text-show operator handler in `extract_from_page` (`Tj`, `TJ`, `'`,
2471/// `"`). The caller supplies the pen origin `(x, y)` already mapped to
2472/// user space (typically via `text_origin(&state)`); doing so avoids the
2473/// double `multiply_matrix + transform_point` that prior versions did
2474/// (handler computed it for `last_x`/`last_y`, then this fn recomputed
2475/// it on the same `state`).
2476///
2477/// Skips emission when an ancestor in the marked-content stack is `/Artifact`
2478/// and `include_artifacts` is false. When a pending ActualText run is
2479/// active in the current scope, accumulates the text-width contribution and
2480/// records the first origin instead of pushing a fragment (the run is flushed
2481/// once on EMC, see Task 8's EndMarkedContent handler).
2482///
2483/// `mcid` and `struct_tag` come from the innermost ancestor on the stack that
2484/// declared `/MCID`; non-tagged content leaves both as `None`.
2485/// Whether the current marked-content stack should suppress text emission.
2486///
2487/// Mirrors the gate inside [`emit_text_fragment`]: when an ancestor in the
2488/// stack is `/Artifact` and the caller has not opted into artifact content
2489/// via `include_artifacts`, neither `.text` nor `.fragments` should receive
2490/// the run. Used by the four show-text operator arms to keep `extracted_text`
2491/// and `fragments` symmetric — a page whose entire content is an
2492/// `/Artifact BMC … EMC` scope (the common pattern for screen-reader-skipped
2493/// disclaimers / footers / decorative tagged-PDF content) used to surface
2494/// text in `.text` while leaving `.fragments` empty, silently dropping the
2495/// page from `partition_with(...)` / `rag_chunks(...)` (issue #330).
2496fn skip_artifact_text(state: &TextState, include_artifacts: bool) -> bool {
2497    !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact)
2498}
2499
2500/// Append an optional `separator` plus `decoded` to `acc`, honouring the
2501/// per-page byte budget `limit` (issue #382).
2502///
2503/// Returns `true` when the run was appended. Returns `false` — appending
2504/// nothing and setting `*truncated` — when the combined bytes would exceed
2505/// `limit`. The separator is counted against the budget so the invariant
2506/// `acc.len() <= limit` holds *exactly*, and because whole runs are the unit of
2507/// truncation a multi-byte UTF-8 character is never split (undershoot
2508/// semantics). A `None` limit always appends and never truncates, keeping the
2509/// no-limit path byte-identical to before. Once `*truncated` is set the helper
2510/// is a no-op, so a caller that keeps calling it after the budget is reached
2511/// simply accumulates nothing further.
2512fn append_bounded(
2513    acc: &mut String,
2514    separator: Option<char>,
2515    decoded: &str,
2516    limit: Option<usize>,
2517    truncated: &mut bool,
2518) -> bool {
2519    if *truncated {
2520        return false;
2521    }
2522    if let Some(max) = limit {
2523        let add = separator.map_or(0, char::len_utf8) + decoded.len();
2524        if acc.len() + add > max {
2525            *truncated = true;
2526            return false;
2527        }
2528    }
2529    if let Some(sep) = separator {
2530        acc.push(sep);
2531    }
2532    acc.push_str(decoded);
2533    true
2534}
2535
2536/// Defensive final clamp of a page's text to the byte budget (issue #382).
2537///
2538/// The `preserve_layout` / `reorder_columns` paths rebuild `.text` from the
2539/// already-bounded fragment set via `reconstruct_text_from_fragments`, which
2540/// reorders fragments and inserts its own separators — so the reconstructed
2541/// length is not provably `<= limit` from the accumulation-time accounting
2542/// alone. This clamps the result to `limit` at a UTF-8 char boundary (never
2543/// splitting a character) and sets `*truncated` if it had to cut, making the
2544/// `text.len() <= max_extracted_bytes` invariant hold for *every* path. A no-op
2545/// when there is no limit or the text already fits.
2546fn clamp_to_budget(text: &mut String, limit: Option<usize>, truncated: &mut bool) {
2547    if let Some(max) = limit {
2548        if text.len() > max {
2549            let mut cut = max;
2550            while cut > 0 && !text.is_char_boundary(cut) {
2551                cut -= 1;
2552            }
2553            text.truncate(cut);
2554            *truncated = true;
2555        }
2556    }
2557}
2558
2559fn emit_text_fragment(
2560    fragments: &mut Vec<TextFragment>,
2561    decoded: &str,
2562    text_width: f64,
2563    x: f64,
2564    y: f64,
2565    state: &mut TextState,
2566    include_artifacts: bool,
2567) {
2568    if decoded.is_empty() {
2569        return;
2570    }
2571
2572    // Artifact filter (default: skip emission for Artifact subtrees).
2573    if !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact) {
2574        return;
2575    }
2576
2577    let (is_bold, is_italic) = state
2578        .font_name
2579        .as_ref()
2580        .map(|name| parse_font_style(name))
2581        .unwrap_or((false, false));
2582
2583    // Issue #262: font_size, height, and width must be in page space so that
2584    // downstream heuristics (line/paragraph reconstruction, header/footer zone
2585    // detection, table detection) reason about real geometry. `x` and `y` are
2586    // already page-space (caller transforms via `text_origin`); we still need
2587    // to scale the size/width fields by the combined `text_matrix × CTM`.
2588    let combined = multiply_matrix(&state.text_matrix, &state.ctm);
2589    let x_scale = (combined[0] * combined[0] + combined[1] * combined[1]).sqrt();
2590    let y_scale = (combined[2] * combined[2] + combined[3] * combined[3]).sqrt();
2591    let effective_width = text_width * x_scale;
2592    let effective_size = state.font_size * y_scale;
2593
2594    // If a pending ActualText run is active in the current scope, accumulate
2595    // into it instead of emitting a fragment now. The run is flushed on the
2596    // matching EMC by the EndMarkedContent arm (Task 8).
2597    // Hoist font_name/fill_color reads before taking &mut on pending_actualtext
2598    // to avoid borrow-checker conflicts with the disjoint fields.
2599    let local_font_name = state.font_name.clone();
2600    let local_fill_color = state.fill_color;
2601    if let Some(pending) = state.pending_actualtext.as_mut() {
2602        if !pending.populated {
2603            pending.first_x = x;
2604            pending.first_y = y;
2605            pending.font_size = effective_size;
2606            pending.font_name = local_font_name;
2607            pending.is_bold = is_bold;
2608            pending.is_italic = is_italic;
2609            pending.color = local_fill_color;
2610            pending.populated = true;
2611        }
2612        pending.width += effective_width;
2613        return;
2614    }
2615
2616    let (mcid, struct_tag) = innermost_mc_tag(&state.mc_stack);
2617
2618    fragments.push(TextFragment {
2619        text: decoded.to_owned(),
2620        x,
2621        y,
2622        width: effective_width,
2623        height: effective_size,
2624        font_size: effective_size,
2625        font_name: state.font_name.clone(),
2626        is_bold,
2627        is_italic,
2628        color: state.fill_color,
2629        space_decisions: Vec::new(),
2630        mcid,
2631        struct_tag,
2632    });
2633}
2634
2635/// Pen origin (user-space coordinates) of the next glyph in the current
2636/// text state.
2637///
2638/// Per ISO 32000-1 §8.3.4, the text rendering matrix is `Tm × CTM` (row-vector
2639/// convention). `multiply_matrix(a, b)` returns the matrix that applies `a`
2640/// first and then `b`, so the correct composition is
2641/// `multiply_matrix(text_matrix, ctm)`. Prior to issue #262 this used the
2642/// reverse order which gave correct results only when the CTM was an identity
2643/// or pure-translation matrix; non-uniform CTM scaling produced wrong origins.
2644fn text_origin(state: &TextState) -> (f64, f64) {
2645    let combined = multiply_matrix(&state.text_matrix, &state.ctm);
2646    transform_point(0.0, 0.0, &combined)
2647}
2648
2649/// Advance the text matrix by one shown glyph run of unscaled width
2650/// `text_width` and return the pen's new x in user space.
2651///
2652/// The advance applied to the text matrix is `text_width * Tz/100`
2653/// (`state.horizontal_scale`), and the resulting user-space displacement also
2654/// folds in the CTM's x-scale. The caller's `last_x` (used for `dx`-based
2655/// space decisions) must therefore come from the post-advance pen origin, not
2656/// from `origin_x + text_width`, which ignores both factors and trails the
2657/// real pen whenever `Tz != 100` or the CTM scales x (issue #386).
2658fn advance_pen(state: &mut TextState, text_width: f64) -> (f64, f64) {
2659    let tx = text_width * state.horizontal_scale / 100.0;
2660    state.text_matrix = multiply_matrix(&[1.0, 0.0, 0.0, 1.0, tx, 0.0], &state.text_matrix);
2661    text_origin(state)
2662}
2663
2664/// Projection-noise floor for the perpendicular pen delta. Same-baseline
2665/// glyph runs produce a `dy` that is exactly 0 in real arithmetic but can
2666/// carry ~1e-13 of float rounding after the baseline projection; anything
2667/// below this epsilon is "the same baseline". The smallest meaningful
2668/// leading in real documents is orders of magnitude above it.
2669const SAME_LINE_EPS: f64 = 1e-6;
2670
2671/// Backward-jump magnitude, in multiples of the font size, above which a
2672/// same-baseline (`dy == 0`) backward pen jump is a line wrap rather than a
2673/// glyph reposition (issue #447).
2674///
2675/// At `dy == 0` a backward jump is ambiguous: a same-line reposition
2676/// (justification, kerned overlay, out-of-order emission — issue #441) and a
2677/// real wrap whose two lines happen to land on the same content-stream Y
2678/// (issue #447) both produce it. They separate by MAGNITUDE: a reposition is
2679/// local (a word/phrase — a few em), while a wrap returns across the whole
2680/// text column (many em). This bound sits in that gap, scaled to font size
2681/// because the reposition scale is the glyph/word scale, not the fixed
2682/// paragraph-break `newline_threshold`. Scaled to `font_size.abs()`: `Tf`
2683/// accepts negative sizes (mirrored text), and the sign must not flip the
2684/// threshold's sense — otherwise a negative size makes every backward jump a
2685/// "wrap" and resurrects the #441 defect.
2686///
2687/// Accepted, documented limitation (the #417/#422 trade-off): a same-line
2688/// reposition that jumps back more than this many em is misread as a wrap, and
2689/// a same-Y wrap of a line shorter than this is glued. Both are rare and
2690/// neither loses a glyph — only the separator is wrong. A wrap with any
2691/// nonzero leading (the common case, issue #390) is unaffected: it breaks on
2692/// the `dy`-aware gate regardless of magnitude.
2693const SAME_Y_WRAP_EM: f64 = 10.0;
2694
2695/// Pen movement from the previous post-advance pen point `last` to the
2696/// current glyph origin `cur` (both user space), measured in the frame of the
2697/// current text baseline (issue #443): `dx` along the baseline direction,
2698/// `dy` perpendicular to it (signed; callers take `.abs()` for line
2699/// detection).
2700///
2701/// The baseline direction is the image of the text-space x-axis under the
2702/// text rendering matrix `Tm × CTM`. For an axis-aligned matrix
2703/// (identity/translation/positive scale — the overwhelming majority of
2704/// content) the baseline IS the user-space x-axis and this returns exactly
2705/// `(Δx, Δy)`, the pre-#443 behavior. Under a rotated CTM (and any
2706/// similarity transform) the projection recovers the text's own line
2707/// geometry exactly, which raw user-space deltas conflate: a plain forward
2708/// advance along a rotated baseline changes the user-space y, which the
2709/// separator heuristics misread as a line change. Axis-aligned shear
2710/// (`b == 0`, `c != 0`) also projects exactly (the perpendicular reduces to
2711/// the y-axis); a shear COMBINED with a rotated baseline is approximated —
2712/// the perpendicular is built by rotating the baseline 90°, not from the
2713/// true image of the text-space y-axis.
2714///
2715/// A mirrored baseline (negative x-scale) measures `dx` along the text's own
2716/// advance direction, so a forward advance is positive `dx` — the spacing
2717/// and wrap gates apply as for unmirrored text (pre-#443 they saw a raw
2718/// negative `dx` and misfired the wrap gate on plain advances).
2719///
2720/// A degenerate baseline (zero-length or non-finite) falls back to the raw
2721/// user-space deltas, preserving pre-#443 behavior for malformed matrices.
2722fn pen_delta(state: &TextState, last: (f64, f64), cur: (f64, f64)) -> (f64, f64) {
2723    let dxu = cur.0 - last.0;
2724    let dyu = cur.1 - last.1;
2725    let m = multiply_matrix(&state.text_matrix, &state.ctm);
2726    let (bx, by) = (m[0], m[1]);
2727    let norm = (bx * bx + by * by).sqrt();
2728    if !norm.is_finite() || norm <= f64::EPSILON {
2729        return (dxu, dyu);
2730    }
2731    let (ux, uy) = (bx / norm, by / norm);
2732    (dxu * ux + dyu * uy, -dxu * uy + dyu * ux)
2733}
2734
2735/// Multiply two transformation matrices
2736fn multiply_matrix(a: &[f64; 6], b: &[f64; 6]) -> [f64; 6] {
2737    [
2738        a[0] * b[0] + a[1] * b[2],
2739        a[0] * b[1] + a[1] * b[3],
2740        a[2] * b[0] + a[3] * b[2],
2741        a[2] * b[1] + a[3] * b[3],
2742        a[4] * b[0] + a[5] * b[2] + b[4],
2743        a[4] * b[1] + a[5] * b[3] + b[5],
2744    ]
2745}
2746
2747/// Decode a PDF string operand into Rust `String`.
2748///
2749/// A string inside marked-content properties (notably `/ActualText`) is a PDF
2750/// text string like any other, so this is
2751/// [`PdfString::to_text`](crate::parser::objects::PdfString::to_text): UTF-16BE
2752/// when a byte order mark is present — the canonical encoding for non-ASCII
2753/// `/ActualText`, e.g. an `fi` ligature or a Greek symbol — and the WinAnsi
2754/// reading of PDFDocEncoding otherwise. Before that helper existed this mapped
2755/// non-BOM bytes to `char` one by one, which is Latin-1 and wrong for the
2756/// typographic punctuation WinAnsi puts in `0x80..=0x9F`.
2757fn decode_pdf_string(bytes: &[u8]) -> String {
2758    crate::parser::objects::decode_text_string(bytes)
2759}
2760
2761/// Resolve a `MarkedContentProps` to `(mcid, actual_text)`.
2762///
2763/// For `Inline` props, walk the map: `/MCID` (Integer, must fit in `u32`)
2764/// becomes `mcid`; `/ActualText` (String) is decoded via `decode_pdf_string`.
2765///
2766/// For `ResourceRef(name)`, look up `properties.get(name)`. If found and
2767/// it's a Dictionary, extract `/MCID` and `/ActualText` from there. If
2768/// not found (or the named entry is not a dict), return `(None, None)`
2769/// — a malformed reference must not abort extraction.
2770fn resolve_props(
2771    props: &crate::parser::content::MarkedContentProps,
2772    properties: Option<&crate::parser::objects::PdfDictionary>,
2773) -> (Option<u32>, Option<String>) {
2774    use crate::parser::content::{MarkedContentProps, MarkedContentValue};
2775
2776    let map_mcid_actual =
2777        |map: &std::collections::HashMap<String, MarkedContentValue>| -> (Option<u32>, Option<String>) {
2778            let mcid = match map.get("MCID") {
2779                Some(MarkedContentValue::Integer(n)) if *n >= 0 && *n <= u32::MAX as i64 => {
2780                    Some(*n as u32)
2781                }
2782                _ => None,
2783            };
2784            let actual = match map.get("ActualText") {
2785                Some(MarkedContentValue::String(bytes)) => Some(decode_pdf_string(bytes)),
2786                _ => None,
2787            };
2788            (mcid, actual)
2789        };
2790
2791    match props {
2792        MarkedContentProps::Inline(map) => map_mcid_actual(map),
2793        MarkedContentProps::ResourceRef(name) => {
2794            let Some(properties) = properties else {
2795                return (None, None);
2796            };
2797            let Some(entry) = properties.get(name) else {
2798                return (None, None);
2799            };
2800            let crate::parser::objects::PdfObject::Dictionary(dict) = entry else {
2801                return (None, None);
2802            };
2803            let mcid = dict.get("MCID").and_then(|o| match o {
2804                crate::parser::objects::PdfObject::Integer(n)
2805                    if *n >= 0 && *n <= u32::MAX as i64 =>
2806                {
2807                    Some(*n as u32)
2808                }
2809                _ => None,
2810            });
2811            let actual_text = dict.get("ActualText").and_then(|o| match o {
2812                crate::parser::objects::PdfObject::String(s) => {
2813                    Some(decode_pdf_string(s.as_bytes()))
2814                }
2815                _ => None,
2816            });
2817            (mcid, actual_text)
2818        }
2819    }
2820}
2821
2822/// Walk the marked-content stack from innermost (top) outward, returning the
2823/// first entry's `(mcid, tag)` pair whose `mcid` is `Some`. Returns
2824/// `(None, None)` when no ancestor declared an MCID — typical of non-tagged
2825/// PDFs, in which case the `None == None` grouping-key invariant preserves
2826/// legacy behaviour.
2827fn innermost_mc_tag(stack: &[MarkedContentEntry]) -> (Option<u32>, Option<String>) {
2828    stack
2829        .iter()
2830        .rev()
2831        .find(|e| e.mcid.is_some())
2832        .map_or((None, None), |e| (e.mcid, Some(e.tag.clone())))
2833}
2834
2835/// Transform a point using a transformation matrix
2836fn transform_point(x: f64, y: f64, matrix: &[f64; 6]) -> (f64, f64) {
2837    let tx = matrix[0] * x + matrix[2] * y + matrix[4];
2838    let ty = matrix[1] * x + matrix[3] * y + matrix[5];
2839    (tx, ty)
2840}
2841
2842/// Calculate text width using actual font metrics (including kerning)
2843fn calculate_text_width(text: &str, font_size: f64, font_info: Option<&FontInfo>) -> f64 {
2844    // If we have font metrics, use them for accurate width calculation
2845    if let Some(font) = font_info {
2846        if let Some(ref widths) = font.metrics.widths {
2847            let first_char = font.metrics.first_char.unwrap_or(0);
2848            let last_char = font.metrics.last_char.unwrap_or(255);
2849            let missing_width = font.metrics.missing_width.unwrap_or(500.0);
2850
2851            let mut total_width = 0.0;
2852            let mut chars = text.chars().peekable();
2853
2854            while let Some(ch) = chars.next() {
2855                let char_code = ch as u32;
2856
2857                // Get width from Widths array or use missing_width
2858                let width = if char_code >= first_char && char_code <= last_char {
2859                    let index = (char_code - first_char) as usize;
2860                    widths.get(index).copied().unwrap_or(missing_width)
2861                } else {
2862                    missing_width
2863                };
2864
2865                // Convert from glyph space (1/1000 units) to user space
2866                total_width += width / 1000.0 * font_size;
2867
2868                // Apply kerning if available (for character pairs)
2869                if let Some(ref kerning) = font.metrics.kerning {
2870                    if let Some(&next_ch) = chars.peek() {
2871                        let next_char = next_ch as u32;
2872                        if let Some(&kern_value) = kerning.get(&(char_code, next_char)) {
2873                            // Kerning is in FUnits (1/1000), convert to user space
2874                            total_width += kern_value / 1000.0 * font_size;
2875                        }
2876                    }
2877                }
2878            }
2879
2880            return total_width;
2881        }
2882    }
2883
2884    // Fallback to simplified calculation if no metrics available
2885    text.len() as f64 * font_size * 0.5
2886}
2887
2888/// Compute advance width from the original character **codes**, not the decoded
2889/// Unicode text.
2890///
2891/// A simple font's `Widths` array is indexed by character code (`first_char..=
2892/// last_char`), i.e. the byte value in the content stream — not by the Unicode
2893/// codepoint the code decodes to. [`calculate_text_width`] indexes by the decoded
2894/// codepoint (`ch as u32`), which is correct only when code == codepoint (ASCII /
2895/// WinAnsi fonts). For custom-encoded fonts (Type1 with `Differences`, embedded
2896/// Computer Modern in LaTeX PDFs, ToUnicode remaps) the codepoint diverges from
2897/// the code, so the wrong slot — or `missing_width` — is read, desyncing glyph
2898/// advance and scrambling word order once fragments are sorted by position
2899/// (issue #302).
2900///
2901/// `decoded` is the already-decoded text for this run; it is only consulted for
2902/// composite (Type0) fonts, whose multi-byte codes cannot be indexed byte-wise
2903/// and whose width path is unchanged here to avoid regressing CJK extraction.
2904fn calculate_text_width_from_codes(
2905    codes: &[u8],
2906    decoded: &str,
2907    font_size: f64,
2908    font_info: Option<&FontInfo>,
2909) -> f64 {
2910    // Composite (Type0) fonts use multi-byte codes; a single byte is not a code,
2911    // so byte-indexed width lookup is invalid. Preserve the existing decoded-based
2912    // behavior for them.
2913    let is_composite =
2914        font_info.is_some_and(|f| f.font_type == "Type0" || f.descendant_font.is_some());
2915    if is_composite {
2916        return calculate_text_width(decoded, font_size, font_info);
2917    }
2918
2919    if let Some(font) = font_info {
2920        if let Some(ref widths) = font.metrics.widths {
2921            let first_char = font.metrics.first_char.unwrap_or(0);
2922            let last_char = font.metrics.last_char.unwrap_or(255);
2923            let missing_width = font.metrics.missing_width.unwrap_or(500.0);
2924
2925            let mut total_width = 0.0;
2926            let mut iter = codes.iter().peekable();
2927            while let Some(&byte) = iter.next() {
2928                let code = byte as u32;
2929                let width = if code >= first_char && code <= last_char {
2930                    widths
2931                        .get((code - first_char) as usize)
2932                        .copied()
2933                        .unwrap_or(missing_width)
2934                } else {
2935                    missing_width
2936                };
2937                total_width += width / 1000.0 * font_size;
2938
2939                // Kerning is keyed by code pair, consistent with code-based widths.
2940                if let Some(ref kerning) = font.metrics.kerning {
2941                    if let Some(&next_byte) = iter.peek() {
2942                        if let Some(&kern_value) = kerning.get(&(code, *next_byte as u32)) {
2943                            total_width += kern_value / 1000.0 * font_size;
2944                        }
2945                    }
2946                }
2947            }
2948
2949            return total_width;
2950        }
2951    }
2952
2953    // No metrics: one fallback width per code (byte), the simple-font glyph count.
2954    codes.len() as f64 * font_size * 0.5
2955}
2956
2957/// Sanitize extracted text by removing or replacing control characters.
2958///
2959/// This function addresses Issue #116 where extracted text contains NUL bytes (`\0`)
2960/// and ETX characters (`\u{3}`) where spaces should appear.
2961///
2962/// # Behavior
2963///
2964/// - Replaces `\0\u{3}` sequences with a single space (common word separator pattern)
2965/// - Replaces standalone `\0` (NUL) with space
2966/// - Removes other ASCII control characters (0x01-0x1F) except:
2967///   - `\t` (0x09) - Tab
2968///   - `\n` (0x0A) - Line feed
2969///   - `\r` (0x0D) - Carriage return
2970/// - Collapses multiple consecutive spaces into a single space
2971///
2972/// # Examples
2973///
2974/// ```
2975/// use oxidize_pdf::text::extraction::sanitize_extracted_text;
2976///
2977/// // Issue #116 pattern: NUL+ETX as word separator
2978/// let dirty = "a\0\u{3}sergeant\0\u{3}and";
2979/// assert_eq!(sanitize_extracted_text(dirty), "a sergeant and");
2980///
2981/// // Standalone NUL becomes space
2982/// let with_nul = "word\0another";
2983/// assert_eq!(sanitize_extracted_text(with_nul), "word another");
2984///
2985/// // Clean text passes through unchanged
2986/// let clean = "Normal text";
2987/// assert_eq!(sanitize_extracted_text(clean), "Normal text");
2988/// ```
2989pub fn sanitize_extracted_text(text: &str) -> String {
2990    if text.is_empty() {
2991        return String::new();
2992    }
2993
2994    // Pre-allocate with same capacity (result will be <= input length)
2995    let mut result = String::with_capacity(text.len());
2996    let mut chars = text.chars().peekable();
2997    let mut last_was_space = false;
2998
2999    while let Some(ch) = chars.next() {
3000        match ch {
3001            // NUL byte - check if followed by ETX for the \0\u{3} pattern
3002            '\0' => {
3003                // Peek at next char to detect \0\u{3} sequence
3004                if chars.peek() == Some(&'\u{3}') {
3005                    chars.next(); // consume the ETX
3006                }
3007                // In both cases (standalone NUL or NUL+ETX), emit space
3008                if !last_was_space {
3009                    result.push(' ');
3010                    last_was_space = true;
3011                }
3012            }
3013
3014            // ETX alone (not preceded by NUL) - remove it
3015            '\u{3}' => {
3016                // Don't emit anything, just skip
3017            }
3018
3019            // Preserve allowed whitespace
3020            '\t' | '\n' | '\r' => {
3021                result.push(ch);
3022                // Reset space tracking on newlines but not tabs
3023                last_was_space = ch == '\t';
3024            }
3025
3026            // Regular space - collapse multiples
3027            ' ' => {
3028                if !last_was_space {
3029                    result.push(' ');
3030                    last_was_space = true;
3031                }
3032            }
3033
3034            // Other control characters (0x01-0x1F except tab/newline/CR) - remove
3035            c if c.is_ascii_control() => {
3036                // Skip control characters
3037            }
3038
3039            // Normal characters - keep them
3040            _ => {
3041                result.push(ch);
3042                last_was_space = false;
3043            }
3044        }
3045    }
3046
3047    result
3048}
3049
3050/// Assign a logical row identifier to each fragment based on Y-up-jumps in
3051/// emission order. Used by `merge_into_lines` to distinguish columns in
3052/// multi-column layouts where a single outer BDC scope makes mcid uniform.
3053///
3054/// Increments `row_id` whenever the next fragment's Y exceeds the previous
3055/// by more than `max(font_size * 0.5, 2.0)`. Superscripts (small positive
3056/// deltas) and normal line descents (negative deltas) leave `row_id`
3057/// unchanged. See `docs/superpowers/specs/2026-05-23-issue-265-line-interleaving-design.md`.
3058///
3059/// # Invariants
3060/// Returns a `Vec<u32>` with exactly `fragments.len()` elements — one
3061/// row id per input fragment, in input order. Callers may safely `.zip(fragments)`.
3062fn assign_row_ids(fragments: &[TextFragment]) -> Vec<u32> {
3063    let mut result = Vec::with_capacity(fragments.len());
3064    let mut row_id: u32 = 0;
3065    let mut prev_y: Option<f64> = None;
3066    for frag in fragments {
3067        if let Some(py) = prev_y {
3068            let delta = frag.y - py;
3069            // Threshold anchored to the arriving fragment's font_size; for the
3070            // symmetric same-font case (body→body, same font) this is equivalent
3071            // to anchoring to the previous fragment.
3072            let threshold = (frag.font_size * 0.5).max(2.0);
3073            if delta > threshold {
3074                row_id += 1;
3075            }
3076        }
3077        result.push(row_id);
3078        prev_y = Some(frag.y);
3079    }
3080    debug_assert_eq!(
3081        result.len(),
3082        fragments.len(),
3083        "assign_row_ids: output length must equal input length"
3084    );
3085    result
3086}
3087
3088/// Decide whether a single visual line should be read in emission order.
3089///
3090/// `line` holds `(emission_index, fragment)` pairs for one visual line in any
3091/// order. Returns `true` when, walked in emission order, the line has no
3092/// DISJOINT backward x-step — i.e. no fragment lands entirely to the LEFT of
3093/// everything emitted so far on the line. Such a left jump is the signature of
3094/// a genuinely scrambled stream (right-to-left / random generators), for which
3095/// x-order is authoritative.
3096///
3097/// The comparison is against the line's running left edge, not the immediately
3098/// preceding fragment: dense bodies are split into sub-word glyph runs, so a
3099/// run that legitimately backfills the line (a font-switched math symbol, or a
3100/// word whose run starts left of the previous short run — #302 symptom 1 /
3101/// #305) overlaps the *covered span* even when it does not overlap the single
3102/// fragment right before it. As long as it does not jump past the line's left
3103/// edge, emission order is preserved. Lines that are already x-monotone in
3104/// emission satisfy this trivially and decode identically under either policy.
3105fn line_prefers_emission_order(line: &[(usize, &TextFragment)]) -> bool {
3106    if line.len() < 2 {
3107        return true;
3108    }
3109    let mut em: Vec<&(usize, &TextFragment)> = line.iter().collect();
3110    em.sort_by_key(|&&(idx, _)| idx);
3111    let mut min_start = em[0].1.x;
3112    for &&(_, f) in &em[1..] {
3113        let end = f.x + f.width;
3114        // A fragment whose right edge is at or left of the leftmost glyph seen
3115        // so far is a true backward jump — emission order is not reading order.
3116        if end <= min_start {
3117            return false;
3118        }
3119        min_start = min_start.min(f.x);
3120    }
3121    true
3122}
3123
3124/// Space-glyph advance width (1000-em units) for the Adobe Core-14 base fonts,
3125/// keyed by `/BaseFont`. Subset prefixes (`ABCDEF+`) are stripped; common
3126/// substitute names (Arial→Helvetica, TimesNewRoman→Times, CourierNew→Courier)
3127/// map to their metric-compatible base. Returns `None` for unknown fonts, which
3128/// leaves the caller on its fixed-fraction fallback. These fonts legitimately
3129/// ship no `/Widths` array, so their space metric is only available here.
3130fn standard_14_space_width(base_font: &str) -> Option<f64> {
3131    let name = base_font.rsplit('+').next().unwrap_or(base_font);
3132    let lower = name.to_ascii_lowercase();
3133    if lower.contains("courier") {
3134        Some(600.0)
3135    } else if lower.contains("helvetica") || lower.contains("arial") {
3136        Some(278.0)
3137    } else if lower.contains("times") {
3138        Some(250.0)
3139    } else if lower == "symbol" {
3140        Some(250.0)
3141    } else if lower.contains("zapfdingbats") || lower.contains("dingbats") {
3142        Some(278.0)
3143    } else {
3144        None
3145    }
3146}
3147
3148#[cfg(test)]
3149mod tests {
3150    use super::*;
3151
3152    // ── issue #443: baseline-frame pen deltas ────────────────────────────────
3153
3154    fn state_with_ctm(ctm: [f64; 6]) -> TextState {
3155        TextState {
3156            ctm,
3157            ..Default::default()
3158        }
3159    }
3160
3161    #[test]
3162    fn pen_delta_identity_matrix_returns_raw_deltas() {
3163        let state = state_with_ctm([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3164        let (dx, dy) = pen_delta(&state, (10.0, 20.0), (14.5, 17.0));
3165        assert_eq!((dx, dy), (4.5, -3.0), "axis-aligned = raw Δx/Δy exactly");
3166    }
3167
3168    #[test]
3169    fn pen_delta_rotation_recovers_text_space_advance() {
3170        // 30° rotation; the pen advances 5 units along the rotated baseline.
3171        let (s30, c30) = 30f64.to_radians().sin_cos();
3172        let state = state_with_ctm([c30, s30, -s30, c30, 0.0, 0.0]);
3173        let (dx, dy) = pen_delta(&state, (0.0, 0.0), (5.0 * c30, 5.0 * s30));
3174        assert!((dx - 5.0).abs() < 1e-12, "advance recovered: {dx}");
3175        assert!(dy.abs() < 1e-12, "same baseline → dy ≈ 0: {dy}");
3176        assert!(
3177            dy.abs() < SAME_LINE_EPS,
3178            "noise below the same-line epsilon"
3179        );
3180    }
3181
3182    #[test]
3183    fn pen_delta_mirrored_baseline_measures_advance_direction() {
3184        // Horizontal mirror: a forward text-space advance moves the pen LEFT
3185        // in user space. dx must still be positive (the text's own advance
3186        // direction), so the wrap gate does not misfire on plain advances.
3187        let state = state_with_ctm([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3188        let (dx, dy) = pen_delta(&state, (100.0, 50.0), (95.0, 50.0));
3189        assert_eq!(dx, 5.0, "forward advance is positive along the baseline");
3190        assert_eq!(dy.abs(), 0.0, "same baseline");
3191    }
3192
3193    #[test]
3194    fn pen_delta_degenerate_matrix_falls_back_to_raw_deltas() {
3195        // Zero baseline (a=b=0): projection impossible → raw user-space
3196        // deltas, the pre-#443 behavior.
3197        let state = state_with_ctm([0.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3198        assert_eq!(pen_delta(&state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
3199        // Non-finite baseline: same fallback.
3200        let nan_state = state_with_ctm([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]);
3201        assert_eq!(pen_delta(&nan_state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
3202    }
3203
3204    // ── issue #382: per-page byte-budget helper ──────────────────────────────
3205
3206    #[test]
3207    fn test_append_bounded_no_limit_always_appends() {
3208        let mut s = String::new();
3209        let mut trunc = false;
3210        assert!(append_bounded(&mut s, None, "hello", None, &mut trunc));
3211        assert!(append_bounded(&mut s, Some(' '), "world", None, &mut trunc));
3212        assert_eq!(s, "hello world");
3213        assert!(!trunc, "no limit never truncates");
3214    }
3215
3216    #[test]
3217    fn test_append_bounded_undershoot_counts_separator() {
3218        // "abcd" (4) is at budget 5; a Some('\n') + "x" would need 2 more → over.
3219        let mut s = String::from("abcd");
3220        let mut trunc = false;
3221        assert!(!append_bounded(
3222            &mut s,
3223            Some('\n'),
3224            "x",
3225            Some(5),
3226            &mut trunc
3227        ));
3228        assert_eq!(s, "abcd", "nothing appended when it would overshoot");
3229        assert!(trunc, "budget hit sets truncated");
3230        // Exactly-fits case: "e" alone (1 byte, no separator) reaches 5.
3231        let mut s2 = String::from("abcd");
3232        let mut t2 = false;
3233        assert!(append_bounded(&mut s2, None, "e", Some(5), &mut t2));
3234        assert_eq!(s2, "abcde");
3235        assert!(!t2);
3236        assert!(s2.len() <= 5, "invariant: len <= limit exactly");
3237    }
3238
3239    #[test]
3240    fn test_append_bounded_zero_limit_truncates_immediately() {
3241        let mut s = String::new();
3242        let mut trunc = false;
3243        assert!(!append_bounded(&mut s, None, "a", Some(0), &mut trunc));
3244        assert!(s.is_empty());
3245        assert!(trunc);
3246    }
3247
3248    #[test]
3249    fn test_append_bounded_is_noop_once_truncated() {
3250        let mut s = String::from("kept");
3251        let mut trunc = true; // already truncated
3252        assert!(!append_bounded(
3253            &mut s,
3254            None,
3255            "more",
3256            Some(1_000),
3257            &mut trunc
3258        ));
3259        assert_eq!(s, "kept", "no further accumulation after truncation");
3260    }
3261
3262    #[test]
3263    fn test_clamp_to_budget_no_limit_or_fits_is_noop() {
3264        let mut a = String::from("hello");
3265        let mut t = false;
3266        clamp_to_budget(&mut a, None, &mut t);
3267        assert_eq!(a, "hello");
3268        assert!(!t, "no limit never truncates");
3269
3270        let mut b = String::from("hi");
3271        clamp_to_budget(&mut b, Some(10), &mut t);
3272        assert_eq!(b, "hi", "already fits");
3273        assert!(!t);
3274    }
3275
3276    #[test]
3277    fn test_clamp_to_budget_cuts_and_flags() {
3278        let mut s = String::from("abcdefgh");
3279        let mut t = false;
3280        clamp_to_budget(&mut s, Some(3), &mut t);
3281        assert_eq!(s, "abc");
3282        assert!(t, "clamp that cut must set truncated");
3283    }
3284
3285    #[test]
3286    fn test_clamp_to_budget_never_splits_utf8() {
3287        // "é" is 2 bytes (0xC3 0xA9). A 3-byte budget on "éé" (4 bytes) must cut
3288        // back to the char boundary at 2, keeping one whole "é".
3289        let mut s = String::from("éé");
3290        let mut t = false;
3291        clamp_to_budget(&mut s, Some(3), &mut t);
3292        assert_eq!(s, "é", "must retreat to a char boundary, not split 'é'");
3293        assert!(s.len() <= 3);
3294        assert!(t);
3295    }
3296
3297    #[test]
3298    fn test_matrix_multiplication() {
3299        let identity = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
3300        let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
3301
3302        let result = multiply_matrix(&identity, &translation);
3303        assert_eq!(result, translation);
3304
3305        let result2 = multiply_matrix(&translation, &identity);
3306        assert_eq!(result2, translation);
3307    }
3308
3309    #[test]
3310    fn test_transform_point() {
3311        let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
3312        let (x, y) = transform_point(5.0, 5.0, &translation);
3313        assert_eq!(x, 15.0);
3314        assert_eq!(y, 25.0);
3315    }
3316
3317    #[test]
3318    fn test_extraction_options_default() {
3319        let options = ExtractionOptions::default();
3320        assert!(!options.preserve_layout);
3321        assert_eq!(options.space_threshold, 0.3);
3322        assert_eq!(options.newline_threshold, 10.0);
3323        assert!(options.sort_by_position);
3324        assert!(!options.detect_columns);
3325        assert_eq!(options.column_threshold, 50.0);
3326        assert!(options.merge_hyphenated);
3327    }
3328
3329    #[test]
3330    fn test_extraction_options_custom() {
3331        let options = ExtractionOptions {
3332            preserve_layout: true,
3333            space_threshold: 0.5,
3334            tj_space_threshold: 0.15,
3335            newline_threshold: 15.0,
3336            sort_by_position: false,
3337            detect_columns: true,
3338            column_threshold: 75.0,
3339            merge_hyphenated: false,
3340            track_space_decisions: false,
3341            reconstruct_paragraphs: false,
3342            include_artifacts: false,
3343            reorder_columns: false,
3344            max_extracted_bytes: None,
3345        };
3346        assert!(options.preserve_layout);
3347        assert_eq!(options.space_threshold, 0.5);
3348        assert_eq!(options.tj_space_threshold, 0.15);
3349        assert_eq!(options.newline_threshold, 15.0);
3350        assert!(!options.sort_by_position);
3351        assert!(options.detect_columns);
3352        assert_eq!(options.column_threshold, 75.0);
3353        assert!(!options.merge_hyphenated);
3354    }
3355
3356    #[test]
3357    fn test_parse_font_style_bold() {
3358        // PostScript style
3359        assert_eq!(parse_font_style("Helvetica-Bold"), (true, false));
3360        assert_eq!(parse_font_style("TimesNewRoman-Bold"), (true, false));
3361
3362        // TrueType style
3363        assert_eq!(parse_font_style("Arial Bold"), (true, false));
3364        assert_eq!(parse_font_style("Calibri Bold"), (true, false));
3365
3366        // Short form
3367        assert_eq!(parse_font_style("Helvetica-B"), (true, false));
3368    }
3369
3370    #[test]
3371    fn test_parse_font_style_italic() {
3372        // PostScript style
3373        assert_eq!(parse_font_style("Helvetica-Italic"), (false, true));
3374        assert_eq!(parse_font_style("Times-Oblique"), (false, true));
3375
3376        // TrueType style
3377        assert_eq!(parse_font_style("Arial Italic"), (false, true));
3378        assert_eq!(parse_font_style("Courier Oblique"), (false, true));
3379
3380        // Short form
3381        assert_eq!(parse_font_style("Helvetica-I"), (false, true));
3382    }
3383
3384    #[test]
3385    fn test_parse_font_style_bold_italic() {
3386        assert_eq!(parse_font_style("Helvetica-BoldItalic"), (true, true));
3387        assert_eq!(parse_font_style("Times-BoldOblique"), (true, true));
3388        assert_eq!(parse_font_style("Arial Bold Italic"), (true, true));
3389    }
3390
3391    #[test]
3392    fn test_parse_font_style_regular() {
3393        assert_eq!(parse_font_style("Helvetica"), (false, false));
3394        assert_eq!(parse_font_style("Times-Roman"), (false, false));
3395        assert_eq!(parse_font_style("Courier"), (false, false));
3396        assert_eq!(parse_font_style("Arial"), (false, false));
3397    }
3398
3399    #[test]
3400    fn test_parse_font_style_edge_cases() {
3401        // Empty and unusual cases
3402        assert_eq!(parse_font_style(""), (false, false));
3403        assert_eq!(parse_font_style("UnknownFont"), (false, false));
3404
3405        // Case insensitive
3406        assert_eq!(parse_font_style("HELVETICA-BOLD"), (true, false));
3407        assert_eq!(parse_font_style("times-ITALIC"), (false, true));
3408    }
3409
3410    #[test]
3411    fn test_text_fragment() {
3412        let fragment = TextFragment {
3413            text: "Hello".to_string(),
3414            x: 100.0,
3415            y: 200.0,
3416            width: 50.0,
3417            height: 12.0,
3418            font_size: 10.0,
3419            font_name: None,
3420            is_bold: false,
3421            is_italic: false,
3422            color: None,
3423            space_decisions: Vec::new(),
3424            mcid: None,
3425            struct_tag: None,
3426        };
3427        assert_eq!(fragment.text, "Hello");
3428        assert_eq!(fragment.x, 100.0);
3429        assert_eq!(fragment.y, 200.0);
3430        assert_eq!(fragment.width, 50.0);
3431        assert_eq!(fragment.height, 12.0);
3432        assert_eq!(fragment.font_size, 10.0);
3433    }
3434
3435    #[test]
3436    fn test_extracted_text() {
3437        let fragments = vec![
3438            TextFragment {
3439                text: "Hello".to_string(),
3440                x: 100.0,
3441                y: 200.0,
3442                width: 50.0,
3443                height: 12.0,
3444                font_size: 10.0,
3445                font_name: None,
3446                is_bold: false,
3447                is_italic: false,
3448                color: None,
3449                space_decisions: Vec::new(),
3450                mcid: None,
3451                struct_tag: None,
3452            },
3453            TextFragment {
3454                text: "World".to_string(),
3455                x: 160.0,
3456                y: 200.0,
3457                width: 50.0,
3458                height: 12.0,
3459                font_size: 10.0,
3460                font_name: None,
3461                is_bold: false,
3462                is_italic: false,
3463                color: None,
3464                space_decisions: Vec::new(),
3465                mcid: None,
3466                struct_tag: None,
3467            },
3468        ];
3469
3470        let extracted = ExtractedText {
3471            text: "Hello World".to_string(),
3472            fragments: fragments,
3473            truncated: false,
3474        };
3475
3476        assert_eq!(extracted.text, "Hello World");
3477        assert_eq!(extracted.fragments.len(), 2);
3478        assert_eq!(extracted.fragments[0].text, "Hello");
3479        assert_eq!(extracted.fragments[1].text, "World");
3480    }
3481
3482    #[test]
3483    fn test_text_state_default() {
3484        let state = TextState::default();
3485        assert_eq!(state.text_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3486        assert_eq!(state.text_line_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3487        assert_eq!(state.ctm, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3488        assert_eq!(state.leading, 0.0);
3489        assert_eq!(state.char_space, 0.0);
3490        assert_eq!(state.word_space, 0.0);
3491        assert_eq!(state.horizontal_scale, 100.0);
3492        assert_eq!(state.text_rise, 0.0);
3493        assert_eq!(state.font_size, 0.0);
3494        assert!(state.font_name.is_none());
3495        assert_eq!(state.render_mode, 0);
3496    }
3497
3498    #[test]
3499    fn test_matrix_operations() {
3500        // Test rotation matrix
3501        let rotation = [0.0, 1.0, -1.0, 0.0, 0.0, 0.0]; // 90 degree rotation
3502        let (x, y) = transform_point(1.0, 0.0, &rotation);
3503        assert_eq!(x, 0.0);
3504        assert_eq!(y, 1.0);
3505
3506        // Test scaling matrix
3507        let scale = [2.0, 0.0, 0.0, 3.0, 0.0, 0.0];
3508        let (x, y) = transform_point(5.0, 5.0, &scale);
3509        assert_eq!(x, 10.0);
3510        assert_eq!(y, 15.0);
3511
3512        // Test complex transformation
3513        let complex = [2.0, 1.0, 1.0, 2.0, 10.0, 20.0];
3514        let (x, y) = transform_point(1.0, 1.0, &complex);
3515        assert_eq!(x, 13.0); // 2*1 + 1*1 + 10
3516        assert_eq!(y, 23.0); // 1*1 + 2*1 + 20
3517    }
3518
3519    #[test]
3520    fn test_text_extractor_new() {
3521        let extractor = TextExtractor::new();
3522        let options = extractor.options;
3523        assert!(!options.preserve_layout);
3524        assert_eq!(options.space_threshold, 0.3);
3525        assert_eq!(options.newline_threshold, 10.0);
3526        assert!(options.sort_by_position);
3527        assert!(!options.detect_columns);
3528        assert_eq!(options.column_threshold, 50.0);
3529        assert!(options.merge_hyphenated);
3530    }
3531
3532    #[test]
3533    fn test_text_extractor_with_options() {
3534        let options = ExtractionOptions {
3535            preserve_layout: true,
3536            space_threshold: 0.3,
3537            tj_space_threshold: 0.2,
3538            newline_threshold: 12.0,
3539            sort_by_position: false,
3540            detect_columns: true,
3541            column_threshold: 60.0,
3542            merge_hyphenated: false,
3543            track_space_decisions: false,
3544            reconstruct_paragraphs: false,
3545            include_artifacts: false,
3546            reorder_columns: false,
3547            max_extracted_bytes: None,
3548        };
3549        let extractor = TextExtractor::with_options(options.clone());
3550        assert_eq!(extractor.options.preserve_layout, options.preserve_layout);
3551        assert_eq!(extractor.options.space_threshold, options.space_threshold);
3552        assert_eq!(
3553            extractor.options.newline_threshold,
3554            options.newline_threshold
3555        );
3556        assert_eq!(extractor.options.sort_by_position, options.sort_by_position);
3557        assert_eq!(extractor.options.detect_columns, options.detect_columns);
3558        assert_eq!(extractor.options.column_threshold, options.column_threshold);
3559        assert_eq!(extractor.options.merge_hyphenated, options.merge_hyphenated);
3560    }
3561
3562    // =========================================================================
3563    // RIGOROUS TESTS FOR FONT METRICS TEXT WIDTH CALCULATION
3564    // =========================================================================
3565
3566    #[test]
3567    fn test_calculate_text_width_with_no_font_info() {
3568        // Test fallback: should use simplified calculation
3569        let width = calculate_text_width("Hello", 12.0, None);
3570
3571        // Expected: 5 chars * 12.0 * 0.5 = 30.0
3572        assert_eq!(
3573            width, 30.0,
3574            "Without font info, should use simplified calculation: len * font_size * 0.5"
3575        );
3576    }
3577
3578    #[test]
3579    fn test_calculate_text_width_with_empty_metrics() {
3580        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3581
3582        // Font with no widths array
3583        let font_info = FontInfo {
3584            name: "TestFont".to_string(),
3585            font_type: "Type1".to_string(),
3586            encoding: None,
3587            to_unicode: None,
3588            differences: None,
3589            descendant_font: None,
3590            cid_ordering: None,
3591            metrics: FontMetrics {
3592                first_char: None,
3593                last_char: None,
3594                widths: None,
3595                missing_width: Some(500.0),
3596                kerning: None,
3597            },
3598            cid_encoding: None,
3599        };
3600
3601        let width = calculate_text_width("Hello", 12.0, Some(&font_info));
3602
3603        // Should fall back to simplified calculation
3604        assert_eq!(
3605            width, 30.0,
3606            "Without widths array, should fall back to simplified calculation"
3607        );
3608    }
3609
3610    #[test]
3611    fn test_calculate_text_width_with_complete_metrics() {
3612        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3613
3614        // Font with complete metrics for ASCII range 32-126
3615        // Simulate typical Helvetica widths (in 1/1000 units)
3616        let mut widths = vec![0.0; 95]; // 95 chars from 32 to 126
3617
3618        // Set specific widths for "Hello" (H=722, e=556, l=278, o=611)
3619        widths[72 - 32] = 722.0; // 'H' is ASCII 72
3620        widths[101 - 32] = 556.0; // 'e' is ASCII 101
3621        widths[108 - 32] = 278.0; // 'l' is ASCII 108
3622        widths[111 - 32] = 611.0; // 'o' is ASCII 111
3623
3624        let font_info = FontInfo {
3625            name: "Helvetica".to_string(),
3626            font_type: "Type1".to_string(),
3627            encoding: None,
3628            to_unicode: None,
3629            differences: None,
3630            descendant_font: None,
3631            cid_ordering: None,
3632            metrics: FontMetrics {
3633                first_char: Some(32),
3634                last_char: Some(126),
3635                widths: Some(widths),
3636                missing_width: Some(500.0),
3637                kerning: None,
3638            },
3639            cid_encoding: None,
3640        };
3641
3642        let width = calculate_text_width("Hello", 12.0, Some(&font_info));
3643
3644        // Expected calculation (widths in glyph space / 1000 * font_size):
3645        // H: 722/1000 * 12 = 8.664
3646        // e: 556/1000 * 12 = 6.672
3647        // l: 278/1000 * 12 = 3.336
3648        // l: 278/1000 * 12 = 3.336
3649        // o: 611/1000 * 12 = 7.332
3650        // Total: 29.34
3651        let expected = (722.0 + 556.0 + 278.0 + 278.0 + 611.0) / 1000.0 * 12.0;
3652        let tolerance = 0.0001; // Floating point tolerance
3653        assert!(
3654            (width - expected).abs() < tolerance,
3655            "Should calculate width using actual character metrics: expected {}, got {}, diff {}",
3656            expected,
3657            width,
3658            (width - expected).abs()
3659        );
3660
3661        // Verify it's different from simplified calculation
3662        let simplified = 5.0 * 12.0 * 0.5; // 30.0
3663        assert_ne!(
3664            width, simplified,
3665            "Metrics-based calculation should differ from simplified (30.0)"
3666        );
3667    }
3668
3669    #[test]
3670    fn width_from_codes_uses_char_code_not_decoded_unicode() {
3671        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3672
3673        // Simple Type1 font with a code-indexed Widths array: code 1 -> 1000,
3674        // code 2 -> 100. A custom encoding decodes code 1 -> 'm' (U+006D) and
3675        // code 2 -> 'i' (U+0069), so the decoded Unicode codepoints (109, 105)
3676        // are far from the codes (1, 2). The advance width MUST come from the
3677        // codes; indexing the Widths array by the decoded Unicode codepoint
3678        // reads out-of-range -> missing_width, desyncing glyph advance on
3679        // custom-encoded fonts (issue #302, Higgs/Computer-Modern scramble).
3680        let font_info = FontInfo {
3681            name: "F1".to_string(),
3682            font_type: "Type1".to_string(),
3683            encoding: None,
3684            to_unicode: None,
3685            differences: None,
3686            descendant_font: None,
3687            cid_ordering: None,
3688            metrics: FontMetrics {
3689                first_char: Some(1),
3690                last_char: Some(2),
3691                widths: Some(vec![1000.0, 100.0]),
3692                missing_width: Some(500.0),
3693                kerning: None,
3694            },
3695            cid_encoding: None,
3696        };
3697
3698        let codes = [1u8, 2u8];
3699        let decoded = "mi"; // what decode_text produced for these codes
3700        let width = calculate_text_width_from_codes(&codes, decoded, 10.0, Some(&font_info));
3701        let expected = (1000.0 + 100.0) / 1000.0 * 10.0; // 11.0
3702        assert!(
3703            (width - expected).abs() < 1e-6,
3704            "width must come from char codes: expected {expected}, got {width}"
3705        );
3706
3707        // The decoded-Unicode-indexed path is the bug: 109 and 105 are outside
3708        // [1,2] so both fall back to missing_width -> (500+500)/1000*10 = 10.0.
3709        let buggy = calculate_text_width(decoded, 10.0, Some(&font_info));
3710        assert_eq!(buggy, 10.0);
3711        assert_ne!(
3712            width, buggy,
3713            "code-based width must differ from the Unicode-indexed bug"
3714        );
3715    }
3716
3717    #[test]
3718    fn test_calculate_text_width_character_outside_range() {
3719        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3720
3721        // Font with narrow range (only covers 'A'-'Z')
3722        let widths = vec![722.0; 26]; // All uppercase letters same width
3723
3724        let font_info = FontInfo {
3725            name: "TestFont".to_string(),
3726            font_type: "Type1".to_string(),
3727            encoding: None,
3728            to_unicode: None,
3729            differences: None,
3730            descendant_font: None,
3731            cid_ordering: None,
3732            metrics: FontMetrics {
3733                first_char: Some(65), // 'A'
3734                last_char: Some(90),  // 'Z'
3735                widths: Some(widths),
3736                missing_width: Some(500.0),
3737                kerning: None,
3738            },
3739            cid_encoding: None,
3740        };
3741
3742        // Test with character outside range
3743        let width = calculate_text_width("A1", 10.0, Some(&font_info));
3744
3745        // Expected:
3746        // 'A' (65) is in range: 722/1000 * 10 = 7.22
3747        // '1' (49) is outside range: missing_width 500/1000 * 10 = 5.0
3748        // Total: 12.22
3749        let expected = (722.0 / 1000.0 * 10.0) + (500.0 / 1000.0 * 10.0);
3750        assert_eq!(
3751            width, expected,
3752            "Should use missing_width for characters outside range"
3753        );
3754    }
3755
3756    #[test]
3757    fn test_calculate_text_width_missing_width_in_array() {
3758        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3759
3760        // Font with incomplete widths array (some characters have 0.0)
3761        let mut widths = vec![500.0; 95]; // Default width
3762        widths[10] = 0.0; // Character at index 10 has no width defined
3763
3764        let font_info = FontInfo {
3765            name: "TestFont".to_string(),
3766            font_type: "Type1".to_string(),
3767            encoding: None,
3768            to_unicode: None,
3769            differences: None,
3770            descendant_font: None,
3771            cid_ordering: None,
3772            metrics: FontMetrics {
3773                first_char: Some(32),
3774                last_char: Some(126),
3775                widths: Some(widths),
3776                missing_width: Some(600.0),
3777                kerning: None,
3778            },
3779            cid_encoding: None,
3780        };
3781
3782        // Character 42 (index 10 from first_char 32)
3783        let char_code = 42u8 as char; // '*'
3784        let text = char_code.to_string();
3785        let width = calculate_text_width(&text, 10.0, Some(&font_info));
3786
3787        // Character is in range but width is 0.0, should NOT fall back to missing_width
3788        // (0.0 is a valid width for zero-width characters)
3789        assert_eq!(
3790            width, 0.0,
3791            "Should use 0.0 width from array, not missing_width"
3792        );
3793    }
3794
3795    #[test]
3796    fn test_calculate_text_width_empty_string() {
3797        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3798
3799        let font_info = FontInfo {
3800            name: "TestFont".to_string(),
3801            font_type: "Type1".to_string(),
3802            encoding: None,
3803            to_unicode: None,
3804            differences: None,
3805            descendant_font: None,
3806            cid_ordering: None,
3807            metrics: FontMetrics {
3808                first_char: Some(32),
3809                last_char: Some(126),
3810                widths: Some(vec![500.0; 95]),
3811                missing_width: Some(500.0),
3812                kerning: None,
3813            },
3814            cid_encoding: None,
3815        };
3816
3817        let width = calculate_text_width("", 12.0, Some(&font_info));
3818        assert_eq!(width, 0.0, "Empty string should have zero width");
3819
3820        // Also test without font info
3821        let width_no_font = calculate_text_width("", 12.0, None);
3822        assert_eq!(
3823            width_no_font, 0.0,
3824            "Empty string should have zero width (no font)"
3825        );
3826    }
3827
3828    #[test]
3829    fn test_calculate_text_width_unicode_characters() {
3830        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3831
3832        // Font with limited ASCII range
3833        let font_info = FontInfo {
3834            name: "TestFont".to_string(),
3835            font_type: "Type1".to_string(),
3836            encoding: None,
3837            to_unicode: None,
3838            differences: None,
3839            descendant_font: None,
3840            cid_ordering: None,
3841            metrics: FontMetrics {
3842                first_char: Some(32),
3843                last_char: Some(126),
3844                widths: Some(vec![500.0; 95]),
3845                missing_width: Some(600.0),
3846                kerning: None,
3847            },
3848            cid_encoding: None,
3849        };
3850
3851        // Test with Unicode characters outside ASCII range
3852        let width = calculate_text_width("Ñ", 10.0, Some(&font_info));
3853
3854        // 'Ñ' (U+00D1, code 209) is outside range, should use missing_width
3855        // Expected: 600/1000 * 10 = 6.0
3856        assert_eq!(
3857            width, 6.0,
3858            "Unicode character outside range should use missing_width"
3859        );
3860    }
3861
3862    #[test]
3863    fn test_calculate_text_width_different_font_sizes() {
3864        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3865
3866        let font_info = FontInfo {
3867            name: "TestFont".to_string(),
3868            font_type: "Type1".to_string(),
3869            encoding: None,
3870            to_unicode: None,
3871            differences: None,
3872            descendant_font: None,
3873            cid_ordering: None,
3874            metrics: FontMetrics {
3875                first_char: Some(65), // 'A'
3876                last_char: Some(65),  // 'A'
3877                widths: Some(vec![722.0]),
3878                missing_width: Some(500.0),
3879                kerning: None,
3880            },
3881            cid_encoding: None,
3882        };
3883
3884        // Test same character with different font sizes
3885        let width_10 = calculate_text_width("A", 10.0, Some(&font_info));
3886        let width_20 = calculate_text_width("A", 20.0, Some(&font_info));
3887
3888        // Widths should scale linearly with font size
3889        assert_eq!(width_10, 722.0 / 1000.0 * 10.0);
3890        assert_eq!(width_20, 722.0 / 1000.0 * 20.0);
3891        assert_eq!(
3892            width_20,
3893            width_10 * 2.0,
3894            "Width should scale linearly with font size"
3895        );
3896    }
3897
3898    #[test]
3899    fn test_calculate_text_width_proportional_vs_monospace() {
3900        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3901
3902        // Simulate proportional font (different widths)
3903        let proportional_widths = vec![278.0, 556.0, 722.0]; // i, m, W
3904        let proportional_font = FontInfo {
3905            name: "Helvetica".to_string(),
3906            font_type: "Type1".to_string(),
3907            encoding: None,
3908            to_unicode: None,
3909            differences: None,
3910            descendant_font: None,
3911            cid_ordering: None,
3912            metrics: FontMetrics {
3913                first_char: Some(105), // 'i'
3914                last_char: Some(107),  // covers i, j, k
3915                widths: Some(proportional_widths),
3916                missing_width: Some(500.0),
3917                kerning: None,
3918            },
3919            cid_encoding: None,
3920        };
3921
3922        // Simulate monospace font (same width)
3923        let monospace_widths = vec![600.0, 600.0, 600.0];
3924        let monospace_font = FontInfo {
3925            name: "Courier".to_string(),
3926            font_type: "Type1".to_string(),
3927            encoding: None,
3928            to_unicode: None,
3929            differences: None,
3930            descendant_font: None,
3931            cid_ordering: None,
3932            metrics: FontMetrics {
3933                first_char: Some(105),
3934                last_char: Some(107),
3935                widths: Some(monospace_widths),
3936                missing_width: Some(600.0),
3937                kerning: None,
3938            },
3939            cid_encoding: None,
3940        };
3941
3942        let prop_width = calculate_text_width("i", 12.0, Some(&proportional_font));
3943        let mono_width = calculate_text_width("i", 12.0, Some(&monospace_font));
3944
3945        // Proportional 'i' should be narrower than monospace 'i'
3946        assert!(
3947            prop_width < mono_width,
3948            "Proportional 'i' ({}) should be narrower than monospace 'i' ({})",
3949            prop_width,
3950            mono_width
3951        );
3952    }
3953
3954    // =========================================================================
3955    // CRITICAL KERNING TESTS (Issue #87 - Quality Agent Required)
3956    // =========================================================================
3957
3958    #[test]
3959    fn test_calculate_text_width_with_kerning() {
3960        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3961        use std::collections::HashMap;
3962
3963        // Create a font with kerning pairs
3964        let mut widths = vec![500.0; 95]; // ASCII 32-126
3965        widths[65 - 32] = 722.0; // 'A'
3966        widths[86 - 32] = 722.0; // 'V'
3967        widths[87 - 32] = 944.0; // 'W'
3968
3969        let mut kerning = HashMap::new();
3970        // Typical kerning pairs (in FUnits, 1/1000)
3971        kerning.insert((65, 86), -50.0); // 'A' + 'V' → tighten by 50 FUnits
3972        kerning.insert((65, 87), -40.0); // 'A' + 'W' → tighten by 40 FUnits
3973
3974        let font_info = FontInfo {
3975            name: "Helvetica".to_string(),
3976            font_type: "Type1".to_string(),
3977            encoding: None,
3978            to_unicode: None,
3979            differences: None,
3980            descendant_font: None,
3981            cid_ordering: None,
3982            metrics: FontMetrics {
3983                first_char: Some(32),
3984                last_char: Some(126),
3985                widths: Some(widths),
3986                missing_width: Some(500.0),
3987                kerning: Some(kerning),
3988            },
3989            cid_encoding: None,
3990        };
3991
3992        // Test "AV" with kerning
3993        let width_av = calculate_text_width("AV", 12.0, Some(&font_info));
3994        // Expected: (722 + 722)/1000 * 12 + (-50/1000 * 12)
3995        //         = 17.328 - 0.6 = 16.728
3996        let expected_av = (722.0 + 722.0) / 1000.0 * 12.0 + (-50.0 / 1000.0 * 12.0);
3997        let tolerance = 0.0001;
3998        assert!(
3999            (width_av - expected_av).abs() < tolerance,
4000            "AV with kerning: expected {}, got {}, diff {}",
4001            expected_av,
4002            width_av,
4003            (width_av - expected_av).abs()
4004        );
4005
4006        // Test "AW" with different kerning value
4007        let width_aw = calculate_text_width("AW", 12.0, Some(&font_info));
4008        // Expected: (722 + 944)/1000 * 12 + (-40/1000 * 12)
4009        //         = 19.992 - 0.48 = 19.512
4010        let expected_aw = (722.0 + 944.0) / 1000.0 * 12.0 + (-40.0 / 1000.0 * 12.0);
4011        assert!(
4012            (width_aw - expected_aw).abs() < tolerance,
4013            "AW with kerning: expected {}, got {}, diff {}",
4014            expected_aw,
4015            width_aw,
4016            (width_aw - expected_aw).abs()
4017        );
4018
4019        // Test "VA" with NO kerning (pair not in HashMap)
4020        let width_va = calculate_text_width("VA", 12.0, Some(&font_info));
4021        // Expected: (722 + 722)/1000 * 12 = 17.328 (no kerning adjustment)
4022        let expected_va = (722.0 + 722.0) / 1000.0 * 12.0;
4023        assert!(
4024            (width_va - expected_va).abs() < tolerance,
4025            "VA without kerning: expected {}, got {}, diff {}",
4026            expected_va,
4027            width_va,
4028            (width_va - expected_va).abs()
4029        );
4030
4031        // Verify kerning makes a measurable difference
4032        assert!(
4033            width_av < width_va,
4034            "AV with kerning ({}) should be narrower than VA without kerning ({})",
4035            width_av,
4036            width_va
4037        );
4038    }
4039
4040    #[test]
4041    fn test_parse_truetype_kern_table_minimal() {
4042        use crate::text::extraction_cmap::parse_truetype_kern_table;
4043
4044        // Complete TrueType font with kern table (Format 0, 2 kerning pairs)
4045        // Structure:
4046        // 1. Offset table (12 bytes)
4047        // 2. Table directory (2 tables: 'head' and 'kern', each 16 bytes = 32 total)
4048        // 3. 'head' table data (54 bytes)
4049        // 4. 'kern' table data (30 bytes)
4050        // Total: 128 bytes
4051        let mut ttf_data = vec![
4052            // Offset table
4053            0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
4054            0x00, 0x02, // numTables: 2
4055            0x00, 0x20, // searchRange: 32
4056            0x00, 0x01, // entrySelector: 1
4057            0x00, 0x00, // rangeShift: 0
4058        ];
4059
4060        // Table directory entry 1: 'head' table
4061        ttf_data.extend_from_slice(b"head"); // tag
4062        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
4063        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x2C]); // offset: 44 (12 + 32)
4064        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x36]); // length: 54
4065
4066        // Table directory entry 2: 'kern' table
4067        ttf_data.extend_from_slice(b"kern"); // tag
4068        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
4069        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x62]); // offset: 98 (44 + 54)
4070        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x1E]); // length: 30 (actual kern table size)
4071
4072        // 'head' table data (54 bytes of zeros - minimal valid head table)
4073        ttf_data.extend_from_slice(&[0u8; 54]);
4074
4075        // 'kern' table data (34 bytes)
4076        ttf_data.extend_from_slice(&[
4077            // Kern table header
4078            0x00, 0x00, // version: 0
4079            0x00, 0x01, // nTables: 1
4080            // Subtable header
4081            0x00, 0x00, // version: 0
4082            0x00, 0x1A, // length: 26 bytes (header 6 + nPairs data 8 + pairs 2*6=12)
4083            0x00, 0x00, // coverage: 0x0000 (Format 0 in lower byte, horizontal)
4084            0x00, 0x02, // nPairs: 2
4085            0x00, 0x08, // searchRange: 8
4086            0x00, 0x00, // entrySelector: 0
4087            0x00, 0x04, // rangeShift: 4
4088            // Kerning pair 1: A + V → -50
4089            0x00, 0x41, // left glyph: 65 ('A')
4090            0x00, 0x56, // right glyph: 86 ('V')
4091            0xFF, 0xCE, // value: -50 (signed 16-bit big-endian)
4092            // Kerning pair 2: A + W → -40
4093            0x00, 0x41, // left glyph: 65 ('A')
4094            0x00, 0x57, // right glyph: 87 ('W')
4095            0xFF, 0xD8, // value: -40 (signed 16-bit big-endian)
4096        ]);
4097
4098        let result = parse_truetype_kern_table(&ttf_data);
4099        assert!(
4100            result.is_ok(),
4101            "Should parse minimal kern table successfully: {:?}",
4102            result.err()
4103        );
4104
4105        let kerning_map = result.unwrap();
4106        assert_eq!(kerning_map.len(), 2, "Should extract 2 kerning pairs");
4107
4108        // Verify pair 1: A + V → -50
4109        assert_eq!(
4110            kerning_map.get(&(65, 86)),
4111            Some(&-50.0),
4112            "Should have A+V kerning pair with value -50"
4113        );
4114
4115        // Verify pair 2: A + W → -40
4116        assert_eq!(
4117            kerning_map.get(&(65, 87)),
4118            Some(&-40.0),
4119            "Should have A+W kerning pair with value -40"
4120        );
4121    }
4122
4123    #[test]
4124    fn test_parse_kern_table_no_kern_table() {
4125        use crate::text::extraction_cmap::parse_truetype_kern_table;
4126
4127        // TrueType font data WITHOUT a 'kern' table
4128        // Structure:
4129        // - Offset table: scaler type + numTables + searchRange + entrySelector + rangeShift
4130        // - Table directory: 1 entry for 'head' table (not 'kern')
4131        let ttf_data = vec![
4132            // Offset table
4133            0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
4134            0x00, 0x01, // numTables: 1
4135            0x00, 0x10, // searchRange: 16
4136            0x00, 0x00, // entrySelector: 0
4137            0x00, 0x00, // rangeShift: 0
4138            // Table directory entry: 'head' table (not 'kern')
4139            b'h', b'e', b'a', b'd', // tag: 'head'
4140            0x00, 0x00, 0x00, 0x00, // checksum
4141            0x00, 0x00, 0x00, 0x1C, // offset: 28
4142            0x00, 0x00, 0x00, 0x36, // length: 54
4143            // Mock 'head' table data (54 bytes of zeros)
4144            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4145            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4146            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4147            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4148        ];
4149
4150        let result = parse_truetype_kern_table(&ttf_data);
4151        assert!(
4152            result.is_ok(),
4153            "Should gracefully handle missing kern table"
4154        );
4155
4156        let kerning_map = result.unwrap();
4157        assert!(
4158            kerning_map.is_empty(),
4159            "Should return empty HashMap when no kern table exists"
4160        );
4161    }
4162
4163    // Helper for paragraph-reconstruction unit tests. TextFragment has 11
4164    // fields so a helper keeps the test bodies focused on geometry.
4165    fn tf(text: &str, x: f64, y: f64, width: f64, font_size: f64) -> TextFragment {
4166        TextFragment {
4167            text: text.to_string(),
4168            x,
4169            y,
4170            width,
4171            height: font_size,
4172            font_size,
4173            font_name: None,
4174            is_bold: false,
4175            is_italic: false,
4176            color: None,
4177            space_decisions: Vec::new(),
4178            mcid: None,
4179            struct_tag: None,
4180        }
4181    }
4182
4183    #[test]
4184    fn merge_into_lines_groups_same_baseline_fragments() {
4185        let extractor = TextExtractor::with_options(ExtractionOptions {
4186            reconstruct_paragraphs: true,
4187            ..Default::default()
4188        });
4189        let input = vec![
4190            tf("Hello", 50.0, 400.0, 30.0, 12.0),
4191            tf("world", 90.0, 400.0, 30.0, 12.0),
4192            tf("now.", 130.0, 400.0, 25.0, 12.0),
4193            tf("Next", 50.0, 386.0, 30.0, 12.0),
4194            tf("line.", 90.0, 386.0, 25.0, 12.0),
4195        ];
4196        let lines = extractor.merge_into_lines(&input);
4197        assert_eq!(
4198            lines.len(),
4199            2,
4200            "two distinct baselines must produce two line fragments"
4201        );
4202        assert_eq!(
4203            lines[0].text, "Hello world now.",
4204            "first line concatenated with spaces"
4205        );
4206        assert_eq!(lines[1].text, "Next line.", "second line concatenated");
4207    }
4208
4209    #[test]
4210    fn merge_into_lines_inserts_space_only_when_gap_exceeds_threshold() {
4211        let extractor = TextExtractor::with_options(ExtractionOptions {
4212            reconstruct_paragraphs: true,
4213            space_threshold: 0.3,
4214            ..Default::default()
4215        });
4216        // Gap of 4pt at font_size 12 = 0.33x — above threshold 0.3
4217        let with_gap = vec![
4218            tf("AB", 50.0, 400.0, 10.0, 12.0),
4219            tf("CD", 64.0, 400.0, 10.0, 12.0),
4220        ];
4221        let lines = extractor.merge_into_lines(&with_gap);
4222        assert_eq!(
4223            lines[0].text, "AB CD",
4224            "gap above threshold must insert space"
4225        );
4226
4227        // Gap of 1pt = 0.083x — below threshold
4228        let tight = vec![
4229            tf("AB", 50.0, 400.0, 10.0, 12.0),
4230            tf("CD", 61.0, 400.0, 10.0, 12.0),
4231        ];
4232        let lines = extractor.merge_into_lines(&tight);
4233        assert_eq!(lines[0].text, "ABCD", "tight gap must NOT insert space");
4234    }
4235
4236    #[test]
4237    fn standard_14_space_width_maps_base_fonts_and_substitutes() {
4238        // Adobe Core-14 AFM space advances, with subset prefixes stripped and
4239        // metric-compatible substitutes folded in (#302 symptom 2).
4240        assert_eq!(super::standard_14_space_width("Times-Roman"), Some(250.0));
4241        assert_eq!(
4242            super::standard_14_space_width("Times-BoldItalic"),
4243            Some(250.0)
4244        );
4245        assert_eq!(super::standard_14_space_width("Helvetica"), Some(278.0));
4246        assert_eq!(super::standard_14_space_width("Courier-Bold"), Some(600.0));
4247        assert_eq!(super::standard_14_space_width("Symbol"), Some(250.0));
4248        assert_eq!(super::standard_14_space_width("ZapfDingbats"), Some(278.0));
4249        // subset prefix stripped
4250        assert_eq!(
4251            super::standard_14_space_width("ABCDEF+Times-Roman"),
4252            Some(250.0)
4253        );
4254        // metric-compatible substitutes
4255        assert_eq!(super::standard_14_space_width("Arial-BoldMT"), Some(278.0));
4256        assert_eq!(
4257            super::standard_14_space_width("TimesNewRomanPSMT"),
4258            Some(250.0)
4259        );
4260        assert_eq!(
4261            super::standard_14_space_width("CourierNewPSMT"),
4262            Some(600.0)
4263        );
4264        // unknown / embedded fonts fall through to the caller's fallback
4265        assert_eq!(super::standard_14_space_width("Poppins-Regular"), None);
4266        assert_eq!(super::standard_14_space_width("VUNXGH+Calibri"), None);
4267    }
4268
4269    #[test]
4270    fn merge_into_lines_keeps_emission_order_for_font_switch_overlap() {
4271        // #302 symptom 1: a font-switched glyph (e.g. the italic particle
4272        // symbol "Z" in "to the Z boson") is positioned by the producer with
4273        // an x-origin that falls INSIDE the x-span of the preceding roman run
4274        // ("to the"). The content stream still delivers it in correct reading
4275        // order. Sorting a row purely by x-origin interleaves the overlapping
4276        // fragment, yielding "Zto the" instead of "to theZ". When a row's only
4277        // backward emission steps are span overlaps (not disjoint jumps),
4278        // emission order is the authoritative reading order.
4279        let extractor = TextExtractor::with_options(ExtractionOptions {
4280            reconstruct_paragraphs: true,
4281            ..Default::default()
4282        });
4283        // emission order = reading order; "Z" overlaps "to t" + "he" in x.
4284        let row = vec![
4285            tf("to t", 455.5, 400.0, 12.0, 10.0), // 455.5 .. 467.5
4286            tf("he", 467.5, 400.0, 10.0, 10.0),   // 467.5 .. 477.5
4287            tf("Z", 455.3, 400.0, 23.0, 10.0),    // 455.3 .. 478.3 (overlaps both)
4288        ];
4289        let lines = extractor.merge_into_lines(&row);
4290        assert_eq!(lines.len(), 1);
4291        assert_eq!(
4292            lines[0].text, "to theZ",
4293            "overlapping font-switch fragment must keep emission (reading) order"
4294        );
4295    }
4296
4297    #[test]
4298    fn merge_into_lines_keeps_emission_when_run_backfills_covered_span() {
4299        // #305: dense justified body text is split into sub-word fragments by
4300        // the font's arbitrary glyph runs. A later word ("described", x 492..537)
4301        // is emitted with a backward x-origin that lands INSIDE the span already
4302        // covered by the line ("...selections", 479..521), but does NOT overlap
4303        // the short immediately-preceding fragment ("s", 517..521). Emission is
4304        // still the reading order, so the line must keep it — the overlap test
4305        // has to consider the line's running extent, not just the previous
4306        // fragment. (Real case: Higgs p5 "kinematic selections described in".)
4307        let extractor = TextExtractor::with_options(ExtractionOptions {
4308            reconstruct_paragraphs: true,
4309            ..Default::default()
4310        });
4311        let row = vec![
4312            tf("selection", 479.0, 400.0, 38.0, 8.0), // 479..517
4313            tf("s", 517.0, 400.0, 4.0, 8.0),          // 517..521  short predecessor
4314            tf("d", 492.0, 400.0, 4.0, 8.0),          // 492..496  backfill, no overlap with "s"
4315            tf("escribed", 496.0, 400.0, 41.0, 8.0),  // 496..537
4316        ];
4317        let lines = extractor.merge_into_lines(&row);
4318        assert_eq!(
4319            lines[0].text, "selectionsdescribed",
4320            "a run that backfills the line's covered span must keep emission order"
4321        );
4322    }
4323
4324    #[test]
4325    fn merge_into_lines_uses_x_order_for_disjoint_backward_jump() {
4326        // Guard: a genuinely scrambled non-tagged stream (fragments emitted
4327        // out of x-order at DISJOINT positions, e.g. right-to-left or random
4328        // generators) must still be reordered by x. Here "the" is emitted
4329        // after "boson" with no span overlap, so x-order is authoritative.
4330        let extractor = TextExtractor::with_options(ExtractionOptions {
4331            reconstruct_paragraphs: true,
4332            ..Default::default()
4333        });
4334        let row = vec![
4335            tf("boson", 100.0, 400.0, 28.0, 10.0), // 100 .. 128
4336            tf("the", 80.0, 400.0, 15.0, 10.0),    // 80 .. 95 (disjoint, left of boson)
4337        ];
4338        let lines = extractor.merge_into_lines(&row);
4339        assert_eq!(lines.len(), 1);
4340        assert_eq!(
4341            lines[0].text, "the boson",
4342            "disjoint backward emission jump must be reordered by x"
4343        );
4344    }
4345
4346    #[test]
4347    fn merge_into_lines_unioned_bounding_box() {
4348        let extractor = TextExtractor::with_options(ExtractionOptions {
4349            reconstruct_paragraphs: true,
4350            ..Default::default()
4351        });
4352        let input = vec![
4353            tf("A", 50.0, 400.0, 10.0, 12.0),
4354            tf("B", 100.0, 400.0, 10.0, 12.0),
4355        ];
4356        let lines = extractor.merge_into_lines(&input);
4357        assert_eq!(lines.len(), 1);
4358        assert!((lines[0].x - 50.0).abs() < 0.01);
4359        assert!(
4360            (lines[0].width - 60.0).abs() < 0.01,
4361            "width must span 50->110"
4362        );
4363    }
4364
4365    #[test]
4366    fn assign_row_ids_monotone_y_descending_keeps_zero() {
4367        let frags = vec![
4368            tf("A", 50.0, 400.0, 10.0, 9.0),
4369            tf("B", 50.0, 395.0, 10.0, 9.0),
4370            tf("C", 50.0, 390.0, 10.0, 9.0),
4371        ];
4372        let row_ids = super::assign_row_ids(&frags);
4373        assert_eq!(row_ids, vec![0u32, 0, 0]);
4374    }
4375
4376    #[test]
4377    fn assign_row_ids_increments_on_y_up_jump_above_threshold() {
4378        // font_size=9 → threshold = max(4.5, 2.0) = 4.5
4379        // deltas: 395-400=-5, 420-395=+25 (>4.5)
4380        let frags = vec![
4381            tf("A", 50.0, 400.0, 10.0, 9.0),
4382            tf("B", 50.0, 395.0, 10.0, 9.0),
4383            tf("C", 50.0, 420.0, 10.0, 9.0),
4384        ];
4385        let row_ids = super::assign_row_ids(&frags);
4386        assert_eq!(row_ids, vec![0u32, 0, 1]);
4387    }
4388
4389    #[test]
4390    fn assign_row_ids_ignores_superscript_within_threshold() {
4391        // font_size=9 → threshold 4.5. delta 2.5 must NOT trigger.
4392        let frags = vec![
4393            tf("A", 50.0, 400.0, 10.0, 9.0),
4394            tf("^2", 60.0, 402.5, 5.0, 9.0),
4395            tf("B", 65.0, 395.0, 10.0, 9.0),
4396        ];
4397        let row_ids = super::assign_row_ids(&frags);
4398        assert_eq!(row_ids, vec![0u32, 0, 0]);
4399    }
4400
4401    #[test]
4402    fn assign_row_ids_floor_2pt_for_small_fonts() {
4403        // font_size=3 → font_size*0.5 = 1.5; floor lifts threshold to 2.0
4404        // delta = +2.5 > 2.0 must trigger.
4405        let frags = vec![
4406            tf("A", 50.0, 100.0, 10.0, 3.0),
4407            tf("B", 50.0, 102.5, 10.0, 3.0),
4408        ];
4409        let row_ids = super::assign_row_ids(&frags);
4410        assert_eq!(row_ids, vec![0u32, 1]);
4411    }
4412
4413    #[test]
4414    fn assign_row_ids_empty_slice_returns_empty() {
4415        let frags: Vec<TextFragment> = vec![];
4416        let row_ids = super::assign_row_ids(&frags);
4417        assert!(row_ids.is_empty(), "empty input must yield empty output");
4418    }
4419
4420    #[test]
4421    fn merge_into_lines_splits_two_columns_emitted_sequentially() {
4422        let extractor = TextExtractor::with_options(ExtractionOptions {
4423            reconstruct_paragraphs: true,
4424            ..Default::default()
4425        });
4426        // Emission order: col1.l1, col1.l2 (Y monotone down), then col2.l1
4427        // (Y jumps UP by 10 > threshold 5 for font 10pt), col2.l2.
4428        let input = vec![
4429            tf("col1-top", 50.0, 400.0, 80.0, 10.0),
4430            tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
4431            tf("col2-top", 200.0, 405.0, 80.0, 10.0),
4432            tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
4433        ];
4434        let lines = extractor.merge_into_lines(&input);
4435        assert_eq!(
4436            lines.len(),
4437            4,
4438            "two columns at near-identical Y must split into 4 lines"
4439        );
4440        // row_id=0 batch first (col1), then row_id=1 (col2). Within each batch, Y desc.
4441        assert_eq!(lines[0].text, "col1-top");
4442        assert_eq!(lines[0].y, 400.0);
4443        assert_eq!(lines[1].text, "col1-bot");
4444        assert_eq!(lines[1].y, 395.0);
4445        assert_eq!(lines[2].text, "col2-top");
4446        assert_eq!(lines[2].y, 405.0);
4447        assert_eq!(lines[3].text, "col2-bot");
4448        assert_eq!(lines[3].y, 400.0);
4449    }
4450
4451    #[test]
4452    fn merge_into_lines_preserves_single_column_continuation() {
4453        let extractor = TextExtractor::with_options(ExtractionOptions {
4454            reconstruct_paragraphs: true,
4455            ..Default::default()
4456        });
4457        // Single column: same Y continuation (X grows), then next line down.
4458        let input = vec![
4459            tf("Hello", 50.0, 400.0, 30.0, 10.0),
4460            tf("world", 90.0, 400.0, 30.0, 10.0),
4461            tf("next-line", 50.0, 395.0, 70.0, 10.0),
4462        ];
4463        let lines = extractor.merge_into_lines(&input);
4464        assert_eq!(
4465            lines.len(),
4466            2,
4467            "single column continuation must collapse to 2 lines"
4468        );
4469        assert!(lines[0].text.contains("Hello"));
4470        assert!(lines[0].text.contains("world"));
4471        assert_eq!(lines[1].text, "next-line");
4472    }
4473
4474    #[test]
4475    fn merge_into_lines_splits_columns_with_uniform_mcid() {
4476        // Regression guard for #265 root cause: NCSC page 12 has a single
4477        // outer BDC, so every fragment has mcid=Some(0). Column separation
4478        // must come from row_id alone, not from mcid.
4479        let extractor = TextExtractor::with_options(ExtractionOptions {
4480            reconstruct_paragraphs: true,
4481            ..Default::default()
4482        });
4483        let mut frags = vec![
4484            tf("col1-top", 50.0, 400.0, 80.0, 10.0),
4485            tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
4486            tf("col2-top", 200.0, 405.0, 80.0, 10.0),
4487            tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
4488        ];
4489        for f in &mut frags {
4490            f.mcid = Some(0);
4491        }
4492        let lines = extractor.merge_into_lines(&frags);
4493        assert_eq!(
4494            lines.len(),
4495            4,
4496            "uniform mcid must not prevent row_id-based column split (NCSC root cause)"
4497        );
4498        assert_eq!(lines[0].text, "col1-top");
4499        assert_eq!(lines[1].text, "col1-bot");
4500        assert_eq!(lines[2].text, "col2-top");
4501        assert_eq!(lines[3].text, "col2-bot");
4502    }
4503
4504    #[test]
4505    fn merge_close_fragments_superscript_merges_when_reconstruct_paragraphs() {
4506        let extractor = TextExtractor::with_options(ExtractionOptions {
4507            reconstruct_paragraphs: true,
4508            ..Default::default()
4509        });
4510        // Citation superscript: body text at y=400, raised digit at y=403.5
4511        // (3.5pt above baseline for 10pt font). y_tol = 0.5 * 10 = 5.0 > 3.5
4512        // and x_gap = 4pt < 10*0.5 = 5pt, so the superscript must merge into
4513        // the body fragment.
4514        let frags = vec![
4515            tf("body-text", 50.0, 400.0, 25.0, 10.0),
4516            tf("1", 79.0, 403.5, 4.0, 10.0),
4517        ];
4518        let merged = extractor.merge_close_fragments(&frags);
4519        assert_eq!(
4520            merged.len(),
4521            1,
4522            "superscript within 5pt of baseline must merge in reconstruct path"
4523        );
4524        assert!(merged[0].text.contains("body-text"));
4525        assert!(merged[0].text.contains("1"));
4526    }
4527
4528    #[test]
4529    fn merge_close_fragments_superscript_does_not_merge_in_legacy_path() {
4530        let extractor = TextExtractor::with_options(ExtractionOptions {
4531            reconstruct_paragraphs: false,
4532            ..Default::default()
4533        });
4534        // Legacy path: y_tol=1.0 fixed. A 3.5pt delta must NOT merge.
4535        let frags = vec![
4536            tf("body-text", 50.0, 400.0, 25.0, 10.0),
4537            tf("1", 79.0, 403.5, 4.0, 10.0),
4538        ];
4539        let merged = extractor.merge_close_fragments(&frags);
4540        assert_eq!(
4541            merged.len(),
4542            2,
4543            "3.5pt Y delta exceeds legacy 1.0pt threshold; superscript stays separate"
4544        );
4545    }
4546
4547    #[test]
4548    fn merge_into_paragraphs_groups_consecutive_lines() {
4549        let extractor = TextExtractor::with_options(ExtractionOptions {
4550            reconstruct_paragraphs: true,
4551            ..Default::default()
4552        });
4553        // Three lines, 14pt leading (line height 12pt, gap 2pt)
4554        let lines = vec![
4555            tf("Line one.", 50.0, 400.0, 60.0, 12.0),
4556            tf("Line two.", 50.0, 386.0, 60.0, 12.0),
4557            tf("Line three.", 50.0, 372.0, 70.0, 12.0),
4558        ];
4559        let paragraphs = extractor.merge_into_paragraphs(&lines);
4560        assert_eq!(paragraphs.len(), 1);
4561        assert_eq!(paragraphs[0].text, "Line one.\nLine two.\nLine three.");
4562    }
4563
4564    #[test]
4565    fn merge_into_paragraphs_splits_on_large_vertical_gap() {
4566        let extractor = TextExtractor::with_options(ExtractionOptions {
4567            reconstruct_paragraphs: true,
4568            ..Default::default()
4569        });
4570        let lines = vec![
4571            tf("P1L1.", 50.0, 400.0, 40.0, 12.0),
4572            tf("P1L2.", 50.0, 386.0, 40.0, 12.0),
4573            tf("P2L1.", 50.0, 300.0, 40.0, 12.0),
4574        ];
4575        let paragraphs = extractor.merge_into_paragraphs(&lines);
4576        assert_eq!(paragraphs.len(), 2);
4577        assert_eq!(paragraphs[0].text, "P1L1.\nP1L2.");
4578        assert_eq!(paragraphs[1].text, "P2L1.");
4579    }
4580
4581    /// A heading is a different block from the body that follows it, even when
4582    /// the vertical gap is small enough to look like line spacing. Merging them
4583    /// destroys the two signals `partition` uses to classify a `Title`
4584    /// (font-size ratio and bold-short), so the heading text is never
4585    /// recoverable downstream (issue #436).
4586    #[test]
4587    fn merge_into_paragraphs_splits_on_font_size_change() {
4588        let extractor = TextExtractor::with_options(ExtractionOptions {
4589            reconstruct_paragraphs: true,
4590            ..Default::default()
4591        });
4592        // 20pt title at y=760, 10pt body line 40pt below: gap = 30pt, which is
4593        // exactly the 1.5 * median(20, 10) = 30pt vertical threshold, so only
4594        // the style change can separate them.
4595        let lines = vec![
4596            tf("Section Heading", 72.0, 760.0, 120.0, 20.0),
4597            tf("Body text of this section.", 72.0, 720.0, 150.0, 10.0),
4598        ];
4599        let paragraphs = extractor.merge_into_paragraphs(&lines);
4600        assert_eq!(
4601            paragraphs.len(),
4602            2,
4603            "font-size change must end the paragraph"
4604        );
4605        assert_eq!(paragraphs[0].text, "Section Heading");
4606        assert_eq!(paragraphs[0].font_size, 20.0);
4607        assert_eq!(paragraphs[1].text, "Body text of this section.");
4608    }
4609
4610    /// Same size, different weight: the classic run-in bold heading. `partition`
4611    /// classifies it through `bold_short_title`, which needs the heading to
4612    /// survive extraction as its own fragment (issue #436).
4613    #[test]
4614    fn merge_into_paragraphs_splits_on_weight_change() {
4615        let extractor = TextExtractor::with_options(ExtractionOptions {
4616            reconstruct_paragraphs: true,
4617            ..Default::default()
4618        });
4619        let mut heading = tf("Overview", 72.0, 400.0, 60.0, 12.0);
4620        heading.is_bold = true;
4621        let lines = vec![heading, tf("Body line.", 72.0, 386.0, 60.0, 12.0)];
4622        let paragraphs = extractor.merge_into_paragraphs(&lines);
4623        assert_eq!(paragraphs.len(), 2, "weight change must end the paragraph");
4624        assert_eq!(paragraphs[0].text, "Overview");
4625        assert!(paragraphs[0].is_bold);
4626        assert_eq!(paragraphs[1].text, "Body line.");
4627    }
4628
4629    /// Sub-point rounding (11.96pt vs 12pt from a scaled text matrix) is not a
4630    /// style change: the paragraph must stay whole.
4631    #[test]
4632    fn merge_into_paragraphs_tolerates_subpoint_font_size_jitter() {
4633        let extractor = TextExtractor::with_options(ExtractionOptions {
4634            reconstruct_paragraphs: true,
4635            ..Default::default()
4636        });
4637        let lines = vec![
4638            tf("Line one.", 50.0, 400.0, 60.0, 12.0),
4639            tf("Line two.", 50.0, 386.0, 60.0, 11.96),
4640        ];
4641        let paragraphs = extractor.merge_into_paragraphs(&lines);
4642        assert_eq!(
4643            paragraphs.len(),
4644            1,
4645            "0.3% size jitter is not a style change"
4646        );
4647        assert_eq!(paragraphs[0].text, "Line one.\nLine two.");
4648    }
4649
4650    #[test]
4651    fn merge_into_paragraphs_drops_hyphen_when_merge_hyphenated() {
4652        let extractor = TextExtractor::with_options(ExtractionOptions {
4653            reconstruct_paragraphs: true,
4654            merge_hyphenated: true,
4655            ..Default::default()
4656        });
4657        let lines = vec![
4658            tf("Kryp-", 50.0, 400.0, 30.0, 12.0),
4659            tf("tographie", 50.0, 386.0, 60.0, 12.0),
4660        ];
4661        let paragraphs = extractor.merge_into_paragraphs(&lines);
4662        assert_eq!(paragraphs.len(), 1);
4663        assert_eq!(
4664            paragraphs[0].text, "Kryptographie",
4665            "hyphen elided, no newline inserted"
4666        );
4667    }
4668
4669    #[test]
4670    fn decode_pdf_string_utf16be_bom_decodes_fi_ligature() {
4671        let bytes = [0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69];
4672        assert_eq!(super::decode_pdf_string(&bytes), "fi");
4673    }
4674
4675    #[test]
4676    fn decode_pdf_string_ascii_pdfdocencoding_passthrough() {
4677        let bytes = b"page 12";
4678        assert_eq!(super::decode_pdf_string(bytes), "page 12");
4679    }
4680
4681    #[test]
4682    fn decode_pdf_string_empty_input_returns_empty() {
4683        assert_eq!(super::decode_pdf_string(&[]), "");
4684    }
4685
4686    #[test]
4687    fn decode_pdf_string_lone_bom_returns_empty() {
4688        // BOM only, no code units after.
4689        assert_eq!(super::decode_pdf_string(&[0xFE, 0xFF]), "");
4690    }
4691
4692    #[test]
4693    fn resolve_props_extracts_integer_mcid() {
4694        use crate::parser::content::{MarkedContentProps, MarkedContentValue};
4695        use std::collections::HashMap;
4696        let mut map = HashMap::new();
4697        map.insert("MCID".to_string(), MarkedContentValue::Integer(7));
4698        let props = MarkedContentProps::Inline(map);
4699
4700        let (mcid, actual) = super::resolve_props(&props, None);
4701        assert_eq!(mcid, Some(7));
4702        assert_eq!(actual, None);
4703    }
4704
4705    #[test]
4706    fn resolve_props_decodes_utf16be_actualtext() {
4707        use crate::parser::content::{MarkedContentProps, MarkedContentValue};
4708        use std::collections::HashMap;
4709        let mut map = HashMap::new();
4710        map.insert(
4711            "ActualText".to_string(),
4712            MarkedContentValue::String(vec![0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69]),
4713        );
4714        let props = MarkedContentProps::Inline(map);
4715
4716        let (mcid, actual) = super::resolve_props(&props, None);
4717        assert_eq!(mcid, None);
4718        assert_eq!(actual.as_deref(), Some("fi"));
4719    }
4720
4721    #[test]
4722    fn resolve_props_returns_none_for_unresolvable_resource_ref() {
4723        use crate::parser::content::MarkedContentProps;
4724        let props = MarkedContentProps::ResourceRef("PropsName".to_string());
4725        let (mcid, actual) = super::resolve_props(&props, None);
4726        assert_eq!((mcid, actual), (None, None));
4727    }
4728
4729    #[test]
4730    fn resolve_props_negative_mcid_rejected() {
4731        use crate::parser::content::{MarkedContentProps, MarkedContentValue};
4732        use std::collections::HashMap;
4733        // MCID is unsigned per ISO 32000-1; negative integer is malformed.
4734        let mut map = HashMap::new();
4735        map.insert("MCID".to_string(), MarkedContentValue::Integer(-1));
4736        let props = MarkedContentProps::Inline(map);
4737
4738        let (mcid, _) = super::resolve_props(&props, None);
4739        assert_eq!(mcid, None);
4740    }
4741
4742    #[test]
4743    fn resolve_props_resource_ref_overflow_mcid_rejected() {
4744        // ISO 32000-1 §14.7.4: MCID is an unsigned 32-bit integer. A
4745        // PdfObject::Integer holds an i64, so a malformed PDF can carry an
4746        // out-of-range MCID. The ResourceRef path must reject those rather
4747        // than wrap silently via `as u32`. Mirrors the Inline-path guard
4748        // already covered by `resolve_props_negative_mcid_rejected`.
4749        use crate::parser::content::MarkedContentProps;
4750        use crate::parser::objects::{PdfDictionary, PdfObject};
4751
4752        let mut inner = PdfDictionary::new();
4753        inner.insert("MCID".to_string(), PdfObject::Integer(i64::MAX));
4754
4755        let mut properties = PdfDictionary::new();
4756        properties.insert("PropsName".to_string(), PdfObject::Dictionary(inner));
4757
4758        let props = MarkedContentProps::ResourceRef("PropsName".to_string());
4759        let (mcid, _) = super::resolve_props(&props, Some(&properties));
4760        assert_eq!(mcid, None);
4761    }
4762
4763    #[test]
4764    fn sort_and_merge_fragments_nan_y_does_not_swallow_other_lines() {
4765        // A fragment with a non-finite Y (reachable from a degenerate text
4766        // matrix in a malformed PDF) must not chain every remaining fragment
4767        // into one pseudo-line. The tolerance filter compares with `< tol`; a
4768        // `>= tol` phrasing would let a NaN anchor never terminate the line,
4769        // collapsing the whole page into a single X-sorted "line".
4770        let extractor = TextExtractor::with_options(ExtractionOptions::default());
4771
4772        // Four well-separated lines whose X order is the reverse of their Y
4773        // (reading) order: if the NaN anchor swallows the rest, they get
4774        // re-sorted purely by X into D,C,B,A instead of the reading order.
4775        let mut fragments = vec![
4776            tf("A", 400.0, f64::NAN, 10.0, 12.0),
4777            tf("B", 300.0, 500.0, 10.0, 12.0),
4778            tf("C", 200.0, 300.0, 10.0, 12.0),
4779            tf("D", 100.0, 100.0, 10.0, 12.0),
4780        ];
4781        extractor.sort_and_merge_fragments(&mut fragments);
4782
4783        let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
4784        assert_eq!(
4785            order,
4786            vec!["A", "B", "C", "D"],
4787            "NaN-Y fragment must stay its own line; the finite lines keep \
4788             top-to-bottom reading order instead of collapsing to X order"
4789        );
4790    }
4791}