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