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