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