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