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        let font_dict = match resources.get("Font") {
2315            Some(PdfObject::Dictionary(dict)) => Some(dict.clone()),
2316            Some(PdfObject::Reference(num, gen)) => match document.get_object(*num, *gen) {
2317                Ok(PdfObject::Dictionary(dict)) => Some(dict),
2318                _ => None,
2319            },
2320            _ => None,
2321        };
2322
2323        if let Some(font_dict) = font_dict {
2324            for (font_name, font_obj) in font_dict.0.iter() {
2325                if let Some(font_ref) = font_obj.as_reference() {
2326                    self.cache_font_by_ref::<R>(&font_name.0, font_ref, document);
2327                }
2328            }
2329        }
2330    }
2331
2332    /// Cache a font, reusing the persistent object cache when possible.
2333    fn cache_font_by_ref<R: Read + Seek>(
2334        &mut self,
2335        font_name: &str,
2336        font_ref: (u32, u16),
2337        document: &PdfDocument<R>,
2338    ) {
2339        // Check persistent object cache first — avoids re-parsing across pages
2340        if let Some(cached) = self.font_object_cache.get(&font_ref) {
2341            self.font_cache
2342                .insert(font_name.to_string(), cached.clone());
2343            tracing::debug!(
2344                "Reused cached font object ({}, {}): {} (ToUnicode: {})",
2345                font_ref.0,
2346                font_ref.1,
2347                font_name,
2348                cached.to_unicode.is_some()
2349            );
2350            return;
2351        }
2352
2353        // Parse font object
2354        if let Ok(PdfObject::Dictionary(font_dict)) = document.get_object(font_ref.0, font_ref.1) {
2355            let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
2356            if let Ok(font_info) = cmap_extractor.extract_font_info(&font_dict, document) {
2357                let has_to_unicode = font_info.to_unicode.is_some();
2358                // Store in persistent cache
2359                self.font_object_cache.insert(font_ref, font_info.clone());
2360                // Store in per-page name cache
2361                self.font_cache.insert(font_name.to_string(), font_info);
2362                tracing::debug!(
2363                    "Parsed and cached font ({}, {}): {} (ToUnicode: {})",
2364                    font_ref.0,
2365                    font_ref.1,
2366                    font_name,
2367                    has_to_unicode
2368                );
2369            }
2370        }
2371    }
2372
2373    /// Decode text using the current font encoding and ToUnicode mapping
2374    fn decode_text(&self, text: &[u8], state: &TextState) -> ParseResult<String> {
2375        use crate::text::encoding::TextEncoding;
2376
2377        // First, try to use cached font information with ToUnicode CMap
2378        if let Some(ref font_name) = state.font_name {
2379            if let Some(font_info) = self.font_cache.get(font_name) {
2380                // Try CMap-based decoding first (free function — no allocation)
2381                if let Ok(decoded) =
2382                    crate::text::extraction_cmap::decode_text_with_font(text, font_info)
2383                {
2384                    // Only accept if we got meaningful text (not all null bytes
2385                    // or garbage). Whitespace counts as meaningful: a decode
2386                    // that is exactly a space is a space, not a failed decode
2387                    // (#438). See `decode_is_usable`.
2388                    if crate::text::extraction_cmap::decode_is_usable(&decoded) {
2389                        // Apply sanitization to remove control characters (Issue #116)
2390                        let sanitized = sanitize_extracted_text(&decoded);
2391                        tracing::debug!(
2392                            "Successfully decoded text using CMap for font {}: {:?} -> \"{}\"",
2393                            font_name,
2394                            text,
2395                            sanitized
2396                        );
2397                        return Ok(sanitized);
2398                    }
2399                }
2400
2401                tracing::debug!(
2402                    "CMap decoding failed or produced garbage for font {}, falling back to encoding",
2403                    font_name
2404                );
2405            }
2406        }
2407
2408        // Fall back to encoding-based decoding
2409        let encoding = if let Some(ref font_name) = state.font_name {
2410            match font_name.to_lowercase().as_str() {
2411                name if name.contains("macroman") => TextEncoding::MacRomanEncoding,
2412                name if name.contains("winansi") => TextEncoding::WinAnsiEncoding,
2413                name if name.contains("standard") => TextEncoding::StandardEncoding,
2414                name if name.contains("pdfdoc") => TextEncoding::PdfDocEncoding,
2415                _ => {
2416                    // Default based on common patterns
2417                    if font_name.starts_with("Times")
2418                        || font_name.starts_with("Helvetica")
2419                        || font_name.starts_with("Courier")
2420                    {
2421                        TextEncoding::WinAnsiEncoding // Most common for standard fonts
2422                    } else {
2423                        TextEncoding::PdfDocEncoding // Safe default
2424                    }
2425                }
2426            }
2427        } else {
2428            TextEncoding::WinAnsiEncoding // Default for most PDFs
2429        };
2430
2431        let fallback_result = encoding.decode(text);
2432        // Apply sanitization to remove control characters (Issue #116)
2433        let sanitized = sanitize_extracted_text(&fallback_result);
2434        tracing::debug!(
2435            "Fallback encoding decoding: {:?} -> \"{}\"",
2436            text,
2437            sanitized
2438        );
2439        Ok(sanitized)
2440    }
2441}
2442
2443impl Default for TextExtractor {
2444    fn default() -> Self {
2445        Self::new()
2446    }
2447}
2448
2449/// Emit a `TextFragment` for one decoded text-show event under `preserve_layout`.
2450///
2451/// Encapsulates the style-derivation + push sequence shared by every
2452/// text-show operator handler in `extract_from_page` (`Tj`, `TJ`, `'`,
2453/// `"`). The caller supplies the pen origin `(x, y)` already mapped to
2454/// user space (typically via `text_origin(&state)`); doing so avoids the
2455/// double `multiply_matrix + transform_point` that prior versions did
2456/// (handler computed it for `last_x`/`last_y`, then this fn recomputed
2457/// it on the same `state`).
2458///
2459/// Skips emission when an ancestor in the marked-content stack is `/Artifact`
2460/// and `include_artifacts` is false. When a pending ActualText run is
2461/// active in the current scope, accumulates the text-width contribution and
2462/// records the first origin instead of pushing a fragment (the run is flushed
2463/// once on EMC, see Task 8's EndMarkedContent handler).
2464///
2465/// `mcid` and `struct_tag` come from the innermost ancestor on the stack that
2466/// declared `/MCID`; non-tagged content leaves both as `None`.
2467/// Whether the current marked-content stack should suppress text emission.
2468///
2469/// Mirrors the gate inside [`emit_text_fragment`]: when an ancestor in the
2470/// stack is `/Artifact` and the caller has not opted into artifact content
2471/// via `include_artifacts`, neither `.text` nor `.fragments` should receive
2472/// the run. Used by the four show-text operator arms to keep `extracted_text`
2473/// and `fragments` symmetric — a page whose entire content is an
2474/// `/Artifact BMC … EMC` scope (the common pattern for screen-reader-skipped
2475/// disclaimers / footers / decorative tagged-PDF content) used to surface
2476/// text in `.text` while leaving `.fragments` empty, silently dropping the
2477/// page from `partition_with(...)` / `rag_chunks(...)` (issue #330).
2478fn skip_artifact_text(state: &TextState, include_artifacts: bool) -> bool {
2479    !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact)
2480}
2481
2482/// Append an optional `separator` plus `decoded` to `acc`, honouring the
2483/// per-page byte budget `limit` (issue #382).
2484///
2485/// Returns `true` when the run was appended. Returns `false` — appending
2486/// nothing and setting `*truncated` — when the combined bytes would exceed
2487/// `limit`. The separator is counted against the budget so the invariant
2488/// `acc.len() <= limit` holds *exactly*, and because whole runs are the unit of
2489/// truncation a multi-byte UTF-8 character is never split (undershoot
2490/// semantics). A `None` limit always appends and never truncates, keeping the
2491/// no-limit path byte-identical to before. Once `*truncated` is set the helper
2492/// is a no-op, so a caller that keeps calling it after the budget is reached
2493/// simply accumulates nothing further.
2494fn append_bounded(
2495    acc: &mut String,
2496    separator: Option<char>,
2497    decoded: &str,
2498    limit: Option<usize>,
2499    truncated: &mut bool,
2500) -> bool {
2501    if *truncated {
2502        return false;
2503    }
2504    if let Some(max) = limit {
2505        let add = separator.map_or(0, char::len_utf8) + decoded.len();
2506        if acc.len() + add > max {
2507            *truncated = true;
2508            return false;
2509        }
2510    }
2511    if let Some(sep) = separator {
2512        acc.push(sep);
2513    }
2514    acc.push_str(decoded);
2515    true
2516}
2517
2518/// Defensive final clamp of a page's text to the byte budget (issue #382).
2519///
2520/// The `preserve_layout` / `reorder_columns` paths rebuild `.text` from the
2521/// already-bounded fragment set via `reconstruct_text_from_fragments`, which
2522/// reorders fragments and inserts its own separators — so the reconstructed
2523/// length is not provably `<= limit` from the accumulation-time accounting
2524/// alone. This clamps the result to `limit` at a UTF-8 char boundary (never
2525/// splitting a character) and sets `*truncated` if it had to cut, making the
2526/// `text.len() <= max_extracted_bytes` invariant hold for *every* path. A no-op
2527/// when there is no limit or the text already fits.
2528fn clamp_to_budget(text: &mut String, limit: Option<usize>, truncated: &mut bool) {
2529    if let Some(max) = limit {
2530        if text.len() > max {
2531            let mut cut = max;
2532            while cut > 0 && !text.is_char_boundary(cut) {
2533                cut -= 1;
2534            }
2535            text.truncate(cut);
2536            *truncated = true;
2537        }
2538    }
2539}
2540
2541fn emit_text_fragment(
2542    fragments: &mut Vec<TextFragment>,
2543    decoded: &str,
2544    text_width: f64,
2545    x: f64,
2546    y: f64,
2547    state: &mut TextState,
2548    include_artifacts: bool,
2549) {
2550    if decoded.is_empty() {
2551        return;
2552    }
2553
2554    // Artifact filter (default: skip emission for Artifact subtrees).
2555    if !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact) {
2556        return;
2557    }
2558
2559    let (is_bold, is_italic) = state
2560        .font_name
2561        .as_ref()
2562        .map(|name| parse_font_style(name))
2563        .unwrap_or((false, false));
2564
2565    // Issue #262: font_size, height, and width must be in page space so that
2566    // downstream heuristics (line/paragraph reconstruction, header/footer zone
2567    // detection, table detection) reason about real geometry. `x` and `y` are
2568    // already page-space (caller transforms via `text_origin`); we still need
2569    // to scale the size/width fields by the combined `text_matrix × CTM`.
2570    let combined = multiply_matrix(&state.text_matrix, &state.ctm);
2571    let x_scale = (combined[0] * combined[0] + combined[1] * combined[1]).sqrt();
2572    let y_scale = (combined[2] * combined[2] + combined[3] * combined[3]).sqrt();
2573    let effective_width = text_width * x_scale;
2574    let effective_size = state.font_size * y_scale;
2575
2576    // If a pending ActualText run is active in the current scope, accumulate
2577    // into it instead of emitting a fragment now. The run is flushed on the
2578    // matching EMC by the EndMarkedContent arm (Task 8).
2579    // Hoist font_name/fill_color reads before taking &mut on pending_actualtext
2580    // to avoid borrow-checker conflicts with the disjoint fields.
2581    let local_font_name = state.font_name.clone();
2582    let local_fill_color = state.fill_color;
2583    if let Some(pending) = state.pending_actualtext.as_mut() {
2584        if !pending.populated {
2585            pending.first_x = x;
2586            pending.first_y = y;
2587            pending.font_size = effective_size;
2588            pending.font_name = local_font_name;
2589            pending.is_bold = is_bold;
2590            pending.is_italic = is_italic;
2591            pending.color = local_fill_color;
2592            pending.populated = true;
2593        }
2594        pending.width += effective_width;
2595        return;
2596    }
2597
2598    let (mcid, struct_tag) = innermost_mc_tag(&state.mc_stack);
2599
2600    fragments.push(TextFragment {
2601        text: decoded.to_owned(),
2602        x,
2603        y,
2604        width: effective_width,
2605        height: effective_size,
2606        font_size: effective_size,
2607        font_name: state.font_name.clone(),
2608        is_bold,
2609        is_italic,
2610        color: state.fill_color,
2611        space_decisions: Vec::new(),
2612        mcid,
2613        struct_tag,
2614    });
2615}
2616
2617/// Pen origin (user-space coordinates) of the next glyph in the current
2618/// text state.
2619///
2620/// Per ISO 32000-1 §8.3.4, the text rendering matrix is `Tm × CTM` (row-vector
2621/// convention). `multiply_matrix(a, b)` returns the matrix that applies `a`
2622/// first and then `b`, so the correct composition is
2623/// `multiply_matrix(text_matrix, ctm)`. Prior to issue #262 this used the
2624/// reverse order which gave correct results only when the CTM was an identity
2625/// or pure-translation matrix; non-uniform CTM scaling produced wrong origins.
2626fn text_origin(state: &TextState) -> (f64, f64) {
2627    let combined = multiply_matrix(&state.text_matrix, &state.ctm);
2628    transform_point(0.0, 0.0, &combined)
2629}
2630
2631/// Advance the text matrix by one shown glyph run of unscaled width
2632/// `text_width` and return the pen's new x in user space.
2633///
2634/// The advance applied to the text matrix is `text_width * Tz/100`
2635/// (`state.horizontal_scale`), and the resulting user-space displacement also
2636/// folds in the CTM's x-scale. The caller's `last_x` (used for `dx`-based
2637/// space decisions) must therefore come from the post-advance pen origin, not
2638/// from `origin_x + text_width`, which ignores both factors and trails the
2639/// real pen whenever `Tz != 100` or the CTM scales x (issue #386).
2640fn advance_pen(state: &mut TextState, text_width: f64) -> (f64, f64) {
2641    let tx = text_width * state.horizontal_scale / 100.0;
2642    state.text_matrix = multiply_matrix(&[1.0, 0.0, 0.0, 1.0, tx, 0.0], &state.text_matrix);
2643    text_origin(state)
2644}
2645
2646/// Projection-noise floor for the perpendicular pen delta. Same-baseline
2647/// glyph runs produce a `dy` that is exactly 0 in real arithmetic but can
2648/// carry ~1e-13 of float rounding after the baseline projection; anything
2649/// below this epsilon is "the same baseline". The smallest meaningful
2650/// leading in real documents is orders of magnitude above it.
2651const SAME_LINE_EPS: f64 = 1e-6;
2652
2653/// Backward-jump magnitude, in multiples of the font size, above which a
2654/// same-baseline (`dy == 0`) backward pen jump is a line wrap rather than a
2655/// glyph reposition (issue #447).
2656///
2657/// At `dy == 0` a backward jump is ambiguous: a same-line reposition
2658/// (justification, kerned overlay, out-of-order emission — issue #441) and a
2659/// real wrap whose two lines happen to land on the same content-stream Y
2660/// (issue #447) both produce it. They separate by MAGNITUDE: a reposition is
2661/// local (a word/phrase — a few em), while a wrap returns across the whole
2662/// text column (many em). This bound sits in that gap, scaled to font size
2663/// because the reposition scale is the glyph/word scale, not the fixed
2664/// paragraph-break `newline_threshold`. Scaled to `font_size.abs()`: `Tf`
2665/// accepts negative sizes (mirrored text), and the sign must not flip the
2666/// threshold's sense — otherwise a negative size makes every backward jump a
2667/// "wrap" and resurrects the #441 defect.
2668///
2669/// Accepted, documented limitation (the #417/#422 trade-off): a same-line
2670/// reposition that jumps back more than this many em is misread as a wrap, and
2671/// a same-Y wrap of a line shorter than this is glued. Both are rare and
2672/// neither loses a glyph — only the separator is wrong. A wrap with any
2673/// nonzero leading (the common case, issue #390) is unaffected: it breaks on
2674/// the `dy`-aware gate regardless of magnitude.
2675const SAME_Y_WRAP_EM: f64 = 10.0;
2676
2677/// Pen movement from the previous post-advance pen point `last` to the
2678/// current glyph origin `cur` (both user space), measured in the frame of the
2679/// current text baseline (issue #443): `dx` along the baseline direction,
2680/// `dy` perpendicular to it (signed; callers take `.abs()` for line
2681/// detection).
2682///
2683/// The baseline direction is the image of the text-space x-axis under the
2684/// text rendering matrix `Tm × CTM`. For an axis-aligned matrix
2685/// (identity/translation/positive scale — the overwhelming majority of
2686/// content) the baseline IS the user-space x-axis and this returns exactly
2687/// `(Δx, Δy)`, the pre-#443 behavior. Under a rotated CTM (and any
2688/// similarity transform) the projection recovers the text's own line
2689/// geometry exactly, which raw user-space deltas conflate: a plain forward
2690/// advance along a rotated baseline changes the user-space y, which the
2691/// separator heuristics misread as a line change. Axis-aligned shear
2692/// (`b == 0`, `c != 0`) also projects exactly (the perpendicular reduces to
2693/// the y-axis); a shear COMBINED with a rotated baseline is approximated —
2694/// the perpendicular is built by rotating the baseline 90°, not from the
2695/// true image of the text-space y-axis.
2696///
2697/// A mirrored baseline (negative x-scale) measures `dx` along the text's own
2698/// advance direction, so a forward advance is positive `dx` — the spacing
2699/// and wrap gates apply as for unmirrored text (pre-#443 they saw a raw
2700/// negative `dx` and misfired the wrap gate on plain advances).
2701///
2702/// A degenerate baseline (zero-length or non-finite) falls back to the raw
2703/// user-space deltas, preserving pre-#443 behavior for malformed matrices.
2704fn pen_delta(state: &TextState, last: (f64, f64), cur: (f64, f64)) -> (f64, f64) {
2705    let dxu = cur.0 - last.0;
2706    let dyu = cur.1 - last.1;
2707    let m = multiply_matrix(&state.text_matrix, &state.ctm);
2708    let (bx, by) = (m[0], m[1]);
2709    let norm = (bx * bx + by * by).sqrt();
2710    if !norm.is_finite() || norm <= f64::EPSILON {
2711        return (dxu, dyu);
2712    }
2713    let (ux, uy) = (bx / norm, by / norm);
2714    (dxu * ux + dyu * uy, -dxu * uy + dyu * ux)
2715}
2716
2717/// Multiply two transformation matrices
2718fn multiply_matrix(a: &[f64; 6], b: &[f64; 6]) -> [f64; 6] {
2719    [
2720        a[0] * b[0] + a[1] * b[2],
2721        a[0] * b[1] + a[1] * b[3],
2722        a[2] * b[0] + a[3] * b[2],
2723        a[2] * b[1] + a[3] * b[3],
2724        a[4] * b[0] + a[5] * b[2] + b[4],
2725        a[4] * b[1] + a[5] * b[3] + b[5],
2726    ]
2727}
2728
2729/// Decode a PDF string operand into Rust `String`.
2730///
2731/// A string inside marked-content properties (notably `/ActualText`) is a PDF
2732/// text string like any other, so this is
2733/// [`PdfString::to_text`](crate::parser::objects::PdfString::to_text): UTF-16BE
2734/// when a byte order mark is present — the canonical encoding for non-ASCII
2735/// `/ActualText`, e.g. an `fi` ligature or a Greek symbol — and the WinAnsi
2736/// reading of PDFDocEncoding otherwise. Before that helper existed this mapped
2737/// non-BOM bytes to `char` one by one, which is Latin-1 and wrong for the
2738/// typographic punctuation WinAnsi puts in `0x80..=0x9F`.
2739fn decode_pdf_string(bytes: &[u8]) -> String {
2740    crate::parser::objects::decode_text_string(bytes)
2741}
2742
2743/// Resolve a `MarkedContentProps` to `(mcid, actual_text)`.
2744///
2745/// For `Inline` props, walk the map: `/MCID` (Integer, must fit in `u32`)
2746/// becomes `mcid`; `/ActualText` (String) is decoded via `decode_pdf_string`.
2747///
2748/// For `ResourceRef(name)`, look up `properties.get(name)`. If found and
2749/// it's a Dictionary, extract `/MCID` and `/ActualText` from there. If
2750/// not found (or the named entry is not a dict), return `(None, None)`
2751/// — a malformed reference must not abort extraction.
2752fn resolve_props(
2753    props: &crate::parser::content::MarkedContentProps,
2754    properties: Option<&crate::parser::objects::PdfDictionary>,
2755) -> (Option<u32>, Option<String>) {
2756    use crate::parser::content::{MarkedContentProps, MarkedContentValue};
2757
2758    let map_mcid_actual =
2759        |map: &std::collections::HashMap<String, MarkedContentValue>| -> (Option<u32>, Option<String>) {
2760            let mcid = match map.get("MCID") {
2761                Some(MarkedContentValue::Integer(n)) if *n >= 0 && *n <= u32::MAX as i64 => {
2762                    Some(*n as u32)
2763                }
2764                _ => None,
2765            };
2766            let actual = match map.get("ActualText") {
2767                Some(MarkedContentValue::String(bytes)) => Some(decode_pdf_string(bytes)),
2768                _ => None,
2769            };
2770            (mcid, actual)
2771        };
2772
2773    match props {
2774        MarkedContentProps::Inline(map) => map_mcid_actual(map),
2775        MarkedContentProps::ResourceRef(name) => {
2776            let Some(properties) = properties else {
2777                return (None, None);
2778            };
2779            let Some(entry) = properties.get(name) else {
2780                return (None, None);
2781            };
2782            let crate::parser::objects::PdfObject::Dictionary(dict) = entry else {
2783                return (None, None);
2784            };
2785            let mcid = dict.get("MCID").and_then(|o| match o {
2786                crate::parser::objects::PdfObject::Integer(n)
2787                    if *n >= 0 && *n <= u32::MAX as i64 =>
2788                {
2789                    Some(*n as u32)
2790                }
2791                _ => None,
2792            });
2793            let actual_text = dict.get("ActualText").and_then(|o| match o {
2794                crate::parser::objects::PdfObject::String(s) => {
2795                    Some(decode_pdf_string(s.as_bytes()))
2796                }
2797                _ => None,
2798            });
2799            (mcid, actual_text)
2800        }
2801    }
2802}
2803
2804/// Walk the marked-content stack from innermost (top) outward, returning the
2805/// first entry's `(mcid, tag)` pair whose `mcid` is `Some`. Returns
2806/// `(None, None)` when no ancestor declared an MCID — typical of non-tagged
2807/// PDFs, in which case the `None == None` grouping-key invariant preserves
2808/// legacy behaviour.
2809fn innermost_mc_tag(stack: &[MarkedContentEntry]) -> (Option<u32>, Option<String>) {
2810    stack
2811        .iter()
2812        .rev()
2813        .find(|e| e.mcid.is_some())
2814        .map_or((None, None), |e| (e.mcid, Some(e.tag.clone())))
2815}
2816
2817/// Transform a point using a transformation matrix
2818fn transform_point(x: f64, y: f64, matrix: &[f64; 6]) -> (f64, f64) {
2819    let tx = matrix[0] * x + matrix[2] * y + matrix[4];
2820    let ty = matrix[1] * x + matrix[3] * y + matrix[5];
2821    (tx, ty)
2822}
2823
2824/// Calculate text width using actual font metrics (including kerning)
2825fn calculate_text_width(text: &str, font_size: f64, font_info: Option<&FontInfo>) -> f64 {
2826    // If we have font metrics, use them for accurate width calculation
2827    if let Some(font) = font_info {
2828        if let Some(ref widths) = font.metrics.widths {
2829            let first_char = font.metrics.first_char.unwrap_or(0);
2830            let last_char = font.metrics.last_char.unwrap_or(255);
2831            let missing_width = font.metrics.missing_width.unwrap_or(500.0);
2832
2833            let mut total_width = 0.0;
2834            let mut chars = text.chars().peekable();
2835
2836            while let Some(ch) = chars.next() {
2837                let char_code = ch as u32;
2838
2839                // Get width from Widths array or use missing_width
2840                let width = if char_code >= first_char && char_code <= last_char {
2841                    let index = (char_code - first_char) as usize;
2842                    widths.get(index).copied().unwrap_or(missing_width)
2843                } else {
2844                    missing_width
2845                };
2846
2847                // Convert from glyph space (1/1000 units) to user space
2848                total_width += width / 1000.0 * font_size;
2849
2850                // Apply kerning if available (for character pairs)
2851                if let Some(ref kerning) = font.metrics.kerning {
2852                    if let Some(&next_ch) = chars.peek() {
2853                        let next_char = next_ch as u32;
2854                        if let Some(&kern_value) = kerning.get(&(char_code, next_char)) {
2855                            // Kerning is in FUnits (1/1000), convert to user space
2856                            total_width += kern_value / 1000.0 * font_size;
2857                        }
2858                    }
2859                }
2860            }
2861
2862            return total_width;
2863        }
2864    }
2865
2866    // Fallback to simplified calculation if no metrics available
2867    text.len() as f64 * font_size * 0.5
2868}
2869
2870/// Compute advance width from the original character **codes**, not the decoded
2871/// Unicode text.
2872///
2873/// A simple font's `Widths` array is indexed by character code (`first_char..=
2874/// last_char`), i.e. the byte value in the content stream — not by the Unicode
2875/// codepoint the code decodes to. [`calculate_text_width`] indexes by the decoded
2876/// codepoint (`ch as u32`), which is correct only when code == codepoint (ASCII /
2877/// WinAnsi fonts). For custom-encoded fonts (Type1 with `Differences`, embedded
2878/// Computer Modern in LaTeX PDFs, ToUnicode remaps) the codepoint diverges from
2879/// the code, so the wrong slot — or `missing_width` — is read, desyncing glyph
2880/// advance and scrambling word order once fragments are sorted by position
2881/// (issue #302).
2882///
2883/// `decoded` is the already-decoded text for this run; it is only consulted for
2884/// composite (Type0) fonts, whose multi-byte codes cannot be indexed byte-wise
2885/// and whose width path is unchanged here to avoid regressing CJK extraction.
2886fn calculate_text_width_from_codes(
2887    codes: &[u8],
2888    decoded: &str,
2889    font_size: f64,
2890    font_info: Option<&FontInfo>,
2891) -> f64 {
2892    // Composite (Type0) fonts use multi-byte codes; a single byte is not a code,
2893    // so byte-indexed width lookup is invalid. Preserve the existing decoded-based
2894    // behavior for them.
2895    let is_composite =
2896        font_info.is_some_and(|f| f.font_type == "Type0" || f.descendant_font.is_some());
2897    if is_composite {
2898        return calculate_text_width(decoded, font_size, font_info);
2899    }
2900
2901    if let Some(font) = font_info {
2902        if let Some(ref widths) = font.metrics.widths {
2903            let first_char = font.metrics.first_char.unwrap_or(0);
2904            let last_char = font.metrics.last_char.unwrap_or(255);
2905            let missing_width = font.metrics.missing_width.unwrap_or(500.0);
2906
2907            let mut total_width = 0.0;
2908            let mut iter = codes.iter().peekable();
2909            while let Some(&byte) = iter.next() {
2910                let code = byte as u32;
2911                let width = if code >= first_char && code <= last_char {
2912                    widths
2913                        .get((code - first_char) as usize)
2914                        .copied()
2915                        .unwrap_or(missing_width)
2916                } else {
2917                    missing_width
2918                };
2919                total_width += width / 1000.0 * font_size;
2920
2921                // Kerning is keyed by code pair, consistent with code-based widths.
2922                if let Some(ref kerning) = font.metrics.kerning {
2923                    if let Some(&next_byte) = iter.peek() {
2924                        if let Some(&kern_value) = kerning.get(&(code, *next_byte as u32)) {
2925                            total_width += kern_value / 1000.0 * font_size;
2926                        }
2927                    }
2928                }
2929            }
2930
2931            return total_width;
2932        }
2933    }
2934
2935    // No metrics: one fallback width per code (byte), the simple-font glyph count.
2936    codes.len() as f64 * font_size * 0.5
2937}
2938
2939/// Sanitize extracted text by removing or replacing control characters.
2940///
2941/// This function addresses Issue #116 where extracted text contains NUL bytes (`\0`)
2942/// and ETX characters (`\u{3}`) where spaces should appear.
2943///
2944/// # Behavior
2945///
2946/// - Replaces `\0\u{3}` sequences with a single space (common word separator pattern)
2947/// - Replaces standalone `\0` (NUL) with space
2948/// - Removes other ASCII control characters (0x01-0x1F) except:
2949///   - `\t` (0x09) - Tab
2950///   - `\n` (0x0A) - Line feed
2951///   - `\r` (0x0D) - Carriage return
2952/// - Collapses multiple consecutive spaces into a single space
2953///
2954/// # Examples
2955///
2956/// ```
2957/// use oxidize_pdf::text::extraction::sanitize_extracted_text;
2958///
2959/// // Issue #116 pattern: NUL+ETX as word separator
2960/// let dirty = "a\0\u{3}sergeant\0\u{3}and";
2961/// assert_eq!(sanitize_extracted_text(dirty), "a sergeant and");
2962///
2963/// // Standalone NUL becomes space
2964/// let with_nul = "word\0another";
2965/// assert_eq!(sanitize_extracted_text(with_nul), "word another");
2966///
2967/// // Clean text passes through unchanged
2968/// let clean = "Normal text";
2969/// assert_eq!(sanitize_extracted_text(clean), "Normal text");
2970/// ```
2971pub fn sanitize_extracted_text(text: &str) -> String {
2972    if text.is_empty() {
2973        return String::new();
2974    }
2975
2976    // Pre-allocate with same capacity (result will be <= input length)
2977    let mut result = String::with_capacity(text.len());
2978    let mut chars = text.chars().peekable();
2979    let mut last_was_space = false;
2980
2981    while let Some(ch) = chars.next() {
2982        match ch {
2983            // NUL byte - check if followed by ETX for the \0\u{3} pattern
2984            '\0' => {
2985                // Peek at next char to detect \0\u{3} sequence
2986                if chars.peek() == Some(&'\u{3}') {
2987                    chars.next(); // consume the ETX
2988                }
2989                // In both cases (standalone NUL or NUL+ETX), emit space
2990                if !last_was_space {
2991                    result.push(' ');
2992                    last_was_space = true;
2993                }
2994            }
2995
2996            // ETX alone (not preceded by NUL) - remove it
2997            '\u{3}' => {
2998                // Don't emit anything, just skip
2999            }
3000
3001            // Preserve allowed whitespace
3002            '\t' | '\n' | '\r' => {
3003                result.push(ch);
3004                // Reset space tracking on newlines but not tabs
3005                last_was_space = ch == '\t';
3006            }
3007
3008            // Regular space - collapse multiples
3009            ' ' => {
3010                if !last_was_space {
3011                    result.push(' ');
3012                    last_was_space = true;
3013                }
3014            }
3015
3016            // Other control characters (0x01-0x1F except tab/newline/CR) - remove
3017            c if c.is_ascii_control() => {
3018                // Skip control characters
3019            }
3020
3021            // Normal characters - keep them
3022            _ => {
3023                result.push(ch);
3024                last_was_space = false;
3025            }
3026        }
3027    }
3028
3029    result
3030}
3031
3032/// Assign a logical row identifier to each fragment based on Y-up-jumps in
3033/// emission order. Used by `merge_into_lines` to distinguish columns in
3034/// multi-column layouts where a single outer BDC scope makes mcid uniform.
3035///
3036/// Increments `row_id` whenever the next fragment's Y exceeds the previous
3037/// by more than `max(font_size * 0.5, 2.0)`. Superscripts (small positive
3038/// deltas) and normal line descents (negative deltas) leave `row_id`
3039/// unchanged. See `docs/superpowers/specs/2026-05-23-issue-265-line-interleaving-design.md`.
3040///
3041/// # Invariants
3042/// Returns a `Vec<u32>` with exactly `fragments.len()` elements — one
3043/// row id per input fragment, in input order. Callers may safely `.zip(fragments)`.
3044fn assign_row_ids(fragments: &[TextFragment]) -> Vec<u32> {
3045    let mut result = Vec::with_capacity(fragments.len());
3046    let mut row_id: u32 = 0;
3047    let mut prev_y: Option<f64> = None;
3048    for frag in fragments {
3049        if let Some(py) = prev_y {
3050            let delta = frag.y - py;
3051            // Threshold anchored to the arriving fragment's font_size; for the
3052            // symmetric same-font case (body→body, same font) this is equivalent
3053            // to anchoring to the previous fragment.
3054            let threshold = (frag.font_size * 0.5).max(2.0);
3055            if delta > threshold {
3056                row_id += 1;
3057            }
3058        }
3059        result.push(row_id);
3060        prev_y = Some(frag.y);
3061    }
3062    debug_assert_eq!(
3063        result.len(),
3064        fragments.len(),
3065        "assign_row_ids: output length must equal input length"
3066    );
3067    result
3068}
3069
3070/// Decide whether a single visual line should be read in emission order.
3071///
3072/// `line` holds `(emission_index, fragment)` pairs for one visual line in any
3073/// order. Returns `true` when, walked in emission order, the line has no
3074/// DISJOINT backward x-step — i.e. no fragment lands entirely to the LEFT of
3075/// everything emitted so far on the line. Such a left jump is the signature of
3076/// a genuinely scrambled stream (right-to-left / random generators), for which
3077/// x-order is authoritative.
3078///
3079/// The comparison is against the line's running left edge, not the immediately
3080/// preceding fragment: dense bodies are split into sub-word glyph runs, so a
3081/// run that legitimately backfills the line (a font-switched math symbol, or a
3082/// word whose run starts left of the previous short run — #302 symptom 1 /
3083/// #305) overlaps the *covered span* even when it does not overlap the single
3084/// fragment right before it. As long as it does not jump past the line's left
3085/// edge, emission order is preserved. Lines that are already x-monotone in
3086/// emission satisfy this trivially and decode identically under either policy.
3087fn line_prefers_emission_order(line: &[(usize, &TextFragment)]) -> bool {
3088    if line.len() < 2 {
3089        return true;
3090    }
3091    let mut em: Vec<&(usize, &TextFragment)> = line.iter().collect();
3092    em.sort_by_key(|&&(idx, _)| idx);
3093    let mut min_start = em[0].1.x;
3094    for &&(_, f) in &em[1..] {
3095        let end = f.x + f.width;
3096        // A fragment whose right edge is at or left of the leftmost glyph seen
3097        // so far is a true backward jump — emission order is not reading order.
3098        if end <= min_start {
3099            return false;
3100        }
3101        min_start = min_start.min(f.x);
3102    }
3103    true
3104}
3105
3106/// Space-glyph advance width (1000-em units) for the Adobe Core-14 base fonts,
3107/// keyed by `/BaseFont`. Subset prefixes (`ABCDEF+`) are stripped; common
3108/// substitute names (Arial→Helvetica, TimesNewRoman→Times, CourierNew→Courier)
3109/// map to their metric-compatible base. Returns `None` for unknown fonts, which
3110/// leaves the caller on its fixed-fraction fallback. These fonts legitimately
3111/// ship no `/Widths` array, so their space metric is only available here.
3112fn standard_14_space_width(base_font: &str) -> Option<f64> {
3113    let name = base_font.rsplit('+').next().unwrap_or(base_font);
3114    let lower = name.to_ascii_lowercase();
3115    if lower.contains("courier") {
3116        Some(600.0)
3117    } else if lower.contains("helvetica") || lower.contains("arial") {
3118        Some(278.0)
3119    } else if lower.contains("times") {
3120        Some(250.0)
3121    } else if lower == "symbol" {
3122        Some(250.0)
3123    } else if lower.contains("zapfdingbats") || lower.contains("dingbats") {
3124        Some(278.0)
3125    } else {
3126        None
3127    }
3128}
3129
3130#[cfg(test)]
3131mod tests {
3132    use super::*;
3133
3134    // ── issue #443: baseline-frame pen deltas ────────────────────────────────
3135
3136    fn state_with_ctm(ctm: [f64; 6]) -> TextState {
3137        TextState {
3138            ctm,
3139            ..Default::default()
3140        }
3141    }
3142
3143    #[test]
3144    fn pen_delta_identity_matrix_returns_raw_deltas() {
3145        let state = state_with_ctm([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3146        let (dx, dy) = pen_delta(&state, (10.0, 20.0), (14.5, 17.0));
3147        assert_eq!((dx, dy), (4.5, -3.0), "axis-aligned = raw Δx/Δy exactly");
3148    }
3149
3150    #[test]
3151    fn pen_delta_rotation_recovers_text_space_advance() {
3152        // 30° rotation; the pen advances 5 units along the rotated baseline.
3153        let (s30, c30) = 30f64.to_radians().sin_cos();
3154        let state = state_with_ctm([c30, s30, -s30, c30, 0.0, 0.0]);
3155        let (dx, dy) = pen_delta(&state, (0.0, 0.0), (5.0 * c30, 5.0 * s30));
3156        assert!((dx - 5.0).abs() < 1e-12, "advance recovered: {dx}");
3157        assert!(dy.abs() < 1e-12, "same baseline → dy ≈ 0: {dy}");
3158        assert!(
3159            dy.abs() < SAME_LINE_EPS,
3160            "noise below the same-line epsilon"
3161        );
3162    }
3163
3164    #[test]
3165    fn pen_delta_mirrored_baseline_measures_advance_direction() {
3166        // Horizontal mirror: a forward text-space advance moves the pen LEFT
3167        // in user space. dx must still be positive (the text's own advance
3168        // direction), so the wrap gate does not misfire on plain advances.
3169        let state = state_with_ctm([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3170        let (dx, dy) = pen_delta(&state, (100.0, 50.0), (95.0, 50.0));
3171        assert_eq!(dx, 5.0, "forward advance is positive along the baseline");
3172        assert_eq!(dy.abs(), 0.0, "same baseline");
3173    }
3174
3175    #[test]
3176    fn pen_delta_degenerate_matrix_falls_back_to_raw_deltas() {
3177        // Zero baseline (a=b=0): projection impossible → raw user-space
3178        // deltas, the pre-#443 behavior.
3179        let state = state_with_ctm([0.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3180        assert_eq!(pen_delta(&state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
3181        // Non-finite baseline: same fallback.
3182        let nan_state = state_with_ctm([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]);
3183        assert_eq!(pen_delta(&nan_state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
3184    }
3185
3186    // ── issue #382: per-page byte-budget helper ──────────────────────────────
3187
3188    #[test]
3189    fn test_append_bounded_no_limit_always_appends() {
3190        let mut s = String::new();
3191        let mut trunc = false;
3192        assert!(append_bounded(&mut s, None, "hello", None, &mut trunc));
3193        assert!(append_bounded(&mut s, Some(' '), "world", None, &mut trunc));
3194        assert_eq!(s, "hello world");
3195        assert!(!trunc, "no limit never truncates");
3196    }
3197
3198    #[test]
3199    fn test_append_bounded_undershoot_counts_separator() {
3200        // "abcd" (4) is at budget 5; a Some('\n') + "x" would need 2 more → over.
3201        let mut s = String::from("abcd");
3202        let mut trunc = false;
3203        assert!(!append_bounded(
3204            &mut s,
3205            Some('\n'),
3206            "x",
3207            Some(5),
3208            &mut trunc
3209        ));
3210        assert_eq!(s, "abcd", "nothing appended when it would overshoot");
3211        assert!(trunc, "budget hit sets truncated");
3212        // Exactly-fits case: "e" alone (1 byte, no separator) reaches 5.
3213        let mut s2 = String::from("abcd");
3214        let mut t2 = false;
3215        assert!(append_bounded(&mut s2, None, "e", Some(5), &mut t2));
3216        assert_eq!(s2, "abcde");
3217        assert!(!t2);
3218        assert!(s2.len() <= 5, "invariant: len <= limit exactly");
3219    }
3220
3221    #[test]
3222    fn test_append_bounded_zero_limit_truncates_immediately() {
3223        let mut s = String::new();
3224        let mut trunc = false;
3225        assert!(!append_bounded(&mut s, None, "a", Some(0), &mut trunc));
3226        assert!(s.is_empty());
3227        assert!(trunc);
3228    }
3229
3230    #[test]
3231    fn test_append_bounded_is_noop_once_truncated() {
3232        let mut s = String::from("kept");
3233        let mut trunc = true; // already truncated
3234        assert!(!append_bounded(
3235            &mut s,
3236            None,
3237            "more",
3238            Some(1_000),
3239            &mut trunc
3240        ));
3241        assert_eq!(s, "kept", "no further accumulation after truncation");
3242    }
3243
3244    #[test]
3245    fn test_clamp_to_budget_no_limit_or_fits_is_noop() {
3246        let mut a = String::from("hello");
3247        let mut t = false;
3248        clamp_to_budget(&mut a, None, &mut t);
3249        assert_eq!(a, "hello");
3250        assert!(!t, "no limit never truncates");
3251
3252        let mut b = String::from("hi");
3253        clamp_to_budget(&mut b, Some(10), &mut t);
3254        assert_eq!(b, "hi", "already fits");
3255        assert!(!t);
3256    }
3257
3258    #[test]
3259    fn test_clamp_to_budget_cuts_and_flags() {
3260        let mut s = String::from("abcdefgh");
3261        let mut t = false;
3262        clamp_to_budget(&mut s, Some(3), &mut t);
3263        assert_eq!(s, "abc");
3264        assert!(t, "clamp that cut must set truncated");
3265    }
3266
3267    #[test]
3268    fn test_clamp_to_budget_never_splits_utf8() {
3269        // "é" is 2 bytes (0xC3 0xA9). A 3-byte budget on "éé" (4 bytes) must cut
3270        // back to the char boundary at 2, keeping one whole "é".
3271        let mut s = String::from("éé");
3272        let mut t = false;
3273        clamp_to_budget(&mut s, Some(3), &mut t);
3274        assert_eq!(s, "é", "must retreat to a char boundary, not split 'é'");
3275        assert!(s.len() <= 3);
3276        assert!(t);
3277    }
3278
3279    #[test]
3280    fn test_matrix_multiplication() {
3281        let identity = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
3282        let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
3283
3284        let result = multiply_matrix(&identity, &translation);
3285        assert_eq!(result, translation);
3286
3287        let result2 = multiply_matrix(&translation, &identity);
3288        assert_eq!(result2, translation);
3289    }
3290
3291    #[test]
3292    fn test_transform_point() {
3293        let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
3294        let (x, y) = transform_point(5.0, 5.0, &translation);
3295        assert_eq!(x, 15.0);
3296        assert_eq!(y, 25.0);
3297    }
3298
3299    #[test]
3300    fn test_extraction_options_default() {
3301        let options = ExtractionOptions::default();
3302        assert!(!options.preserve_layout);
3303        assert_eq!(options.space_threshold, 0.3);
3304        assert_eq!(options.newline_threshold, 10.0);
3305        assert!(options.sort_by_position);
3306        assert!(!options.detect_columns);
3307        assert_eq!(options.column_threshold, 50.0);
3308        assert!(options.merge_hyphenated);
3309    }
3310
3311    #[test]
3312    fn test_extraction_options_custom() {
3313        let options = ExtractionOptions {
3314            preserve_layout: true,
3315            space_threshold: 0.5,
3316            tj_space_threshold: 0.15,
3317            newline_threshold: 15.0,
3318            sort_by_position: false,
3319            detect_columns: true,
3320            column_threshold: 75.0,
3321            merge_hyphenated: false,
3322            track_space_decisions: false,
3323            reconstruct_paragraphs: false,
3324            include_artifacts: false,
3325            reorder_columns: false,
3326            max_extracted_bytes: None,
3327        };
3328        assert!(options.preserve_layout);
3329        assert_eq!(options.space_threshold, 0.5);
3330        assert_eq!(options.tj_space_threshold, 0.15);
3331        assert_eq!(options.newline_threshold, 15.0);
3332        assert!(!options.sort_by_position);
3333        assert!(options.detect_columns);
3334        assert_eq!(options.column_threshold, 75.0);
3335        assert!(!options.merge_hyphenated);
3336    }
3337
3338    #[test]
3339    fn test_parse_font_style_bold() {
3340        // PostScript style
3341        assert_eq!(parse_font_style("Helvetica-Bold"), (true, false));
3342        assert_eq!(parse_font_style("TimesNewRoman-Bold"), (true, false));
3343
3344        // TrueType style
3345        assert_eq!(parse_font_style("Arial Bold"), (true, false));
3346        assert_eq!(parse_font_style("Calibri Bold"), (true, false));
3347
3348        // Short form
3349        assert_eq!(parse_font_style("Helvetica-B"), (true, false));
3350    }
3351
3352    #[test]
3353    fn test_parse_font_style_italic() {
3354        // PostScript style
3355        assert_eq!(parse_font_style("Helvetica-Italic"), (false, true));
3356        assert_eq!(parse_font_style("Times-Oblique"), (false, true));
3357
3358        // TrueType style
3359        assert_eq!(parse_font_style("Arial Italic"), (false, true));
3360        assert_eq!(parse_font_style("Courier Oblique"), (false, true));
3361
3362        // Short form
3363        assert_eq!(parse_font_style("Helvetica-I"), (false, true));
3364    }
3365
3366    #[test]
3367    fn test_parse_font_style_bold_italic() {
3368        assert_eq!(parse_font_style("Helvetica-BoldItalic"), (true, true));
3369        assert_eq!(parse_font_style("Times-BoldOblique"), (true, true));
3370        assert_eq!(parse_font_style("Arial Bold Italic"), (true, true));
3371    }
3372
3373    #[test]
3374    fn test_parse_font_style_regular() {
3375        assert_eq!(parse_font_style("Helvetica"), (false, false));
3376        assert_eq!(parse_font_style("Times-Roman"), (false, false));
3377        assert_eq!(parse_font_style("Courier"), (false, false));
3378        assert_eq!(parse_font_style("Arial"), (false, false));
3379    }
3380
3381    #[test]
3382    fn test_parse_font_style_edge_cases() {
3383        // Empty and unusual cases
3384        assert_eq!(parse_font_style(""), (false, false));
3385        assert_eq!(parse_font_style("UnknownFont"), (false, false));
3386
3387        // Case insensitive
3388        assert_eq!(parse_font_style("HELVETICA-BOLD"), (true, false));
3389        assert_eq!(parse_font_style("times-ITALIC"), (false, true));
3390    }
3391
3392    #[test]
3393    fn test_text_fragment() {
3394        let fragment = TextFragment {
3395            text: "Hello".to_string(),
3396            x: 100.0,
3397            y: 200.0,
3398            width: 50.0,
3399            height: 12.0,
3400            font_size: 10.0,
3401            font_name: None,
3402            is_bold: false,
3403            is_italic: false,
3404            color: None,
3405            space_decisions: Vec::new(),
3406            mcid: None,
3407            struct_tag: None,
3408        };
3409        assert_eq!(fragment.text, "Hello");
3410        assert_eq!(fragment.x, 100.0);
3411        assert_eq!(fragment.y, 200.0);
3412        assert_eq!(fragment.width, 50.0);
3413        assert_eq!(fragment.height, 12.0);
3414        assert_eq!(fragment.font_size, 10.0);
3415    }
3416
3417    #[test]
3418    fn test_extracted_text() {
3419        let fragments = vec![
3420            TextFragment {
3421                text: "Hello".to_string(),
3422                x: 100.0,
3423                y: 200.0,
3424                width: 50.0,
3425                height: 12.0,
3426                font_size: 10.0,
3427                font_name: None,
3428                is_bold: false,
3429                is_italic: false,
3430                color: None,
3431                space_decisions: Vec::new(),
3432                mcid: None,
3433                struct_tag: None,
3434            },
3435            TextFragment {
3436                text: "World".to_string(),
3437                x: 160.0,
3438                y: 200.0,
3439                width: 50.0,
3440                height: 12.0,
3441                font_size: 10.0,
3442                font_name: None,
3443                is_bold: false,
3444                is_italic: false,
3445                color: None,
3446                space_decisions: Vec::new(),
3447                mcid: None,
3448                struct_tag: None,
3449            },
3450        ];
3451
3452        let extracted = ExtractedText {
3453            text: "Hello World".to_string(),
3454            fragments: fragments,
3455            truncated: false,
3456        };
3457
3458        assert_eq!(extracted.text, "Hello World");
3459        assert_eq!(extracted.fragments.len(), 2);
3460        assert_eq!(extracted.fragments[0].text, "Hello");
3461        assert_eq!(extracted.fragments[1].text, "World");
3462    }
3463
3464    #[test]
3465    fn test_text_state_default() {
3466        let state = TextState::default();
3467        assert_eq!(state.text_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3468        assert_eq!(state.text_line_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3469        assert_eq!(state.ctm, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3470        assert_eq!(state.leading, 0.0);
3471        assert_eq!(state.char_space, 0.0);
3472        assert_eq!(state.word_space, 0.0);
3473        assert_eq!(state.horizontal_scale, 100.0);
3474        assert_eq!(state.text_rise, 0.0);
3475        assert_eq!(state.font_size, 0.0);
3476        assert!(state.font_name.is_none());
3477        assert_eq!(state.render_mode, 0);
3478    }
3479
3480    #[test]
3481    fn test_matrix_operations() {
3482        // Test rotation matrix
3483        let rotation = [0.0, 1.0, -1.0, 0.0, 0.0, 0.0]; // 90 degree rotation
3484        let (x, y) = transform_point(1.0, 0.0, &rotation);
3485        assert_eq!(x, 0.0);
3486        assert_eq!(y, 1.0);
3487
3488        // Test scaling matrix
3489        let scale = [2.0, 0.0, 0.0, 3.0, 0.0, 0.0];
3490        let (x, y) = transform_point(5.0, 5.0, &scale);
3491        assert_eq!(x, 10.0);
3492        assert_eq!(y, 15.0);
3493
3494        // Test complex transformation
3495        let complex = [2.0, 1.0, 1.0, 2.0, 10.0, 20.0];
3496        let (x, y) = transform_point(1.0, 1.0, &complex);
3497        assert_eq!(x, 13.0); // 2*1 + 1*1 + 10
3498        assert_eq!(y, 23.0); // 1*1 + 2*1 + 20
3499    }
3500
3501    #[test]
3502    fn test_text_extractor_new() {
3503        let extractor = TextExtractor::new();
3504        let options = extractor.options;
3505        assert!(!options.preserve_layout);
3506        assert_eq!(options.space_threshold, 0.3);
3507        assert_eq!(options.newline_threshold, 10.0);
3508        assert!(options.sort_by_position);
3509        assert!(!options.detect_columns);
3510        assert_eq!(options.column_threshold, 50.0);
3511        assert!(options.merge_hyphenated);
3512    }
3513
3514    #[test]
3515    fn test_text_extractor_with_options() {
3516        let options = ExtractionOptions {
3517            preserve_layout: true,
3518            space_threshold: 0.3,
3519            tj_space_threshold: 0.2,
3520            newline_threshold: 12.0,
3521            sort_by_position: false,
3522            detect_columns: true,
3523            column_threshold: 60.0,
3524            merge_hyphenated: false,
3525            track_space_decisions: false,
3526            reconstruct_paragraphs: false,
3527            include_artifacts: false,
3528            reorder_columns: false,
3529            max_extracted_bytes: None,
3530        };
3531        let extractor = TextExtractor::with_options(options.clone());
3532        assert_eq!(extractor.options.preserve_layout, options.preserve_layout);
3533        assert_eq!(extractor.options.space_threshold, options.space_threshold);
3534        assert_eq!(
3535            extractor.options.newline_threshold,
3536            options.newline_threshold
3537        );
3538        assert_eq!(extractor.options.sort_by_position, options.sort_by_position);
3539        assert_eq!(extractor.options.detect_columns, options.detect_columns);
3540        assert_eq!(extractor.options.column_threshold, options.column_threshold);
3541        assert_eq!(extractor.options.merge_hyphenated, options.merge_hyphenated);
3542    }
3543
3544    // =========================================================================
3545    // RIGOROUS TESTS FOR FONT METRICS TEXT WIDTH CALCULATION
3546    // =========================================================================
3547
3548    #[test]
3549    fn test_calculate_text_width_with_no_font_info() {
3550        // Test fallback: should use simplified calculation
3551        let width = calculate_text_width("Hello", 12.0, None);
3552
3553        // Expected: 5 chars * 12.0 * 0.5 = 30.0
3554        assert_eq!(
3555            width, 30.0,
3556            "Without font info, should use simplified calculation: len * font_size * 0.5"
3557        );
3558    }
3559
3560    #[test]
3561    fn test_calculate_text_width_with_empty_metrics() {
3562        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3563
3564        // Font with no widths array
3565        let font_info = FontInfo {
3566            name: "TestFont".to_string(),
3567            font_type: "Type1".to_string(),
3568            encoding: None,
3569            to_unicode: None,
3570            differences: None,
3571            descendant_font: None,
3572            cid_to_gid_map: None,
3573            cid_ordering: None,
3574            metrics: FontMetrics {
3575                first_char: None,
3576                last_char: None,
3577                widths: None,
3578                missing_width: Some(500.0),
3579                kerning: None,
3580            },
3581            cid_encoding: None,
3582        };
3583
3584        let width = calculate_text_width("Hello", 12.0, Some(&font_info));
3585
3586        // Should fall back to simplified calculation
3587        assert_eq!(
3588            width, 30.0,
3589            "Without widths array, should fall back to simplified calculation"
3590        );
3591    }
3592
3593    #[test]
3594    fn test_calculate_text_width_with_complete_metrics() {
3595        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3596
3597        // Font with complete metrics for ASCII range 32-126
3598        // Simulate typical Helvetica widths (in 1/1000 units)
3599        let mut widths = vec![0.0; 95]; // 95 chars from 32 to 126
3600
3601        // Set specific widths for "Hello" (H=722, e=556, l=278, o=611)
3602        widths[72 - 32] = 722.0; // 'H' is ASCII 72
3603        widths[101 - 32] = 556.0; // 'e' is ASCII 101
3604        widths[108 - 32] = 278.0; // 'l' is ASCII 108
3605        widths[111 - 32] = 611.0; // 'o' is ASCII 111
3606
3607        let font_info = FontInfo {
3608            name: "Helvetica".to_string(),
3609            font_type: "Type1".to_string(),
3610            encoding: None,
3611            to_unicode: None,
3612            differences: None,
3613            descendant_font: None,
3614            cid_to_gid_map: None,
3615            cid_ordering: None,
3616            metrics: FontMetrics {
3617                first_char: Some(32),
3618                last_char: Some(126),
3619                widths: Some(widths),
3620                missing_width: Some(500.0),
3621                kerning: None,
3622            },
3623            cid_encoding: None,
3624        };
3625
3626        let width = calculate_text_width("Hello", 12.0, Some(&font_info));
3627
3628        // Expected calculation (widths in glyph space / 1000 * font_size):
3629        // H: 722/1000 * 12 = 8.664
3630        // e: 556/1000 * 12 = 6.672
3631        // l: 278/1000 * 12 = 3.336
3632        // l: 278/1000 * 12 = 3.336
3633        // o: 611/1000 * 12 = 7.332
3634        // Total: 29.34
3635        let expected = (722.0 + 556.0 + 278.0 + 278.0 + 611.0) / 1000.0 * 12.0;
3636        let tolerance = 0.0001; // Floating point tolerance
3637        assert!(
3638            (width - expected).abs() < tolerance,
3639            "Should calculate width using actual character metrics: expected {}, got {}, diff {}",
3640            expected,
3641            width,
3642            (width - expected).abs()
3643        );
3644
3645        // Verify it's different from simplified calculation
3646        let simplified = 5.0 * 12.0 * 0.5; // 30.0
3647        assert_ne!(
3648            width, simplified,
3649            "Metrics-based calculation should differ from simplified (30.0)"
3650        );
3651    }
3652
3653    #[test]
3654    fn width_from_codes_uses_char_code_not_decoded_unicode() {
3655        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3656
3657        // Simple Type1 font with a code-indexed Widths array: code 1 -> 1000,
3658        // code 2 -> 100. A custom encoding decodes code 1 -> 'm' (U+006D) and
3659        // code 2 -> 'i' (U+0069), so the decoded Unicode codepoints (109, 105)
3660        // are far from the codes (1, 2). The advance width MUST come from the
3661        // codes; indexing the Widths array by the decoded Unicode codepoint
3662        // reads out-of-range -> missing_width, desyncing glyph advance on
3663        // custom-encoded fonts (issue #302, Higgs/Computer-Modern scramble).
3664        let font_info = FontInfo {
3665            name: "F1".to_string(),
3666            font_type: "Type1".to_string(),
3667            encoding: None,
3668            to_unicode: None,
3669            differences: None,
3670            descendant_font: None,
3671            cid_to_gid_map: None,
3672            cid_ordering: None,
3673            metrics: FontMetrics {
3674                first_char: Some(1),
3675                last_char: Some(2),
3676                widths: Some(vec![1000.0, 100.0]),
3677                missing_width: Some(500.0),
3678                kerning: None,
3679            },
3680            cid_encoding: None,
3681        };
3682
3683        let codes = [1u8, 2u8];
3684        let decoded = "mi"; // what decode_text produced for these codes
3685        let width = calculate_text_width_from_codes(&codes, decoded, 10.0, Some(&font_info));
3686        let expected = (1000.0 + 100.0) / 1000.0 * 10.0; // 11.0
3687        assert!(
3688            (width - expected).abs() < 1e-6,
3689            "width must come from char codes: expected {expected}, got {width}"
3690        );
3691
3692        // The decoded-Unicode-indexed path is the bug: 109 and 105 are outside
3693        // [1,2] so both fall back to missing_width -> (500+500)/1000*10 = 10.0.
3694        let buggy = calculate_text_width(decoded, 10.0, Some(&font_info));
3695        assert_eq!(buggy, 10.0);
3696        assert_ne!(
3697            width, buggy,
3698            "code-based width must differ from the Unicode-indexed bug"
3699        );
3700    }
3701
3702    #[test]
3703    fn test_calculate_text_width_character_outside_range() {
3704        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3705
3706        // Font with narrow range (only covers 'A'-'Z')
3707        let widths = vec![722.0; 26]; // All uppercase letters same width
3708
3709        let font_info = FontInfo {
3710            name: "TestFont".to_string(),
3711            font_type: "Type1".to_string(),
3712            encoding: None,
3713            to_unicode: None,
3714            differences: None,
3715            descendant_font: None,
3716            cid_to_gid_map: None,
3717            cid_ordering: None,
3718            metrics: FontMetrics {
3719                first_char: Some(65), // 'A'
3720                last_char: Some(90),  // 'Z'
3721                widths: Some(widths),
3722                missing_width: Some(500.0),
3723                kerning: None,
3724            },
3725            cid_encoding: None,
3726        };
3727
3728        // Test with character outside range
3729        let width = calculate_text_width("A1", 10.0, Some(&font_info));
3730
3731        // Expected:
3732        // 'A' (65) is in range: 722/1000 * 10 = 7.22
3733        // '1' (49) is outside range: missing_width 500/1000 * 10 = 5.0
3734        // Total: 12.22
3735        let expected = (722.0 / 1000.0 * 10.0) + (500.0 / 1000.0 * 10.0);
3736        assert_eq!(
3737            width, expected,
3738            "Should use missing_width for characters outside range"
3739        );
3740    }
3741
3742    #[test]
3743    fn test_calculate_text_width_missing_width_in_array() {
3744        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3745
3746        // Font with incomplete widths array (some characters have 0.0)
3747        let mut widths = vec![500.0; 95]; // Default width
3748        widths[10] = 0.0; // Character at index 10 has no width defined
3749
3750        let font_info = FontInfo {
3751            name: "TestFont".to_string(),
3752            font_type: "Type1".to_string(),
3753            encoding: None,
3754            to_unicode: None,
3755            differences: None,
3756            descendant_font: None,
3757            cid_to_gid_map: None,
3758            cid_ordering: None,
3759            metrics: FontMetrics {
3760                first_char: Some(32),
3761                last_char: Some(126),
3762                widths: Some(widths),
3763                missing_width: Some(600.0),
3764                kerning: None,
3765            },
3766            cid_encoding: None,
3767        };
3768
3769        // Character 42 (index 10 from first_char 32)
3770        let char_code = 42u8 as char; // '*'
3771        let text = char_code.to_string();
3772        let width = calculate_text_width(&text, 10.0, Some(&font_info));
3773
3774        // Character is in range but width is 0.0, should NOT fall back to missing_width
3775        // (0.0 is a valid width for zero-width characters)
3776        assert_eq!(
3777            width, 0.0,
3778            "Should use 0.0 width from array, not missing_width"
3779        );
3780    }
3781
3782    #[test]
3783    fn test_calculate_text_width_empty_string() {
3784        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3785
3786        let font_info = FontInfo {
3787            name: "TestFont".to_string(),
3788            font_type: "Type1".to_string(),
3789            encoding: None,
3790            to_unicode: None,
3791            differences: None,
3792            descendant_font: None,
3793            cid_to_gid_map: None,
3794            cid_ordering: None,
3795            metrics: FontMetrics {
3796                first_char: Some(32),
3797                last_char: Some(126),
3798                widths: Some(vec![500.0; 95]),
3799                missing_width: Some(500.0),
3800                kerning: None,
3801            },
3802            cid_encoding: None,
3803        };
3804
3805        let width = calculate_text_width("", 12.0, Some(&font_info));
3806        assert_eq!(width, 0.0, "Empty string should have zero width");
3807
3808        // Also test without font info
3809        let width_no_font = calculate_text_width("", 12.0, None);
3810        assert_eq!(
3811            width_no_font, 0.0,
3812            "Empty string should have zero width (no font)"
3813        );
3814    }
3815
3816    #[test]
3817    fn test_calculate_text_width_unicode_characters() {
3818        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3819
3820        // Font with limited ASCII range
3821        let font_info = FontInfo {
3822            name: "TestFont".to_string(),
3823            font_type: "Type1".to_string(),
3824            encoding: None,
3825            to_unicode: None,
3826            differences: None,
3827            descendant_font: None,
3828            cid_to_gid_map: None,
3829            cid_ordering: None,
3830            metrics: FontMetrics {
3831                first_char: Some(32),
3832                last_char: Some(126),
3833                widths: Some(vec![500.0; 95]),
3834                missing_width: Some(600.0),
3835                kerning: None,
3836            },
3837            cid_encoding: None,
3838        };
3839
3840        // Test with Unicode characters outside ASCII range
3841        let width = calculate_text_width("Ñ", 10.0, Some(&font_info));
3842
3843        // 'Ñ' (U+00D1, code 209) is outside range, should use missing_width
3844        // Expected: 600/1000 * 10 = 6.0
3845        assert_eq!(
3846            width, 6.0,
3847            "Unicode character outside range should use missing_width"
3848        );
3849    }
3850
3851    #[test]
3852    fn test_calculate_text_width_different_font_sizes() {
3853        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3854
3855        let font_info = FontInfo {
3856            name: "TestFont".to_string(),
3857            font_type: "Type1".to_string(),
3858            encoding: None,
3859            to_unicode: None,
3860            differences: None,
3861            descendant_font: None,
3862            cid_to_gid_map: None,
3863            cid_ordering: None,
3864            metrics: FontMetrics {
3865                first_char: Some(65), // 'A'
3866                last_char: Some(65),  // 'A'
3867                widths: Some(vec![722.0]),
3868                missing_width: Some(500.0),
3869                kerning: None,
3870            },
3871            cid_encoding: None,
3872        };
3873
3874        // Test same character with different font sizes
3875        let width_10 = calculate_text_width("A", 10.0, Some(&font_info));
3876        let width_20 = calculate_text_width("A", 20.0, Some(&font_info));
3877
3878        // Widths should scale linearly with font size
3879        assert_eq!(width_10, 722.0 / 1000.0 * 10.0);
3880        assert_eq!(width_20, 722.0 / 1000.0 * 20.0);
3881        assert_eq!(
3882            width_20,
3883            width_10 * 2.0,
3884            "Width should scale linearly with font size"
3885        );
3886    }
3887
3888    #[test]
3889    fn test_calculate_text_width_proportional_vs_monospace() {
3890        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3891
3892        // Simulate proportional font (different widths)
3893        let proportional_widths = vec![278.0, 556.0, 722.0]; // i, m, W
3894        let proportional_font = FontInfo {
3895            name: "Helvetica".to_string(),
3896            font_type: "Type1".to_string(),
3897            encoding: None,
3898            to_unicode: None,
3899            differences: None,
3900            descendant_font: None,
3901            cid_to_gid_map: None,
3902            cid_ordering: None,
3903            metrics: FontMetrics {
3904                first_char: Some(105), // 'i'
3905                last_char: Some(107),  // covers i, j, k
3906                widths: Some(proportional_widths),
3907                missing_width: Some(500.0),
3908                kerning: None,
3909            },
3910            cid_encoding: None,
3911        };
3912
3913        // Simulate monospace font (same width)
3914        let monospace_widths = vec![600.0, 600.0, 600.0];
3915        let monospace_font = FontInfo {
3916            name: "Courier".to_string(),
3917            font_type: "Type1".to_string(),
3918            encoding: None,
3919            to_unicode: None,
3920            differences: None,
3921            descendant_font: None,
3922            cid_to_gid_map: None,
3923            cid_ordering: None,
3924            metrics: FontMetrics {
3925                first_char: Some(105),
3926                last_char: Some(107),
3927                widths: Some(monospace_widths),
3928                missing_width: Some(600.0),
3929                kerning: None,
3930            },
3931            cid_encoding: None,
3932        };
3933
3934        let prop_width = calculate_text_width("i", 12.0, Some(&proportional_font));
3935        let mono_width = calculate_text_width("i", 12.0, Some(&monospace_font));
3936
3937        // Proportional 'i' should be narrower than monospace 'i'
3938        assert!(
3939            prop_width < mono_width,
3940            "Proportional 'i' ({}) should be narrower than monospace 'i' ({})",
3941            prop_width,
3942            mono_width
3943        );
3944    }
3945
3946    // =========================================================================
3947    // CRITICAL KERNING TESTS (Issue #87 - Quality Agent Required)
3948    // =========================================================================
3949
3950    #[test]
3951    fn test_calculate_text_width_with_kerning() {
3952        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3953        use std::collections::HashMap;
3954
3955        // Create a font with kerning pairs
3956        let mut widths = vec![500.0; 95]; // ASCII 32-126
3957        widths[65 - 32] = 722.0; // 'A'
3958        widths[86 - 32] = 722.0; // 'V'
3959        widths[87 - 32] = 944.0; // 'W'
3960
3961        let mut kerning = HashMap::new();
3962        // Typical kerning pairs (in FUnits, 1/1000)
3963        kerning.insert((65, 86), -50.0); // 'A' + 'V' → tighten by 50 FUnits
3964        kerning.insert((65, 87), -40.0); // 'A' + 'W' → tighten by 40 FUnits
3965
3966        let font_info = FontInfo {
3967            name: "Helvetica".to_string(),
3968            font_type: "Type1".to_string(),
3969            encoding: None,
3970            to_unicode: None,
3971            differences: None,
3972            descendant_font: None,
3973            cid_to_gid_map: None,
3974            cid_ordering: None,
3975            metrics: FontMetrics {
3976                first_char: Some(32),
3977                last_char: Some(126),
3978                widths: Some(widths),
3979                missing_width: Some(500.0),
3980                kerning: Some(kerning),
3981            },
3982            cid_encoding: None,
3983        };
3984
3985        // Test "AV" with kerning
3986        let width_av = calculate_text_width("AV", 12.0, Some(&font_info));
3987        // Expected: (722 + 722)/1000 * 12 + (-50/1000 * 12)
3988        //         = 17.328 - 0.6 = 16.728
3989        let expected_av = (722.0 + 722.0) / 1000.0 * 12.0 + (-50.0 / 1000.0 * 12.0);
3990        let tolerance = 0.0001;
3991        assert!(
3992            (width_av - expected_av).abs() < tolerance,
3993            "AV with kerning: expected {}, got {}, diff {}",
3994            expected_av,
3995            width_av,
3996            (width_av - expected_av).abs()
3997        );
3998
3999        // Test "AW" with different kerning value
4000        let width_aw = calculate_text_width("AW", 12.0, Some(&font_info));
4001        // Expected: (722 + 944)/1000 * 12 + (-40/1000 * 12)
4002        //         = 19.992 - 0.48 = 19.512
4003        let expected_aw = (722.0 + 944.0) / 1000.0 * 12.0 + (-40.0 / 1000.0 * 12.0);
4004        assert!(
4005            (width_aw - expected_aw).abs() < tolerance,
4006            "AW with kerning: expected {}, got {}, diff {}",
4007            expected_aw,
4008            width_aw,
4009            (width_aw - expected_aw).abs()
4010        );
4011
4012        // Test "VA" with NO kerning (pair not in HashMap)
4013        let width_va = calculate_text_width("VA", 12.0, Some(&font_info));
4014        // Expected: (722 + 722)/1000 * 12 = 17.328 (no kerning adjustment)
4015        let expected_va = (722.0 + 722.0) / 1000.0 * 12.0;
4016        assert!(
4017            (width_va - expected_va).abs() < tolerance,
4018            "VA without kerning: expected {}, got {}, diff {}",
4019            expected_va,
4020            width_va,
4021            (width_va - expected_va).abs()
4022        );
4023
4024        // Verify kerning makes a measurable difference
4025        assert!(
4026            width_av < width_va,
4027            "AV with kerning ({}) should be narrower than VA without kerning ({})",
4028            width_av,
4029            width_va
4030        );
4031    }
4032
4033    #[test]
4034    fn test_parse_truetype_kern_table_minimal() {
4035        use crate::text::extraction_cmap::parse_truetype_kern_table;
4036
4037        // Complete TrueType font with kern table (Format 0, 2 kerning pairs)
4038        // Structure:
4039        // 1. Offset table (12 bytes)
4040        // 2. Table directory (2 tables: 'head' and 'kern', each 16 bytes = 32 total)
4041        // 3. 'head' table data (54 bytes)
4042        // 4. 'kern' table data (30 bytes)
4043        // Total: 128 bytes
4044        let mut ttf_data = vec![
4045            // Offset table
4046            0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
4047            0x00, 0x02, // numTables: 2
4048            0x00, 0x20, // searchRange: 32
4049            0x00, 0x01, // entrySelector: 1
4050            0x00, 0x00, // rangeShift: 0
4051        ];
4052
4053        // Table directory entry 1: 'head' table
4054        ttf_data.extend_from_slice(b"head"); // tag
4055        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
4056        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x2C]); // offset: 44 (12 + 32)
4057        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x36]); // length: 54
4058
4059        // Table directory entry 2: 'kern' table
4060        ttf_data.extend_from_slice(b"kern"); // tag
4061        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
4062        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x62]); // offset: 98 (44 + 54)
4063        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x1E]); // length: 30 (actual kern table size)
4064
4065        // 'head' table data (54 bytes of zeros - minimal valid head table)
4066        ttf_data.extend_from_slice(&[0u8; 54]);
4067
4068        // 'kern' table data (34 bytes)
4069        ttf_data.extend_from_slice(&[
4070            // Kern table header
4071            0x00, 0x00, // version: 0
4072            0x00, 0x01, // nTables: 1
4073            // Subtable header
4074            0x00, 0x00, // version: 0
4075            0x00, 0x1A, // length: 26 bytes (header 6 + nPairs data 8 + pairs 2*6=12)
4076            0x00, 0x00, // coverage: 0x0000 (Format 0 in lower byte, horizontal)
4077            0x00, 0x02, // nPairs: 2
4078            0x00, 0x08, // searchRange: 8
4079            0x00, 0x00, // entrySelector: 0
4080            0x00, 0x04, // rangeShift: 4
4081            // Kerning pair 1: A + V → -50
4082            0x00, 0x41, // left glyph: 65 ('A')
4083            0x00, 0x56, // right glyph: 86 ('V')
4084            0xFF, 0xCE, // value: -50 (signed 16-bit big-endian)
4085            // Kerning pair 2: A + W → -40
4086            0x00, 0x41, // left glyph: 65 ('A')
4087            0x00, 0x57, // right glyph: 87 ('W')
4088            0xFF, 0xD8, // value: -40 (signed 16-bit big-endian)
4089        ]);
4090
4091        let result = parse_truetype_kern_table(&ttf_data);
4092        assert!(
4093            result.is_ok(),
4094            "Should parse minimal kern table successfully: {:?}",
4095            result.err()
4096        );
4097
4098        let kerning_map = result.unwrap();
4099        assert_eq!(kerning_map.len(), 2, "Should extract 2 kerning pairs");
4100
4101        // Verify pair 1: A + V → -50
4102        assert_eq!(
4103            kerning_map.get(&(65, 86)),
4104            Some(&-50.0),
4105            "Should have A+V kerning pair with value -50"
4106        );
4107
4108        // Verify pair 2: A + W → -40
4109        assert_eq!(
4110            kerning_map.get(&(65, 87)),
4111            Some(&-40.0),
4112            "Should have A+W kerning pair with value -40"
4113        );
4114    }
4115
4116    #[test]
4117    fn test_parse_kern_table_no_kern_table() {
4118        use crate::text::extraction_cmap::extract_truetype_kerning;
4119
4120        // TrueType font data WITHOUT a 'kern' table
4121        // Structure:
4122        // - Offset table: scaler type + numTables + searchRange + entrySelector + rangeShift
4123        // - Table directory: 1 entry for 'head' table (not 'kern')
4124        let ttf_data = vec![
4125            // Offset table
4126            0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
4127            0x00, 0x01, // numTables: 1
4128            0x00, 0x10, // searchRange: 16
4129            0x00, 0x00, // entrySelector: 0
4130            0x00, 0x00, // rangeShift: 0
4131            // Table directory entry: 'head' table (not 'kern')
4132            b'h', b'e', b'a', b'd', // tag: 'head'
4133            0x00, 0x00, 0x00, 0x00, // checksum
4134            0x00, 0x00, 0x00, 0x1C, // offset: 28
4135            0x00, 0x00, 0x00, 0x36, // length: 54
4136            // Mock 'head' table data (54 bytes of zeros)
4137            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4138            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4139            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4140            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4141        ];
4142
4143        let result = extract_truetype_kerning(&ttf_data);
4144        assert!(
4145            result.is_ok(),
4146            "Should gracefully handle missing kern table"
4147        );
4148
4149        let kerning_map = result.unwrap();
4150        assert!(
4151            kerning_map.is_empty(),
4152            "Should return empty HashMap when no kern table exists"
4153        );
4154    }
4155
4156    // Helper for paragraph-reconstruction unit tests. TextFragment has 11
4157    // fields so a helper keeps the test bodies focused on geometry.
4158    fn tf(text: &str, x: f64, y: f64, width: f64, font_size: f64) -> TextFragment {
4159        TextFragment {
4160            text: text.to_string(),
4161            x,
4162            y,
4163            width,
4164            height: font_size,
4165            font_size,
4166            font_name: None,
4167            is_bold: false,
4168            is_italic: false,
4169            color: None,
4170            space_decisions: Vec::new(),
4171            mcid: None,
4172            struct_tag: None,
4173        }
4174    }
4175
4176    #[test]
4177    fn merge_into_lines_groups_same_baseline_fragments() {
4178        let extractor = TextExtractor::with_options(ExtractionOptions {
4179            reconstruct_paragraphs: true,
4180            ..Default::default()
4181        });
4182        let input = vec![
4183            tf("Hello", 50.0, 400.0, 30.0, 12.0),
4184            tf("world", 90.0, 400.0, 30.0, 12.0),
4185            tf("now.", 130.0, 400.0, 25.0, 12.0),
4186            tf("Next", 50.0, 386.0, 30.0, 12.0),
4187            tf("line.", 90.0, 386.0, 25.0, 12.0),
4188        ];
4189        let lines = extractor.merge_into_lines(&input);
4190        assert_eq!(
4191            lines.len(),
4192            2,
4193            "two distinct baselines must produce two line fragments"
4194        );
4195        assert_eq!(
4196            lines[0].text, "Hello world now.",
4197            "first line concatenated with spaces"
4198        );
4199        assert_eq!(lines[1].text, "Next line.", "second line concatenated");
4200    }
4201
4202    #[test]
4203    fn merge_into_lines_inserts_space_only_when_gap_exceeds_threshold() {
4204        let extractor = TextExtractor::with_options(ExtractionOptions {
4205            reconstruct_paragraphs: true,
4206            space_threshold: 0.3,
4207            ..Default::default()
4208        });
4209        // Gap of 4pt at font_size 12 = 0.33x — above threshold 0.3
4210        let with_gap = vec![
4211            tf("AB", 50.0, 400.0, 10.0, 12.0),
4212            tf("CD", 64.0, 400.0, 10.0, 12.0),
4213        ];
4214        let lines = extractor.merge_into_lines(&with_gap);
4215        assert_eq!(
4216            lines[0].text, "AB CD",
4217            "gap above threshold must insert space"
4218        );
4219
4220        // Gap of 1pt = 0.083x — below threshold
4221        let tight = vec![
4222            tf("AB", 50.0, 400.0, 10.0, 12.0),
4223            tf("CD", 61.0, 400.0, 10.0, 12.0),
4224        ];
4225        let lines = extractor.merge_into_lines(&tight);
4226        assert_eq!(lines[0].text, "ABCD", "tight gap must NOT insert space");
4227    }
4228
4229    #[test]
4230    fn standard_14_space_width_maps_base_fonts_and_substitutes() {
4231        // Adobe Core-14 AFM space advances, with subset prefixes stripped and
4232        // metric-compatible substitutes folded in (#302 symptom 2).
4233        assert_eq!(super::standard_14_space_width("Times-Roman"), Some(250.0));
4234        assert_eq!(
4235            super::standard_14_space_width("Times-BoldItalic"),
4236            Some(250.0)
4237        );
4238        assert_eq!(super::standard_14_space_width("Helvetica"), Some(278.0));
4239        assert_eq!(super::standard_14_space_width("Courier-Bold"), Some(600.0));
4240        assert_eq!(super::standard_14_space_width("Symbol"), Some(250.0));
4241        assert_eq!(super::standard_14_space_width("ZapfDingbats"), Some(278.0));
4242        // subset prefix stripped
4243        assert_eq!(
4244            super::standard_14_space_width("ABCDEF+Times-Roman"),
4245            Some(250.0)
4246        );
4247        // metric-compatible substitutes
4248        assert_eq!(super::standard_14_space_width("Arial-BoldMT"), Some(278.0));
4249        assert_eq!(
4250            super::standard_14_space_width("TimesNewRomanPSMT"),
4251            Some(250.0)
4252        );
4253        assert_eq!(
4254            super::standard_14_space_width("CourierNewPSMT"),
4255            Some(600.0)
4256        );
4257        // unknown / embedded fonts fall through to the caller's fallback
4258        assert_eq!(super::standard_14_space_width("Poppins-Regular"), None);
4259        assert_eq!(super::standard_14_space_width("VUNXGH+Calibri"), None);
4260    }
4261
4262    #[test]
4263    fn merge_into_lines_keeps_emission_order_for_font_switch_overlap() {
4264        // #302 symptom 1: a font-switched glyph (e.g. the italic particle
4265        // symbol "Z" in "to the Z boson") is positioned by the producer with
4266        // an x-origin that falls INSIDE the x-span of the preceding roman run
4267        // ("to the"). The content stream still delivers it in correct reading
4268        // order. Sorting a row purely by x-origin interleaves the overlapping
4269        // fragment, yielding "Zto the" instead of "to theZ". When a row's only
4270        // backward emission steps are span overlaps (not disjoint jumps),
4271        // emission order is the authoritative reading order.
4272        let extractor = TextExtractor::with_options(ExtractionOptions {
4273            reconstruct_paragraphs: true,
4274            ..Default::default()
4275        });
4276        // emission order = reading order; "Z" overlaps "to t" + "he" in x.
4277        let row = vec![
4278            tf("to t", 455.5, 400.0, 12.0, 10.0), // 455.5 .. 467.5
4279            tf("he", 467.5, 400.0, 10.0, 10.0),   // 467.5 .. 477.5
4280            tf("Z", 455.3, 400.0, 23.0, 10.0),    // 455.3 .. 478.3 (overlaps both)
4281        ];
4282        let lines = extractor.merge_into_lines(&row);
4283        assert_eq!(lines.len(), 1);
4284        assert_eq!(
4285            lines[0].text, "to theZ",
4286            "overlapping font-switch fragment must keep emission (reading) order"
4287        );
4288    }
4289
4290    #[test]
4291    fn merge_into_lines_keeps_emission_when_run_backfills_covered_span() {
4292        // #305: dense justified body text is split into sub-word fragments by
4293        // the font's arbitrary glyph runs. A later word ("described", x 492..537)
4294        // is emitted with a backward x-origin that lands INSIDE the span already
4295        // covered by the line ("...selections", 479..521), but does NOT overlap
4296        // the short immediately-preceding fragment ("s", 517..521). Emission is
4297        // still the reading order, so the line must keep it — the overlap test
4298        // has to consider the line's running extent, not just the previous
4299        // fragment. (Real case: Higgs p5 "kinematic selections described in".)
4300        let extractor = TextExtractor::with_options(ExtractionOptions {
4301            reconstruct_paragraphs: true,
4302            ..Default::default()
4303        });
4304        let row = vec![
4305            tf("selection", 479.0, 400.0, 38.0, 8.0), // 479..517
4306            tf("s", 517.0, 400.0, 4.0, 8.0),          // 517..521  short predecessor
4307            tf("d", 492.0, 400.0, 4.0, 8.0),          // 492..496  backfill, no overlap with "s"
4308            tf("escribed", 496.0, 400.0, 41.0, 8.0),  // 496..537
4309        ];
4310        let lines = extractor.merge_into_lines(&row);
4311        assert_eq!(
4312            lines[0].text, "selectionsdescribed",
4313            "a run that backfills the line's covered span must keep emission order"
4314        );
4315    }
4316
4317    #[test]
4318    fn merge_into_lines_uses_x_order_for_disjoint_backward_jump() {
4319        // Guard: a genuinely scrambled non-tagged stream (fragments emitted
4320        // out of x-order at DISJOINT positions, e.g. right-to-left or random
4321        // generators) must still be reordered by x. Here "the" is emitted
4322        // after "boson" with no span overlap, so x-order is authoritative.
4323        let extractor = TextExtractor::with_options(ExtractionOptions {
4324            reconstruct_paragraphs: true,
4325            ..Default::default()
4326        });
4327        let row = vec![
4328            tf("boson", 100.0, 400.0, 28.0, 10.0), // 100 .. 128
4329            tf("the", 80.0, 400.0, 15.0, 10.0),    // 80 .. 95 (disjoint, left of boson)
4330        ];
4331        let lines = extractor.merge_into_lines(&row);
4332        assert_eq!(lines.len(), 1);
4333        assert_eq!(
4334            lines[0].text, "the boson",
4335            "disjoint backward emission jump must be reordered by x"
4336        );
4337    }
4338
4339    #[test]
4340    fn merge_into_lines_unioned_bounding_box() {
4341        let extractor = TextExtractor::with_options(ExtractionOptions {
4342            reconstruct_paragraphs: true,
4343            ..Default::default()
4344        });
4345        let input = vec![
4346            tf("A", 50.0, 400.0, 10.0, 12.0),
4347            tf("B", 100.0, 400.0, 10.0, 12.0),
4348        ];
4349        let lines = extractor.merge_into_lines(&input);
4350        assert_eq!(lines.len(), 1);
4351        assert!((lines[0].x - 50.0).abs() < 0.01);
4352        assert!(
4353            (lines[0].width - 60.0).abs() < 0.01,
4354            "width must span 50->110"
4355        );
4356    }
4357
4358    #[test]
4359    fn assign_row_ids_monotone_y_descending_keeps_zero() {
4360        let frags = vec![
4361            tf("A", 50.0, 400.0, 10.0, 9.0),
4362            tf("B", 50.0, 395.0, 10.0, 9.0),
4363            tf("C", 50.0, 390.0, 10.0, 9.0),
4364        ];
4365        let row_ids = super::assign_row_ids(&frags);
4366        assert_eq!(row_ids, vec![0u32, 0, 0]);
4367    }
4368
4369    #[test]
4370    fn assign_row_ids_increments_on_y_up_jump_above_threshold() {
4371        // font_size=9 → threshold = max(4.5, 2.0) = 4.5
4372        // deltas: 395-400=-5, 420-395=+25 (>4.5)
4373        let frags = vec![
4374            tf("A", 50.0, 400.0, 10.0, 9.0),
4375            tf("B", 50.0, 395.0, 10.0, 9.0),
4376            tf("C", 50.0, 420.0, 10.0, 9.0),
4377        ];
4378        let row_ids = super::assign_row_ids(&frags);
4379        assert_eq!(row_ids, vec![0u32, 0, 1]);
4380    }
4381
4382    #[test]
4383    fn assign_row_ids_ignores_superscript_within_threshold() {
4384        // font_size=9 → threshold 4.5. delta 2.5 must NOT trigger.
4385        let frags = vec![
4386            tf("A", 50.0, 400.0, 10.0, 9.0),
4387            tf("^2", 60.0, 402.5, 5.0, 9.0),
4388            tf("B", 65.0, 395.0, 10.0, 9.0),
4389        ];
4390        let row_ids = super::assign_row_ids(&frags);
4391        assert_eq!(row_ids, vec![0u32, 0, 0]);
4392    }
4393
4394    #[test]
4395    fn assign_row_ids_floor_2pt_for_small_fonts() {
4396        // font_size=3 → font_size*0.5 = 1.5; floor lifts threshold to 2.0
4397        // delta = +2.5 > 2.0 must trigger.
4398        let frags = vec![
4399            tf("A", 50.0, 100.0, 10.0, 3.0),
4400            tf("B", 50.0, 102.5, 10.0, 3.0),
4401        ];
4402        let row_ids = super::assign_row_ids(&frags);
4403        assert_eq!(row_ids, vec![0u32, 1]);
4404    }
4405
4406    #[test]
4407    fn assign_row_ids_empty_slice_returns_empty() {
4408        let frags: Vec<TextFragment> = vec![];
4409        let row_ids = super::assign_row_ids(&frags);
4410        assert!(row_ids.is_empty(), "empty input must yield empty output");
4411    }
4412
4413    #[test]
4414    fn merge_into_lines_splits_two_columns_emitted_sequentially() {
4415        let extractor = TextExtractor::with_options(ExtractionOptions {
4416            reconstruct_paragraphs: true,
4417            ..Default::default()
4418        });
4419        // Emission order: col1.l1, col1.l2 (Y monotone down), then col2.l1
4420        // (Y jumps UP by 10 > threshold 5 for font 10pt), col2.l2.
4421        let input = vec![
4422            tf("col1-top", 50.0, 400.0, 80.0, 10.0),
4423            tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
4424            tf("col2-top", 200.0, 405.0, 80.0, 10.0),
4425            tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
4426        ];
4427        let lines = extractor.merge_into_lines(&input);
4428        assert_eq!(
4429            lines.len(),
4430            4,
4431            "two columns at near-identical Y must split into 4 lines"
4432        );
4433        // row_id=0 batch first (col1), then row_id=1 (col2). Within each batch, Y desc.
4434        assert_eq!(lines[0].text, "col1-top");
4435        assert_eq!(lines[0].y, 400.0);
4436        assert_eq!(lines[1].text, "col1-bot");
4437        assert_eq!(lines[1].y, 395.0);
4438        assert_eq!(lines[2].text, "col2-top");
4439        assert_eq!(lines[2].y, 405.0);
4440        assert_eq!(lines[3].text, "col2-bot");
4441        assert_eq!(lines[3].y, 400.0);
4442    }
4443
4444    #[test]
4445    fn merge_into_lines_preserves_single_column_continuation() {
4446        let extractor = TextExtractor::with_options(ExtractionOptions {
4447            reconstruct_paragraphs: true,
4448            ..Default::default()
4449        });
4450        // Single column: same Y continuation (X grows), then next line down.
4451        let input = vec![
4452            tf("Hello", 50.0, 400.0, 30.0, 10.0),
4453            tf("world", 90.0, 400.0, 30.0, 10.0),
4454            tf("next-line", 50.0, 395.0, 70.0, 10.0),
4455        ];
4456        let lines = extractor.merge_into_lines(&input);
4457        assert_eq!(
4458            lines.len(),
4459            2,
4460            "single column continuation must collapse to 2 lines"
4461        );
4462        assert!(lines[0].text.contains("Hello"));
4463        assert!(lines[0].text.contains("world"));
4464        assert_eq!(lines[1].text, "next-line");
4465    }
4466
4467    #[test]
4468    fn merge_into_lines_splits_columns_with_uniform_mcid() {
4469        // Regression guard for #265 root cause: NCSC page 12 has a single
4470        // outer BDC, so every fragment has mcid=Some(0). Column separation
4471        // must come from row_id alone, not from mcid.
4472        let extractor = TextExtractor::with_options(ExtractionOptions {
4473            reconstruct_paragraphs: true,
4474            ..Default::default()
4475        });
4476        let mut frags = vec![
4477            tf("col1-top", 50.0, 400.0, 80.0, 10.0),
4478            tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
4479            tf("col2-top", 200.0, 405.0, 80.0, 10.0),
4480            tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
4481        ];
4482        for f in &mut frags {
4483            f.mcid = Some(0);
4484        }
4485        let lines = extractor.merge_into_lines(&frags);
4486        assert_eq!(
4487            lines.len(),
4488            4,
4489            "uniform mcid must not prevent row_id-based column split (NCSC root cause)"
4490        );
4491        assert_eq!(lines[0].text, "col1-top");
4492        assert_eq!(lines[1].text, "col1-bot");
4493        assert_eq!(lines[2].text, "col2-top");
4494        assert_eq!(lines[3].text, "col2-bot");
4495    }
4496
4497    #[test]
4498    fn merge_close_fragments_superscript_merges_when_reconstruct_paragraphs() {
4499        let extractor = TextExtractor::with_options(ExtractionOptions {
4500            reconstruct_paragraphs: true,
4501            ..Default::default()
4502        });
4503        // Citation superscript: body text at y=400, raised digit at y=403.5
4504        // (3.5pt above baseline for 10pt font). y_tol = 0.5 * 10 = 5.0 > 3.5
4505        // and x_gap = 4pt < 10*0.5 = 5pt, so the superscript must merge into
4506        // the body fragment.
4507        let frags = vec![
4508            tf("body-text", 50.0, 400.0, 25.0, 10.0),
4509            tf("1", 79.0, 403.5, 4.0, 10.0),
4510        ];
4511        let merged = extractor.merge_close_fragments(&frags);
4512        assert_eq!(
4513            merged.len(),
4514            1,
4515            "superscript within 5pt of baseline must merge in reconstruct path"
4516        );
4517        assert!(merged[0].text.contains("body-text"));
4518        assert!(merged[0].text.contains("1"));
4519    }
4520
4521    #[test]
4522    fn merge_close_fragments_superscript_does_not_merge_in_legacy_path() {
4523        let extractor = TextExtractor::with_options(ExtractionOptions {
4524            reconstruct_paragraphs: false,
4525            ..Default::default()
4526        });
4527        // Legacy path: y_tol=1.0 fixed. A 3.5pt delta must NOT merge.
4528        let frags = vec![
4529            tf("body-text", 50.0, 400.0, 25.0, 10.0),
4530            tf("1", 79.0, 403.5, 4.0, 10.0),
4531        ];
4532        let merged = extractor.merge_close_fragments(&frags);
4533        assert_eq!(
4534            merged.len(),
4535            2,
4536            "3.5pt Y delta exceeds legacy 1.0pt threshold; superscript stays separate"
4537        );
4538    }
4539
4540    #[test]
4541    fn merge_into_paragraphs_groups_consecutive_lines() {
4542        let extractor = TextExtractor::with_options(ExtractionOptions {
4543            reconstruct_paragraphs: true,
4544            ..Default::default()
4545        });
4546        // Three lines, 14pt leading (line height 12pt, gap 2pt)
4547        let lines = vec![
4548            tf("Line one.", 50.0, 400.0, 60.0, 12.0),
4549            tf("Line two.", 50.0, 386.0, 60.0, 12.0),
4550            tf("Line three.", 50.0, 372.0, 70.0, 12.0),
4551        ];
4552        let paragraphs = extractor.merge_into_paragraphs(&lines);
4553        assert_eq!(paragraphs.len(), 1);
4554        assert_eq!(paragraphs[0].text, "Line one.\nLine two.\nLine three.");
4555    }
4556
4557    #[test]
4558    fn merge_into_paragraphs_splits_on_large_vertical_gap() {
4559        let extractor = TextExtractor::with_options(ExtractionOptions {
4560            reconstruct_paragraphs: true,
4561            ..Default::default()
4562        });
4563        let lines = vec![
4564            tf("P1L1.", 50.0, 400.0, 40.0, 12.0),
4565            tf("P1L2.", 50.0, 386.0, 40.0, 12.0),
4566            tf("P2L1.", 50.0, 300.0, 40.0, 12.0),
4567        ];
4568        let paragraphs = extractor.merge_into_paragraphs(&lines);
4569        assert_eq!(paragraphs.len(), 2);
4570        assert_eq!(paragraphs[0].text, "P1L1.\nP1L2.");
4571        assert_eq!(paragraphs[1].text, "P2L1.");
4572    }
4573
4574    /// A heading is a different block from the body that follows it, even when
4575    /// the vertical gap is small enough to look like line spacing. Merging them
4576    /// destroys the two signals `partition` uses to classify a `Title`
4577    /// (font-size ratio and bold-short), so the heading text is never
4578    /// recoverable downstream (issue #436).
4579    #[test]
4580    fn merge_into_paragraphs_splits_on_font_size_change() {
4581        let extractor = TextExtractor::with_options(ExtractionOptions {
4582            reconstruct_paragraphs: true,
4583            ..Default::default()
4584        });
4585        // 20pt title at y=760, 10pt body line 40pt below: gap = 30pt, which is
4586        // exactly the 1.5 * median(20, 10) = 30pt vertical threshold, so only
4587        // the style change can separate them.
4588        let lines = vec![
4589            tf("Section Heading", 72.0, 760.0, 120.0, 20.0),
4590            tf("Body text of this section.", 72.0, 720.0, 150.0, 10.0),
4591        ];
4592        let paragraphs = extractor.merge_into_paragraphs(&lines);
4593        assert_eq!(
4594            paragraphs.len(),
4595            2,
4596            "font-size change must end the paragraph"
4597        );
4598        assert_eq!(paragraphs[0].text, "Section Heading");
4599        assert_eq!(paragraphs[0].font_size, 20.0);
4600        assert_eq!(paragraphs[1].text, "Body text of this section.");
4601    }
4602
4603    /// Same size, different weight: the classic run-in bold heading. `partition`
4604    /// classifies it through `bold_short_title`, which needs the heading to
4605    /// survive extraction as its own fragment (issue #436).
4606    #[test]
4607    fn merge_into_paragraphs_splits_on_weight_change() {
4608        let extractor = TextExtractor::with_options(ExtractionOptions {
4609            reconstruct_paragraphs: true,
4610            ..Default::default()
4611        });
4612        let mut heading = tf("Overview", 72.0, 400.0, 60.0, 12.0);
4613        heading.is_bold = true;
4614        let lines = vec![heading, tf("Body line.", 72.0, 386.0, 60.0, 12.0)];
4615        let paragraphs = extractor.merge_into_paragraphs(&lines);
4616        assert_eq!(paragraphs.len(), 2, "weight change must end the paragraph");
4617        assert_eq!(paragraphs[0].text, "Overview");
4618        assert!(paragraphs[0].is_bold);
4619        assert_eq!(paragraphs[1].text, "Body line.");
4620    }
4621
4622    /// Sub-point rounding (11.96pt vs 12pt from a scaled text matrix) is not a
4623    /// style change: the paragraph must stay whole.
4624    #[test]
4625    fn merge_into_paragraphs_tolerates_subpoint_font_size_jitter() {
4626        let extractor = TextExtractor::with_options(ExtractionOptions {
4627            reconstruct_paragraphs: true,
4628            ..Default::default()
4629        });
4630        let lines = vec![
4631            tf("Line one.", 50.0, 400.0, 60.0, 12.0),
4632            tf("Line two.", 50.0, 386.0, 60.0, 11.96),
4633        ];
4634        let paragraphs = extractor.merge_into_paragraphs(&lines);
4635        assert_eq!(
4636            paragraphs.len(),
4637            1,
4638            "0.3% size jitter is not a style change"
4639        );
4640        assert_eq!(paragraphs[0].text, "Line one.\nLine two.");
4641    }
4642
4643    #[test]
4644    fn merge_into_paragraphs_drops_hyphen_when_merge_hyphenated() {
4645        let extractor = TextExtractor::with_options(ExtractionOptions {
4646            reconstruct_paragraphs: true,
4647            merge_hyphenated: true,
4648            ..Default::default()
4649        });
4650        let lines = vec![
4651            tf("Kryp-", 50.0, 400.0, 30.0, 12.0),
4652            tf("tographie", 50.0, 386.0, 60.0, 12.0),
4653        ];
4654        let paragraphs = extractor.merge_into_paragraphs(&lines);
4655        assert_eq!(paragraphs.len(), 1);
4656        assert_eq!(
4657            paragraphs[0].text, "Kryptographie",
4658            "hyphen elided, no newline inserted"
4659        );
4660    }
4661
4662    #[test]
4663    fn decode_pdf_string_utf16be_bom_decodes_fi_ligature() {
4664        let bytes = [0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69];
4665        assert_eq!(super::decode_pdf_string(&bytes), "fi");
4666    }
4667
4668    #[test]
4669    fn decode_pdf_string_ascii_pdfdocencoding_passthrough() {
4670        let bytes = b"page 12";
4671        assert_eq!(super::decode_pdf_string(bytes), "page 12");
4672    }
4673
4674    #[test]
4675    fn decode_pdf_string_empty_input_returns_empty() {
4676        assert_eq!(super::decode_pdf_string(&[]), "");
4677    }
4678
4679    #[test]
4680    fn decode_pdf_string_lone_bom_returns_empty() {
4681        // BOM only, no code units after.
4682        assert_eq!(super::decode_pdf_string(&[0xFE, 0xFF]), "");
4683    }
4684
4685    #[test]
4686    fn resolve_props_extracts_integer_mcid() {
4687        use crate::parser::content::{MarkedContentProps, MarkedContentValue};
4688        use std::collections::HashMap;
4689        let mut map = HashMap::new();
4690        map.insert("MCID".to_string(), MarkedContentValue::Integer(7));
4691        let props = MarkedContentProps::Inline(map);
4692
4693        let (mcid, actual) = super::resolve_props(&props, None);
4694        assert_eq!(mcid, Some(7));
4695        assert_eq!(actual, None);
4696    }
4697
4698    #[test]
4699    fn resolve_props_decodes_utf16be_actualtext() {
4700        use crate::parser::content::{MarkedContentProps, MarkedContentValue};
4701        use std::collections::HashMap;
4702        let mut map = HashMap::new();
4703        map.insert(
4704            "ActualText".to_string(),
4705            MarkedContentValue::String(vec![0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69]),
4706        );
4707        let props = MarkedContentProps::Inline(map);
4708
4709        let (mcid, actual) = super::resolve_props(&props, None);
4710        assert_eq!(mcid, None);
4711        assert_eq!(actual.as_deref(), Some("fi"));
4712    }
4713
4714    #[test]
4715    fn resolve_props_returns_none_for_unresolvable_resource_ref() {
4716        use crate::parser::content::MarkedContentProps;
4717        let props = MarkedContentProps::ResourceRef("PropsName".to_string());
4718        let (mcid, actual) = super::resolve_props(&props, None);
4719        assert_eq!((mcid, actual), (None, None));
4720    }
4721
4722    #[test]
4723    fn resolve_props_negative_mcid_rejected() {
4724        use crate::parser::content::{MarkedContentProps, MarkedContentValue};
4725        use std::collections::HashMap;
4726        // MCID is unsigned per ISO 32000-1; negative integer is malformed.
4727        let mut map = HashMap::new();
4728        map.insert("MCID".to_string(), MarkedContentValue::Integer(-1));
4729        let props = MarkedContentProps::Inline(map);
4730
4731        let (mcid, _) = super::resolve_props(&props, None);
4732        assert_eq!(mcid, None);
4733    }
4734
4735    #[test]
4736    fn resolve_props_resource_ref_overflow_mcid_rejected() {
4737        // ISO 32000-1 §14.7.4: MCID is an unsigned 32-bit integer. A
4738        // PdfObject::Integer holds an i64, so a malformed PDF can carry an
4739        // out-of-range MCID. The ResourceRef path must reject those rather
4740        // than wrap silently via `as u32`. Mirrors the Inline-path guard
4741        // already covered by `resolve_props_negative_mcid_rejected`.
4742        use crate::parser::content::MarkedContentProps;
4743        use crate::parser::objects::{PdfDictionary, PdfObject};
4744
4745        let mut inner = PdfDictionary::new();
4746        inner.insert("MCID".to_string(), PdfObject::Integer(i64::MAX));
4747
4748        let mut properties = PdfDictionary::new();
4749        properties.insert("PropsName".to_string(), PdfObject::Dictionary(inner));
4750
4751        let props = MarkedContentProps::ResourceRef("PropsName".to_string());
4752        let (mcid, _) = super::resolve_props(&props, Some(&properties));
4753        assert_eq!(mcid, None);
4754    }
4755
4756    #[test]
4757    fn sort_and_merge_fragments_nan_y_does_not_swallow_other_lines() {
4758        // A fragment with a non-finite Y (reachable from a degenerate text
4759        // matrix in a malformed PDF) must not chain every remaining fragment
4760        // into one pseudo-line. The tolerance filter compares with `< tol`; a
4761        // `>= tol` phrasing would let a NaN anchor never terminate the line,
4762        // collapsing the whole page into a single X-sorted "line".
4763        let extractor = TextExtractor::with_options(ExtractionOptions::default());
4764
4765        // Four well-separated lines whose X order is the reverse of their Y
4766        // (reading) order: if the NaN anchor swallows the rest, they get
4767        // re-sorted purely by X into D,C,B,A instead of the reading order.
4768        let mut fragments = vec![
4769            tf("A", 400.0, f64::NAN, 10.0, 12.0),
4770            tf("B", 300.0, 500.0, 10.0, 12.0),
4771            tf("C", 200.0, 300.0, 10.0, 12.0),
4772            tf("D", 100.0, 100.0, 10.0, 12.0),
4773        ];
4774        extractor.sort_and_merge_fragments(&mut fragments);
4775
4776        let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
4777        assert_eq!(
4778            order,
4779            vec!["A", "B", "C", "D"],
4780            "NaN-Y fragment must stay its own line; the finite lines keep \
4781             top-to-bottom reading order instead of collapsing to X order"
4782        );
4783    }
4784}