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            // `x_gap >= 0.0` treats a "touching" pair (the next run starting
2954            // exactly where the previous one's pen advance ended, e.g. two
2955            // runs of the same word/sentence with no positioning operator
2956            // between them) as mergeable. But `current.width` and
2957            // `fragment.x` are usually derived from independent floating-point
2958            // paths (accumulated per-glyph AFM/Widths sums vs. the text
2959            // matrix's absolute origin) that are mathematically identical but
2960            // not bit-identical, so a genuinely zero gap can land a few ULPs
2961            // on either side of 0.0 (issue #521 follow-up). Tolerate that
2962            // rounding noise (see `SAME_LINE_EPS`) without loosening the check
2963            // enough to treat a real, visible overlap as adjacent.
2964            let should_merge = y_diff < y_tol
2965                && x_gap >= -SAME_LINE_EPS  // Fragment is to the right (within FP rounding noise)
2966                && x_gap < fragment.font_size * 0.5 // Gap less than 50% of font size
2967                && current.mcid == fragment.mcid;
2968
2969            if should_merge {
2970                // Merge this fragment into current, preserving word boundaries
2971                // when the gap exceeds the font-anchored space threshold.
2972                if x_gap > self.space_gap_threshold(fragment) {
2973                    current.text.push(' ');
2974                }
2975                current.text.push_str(&fragment.text);
2976                current.width = (fragment.x + fragment.width) - current.x;
2977            } else {
2978                // Start a new fragment
2979                merged.push(current);
2980                current = fragment.clone();
2981            }
2982        }
2983
2984        merged.push(current);
2985        merged
2986    }
2987
2988    /// Extract font resources from page
2989    ///
2990    /// Clears the per-page name cache (font names are page-local in PDF), but
2991    /// reuses previously parsed font objects via `font_object_cache` to avoid
2992    /// re-parsing the same font object across multiple pages.
2993    fn extract_font_resources<R: Read + Seek>(
2994        &mut self,
2995        page: &ParsedPage,
2996        document: &PdfDocument<R>,
2997    ) -> ParseResult<()> {
2998        // Clear per-page name mapping (font names like /F1 are page-local)
2999        self.font_cache.clear();
3000        self.type0_implicit_space_widths.clear();
3001
3002        // Try to get resources manually from page dictionary first
3003        // This is necessary because ParsedPage.get_resources() may not always work
3004        if let Some(res_ref) = page.dict.get("Resources").and_then(|o| o.as_reference()) {
3005            if let Ok(PdfObject::Dictionary(resources)) = document.get_object(res_ref.0, res_ref.1)
3006            {
3007                self.cache_fonts_from_resources::<R>(&resources, document);
3008            }
3009        } else if let Some(resources) = page.get_resources() {
3010            // Fallback to get_resources() if Resources is not a reference
3011            self.cache_fonts_from_resources::<R>(resources, document);
3012        }
3013
3014        Ok(())
3015    }
3016
3017    /// Cache every font declared in a page's `/Resources` `/Font` dictionary.
3018    ///
3019    /// `/Font` itself may be either an inline dictionary or an indirect
3020    /// reference (`/Font 191 0 R`); both are common in real PDFs (e.g. the
3021    /// ATLAS Higgs paper references it). Resolving the reference is required —
3022    /// otherwise the font cache stays empty, decoding loses ToUnicode, and
3023    /// glyph widths fall back to a flat estimate that scrambles multi-column
3024    /// layout (issue #302).
3025    fn cache_fonts_from_resources<R: Read + Seek>(
3026        &mut self,
3027        resources: &PdfDictionary,
3028        document: &PdfDocument<R>,
3029    ) {
3030        for (font_name, entry) in
3031            crate::text::extraction_cmap::resolve_font_entries(resources, document)
3032        {
3033            match entry {
3034                crate::text::extraction_cmap::FontEntry::Indirect(num, gen) => {
3035                    self.cache_font_by_ref::<R>(&font_name, (num, gen), document);
3036                }
3037                crate::text::extraction_cmap::FontEntry::Inline(font_dict) => {
3038                    self.cache_inline_font::<R>(&font_name, &font_dict, document);
3039                }
3040            }
3041        }
3042    }
3043
3044    /// Cache a font written directly into the page's resources.
3045    ///
3046    /// Unlike [`Self::cache_font_by_ref`] this cannot touch the persistent
3047    /// cache: an inline dictionary has no object id to key on, and two pages
3048    /// may write different fonts under the same name. It is parsed per page.
3049    fn cache_inline_font<R: Read + Seek>(
3050        &mut self,
3051        font_name: &str,
3052        font_dict: &PdfDictionary,
3053        document: &PdfDocument<R>,
3054    ) {
3055        let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
3056        if let Ok(font_info) = cmap_extractor.extract_font_info(font_dict, document) {
3057            tracing::debug!(
3058                "Parsed inline font {} (ToUnicode: {})",
3059                font_name,
3060                font_info.to_unicode.is_some()
3061            );
3062            self.cache_page_font(font_name, font_info);
3063        }
3064    }
3065
3066    /// Cache a font, reusing the persistent object cache when possible.
3067    fn cache_font_by_ref<R: Read + Seek>(
3068        &mut self,
3069        font_name: &str,
3070        font_ref: (u32, u16),
3071        document: &PdfDocument<R>,
3072    ) {
3073        // Check persistent object cache first — avoids re-parsing across pages
3074        if let Some(cached) = self.font_object_cache.get(&font_ref) {
3075            let cached = cached.clone();
3076            tracing::debug!(
3077                "Reused cached font object ({}, {}): {} (ToUnicode: {})",
3078                font_ref.0,
3079                font_ref.1,
3080                font_name,
3081                cached.to_unicode.is_some()
3082            );
3083            self.cache_page_font(font_name, cached);
3084            return;
3085        }
3086
3087        // Parse font object
3088        if let Ok(PdfObject::Dictionary(font_dict)) = document.get_object(font_ref.0, font_ref.1) {
3089            let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
3090            if let Ok(font_info) = cmap_extractor.extract_font_info(&font_dict, document) {
3091                let has_to_unicode = font_info.to_unicode.is_some();
3092                // Store in persistent cache
3093                self.font_object_cache.insert(font_ref, font_info.clone());
3094                // Store in per-page name cache
3095                self.cache_page_font(font_name, font_info);
3096                tracing::debug!(
3097                    "Parsed and cached font ({}, {}): {} (ToUnicode: {})",
3098                    font_ref.0,
3099                    font_ref.1,
3100                    font_name,
3101                    has_to_unicode
3102                );
3103            }
3104        }
3105    }
3106
3107    /// Add a parsed font to the page-local caches, deriving the Type0 fallback
3108    /// width once rather than on every text-showing boundary.
3109    fn cache_page_font(&mut self, font_name: &str, font_info: FontInfo) {
3110        if font_info.font_type == "Type0" || font_info.descendant_font.is_some() {
3111            let cid_font = font_info.descendant_font.as_deref().unwrap_or(&font_info);
3112            if let Some(width) = cid_font
3113                .metrics
3114                .cid_widths
3115                .as_ref()
3116                .and_then(|widths| widths.narrowest_positive_width())
3117            {
3118                self.type0_implicit_space_widths
3119                    .insert(font_name.to_string(), width);
3120            }
3121        }
3122        self.font_cache.insert(font_name.to_string(), font_info);
3123    }
3124
3125    /// Decode text using the current font encoding and ToUnicode mapping
3126    fn decode_text(&self, text: &[u8], state: &TextState) -> ParseResult<String> {
3127        use crate::text::encoding::TextEncoding;
3128
3129        // First, try to use cached font information with ToUnicode CMap
3130        if let Some(ref font_name) = state.font_name {
3131            if let Some(font_info) = self.font_cache.get(font_name) {
3132                // Try CMap-based decoding first (free function — no allocation)
3133                if let Ok(decoded) =
3134                    crate::text::extraction_cmap::decode_text_with_font(text, font_info)
3135                {
3136                    // Only accept if we got meaningful text (not all null bytes
3137                    // or garbage). Whitespace counts as meaningful: a decode
3138                    // that is exactly a space is a space, not a failed decode
3139                    // (#438). See `decode_is_usable`.
3140                    let sanitized = sanitize_extracted_text_with_policy(
3141                        &decoded,
3142                        self.carriage_return_handling,
3143                    );
3144                    if crate::text::extraction_cmap::decode_is_usable(&sanitized) {
3145                        tracing::debug!(
3146                            "Successfully decoded text using CMap for font {}: {:?} -> \"{}\"",
3147                            font_name,
3148                            text,
3149                            sanitized
3150                        );
3151                        return Ok(sanitized);
3152                    }
3153                }
3154
3155                tracing::debug!(
3156                    "CMap decoding failed or produced garbage for font {}, falling back to encoding",
3157                    font_name
3158                );
3159            }
3160        }
3161
3162        // Fall back to encoding-based decoding
3163        let encoding = if let Some(ref font_name) = state.font_name {
3164            match font_name.to_lowercase().as_str() {
3165                name if name.contains("macroman") => TextEncoding::MacRomanEncoding,
3166                name if name.contains("winansi") => TextEncoding::WinAnsiEncoding,
3167                name if name.contains("standard") => TextEncoding::StandardEncoding,
3168                name if name.contains("pdfdoc") => TextEncoding::PdfDocEncoding,
3169                _ => {
3170                    // Default based on common patterns
3171                    if font_name.starts_with("Times")
3172                        || font_name.starts_with("Helvetica")
3173                        || font_name.starts_with("Courier")
3174                    {
3175                        TextEncoding::WinAnsiEncoding // Most common for standard fonts
3176                    } else {
3177                        TextEncoding::PdfDocEncoding // Safe default
3178                    }
3179                }
3180            }
3181        } else {
3182            TextEncoding::WinAnsiEncoding // Default for most PDFs
3183        };
3184
3185        let fallback_result = encoding.decode(text);
3186        // Apply sanitization to remove control characters (Issue #116)
3187        let sanitized =
3188            sanitize_extracted_text_with_policy(&fallback_result, self.carriage_return_handling);
3189        tracing::debug!(
3190            "Fallback encoding decoding: {:?} -> \"{}\"",
3191            text,
3192            sanitized
3193        );
3194        Ok(sanitized)
3195    }
3196}
3197
3198impl Default for TextExtractor {
3199    fn default() -> Self {
3200        Self::new()
3201    }
3202}
3203
3204/// Emit a `TextFragment` for one decoded text-show event under `preserve_layout`.
3205///
3206/// Encapsulates the style-derivation + push sequence shared by every
3207/// text-show operator handler in `extract_from_page` (`Tj`, `TJ`, `'`,
3208/// `"`). The caller supplies the pen origin `(x, y)` already mapped to
3209/// user space (typically via `text_origin(&state)`); doing so avoids the
3210/// double `multiply_matrix + transform_point` that prior versions did
3211/// (handler computed it for `last_x`/`last_y`, then this fn recomputed
3212/// it on the same `state`).
3213///
3214/// Skips emission when an ancestor in the marked-content stack is `/Artifact`
3215/// and `include_artifacts` is false. When a pending ActualText run is
3216/// active in the current scope, accumulates the text-width contribution and
3217/// records the first origin instead of pushing a fragment (the run is flushed
3218/// once on EMC, see Task 8's EndMarkedContent handler).
3219///
3220/// `mcid` and `struct_tag` come from the innermost ancestor on the stack that
3221/// declared `/MCID`; non-tagged content leaves both as `None`.
3222/// Whether the current marked-content stack should suppress text emission.
3223///
3224/// Mirrors the gate inside [`emit_text_fragment`]: when an ancestor in the
3225/// stack is `/Artifact` and the caller has not opted into artifact content
3226/// via `include_artifacts`, neither `.text` nor `.fragments` should receive
3227/// the run. Used by the four show-text operator arms to keep `extracted_text`
3228/// and `fragments` symmetric — a page whose entire content is an
3229/// `/Artifact BMC … EMC` scope (the common pattern for screen-reader-skipped
3230/// disclaimers / footers / decorative tagged-PDF content) used to surface
3231/// text in `.text` while leaving `.fragments` empty, silently dropping the
3232/// page from `partition_with(...)` / `rag_chunks(...)` (issue #330).
3233fn skip_artifact_text(state: &TextState, include_artifacts: bool) -> bool {
3234    !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact)
3235}
3236
3237/// Page-space scale factors `(x_scale, y_scale)` of the current text/CTM
3238/// combination (issue #262). Converts a text-space width/size into page space,
3239/// mirroring the scaling [`emit_text_fragment`] applies, so the reading-order
3240/// boxes and the median-font unit that judges their gaps share the page-space
3241/// scale of the `x`/`y` origins.
3242fn combined_text_scale(state: &TextState) -> (f64, f64) {
3243    let combined = multiply_matrix(&state.text_matrix, &state.ctm);
3244    let x_scale = (combined[0] * combined[0] + combined[1] * combined[1]).sqrt();
3245    let y_scale = (combined[2] * combined[2] + combined[3] * combined[3]).sqrt();
3246    (x_scale, y_scale)
3247}
3248
3249/// Record a just-emitted glyph run into the flat-path line groups (issue #448).
3250///
3251/// Called only when `ExtractionOptions::reading_order` is on, right after a
3252/// successful [`append_bounded`], with `text_len` = `extracted_text.len()` after
3253/// the append. `width` and `font_size` must already be page-space (scaled via
3254/// [`combined_text_scale`]) so the box matches the page-space `x`/`y` origin. A
3255/// run whose separator is a newline opens a new group; anything else extends the
3256/// current one. The group's byte range excludes the leading newline (a group's
3257/// text starts at `text_len - decoded_len`, which is past the separator), so
3258/// rejoining slices with `'\n'` reproduces the original exactly.
3259#[allow(clippy::too_many_arguments)]
3260fn record_line_group(
3261    line_groups: &mut Vec<LineGroupGeom>,
3262    cur_group: &mut Option<LineGroupGeom>,
3263    text_len: usize,
3264    decoded_len: usize,
3265    separator: Option<char>,
3266    x: f64,
3267    y: f64,
3268    text_width: f64,
3269    state: &TextState,
3270) {
3271    // Convert the text-space advance and font size to page space so the box
3272    // matches the page-space `x`/`y` and the median-font unit is on the same
3273    // scale as the gaps it judges (issue #262).
3274    let (x_scale, y_scale) = combined_text_scale(state);
3275    let width = text_width * x_scale;
3276    let font_size = state.font_size * y_scale;
3277    let run_start = text_len.saturating_sub(decoded_len);
3278    let (rx0, rx1) = (x.min(x + width), x.max(x + width));
3279    let (ry0, ry1) = (y.min(y + font_size), y.max(y + font_size));
3280    let opens = matches!(separator, Some('\n')) || cur_group.is_none();
3281    if opens {
3282        if let Some(g) = cur_group.take() {
3283            line_groups.push(g);
3284        }
3285        *cur_group = Some(LineGroupGeom {
3286            start: run_start,
3287            end: text_len,
3288            min_x: rx0,
3289            max_x: rx1,
3290            min_y: ry0,
3291            max_y: ry1,
3292            font_size,
3293        });
3294    } else if let Some(g) = cur_group.as_mut() {
3295        g.end = text_len;
3296        g.min_x = g.min_x.min(rx0);
3297        g.max_x = g.max_x.max(rx1);
3298        g.min_y = g.min_y.min(ry0);
3299        g.max_y = g.max_y.max(ry1);
3300        g.font_size = g.font_size.max(font_size);
3301    }
3302}
3303
3304/// Outcome of [`append_bounded`]: whether the run was appended, and — when it
3305/// was — the separator actually applied. The applied separator can differ
3306/// from the one the caller requested when hyphen-wrap fusion (issue #486)
3307/// consumes a trailing `-` instead of inserting the requested `\n`; callers
3308/// that feed the separator into reading-order line grouping (`record_line_group`)
3309/// must use `applied_separator`, not the separator they originally computed,
3310/// so a fused run correctly extends its line group instead of opening a new one.
3311struct AppendOutcome {
3312    appended: bool,
3313    applied_separator: Option<char>,
3314}
3315
3316/// Append an optional `separator` plus `decoded` to `acc`, honouring the
3317/// per-page byte budget `limit` (issue #382), with optional hyphen-wrap
3318/// fusion (issue #486).
3319///
3320/// Returns [`AppendOutcome`] with `appended: true` when the run was appended.
3321/// Returns `appended: false` — appending nothing and setting `*truncated` —
3322/// when the combined bytes would exceed `limit`. The separator is counted
3323/// against the budget so the invariant `acc.len() <= limit` holds *exactly*,
3324/// and because whole runs are the unit of truncation a multi-byte UTF-8
3325/// character is never split (undershoot semantics). A `None` limit always
3326/// appends and never truncates, keeping the no-limit path byte-identical to
3327/// before. Once `*truncated` is set the helper is a no-op, so a caller that
3328/// keeps calling it after the budget is reached simply accumulates nothing
3329/// further.
3330///
3331/// When `merge_hyphenated` is set and the caller requests a `'\n'` separator
3332/// (a genuine line wrap) while `acc` already ends with `-`, the hyphen is
3333/// producer noise from a hyphenated word/number wrapping across two lines,
3334/// not a real word boundary (issue #486: `merge_hyphenated` had no effect on
3335/// this flat/default extraction path, unlike `preserve_layout`'s
3336/// `reconstruct_text_from_fragments` and `reconstruct_paragraphs`'s
3337/// `merge_into_paragraphs`, both of which already apply this same rule). The
3338/// trailing hyphen is popped and `decoded` is appended directly with no
3339/// separator, fusing the wrapped token into one word instead of splitting it
3340/// on a newline — e.g. `"...3016-"` + `"0900"` becomes `"...30160900"`
3341/// instead of `"...3016-\n0900"`. `separator` is only ever `'\n'` here when
3342/// `acc` is already non-empty (every call site gates on that), so the pop is
3343/// always into at least one existing byte.
3344fn append_bounded(
3345    acc: &mut String,
3346    separator: Option<char>,
3347    decoded: &str,
3348    limit: Option<usize>,
3349    truncated: &mut bool,
3350    merge_hyphenated: bool,
3351) -> AppendOutcome {
3352    if *truncated {
3353        return AppendOutcome {
3354            appended: false,
3355            applied_separator: None,
3356        };
3357    }
3358
3359    let hyphen_fusion = merge_hyphenated && separator == Some('\n') && acc.ends_with('-');
3360    let separator = if hyphen_fusion { None } else { separator };
3361
3362    if let Some(max) = limit {
3363        // Popping the hyphen frees one byte before the new run is added, so
3364        // account against the post-pop length — otherwise a run that fits
3365        // once the hyphen is dropped could be wrongly rejected as
3366        // over-budget by one byte.
3367        let base_len = if hyphen_fusion {
3368            acc.len() - 1
3369        } else {
3370            acc.len()
3371        };
3372        let add = separator.map_or(0, char::len_utf8) + decoded.len();
3373        if base_len + add > max {
3374            *truncated = true;
3375            return AppendOutcome {
3376                appended: false,
3377                applied_separator: None,
3378            };
3379        }
3380    }
3381
3382    if hyphen_fusion {
3383        acc.pop();
3384    }
3385    if let Some(sep) = separator {
3386        acc.push(sep);
3387    }
3388    acc.push_str(decoded);
3389    AppendOutcome {
3390        appended: true,
3391        applied_separator: separator,
3392    }
3393}
3394
3395/// Defensive final clamp of a page's text to the byte budget (issue #382).
3396///
3397/// The `preserve_layout` / `reorder_columns` paths rebuild `.text` from the
3398/// already-bounded fragment set via `reconstruct_text_from_fragments`, which
3399/// reorders fragments and inserts its own separators — so the reconstructed
3400/// length is not provably `<= limit` from the accumulation-time accounting
3401/// alone. This clamps the result to `limit` at a UTF-8 char boundary (never
3402/// splitting a character) and sets `*truncated` if it had to cut, making the
3403/// `text.len() <= max_extracted_bytes` invariant hold for *every* path. A no-op
3404/// when there is no limit or the text already fits.
3405fn clamp_to_budget(text: &mut String, limit: Option<usize>, truncated: &mut bool) {
3406    if let Some(max) = limit {
3407        if text.len() > max {
3408            let mut cut = max;
3409            while cut > 0 && !text.is_char_boundary(cut) {
3410                cut -= 1;
3411            }
3412            text.truncate(cut);
3413            *truncated = true;
3414        }
3415    }
3416}
3417
3418fn emit_text_fragment(
3419    fragments: &mut Vec<TextFragment>,
3420    decoded: &str,
3421    text_width: f64,
3422    x: f64,
3423    y: f64,
3424    state: &mut TextState,
3425    include_artifacts: bool,
3426) {
3427    if decoded.is_empty() {
3428        return;
3429    }
3430
3431    // Artifact filter (default: skip emission for Artifact subtrees).
3432    if !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact) {
3433        return;
3434    }
3435
3436    let (is_bold, is_italic) = state
3437        .font_name
3438        .as_ref()
3439        .map(|name| parse_font_style(name))
3440        .unwrap_or((false, false));
3441
3442    // Issue #262: font_size, height, and width must be in page space so that
3443    // downstream heuristics (line/paragraph reconstruction, header/footer zone
3444    // detection, table detection) reason about real geometry. `x` and `y` are
3445    // already page-space (caller transforms via `text_origin`); we still need
3446    // to scale the size/width fields by the combined `text_matrix × CTM`.
3447    let combined = multiply_matrix(&state.text_matrix, &state.ctm);
3448    let x_scale = (combined[0] * combined[0] + combined[1] * combined[1]).sqrt();
3449    let y_scale = (combined[2] * combined[2] + combined[3] * combined[3]).sqrt();
3450    let effective_width = text_width * x_scale;
3451    let effective_size = state.font_size * y_scale;
3452
3453    // If a pending ActualText run is active in the current scope, accumulate
3454    // into it instead of emitting a fragment now. The run is flushed on the
3455    // matching EMC by the EndMarkedContent arm (Task 8).
3456    // Hoist font_name/fill_color reads before taking &mut on pending_actualtext
3457    // to avoid borrow-checker conflicts with the disjoint fields.
3458    let local_font_name = state.font_name.clone();
3459    let local_fill_color = state.fill_color;
3460    if let Some(pending) = state.pending_actualtext.as_mut() {
3461        if !pending.populated {
3462            pending.first_x = x;
3463            pending.first_y = y;
3464            pending.font_size = effective_size;
3465            pending.font_name = local_font_name;
3466            pending.is_bold = is_bold;
3467            pending.is_italic = is_italic;
3468            pending.color = local_fill_color;
3469            pending.populated = true;
3470        }
3471        pending.width += effective_width;
3472        return;
3473    }
3474
3475    let (mcid, struct_tag) = innermost_mc_tag(&state.mc_stack);
3476
3477    fragments.push(TextFragment {
3478        text: decoded.to_owned(),
3479        x,
3480        y,
3481        width: effective_width,
3482        height: effective_size,
3483        font_size: effective_size,
3484        font_name: state.font_name.clone(),
3485        is_bold,
3486        is_italic,
3487        color: state.fill_color,
3488        space_decisions: Vec::new(),
3489        mcid,
3490        struct_tag,
3491    });
3492}
3493
3494/// Pen origin (user-space coordinates) of the next glyph in the current
3495/// text state.
3496///
3497/// Per ISO 32000-1 §8.3.4, the text rendering matrix is `Tm × CTM` (row-vector
3498/// convention). `multiply_matrix(a, b)` returns the matrix that applies `a`
3499/// first and then `b`, so the correct composition is
3500/// `multiply_matrix(text_matrix, ctm)`. Prior to issue #262 this used the
3501/// reverse order which gave correct results only when the CTM was an identity
3502/// or pure-translation matrix; non-uniform CTM scaling produced wrong origins.
3503fn text_origin(state: &TextState) -> (f64, f64) {
3504    let combined = multiply_matrix(&state.text_matrix, &state.ctm);
3505    // Text rise (`Ts`) shifts the glyph origin up the text-space y-axis before
3506    // the text/CTM transform (ISO 32000-1 §9.4.4). For an axis-aligned matrix
3507    // this moves the user-space y by exactly `Ts`.
3508    transform_point(0.0, state.text_rise, &combined)
3509}
3510
3511/// Advance the text matrix by one shown glyph run of unscaled width
3512/// `text_width` and return the pen's new x in user space.
3513///
3514/// The advance applied to the text matrix is `text_width * Tz/100`
3515/// (`state.horizontal_scale`), and the resulting user-space displacement also
3516/// folds in the CTM's x-scale. The caller's `last_x` (used for `dx`-based
3517/// space decisions) must therefore come from the post-advance pen origin, not
3518/// from `origin_x + text_width`, which ignores both factors and trails the
3519/// real pen whenever `Tz != 100` or the CTM scales x (issue #386).
3520fn advance_pen(state: &mut TextState, text_width: f64) -> (f64, f64) {
3521    let tx = text_width * state.horizontal_scale / 100.0;
3522    state.text_matrix = multiply_matrix(&[1.0, 0.0, 0.0, 1.0, tx, 0.0], &state.text_matrix);
3523    text_origin(state)
3524}
3525
3526/// Projection-noise floor for the perpendicular pen delta. Same-baseline
3527/// glyph runs produce a `dy` that is exactly 0 in real arithmetic but can
3528/// carry ~1e-13 of float rounding after the baseline projection; anything
3529/// below this epsilon is "the same baseline". The smallest meaningful
3530/// leading in real documents is orders of magnitude above it.
3531const SAME_LINE_EPS: f64 = 1e-6;
3532
3533/// Scale-relative cut thresholds for the flat-path reading-order option
3534/// (issue #448), in multiples of a region's median glyph size: a horizontal gap
3535/// is a column gutter past `horizontal_k`, a vertical gap a section break past
3536/// `vertical_k`.
3537///
3538/// Validated against the opt-in differential order gate on the full `t3-stress`
3539/// corpus (`t3-stress-reading-order` baseline): with the option on, the
3540/// misplaced-word rate drops 0.2486 → 0.2255 (−9.3%) versus the default flat
3541/// path, at identical alignment coverage — the gain is reordering, not dropped
3542/// text. That clears the design probe's ceiling estimate (~0.2375), so these
3543/// values are kept rather than sweeping for a marginal further gain. (An
3544/// earlier, geometrically wrong build that fed the cut un-CTM-scaled boxes
3545/// scored a hair better here, 0.2226, purely because the corpus is
3546/// identity-CTM-dominated; the correct page-space geometry is kept.)
3547const READING_ORDER_CFG: flat_reading_order::CutConfig = flat_reading_order::CutConfig {
3548    horizontal_k: 1.0,
3549    vertical_k: 1.5,
3550};
3551
3552/// Minimum forward pen jump, in em, that reads as a word break at the boundary
3553/// between two show-text operators — the first element of a `TJ` array whose
3554/// pen jumped forward from the previous operator (a `Tm` reposition, or the
3555/// prior operator's advance). Without it, two `TJ` operators drawn side by side
3556/// on the same line come out glued: a multi-column table cell reads as
3557/// `CellOneCellTwo` (issue #458), and a list bullet 0.75 em from its item text
3558/// reads as `vlarge` (found on preserve_027613.pdf, an IBM manual whose every
3559/// bullet is a separate `TJ`).
3560///
3561/// Calibrated on the full `t3-stress` corpus against poppler, with the
3562/// reading-order (misplaced) rate as the objective and alignment coverage as
3563/// the guard. Recalibrated on the Tc/Tw-corrected pen advance (#456), which
3564/// feeds the `dx` this threshold judges:
3565///
3566/// | em | 0.0 | 0.3 | 0.7 | 1.0 | 2.0 | 3.0 | 6.0 | off |
3567/// |---|---|---|---|---|---|---|---|---|
3568/// | misplaced rate | .2806 | .2485 | **.2486** | .2486 | .2486 | .2512 | .2702 | .2766 |
3569///
3570/// Below ~0.3 em the rule splits words a producer draws as several positioned
3571/// runs — the pen advance is only as accurate as the font widths, so a short
3572/// run inflates the apparent gap, and em=0.0 lands *worse* than not firing at
3573/// all. From 0.3 to 2.0 the corpus cannot discriminate (a flat plateau within
3574/// 1e-4 of the minimum); above 3 em the rule stops firing on genuine column
3575/// gaps and converges back on the un-fixed number.
3576///
3577/// 0.7 sits inside that plateau with margin on both sides: comfortably above
3578/// the word-splitting floor, and below 0.748 em — the narrowest real word gap
3579/// verified by hand (the bullet above), which the threshold must stay under to
3580/// keep separating. On the corrected advance the fix moves the rate .2766 →
3581/// .2486 (vs .2874 → .2714 before #456: an accurate advance lets the boundary
3582/// fire more cleanly).
3583const TJ_BOUNDARY_SPACE_EM: f64 = 0.7;
3584
3585/// Backward-jump magnitude, in multiples of the font size, above which a
3586/// same-baseline (`dy == 0`) backward pen jump is a line wrap rather than a
3587/// glyph reposition (issue #447).
3588///
3589/// At `dy == 0` a backward jump is ambiguous: a same-line reposition
3590/// (justification, kerned overlay, out-of-order emission — issue #441) and a
3591/// real wrap whose two lines happen to land on the same content-stream Y
3592/// (issue #447) both produce it. They separate by MAGNITUDE: a reposition is
3593/// local (a word/phrase — a few em), while a wrap returns across the whole
3594/// text column (many em). This bound sits in that gap, scaled to font size
3595/// because the reposition scale is the glyph/word scale, not the fixed
3596/// paragraph-break `newline_threshold`. Scaled to `font_size.abs()`: `Tf`
3597/// accepts negative sizes (mirrored text), and the sign must not flip the
3598/// threshold's sense — otherwise a negative size makes every backward jump a
3599/// "wrap" and resurrects the #441 defect.
3600///
3601/// Accepted, documented limitation (the #417/#422 trade-off) within one text
3602/// object: a same-line reposition that jumps back more than this many em is
3603/// misread as a wrap, and a same-Y wrap shorter than this is not recognized as
3604/// a wrap. A new `BT`/`ET` object supplies enough evidence to separate #495's
3605/// runs with whitespace, but not enough to promote the separator to a newline.
3606/// A wrap with any nonzero leading (the common case, issue #390) is unaffected:
3607/// it breaks on the `dy`-aware gate.
3608const SAME_Y_WRAP_EM: f64 = 10.0;
3609
3610/// Pen movement from the previous post-advance pen point `last` to the
3611/// current glyph origin `cur` (both user space), measured in the frame of the
3612/// current text baseline (issue #443): `dx` along the baseline direction,
3613/// `dy` perpendicular to it (signed; callers take `.abs()` for line
3614/// detection).
3615///
3616/// The baseline direction is the image of the text-space x-axis under the
3617/// text rendering matrix `Tm × CTM`. For an axis-aligned matrix
3618/// (identity/translation/positive scale — the overwhelming majority of
3619/// content) the baseline IS the user-space x-axis and this returns exactly
3620/// `(Δx, Δy)`, the pre-#443 behavior. Under a rotated CTM (and any
3621/// similarity transform) the projection recovers the text's own line
3622/// geometry exactly, which raw user-space deltas conflate: a plain forward
3623/// advance along a rotated baseline changes the user-space y, which the
3624/// separator heuristics misread as a line change. Axis-aligned shear
3625/// (`b == 0`, `c != 0`) also projects exactly (the perpendicular reduces to
3626/// the y-axis); a shear COMBINED with a rotated baseline is approximated —
3627/// the perpendicular is built by rotating the baseline 90°, not from the
3628/// true image of the text-space y-axis.
3629///
3630/// A mirrored baseline (negative x-scale) measures `dx` along the text's own
3631/// advance direction, so a forward advance is positive `dx` — the spacing
3632/// and wrap gates apply as for unmirrored text (pre-#443 they saw a raw
3633/// negative `dx` and misfired the wrap gate on plain advances).
3634///
3635/// A degenerate baseline (zero-length or non-finite) falls back to the raw
3636/// user-space deltas, preserving pre-#443 behavior for malformed matrices.
3637fn pen_delta(state: &TextState, last: (f64, f64), cur: (f64, f64)) -> (f64, f64) {
3638    let dxu = cur.0 - last.0;
3639    let dyu = cur.1 - last.1;
3640    let m = multiply_matrix(&state.text_matrix, &state.ctm);
3641    let (bx, by) = (m[0], m[1]);
3642    let norm = (bx * bx + by * by).sqrt();
3643    if !norm.is_finite() || norm <= f64::EPSILON {
3644        return (dxu, dyu);
3645    }
3646    let (ux, uy) = (bx / norm, by / norm);
3647    (dxu * ux + dyu * uy, -dxu * uy + dyu * ux)
3648}
3649
3650/// Multiply two transformation matrices
3651fn multiply_matrix(a: &[f64; 6], b: &[f64; 6]) -> [f64; 6] {
3652    [
3653        a[0] * b[0] + a[1] * b[2],
3654        a[0] * b[1] + a[1] * b[3],
3655        a[2] * b[0] + a[3] * b[2],
3656        a[2] * b[1] + a[3] * b[3],
3657        a[4] * b[0] + a[5] * b[2] + b[4],
3658        a[4] * b[1] + a[5] * b[3] + b[5],
3659    ]
3660}
3661
3662/// Decode a PDF string operand into Rust `String`.
3663///
3664/// A string inside marked-content properties (notably `/ActualText`) is a PDF
3665/// text string like any other, so this is
3666/// [`PdfString::to_text`](crate::parser::objects::PdfString::to_text): UTF-16BE
3667/// when a byte order mark is present — the canonical encoding for non-ASCII
3668/// `/ActualText`, e.g. an `fi` ligature or a Greek symbol — and the WinAnsi
3669/// reading of PDFDocEncoding otherwise. Before that helper existed this mapped
3670/// non-BOM bytes to `char` one by one, which is Latin-1 and wrong for the
3671/// typographic punctuation WinAnsi puts in `0x80..=0x9F`.
3672fn decode_pdf_string(bytes: &[u8]) -> String {
3673    crate::parser::objects::decode_text_string(bytes)
3674}
3675
3676/// Build the page-local MCID -> structure-element `/ActualText` map.
3677///
3678/// The parent tree is a PDF number tree, so it may store `/Nums` directly or
3679/// split them across indirect `/Kids`. Every failure is deliberately reduced
3680/// to an empty/partial map: structure metadata must not make text extraction
3681/// fail. Depth, visited-reference, and node-count limits keep malformed trees
3682/// from causing cycles or unbounded traversal.
3683fn resolve_structure_actual_text<R: Read + Seek>(
3684    container: &crate::parser::objects::PdfDictionary,
3685    document: &PdfDocument<R>,
3686) -> StructureActualText {
3687    use crate::parser::objects::PdfObject;
3688
3689    const MAX_NUMBER_TREE_DEPTH: usize = 32;
3690    const MAX_NUMBER_TREE_NODES: usize = 4096;
3691
3692    fn number_tree_value<R: Read + Seek>(
3693        object: &PdfObject,
3694        key: i64,
3695        document: &PdfDocument<R>,
3696        depth: usize,
3697        visited: &mut std::collections::HashSet<(u32, u16)>,
3698        nodes: &mut usize,
3699    ) -> Option<PdfObject> {
3700        if depth > MAX_NUMBER_TREE_DEPTH || *nodes >= MAX_NUMBER_TREE_NODES {
3701            return None;
3702        }
3703        *nodes += 1;
3704
3705        let resolved = match object {
3706            PdfObject::Reference(id, generation) => {
3707                if !visited.insert((*id, *generation)) {
3708                    return None;
3709                }
3710                document.get_object(*id, *generation).ok()?
3711            }
3712            other => other.clone(),
3713        };
3714        let dict = resolved.as_dict()?;
3715
3716        if let Some(PdfObject::Array(nums)) = dict.get("Nums") {
3717            for pair in nums.0.chunks_exact(2) {
3718                if pair[0] == PdfObject::Integer(key) {
3719                    return Some(pair[1].clone());
3720                }
3721            }
3722        }
3723
3724        let PdfObject::Array(kids) = dict.get("Kids")? else {
3725            return None;
3726        };
3727        for kid in &kids.0 {
3728            if let Some(value) = number_tree_value(kid, key, document, depth + 1, visited, nodes) {
3729                return Some(value);
3730            }
3731        }
3732        None
3733    }
3734
3735    let Some(PdfObject::Integer(struct_parent_key)) = container.get("StructParents") else {
3736        return StructureActualText::default();
3737    };
3738    if *struct_parent_key < 0 {
3739        return StructureActualText::default();
3740    }
3741
3742    let Ok(catalog) = document.catalog_dictionary() else {
3743        return StructureActualText::default();
3744    };
3745    let Some(struct_root_object) = catalog.get("StructTreeRoot") else {
3746        return StructureActualText::default();
3747    };
3748    let Ok(struct_root_object) = document.resolve(struct_root_object) else {
3749        return StructureActualText::default();
3750    };
3751    let Some(struct_root) = struct_root_object.as_dict() else {
3752        return StructureActualText::default();
3753    };
3754    let Some(parent_tree) = struct_root.get("ParentTree") else {
3755        return StructureActualText::default();
3756    };
3757
3758    let Some(owner_array_object) = number_tree_value(
3759        parent_tree,
3760        *struct_parent_key,
3761        document,
3762        0,
3763        &mut std::collections::HashSet::new(),
3764        &mut 0,
3765    ) else {
3766        return StructureActualText::default();
3767    };
3768    let Ok(owner_array_object) = document.resolve(&owner_array_object) else {
3769        return StructureActualText::default();
3770    };
3771    let PdfObject::Array(owners) = owner_array_object else {
3772        return StructureActualText::default();
3773    };
3774    StructureActualText {
3775        owners: owners.0,
3776        cache: HashMap::new(),
3777    }
3778}
3779
3780/// Resolve a `MarkedContentProps` to `(mcid, actual_text)`.
3781///
3782/// For `Inline` props, walk the map: `/MCID` (Integer, must fit in `u32`)
3783/// becomes `mcid`; `/ActualText` (String) is decoded via `decode_pdf_string`.
3784///
3785/// For `ResourceRef(name)`, look up `properties.get(name)`. If found and
3786/// it's a Dictionary, extract `/MCID` and `/ActualText` from there. If
3787/// not found (or the named entry is not a dict), return `(None, None)`
3788/// — a malformed reference must not abort extraction.
3789fn resolve_props(
3790    props: &crate::parser::content::MarkedContentProps,
3791    properties: Option<&crate::parser::objects::PdfDictionary>,
3792) -> (Option<u32>, Option<String>) {
3793    use crate::parser::content::{MarkedContentProps, MarkedContentValue};
3794
3795    let map_mcid_actual =
3796        |map: &std::collections::HashMap<String, MarkedContentValue>| -> (Option<u32>, Option<String>) {
3797            let mcid = match map.get("MCID") {
3798                Some(MarkedContentValue::Integer(n)) if *n >= 0 && *n <= u32::MAX as i64 => {
3799                    Some(*n as u32)
3800                }
3801                _ => None,
3802            };
3803            let actual = match map.get("ActualText") {
3804                Some(MarkedContentValue::String(bytes)) => Some(decode_pdf_string(bytes)),
3805                _ => None,
3806            };
3807            (mcid, actual)
3808        };
3809
3810    match props {
3811        MarkedContentProps::Inline(map) => map_mcid_actual(map),
3812        MarkedContentProps::ResourceRef(name) => {
3813            let Some(properties) = properties else {
3814                return (None, None);
3815            };
3816            let Some(entry) = properties.get(name) else {
3817                return (None, None);
3818            };
3819            let crate::parser::objects::PdfObject::Dictionary(dict) = entry else {
3820                return (None, None);
3821            };
3822            let mcid = dict.get("MCID").and_then(|o| match o {
3823                crate::parser::objects::PdfObject::Integer(n)
3824                    if *n >= 0 && *n <= u32::MAX as i64 =>
3825                {
3826                    Some(*n as u32)
3827                }
3828                _ => None,
3829            });
3830            let actual_text = dict.get("ActualText").and_then(|o| match o {
3831                crate::parser::objects::PdfObject::String(s) => {
3832                    Some(decode_pdf_string(s.as_bytes()))
3833                }
3834                _ => None,
3835            });
3836            (mcid, actual_text)
3837        }
3838    }
3839}
3840
3841/// Walk the marked-content stack from innermost (top) outward, returning the
3842/// first entry's `(mcid, tag)` pair whose `mcid` is `Some`. Returns
3843/// `(None, None)` when no ancestor declared an MCID — typical of non-tagged
3844/// PDFs, in which case the `None == None` grouping-key invariant preserves
3845/// legacy behaviour.
3846fn innermost_mc_tag(stack: &[MarkedContentEntry]) -> (Option<u32>, Option<String>) {
3847    stack
3848        .iter()
3849        .rev()
3850        .find(|e| e.mcid.is_some())
3851        .map_or((None, None), |e| (e.mcid, Some(e.tag.clone())))
3852}
3853
3854/// Transform a point using a transformation matrix
3855fn transform_point(x: f64, y: f64, matrix: &[f64; 6]) -> (f64, f64) {
3856    let tx = matrix[0] * x + matrix[2] * y + matrix[4];
3857    let ty = matrix[1] * x + matrix[3] * y + matrix[5];
3858    (tx, ty)
3859}
3860
3861/// Calculate text width using actual font metrics (including kerning)
3862fn calculate_text_width(text: &str, font_size: f64, font_info: Option<&FontInfo>) -> f64 {
3863    // If we have font metrics, use them for accurate width calculation
3864    if let Some(font) = font_info {
3865        if let Some(ref widths) = font.metrics.widths {
3866            let first_char = font.metrics.first_char.unwrap_or(0);
3867            let last_char = font.metrics.last_char.unwrap_or(255);
3868            let missing_width = font.metrics.missing_width.unwrap_or(500.0);
3869
3870            let mut total_width = 0.0;
3871            let mut chars = text.chars().peekable();
3872
3873            while let Some(ch) = chars.next() {
3874                let char_code = ch as u32;
3875
3876                // Get width from Widths array or use missing_width
3877                let width = if char_code >= first_char && char_code <= last_char {
3878                    let index = (char_code - first_char) as usize;
3879                    widths.get(index).copied().unwrap_or(missing_width)
3880                } else {
3881                    missing_width
3882                };
3883
3884                // Convert from glyph space (1/1000 units) to user space
3885                total_width += width / 1000.0 * font_size;
3886
3887                // Apply kerning if available (for character pairs)
3888                if let Some(ref kerning) = font.metrics.kerning {
3889                    if let Some(&next_ch) = chars.peek() {
3890                        let next_char = next_ch as u32;
3891                        if let Some(&kern_value) = kerning.get(&(char_code, next_char)) {
3892                            // Kerning is in FUnits (1/1000), convert to user space
3893                            total_width += kern_value / 1000.0 * font_size;
3894                        }
3895                    }
3896                }
3897            }
3898
3899            return total_width;
3900        }
3901    }
3902
3903    // Fallback to simplified calculation if no metrics available
3904    text.len() as f64 * font_size * 0.5
3905}
3906
3907/// Compute advance width from the original character **codes**, not the decoded
3908/// Unicode text.
3909///
3910/// A simple font's `Widths` array is indexed by character code (`first_char..=
3911/// last_char`), i.e. the byte value in the content stream — not by the Unicode
3912/// codepoint the code decodes to. [`calculate_text_width`] indexes by the decoded
3913/// codepoint (`ch as u32`), which is correct only when code == codepoint (ASCII /
3914/// WinAnsi fonts). For custom-encoded fonts (Type1 with `Differences`, embedded
3915/// Computer Modern in LaTeX PDFs, ToUnicode remaps) the codepoint diverges from
3916/// the code, so the wrong slot — or `missing_width` — is read, desyncing glyph
3917/// advance and scrambling word order once fragments are sorted by position
3918/// (issue #302).
3919///
3920/// `decoded` is the already-decoded text for this run; it is only consulted for
3921/// composite (Type0) fonts, whose multi-byte codes cannot be indexed byte-wise
3922/// and whose width path is unchanged here to avoid regressing CJK extraction.
3923/// Unscaled text-space advance of a run (before `Th`), including the text-state
3924/// spacing parameters (ISO 32000-1 §9.4.4): the glyph displacement is
3925/// `w0/1000 * Tfs + Tc + Tw`, so `char_space` (`Tc`) is added once per glyph and
3926/// `word_space` (`Tw`) once per *single-byte* space (code 32, §9.3.3). Both are
3927/// unscaled text-space units, added directly (not multiplied by the font size);
3928/// the caller's `advance_pen` applies `Th`.
3929/// Decode a Type0 text-showing string's raw bytes into CIDs, for CID-indexed
3930/// `/W` width lookup (issue #496). Mirrors the code-length/lookup rules
3931/// `decode_text_with_font` already uses for Unicode decoding, but stops at
3932/// the CID rather than continuing on to a Unicode codepoint.
3933///
3934/// Returns `None` when the code→CID relation is unavailable or vertical: in
3935/// those cases guessing would select unrelated `/W` entries, so the caller
3936/// retains the legacy fallback.
3937fn cids_for_codes<'a>(codes: &'a [u8], font: &FontInfo) -> Option<Vec<(&'a [u8], u32)>> {
3938    use crate::text::encoding_cmap::CidEncoding;
3939
3940    if matches!(font.cid_encoding, Some(CidEncoding::Utf16Be))
3941        || font
3942            .encoding
3943            .as_deref()
3944            .is_some_and(|name| name.ends_with("-V"))
3945        || matches!(&font.cid_encoding, Some(CidEncoding::Cmap(cmap)) if cmap.wmode == 1)
3946    {
3947        return None;
3948    }
3949    let identity = font.encoding.as_deref() == Some("Identity-H");
3950    if font.cid_encoding.is_none() && !identity {
3951        return None;
3952    }
3953
3954    let mut result = Vec::new();
3955    let mut offset = 0;
3956    while offset < codes.len() {
3957        let code_len = match &font.cid_encoding {
3958            Some(CidEncoding::Cmap(cmap)) => cmap.code_len_at(codes, offset),
3959            Some(CidEncoding::Utf16Be) => unreachable!(),
3960            None => 2,
3961        }
3962        .max(1)
3963        .min(codes.len() - offset);
3964        let code = &codes[offset..offset + code_len];
3965        let cid = match &font.cid_encoding {
3966            Some(CidEncoding::Cmap(cmap)) => cmap
3967                .map_code_to_cid(code)
3968                .or_else(|| cmap.map_notdef(code))
3969                .map(u32::from)?,
3970            Some(CidEncoding::Utf16Be) => unreachable!(),
3971            None => code
3972                .iter()
3973                .fold(0u32, |value, byte| (value << 8) | u32::from(*byte)),
3974        };
3975        result.push((code, cid));
3976        offset += code_len;
3977    }
3978    Some(result)
3979}
3980
3981fn calculate_text_width_from_codes(
3982    codes: &[u8],
3983    decoded: &str,
3984    font_size: f64,
3985    font_info: Option<&FontInfo>,
3986    char_space: f64,
3987    word_space: f64,
3988) -> f64 {
3989    // Composite (Type0) fonts use multi-byte codes; a single byte is not a code,
3990    // so byte-indexed width lookup is invalid. `Tc` is added per glyph below.
3991    // `Tw` applies only to the single-byte code 32 (§9.3.3), which a
3992    // multi-byte code can never be, so it does not apply here.
3993    let is_composite =
3994        font_info.is_some_and(|f| f.font_type == "Type0" || f.descendant_font.is_some());
3995    if is_composite {
3996        // Prefer the descendant CIDFont's `/W`/`/DW` (CID-indexed, per
3997        // §9.7.4.3) over the decoded-Unicode-indexed fallback below: a CID
3998        // is an arbitrary font-internal identifier with no relationship to
3999        // the character it decodes to, so indexing by decoded codepoint
4000        // reads the wrong table slot whenever CID != codepoint (any
4001        // subset/reordered CID font). Without `/W`/`/DW` at all, fall back
4002        // to the previous decoded-text heuristic (issue #496).
4003        if let Some(cid_widths) = font_info
4004            .and_then(|f| f.descendant_font.as_deref().or(Some(f)))
4005            .and_then(|f| f.metrics.cid_widths.as_ref())
4006        {
4007            if let Some(font) = font_info {
4008                if let Some(cid_codes) = cids_for_codes(codes, font) {
4009                    let mut total = 0.0;
4010                    for (code, cid) in cid_codes {
4011                        total += cid_widths.width_for(cid) / 1000.0 * font_size + char_space;
4012                        if code.len() == 1 && code[0] == b' ' {
4013                            total += word_space;
4014                        }
4015                    }
4016                    return total;
4017                }
4018            }
4019        }
4020        let glyphs = decoded.chars().count() as f64;
4021        return calculate_text_width(decoded, font_size, font_info) + char_space * glyphs;
4022    }
4023
4024    // `Tc` on every byte-code, `Tw` on every space byte. Shared by the metric
4025    // and no-metric branches below.
4026    let spacing = |codes: &[u8]| -> f64 {
4027        char_space * codes.len() as f64
4028            + word_space * codes.iter().filter(|&&b| b == b' ').count() as f64
4029    };
4030
4031    if let Some(font) = font_info {
4032        if let Some(ref widths) = font.metrics.widths {
4033            let first_char = font.metrics.first_char.unwrap_or(0);
4034            let last_char = font.metrics.last_char.unwrap_or(255);
4035            let missing_width = font.metrics.missing_width.unwrap_or(500.0);
4036
4037            let mut total_width = 0.0;
4038            let mut iter = codes.iter().peekable();
4039            while let Some(&byte) = iter.next() {
4040                let code = byte as u32;
4041                let width = if code >= first_char && code <= last_char {
4042                    widths
4043                        .get((code - first_char) as usize)
4044                        .copied()
4045                        .unwrap_or(missing_width)
4046                } else {
4047                    missing_width
4048                };
4049                total_width += width / 1000.0 * font_size;
4050
4051                // Kerning is keyed by code pair, consistent with code-based widths.
4052                if let Some(ref kerning) = font.metrics.kerning {
4053                    if let Some(&next_byte) = iter.peek() {
4054                        if let Some(&kern_value) = kerning.get(&(code, *next_byte as u32)) {
4055                            total_width += kern_value / 1000.0 * font_size;
4056                        }
4057                    }
4058                }
4059            }
4060
4061            return total_width + spacing(codes);
4062        }
4063
4064        // Standard-14 simple fonts may legally omit `/Widths`. Resolve each
4065        // original character code through the effective base encoding and
4066        // `/Differences`, then look up the resulting PostScript glyph name in
4067        // the font's AFM metrics (#523). An unresolved code retains the legacy
4068        // 0.5em estimate instead of borrowing a width from the wrong encoding.
4069        if let Some(metrics) =
4070            crate::text::fonts::standard::get_standard_font_metrics_by_name(&font.name)
4071        {
4072            let total_width = codes
4073                .iter()
4074                .map(|&code| {
4075                    metrics
4076                        .encoded_char_width(
4077                            font.encoding.as_deref(),
4078                            font.differences.as_ref(),
4079                            code,
4080                        )
4081                        .unwrap_or(500) as f64
4082                        / 1000.0
4083                        * font_size
4084                })
4085                .sum::<f64>();
4086            return total_width + spacing(codes);
4087        }
4088    }
4089
4090    // No metrics: one fallback width per code (byte), the simple-font glyph count.
4091    codes.len() as f64 * font_size * 0.5 + spacing(codes)
4092}
4093
4094/// Sanitize extracted text by removing or replacing control characters.
4095///
4096/// This function addresses Issue #116 where extracted text contains NUL bytes (`\0`)
4097/// and ETX characters (`\u{3}`) where spaces should appear.
4098///
4099/// # Behavior
4100///
4101/// - Replaces `\0\u{3}` sequences with a single space (common word separator pattern)
4102/// - Replaces standalone `\0` (NUL) with space
4103/// - Removes other ASCII control characters (0x01-0x1F) except:
4104///   - `\t` (0x09) - Tab
4105///   - `\n` (0x0A) - Line feed
4106/// - Normalizes `\r` and `\r\n` to `\n`
4107/// - Collapses multiple consecutive spaces into a single space
4108///
4109/// # Examples
4110///
4111/// ```
4112/// use oxidize_pdf::text::extraction::sanitize_extracted_text;
4113///
4114/// // Issue #116 pattern: NUL+ETX as word separator
4115/// let dirty = "a\0\u{3}sergeant\0\u{3}and";
4116/// assert_eq!(sanitize_extracted_text(dirty), "a sergeant and");
4117///
4118/// // Standalone NUL becomes space
4119/// let with_nul = "word\0another";
4120/// assert_eq!(sanitize_extracted_text(with_nul), "word another");
4121///
4122/// // Clean text passes through unchanged
4123/// let clean = "Normal text";
4124/// assert_eq!(sanitize_extracted_text(clean), "Normal text");
4125/// ```
4126pub fn sanitize_extracted_text(text: &str) -> String {
4127    sanitize_extracted_text_with_policy(text, CarriageReturnHandling::default())
4128}
4129
4130/// Sanitize extracted text using an explicit carriage-return policy.
4131pub fn sanitize_extracted_text_with_policy(
4132    text: &str,
4133    carriage_return_handling: CarriageReturnHandling,
4134) -> String {
4135    if text.is_empty() {
4136        return String::new();
4137    }
4138
4139    // Pre-allocate with same capacity (result will be <= input length)
4140    let mut result = String::with_capacity(text.len());
4141    let mut chars = text.chars().peekable();
4142    let mut last_was_space = false;
4143
4144    while let Some(ch) = chars.next() {
4145        match ch {
4146            // NUL byte - check if followed by ETX for the \0\u{3} pattern
4147            '\0' => {
4148                // Peek at next char to detect \0\u{3} sequence
4149                if chars.peek() == Some(&'\u{3}') {
4150                    chars.next(); // consume the ETX
4151                }
4152                // In both cases (standalone NUL or NUL+ETX), emit space
4153                if !last_was_space {
4154                    result.push(' ');
4155                    last_was_space = true;
4156                }
4157            }
4158
4159            // ETX alone (not preceded by NUL) - remove it
4160            '\u{3}' => {
4161                // Don't emit anything, just skip
4162            }
4163
4164            '\r' => {
4165                // CRLF is unambiguously one line ending under every policy.
4166                // Ignore controls that sanitization would remove between the
4167                // pair, otherwise a first pass could create CRLF and a second
4168                // pass would change it again (for example `"\r\u{1}\n"`).
4169                let removed_controls_before_lf = chars
4170                    .clone()
4171                    .take_while(|next| {
4172                        next.is_ascii_control() && !matches!(next, '\0' | '\t' | '\n' | '\r')
4173                    })
4174                    .count();
4175                let followed_by_lf = chars.clone().nth(removed_controls_before_lf) == Some('\n');
4176
4177                if followed_by_lf {
4178                    for _ in 0..=removed_controls_before_lf {
4179                        chars.next();
4180                    }
4181                    result.push('\n');
4182                    last_was_space = false;
4183                } else {
4184                    match carriage_return_handling {
4185                        CarriageReturnHandling::Remove => {}
4186                        CarriageReturnHandling::ReplaceWithSpace => {
4187                            if !last_was_space {
4188                                result.push(' ');
4189                                last_was_space = true;
4190                            }
4191                        }
4192                        CarriageReturnHandling::NormalizeLineEnding => {
4193                            // A standalone CR is valid input and is not
4194                            // equivalent to LF. Only the CRLF sequence above
4195                            // is normalized as a line ending.
4196                            result.push('\r');
4197                            last_was_space = false;
4198                        }
4199                    }
4200                }
4201            }
4202
4203            // Preserve allowed whitespace
4204            '\t' | '\n' => {
4205                result.push(ch);
4206                // Reset space tracking on newlines but not tabs.
4207                last_was_space = ch == '\t';
4208            }
4209
4210            // Regular space - collapse multiples
4211            ' ' => {
4212                if !last_was_space {
4213                    result.push(' ');
4214                    last_was_space = true;
4215                }
4216            }
4217
4218            // Other control characters (0x01-0x1F except tab/newline) - remove
4219            c if c.is_ascii_control() => {
4220                // Skip control characters
4221            }
4222
4223            // Normal characters - keep them
4224            _ => {
4225                result.push(ch);
4226                last_was_space = false;
4227            }
4228        }
4229    }
4230
4231    result
4232}
4233
4234/// Assign a logical row identifier to each fragment based on Y-up-jumps in
4235/// emission order. Used by `merge_into_lines` to distinguish columns in
4236/// multi-column layouts where a single outer BDC scope makes mcid uniform.
4237///
4238/// Increments `row_id` whenever the next fragment's Y exceeds the previous
4239/// by more than `max(font_size * 0.5, 2.0)`. Superscripts (small positive
4240/// deltas) and normal line descents (negative deltas) leave `row_id`
4241/// unchanged. See `docs/superpowers/specs/2026-05-23-issue-265-line-interleaving-design.md`.
4242///
4243/// # Invariants
4244/// Returns a `Vec<u32>` with exactly `fragments.len()` elements — one
4245/// row id per input fragment, in input order. Callers may safely `.zip(fragments)`.
4246fn assign_row_ids(fragments: &[TextFragment]) -> Vec<u32> {
4247    let mut result = Vec::with_capacity(fragments.len());
4248    let mut row_id: u32 = 0;
4249    let mut prev_y: Option<f64> = None;
4250    for frag in fragments {
4251        if let Some(py) = prev_y {
4252            let delta = frag.y - py;
4253            // Threshold anchored to the arriving fragment's font_size; for the
4254            // symmetric same-font case (body→body, same font) this is equivalent
4255            // to anchoring to the previous fragment.
4256            let threshold = (frag.font_size * 0.5).max(2.0);
4257            if delta > threshold {
4258                row_id += 1;
4259            }
4260        }
4261        result.push(row_id);
4262        prev_y = Some(frag.y);
4263    }
4264    debug_assert_eq!(
4265        result.len(),
4266        fragments.len(),
4267        "assign_row_ids: output length must equal input length"
4268    );
4269    result
4270}
4271
4272/// Assign stable layout-region ids in content-stream emission order.
4273///
4274/// A region ends when the geometric flow restarts (`assign_row_ids`) or when
4275/// marked-content ownership changes. The former covers untagged columns and
4276/// overlays; the latter preserves author-supplied logical structure even when
4277/// two regions occupy overlapping Y ranges (#482).
4278fn assign_layout_region_ids(fragments: &[TextFragment]) -> Vec<u32> {
4279    let mut regions = Vec::with_capacity(fragments.len());
4280    let mut region = 0u32;
4281
4282    for i in 0..fragments.len() {
4283        if i > 0 {
4284            let prev = &fragments[i - 1];
4285            let current = &fragments[i];
4286            // A new flow may restart only a few points above the preceding
4287            // baseline (the real #482 footer/annotation gap is ~2pt), well
4288            // below assign_row_ids' superscript-friendly 0.5em threshold.
4289            // For layout ordering the relevant boundary is the same visual-line
4290            // tolerance used by sorting: an upward move beyond 0.2 line height
4291            // starts a new monotonic emission region.
4292            let line_tol = prev.height.min(current.height) * 0.2;
4293            let flow_restarted = current.y - prev.y > line_tol;
4294            let mcid_changed = fragments[i].mcid != fragments[i - 1].mcid
4295                && (fragments[i].mcid.is_some() || fragments[i - 1].mcid.is_some());
4296            if flow_restarted || mcid_changed {
4297                region = region.saturating_add(1);
4298            }
4299        }
4300        regions.push(region);
4301    }
4302    regions
4303}
4304
4305/// Decide whether a single visual line should be read in emission order.
4306///
4307/// `line` holds `(emission_index, fragment)` pairs for one visual line in any
4308/// order. Returns `true` when, walked in emission order, the line has no
4309/// DISJOINT backward x-step — i.e. no fragment lands entirely to the LEFT of
4310/// everything emitted so far on the line. Such a left jump is the signature of
4311/// a genuinely scrambled stream (right-to-left / random generators), for which
4312/// x-order is authoritative.
4313///
4314/// The comparison is against the line's running left edge, not the immediately
4315/// preceding fragment: dense bodies are split into sub-word glyph runs, so a
4316/// run that legitimately backfills the line (a font-switched math symbol, or a
4317/// word whose run starts left of the previous short run — #302 symptom 1 /
4318/// #305) overlaps the *covered span* even when it does not overlap the single
4319/// fragment right before it. As long as it does not jump past the line's left
4320/// edge, emission order is preserved. Lines that are already x-monotone in
4321/// emission satisfy this trivially and decode identically under either policy.
4322fn line_prefers_emission_order(line: &[(usize, &TextFragment)]) -> bool {
4323    if line.len() < 2 {
4324        return true;
4325    }
4326    let mut em: Vec<&(usize, &TextFragment)> = line.iter().collect();
4327    em.sort_by_key(|&&(idx, _)| idx);
4328    let mut min_start = em[0].1.x;
4329    for &&(_, f) in &em[1..] {
4330        let end = f.x + f.width;
4331        // A fragment whose right edge is at or left of the leftmost glyph seen
4332        // so far is a true backward jump — emission order is not reading order.
4333        if end <= min_start {
4334            return false;
4335        }
4336        min_start = min_start.min(f.x);
4337    }
4338    true
4339}
4340
4341/// Space-glyph advance width (1000-em units) for the Adobe Core-14 base fonts,
4342/// keyed by `/BaseFont`. Subset prefixes (`ABCDEF+`) are stripped; common
4343/// substitute names (Arial→Helvetica, TimesNewRoman→Times, CourierNew→Courier)
4344/// map to their metric-compatible base. Returns `None` for unknown fonts, which
4345/// leaves the caller on its fixed-fraction fallback. These fonts legitimately
4346/// ship no `/Widths` array, so their space metric is only available here.
4347fn standard_14_space_width(base_font: &str) -> Option<f64> {
4348    crate::text::fonts::standard::get_standard_font_metrics_by_name(base_font)
4349        .map(|metrics| f64::from(metrics.get_char_width(b' ')))
4350}
4351
4352#[cfg(test)]
4353mod tests {
4354    use super::*;
4355
4356    // ── issue #443: baseline-frame pen deltas ────────────────────────────────
4357
4358    fn state_with_ctm(ctm: [f64; 6]) -> TextState {
4359        TextState {
4360            ctm,
4361            ..Default::default()
4362        }
4363    }
4364
4365    #[test]
4366    fn pen_delta_identity_matrix_returns_raw_deltas() {
4367        let state = state_with_ctm([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4368        let (dx, dy) = pen_delta(&state, (10.0, 20.0), (14.5, 17.0));
4369        assert_eq!((dx, dy), (4.5, -3.0), "axis-aligned = raw Δx/Δy exactly");
4370    }
4371
4372    #[test]
4373    fn pen_delta_rotation_recovers_text_space_advance() {
4374        // 30° rotation; the pen advances 5 units along the rotated baseline.
4375        let (s30, c30) = 30f64.to_radians().sin_cos();
4376        let state = state_with_ctm([c30, s30, -s30, c30, 0.0, 0.0]);
4377        let (dx, dy) = pen_delta(&state, (0.0, 0.0), (5.0 * c30, 5.0 * s30));
4378        assert!((dx - 5.0).abs() < 1e-12, "advance recovered: {dx}");
4379        assert!(dy.abs() < 1e-12, "same baseline → dy ≈ 0: {dy}");
4380        assert!(
4381            dy.abs() < SAME_LINE_EPS,
4382            "noise below the same-line epsilon"
4383        );
4384    }
4385
4386    #[test]
4387    fn pen_delta_mirrored_baseline_measures_advance_direction() {
4388        // Horizontal mirror: a forward text-space advance moves the pen LEFT
4389        // in user space. dx must still be positive (the text's own advance
4390        // direction), so the wrap gate does not misfire on plain advances.
4391        let state = state_with_ctm([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4392        let (dx, dy) = pen_delta(&state, (100.0, 50.0), (95.0, 50.0));
4393        assert_eq!(dx, 5.0, "forward advance is positive along the baseline");
4394        assert_eq!(dy.abs(), 0.0, "same baseline");
4395    }
4396
4397    #[test]
4398    fn pen_delta_degenerate_matrix_falls_back_to_raw_deltas() {
4399        // Zero baseline (a=b=0): projection impossible → raw user-space
4400        // deltas, the pre-#443 behavior.
4401        let state = state_with_ctm([0.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4402        assert_eq!(pen_delta(&state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
4403        // Non-finite baseline: same fallback.
4404        let nan_state = state_with_ctm([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]);
4405        assert_eq!(pen_delta(&nan_state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
4406    }
4407
4408    #[test]
4409    fn flat_space_threshold_uses_unscaled_fallback_for_tiny_baseline() {
4410        let mut state = TextState::default();
4411        state.font_size = 10.0;
4412        state.text_matrix = [f64::EPSILON, 0.0, 0.0, 1.0, 0.0, 0.0];
4413        let extractor = TextExtractor::new();
4414
4415        assert_eq!(
4416            extractor.flat_space_gap_threshold(&state),
4417            extractor.options.space_threshold * state.font_size
4418        );
4419    }
4420
4421    // ── issue #382: per-page byte-budget helper ──────────────────────────────
4422
4423    #[test]
4424    fn test_append_bounded_no_limit_always_appends() {
4425        let mut s = String::new();
4426        let mut trunc = false;
4427        assert!(append_bounded(&mut s, None, "hello", None, &mut trunc, true).appended);
4428        assert!(append_bounded(&mut s, Some(' '), "world", None, &mut trunc, true).appended);
4429        assert_eq!(s, "hello world");
4430        assert!(!trunc, "no limit never truncates");
4431    }
4432
4433    #[test]
4434    fn test_append_bounded_undershoot_counts_separator() {
4435        // "abcd" (4) is at budget 5; a Some('\n') + "x" would need 2 more → over.
4436        let mut s = String::from("abcd");
4437        let mut trunc = false;
4438        assert!(!append_bounded(&mut s, Some('\n'), "x", Some(5), &mut trunc, true).appended);
4439        assert_eq!(s, "abcd", "nothing appended when it would overshoot");
4440        assert!(trunc, "budget hit sets truncated");
4441        // Exactly-fits case: "e" alone (1 byte, no separator) reaches 5.
4442        let mut s2 = String::from("abcd");
4443        let mut t2 = false;
4444        assert!(append_bounded(&mut s2, None, "e", Some(5), &mut t2, true).appended);
4445        assert_eq!(s2, "abcde");
4446        assert!(!t2);
4447        assert!(s2.len() <= 5, "invariant: len <= limit exactly");
4448    }
4449
4450    #[test]
4451    fn test_append_bounded_zero_limit_truncates_immediately() {
4452        let mut s = String::new();
4453        let mut trunc = false;
4454        assert!(!append_bounded(&mut s, None, "a", Some(0), &mut trunc, true).appended);
4455        assert!(s.is_empty());
4456        assert!(trunc);
4457    }
4458
4459    #[test]
4460    fn test_append_bounded_is_noop_once_truncated() {
4461        let mut s = String::from("kept");
4462        let mut trunc = true; // already truncated
4463        assert!(!append_bounded(&mut s, None, "more", Some(1_000), &mut trunc, true).appended);
4464        assert_eq!(s, "kept", "no further accumulation after truncation");
4465    }
4466
4467    // ── issue #486: flat-path hyphen-wrap fusion ─────────────────────────────
4468
4469    #[test]
4470    fn test_append_bounded_fuses_hyphen_wrap_when_enabled() {
4471        // Real-world shape: a hyphen-wrapped phone number split across two
4472        // lines, e.g. "...3016-" / "0900" must reconstruct as "...30160900".
4473        let mut s = String::from("+55 11 3016-");
4474        let mut trunc = false;
4475        let outcome = append_bounded(&mut s, Some('\n'), "0900", None, &mut trunc, true);
4476        assert!(outcome.appended);
4477        assert_eq!(
4478            outcome.applied_separator, None,
4479            "hyphen fusion applies no separator, not the requested '\\n'"
4480        );
4481        assert_eq!(s, "+55 11 30160900", "hyphen popped, halves fused");
4482    }
4483
4484    #[test]
4485    fn test_append_bounded_no_fusion_without_a_trailing_hyphen() {
4486        let mut s = String::from("hello world");
4487        let mut trunc = false;
4488        let outcome = append_bounded(&mut s, Some('\n'), "next line", None, &mut trunc, true);
4489        assert!(outcome.appended);
4490        assert_eq!(
4491            outcome.applied_separator,
4492            Some('\n'),
4493            "no trailing hyphen: requested separator applies unchanged"
4494        );
4495        assert_eq!(s, "hello world\nnext line");
4496    }
4497
4498    #[test]
4499    fn test_append_bounded_does_not_fuse_when_merge_hyphenated_disabled() {
4500        let mut s = String::from("rating-");
4501        let mut trunc = false;
4502        let outcome = append_bounded(&mut s, Some('\n'), "aa-exp-sf", None, &mut trunc, false);
4503        assert!(outcome.appended);
4504        assert_eq!(outcome.applied_separator, Some('\n'));
4505        assert_eq!(s, "rating-\naa-exp-sf", "no fusion: split as requested");
4506    }
4507
4508    #[test]
4509    fn test_append_bounded_does_not_fuse_a_space_separator() {
4510        // Only a requested '\n' is a wrap candidate; a same-line space must
4511        // never trigger hyphen fusion even if the accumulator ends in '-'.
4512        let mut s = String::from("well-");
4513        let mut trunc = false;
4514        let outcome = append_bounded(&mut s, Some(' '), "known", None, &mut trunc, true);
4515        assert!(outcome.appended);
4516        assert_eq!(outcome.applied_separator, Some(' '));
4517        assert_eq!(s, "well- known");
4518    }
4519
4520    #[test]
4521    fn test_append_bounded_hyphen_fusion_respects_budget() {
4522        // "rating-" (7 bytes, trailing hyphen) minus the popped hyphen (6)
4523        // plus fused "aa-exp" (6 bytes, no separator) = 12.
4524        // Budget 12 must fit; budget 11 must not (would need to drop the
4525        // hyphen-adjusted run, not silently truncate mid-word).
4526        let mut s = String::from("rating-");
4527        let mut trunc = false;
4528        let outcome = append_bounded(&mut s, Some('\n'), "aa-exp", Some(12), &mut trunc, true);
4529        assert!(outcome.appended);
4530        assert_eq!(s, "ratingaa-exp");
4531        assert!(!trunc);
4532
4533        let mut s2 = String::from("rating-");
4534        let mut trunc2 = false;
4535        let outcome2 = append_bounded(&mut s2, Some('\n'), "aa-exp", Some(11), &mut trunc2, true);
4536        assert!(!outcome2.appended);
4537        assert_eq!(s2, "rating-", "nothing appended when over budget");
4538        assert!(trunc2);
4539    }
4540
4541    #[test]
4542    fn test_clamp_to_budget_no_limit_or_fits_is_noop() {
4543        let mut a = String::from("hello");
4544        let mut t = false;
4545        clamp_to_budget(&mut a, None, &mut t);
4546        assert_eq!(a, "hello");
4547        assert!(!t, "no limit never truncates");
4548
4549        let mut b = String::from("hi");
4550        clamp_to_budget(&mut b, Some(10), &mut t);
4551        assert_eq!(b, "hi", "already fits");
4552        assert!(!t);
4553    }
4554
4555    #[test]
4556    fn test_clamp_to_budget_cuts_and_flags() {
4557        let mut s = String::from("abcdefgh");
4558        let mut t = false;
4559        clamp_to_budget(&mut s, Some(3), &mut t);
4560        assert_eq!(s, "abc");
4561        assert!(t, "clamp that cut must set truncated");
4562    }
4563
4564    #[test]
4565    fn test_clamp_to_budget_never_splits_utf8() {
4566        // "é" is 2 bytes (0xC3 0xA9). A 3-byte budget on "éé" (4 bytes) must cut
4567        // back to the char boundary at 2, keeping one whole "é".
4568        let mut s = String::from("éé");
4569        let mut t = false;
4570        clamp_to_budget(&mut s, Some(3), &mut t);
4571        assert_eq!(s, "é", "must retreat to a char boundary, not split 'é'");
4572        assert!(s.len() <= 3);
4573        assert!(t);
4574    }
4575
4576    #[test]
4577    fn test_matrix_multiplication() {
4578        let identity = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
4579        let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
4580
4581        let result = multiply_matrix(&identity, &translation);
4582        assert_eq!(result, translation);
4583
4584        let result2 = multiply_matrix(&translation, &identity);
4585        assert_eq!(result2, translation);
4586    }
4587
4588    #[test]
4589    fn test_transform_point() {
4590        let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
4591        let (x, y) = transform_point(5.0, 5.0, &translation);
4592        assert_eq!(x, 15.0);
4593        assert_eq!(y, 25.0);
4594    }
4595
4596    #[test]
4597    fn test_extraction_options_default() {
4598        let options = ExtractionOptions::default();
4599        assert!(!options.preserve_layout);
4600        assert_eq!(options.space_threshold, 0.3);
4601        assert_eq!(options.newline_threshold, 10.0);
4602        assert!(options.sort_by_position);
4603        assert!(!options.detect_columns);
4604        assert_eq!(options.column_threshold, 50.0);
4605        assert!(options.merge_hyphenated);
4606        assert_eq!(
4607            CarriageReturnHandling::default(),
4608            CarriageReturnHandling::Remove
4609        );
4610    }
4611
4612    #[test]
4613    fn test_extraction_options_custom() {
4614        let options = ExtractionOptions {
4615            preserve_layout: true,
4616            space_threshold: 0.5,
4617            tj_space_threshold: 0.15,
4618            newline_threshold: 15.0,
4619            sort_by_position: false,
4620            detect_columns: true,
4621            column_threshold: 75.0,
4622            merge_hyphenated: false,
4623            track_space_decisions: false,
4624            reconstruct_paragraphs: false,
4625            include_artifacts: false,
4626            reorder_columns: false,
4627            max_extracted_bytes: None,
4628        };
4629        assert!(options.preserve_layout);
4630        assert_eq!(options.space_threshold, 0.5);
4631        assert_eq!(options.tj_space_threshold, 0.15);
4632        assert_eq!(options.newline_threshold, 15.0);
4633        assert!(!options.sort_by_position);
4634        assert!(options.detect_columns);
4635        assert_eq!(options.column_threshold, 75.0);
4636        assert!(!options.merge_hyphenated);
4637    }
4638
4639    #[test]
4640    fn test_parse_font_style_bold() {
4641        // PostScript style
4642        assert_eq!(parse_font_style("Helvetica-Bold"), (true, false));
4643        assert_eq!(parse_font_style("TimesNewRoman-Bold"), (true, false));
4644
4645        // TrueType style
4646        assert_eq!(parse_font_style("Arial Bold"), (true, false));
4647        assert_eq!(parse_font_style("Calibri Bold"), (true, false));
4648
4649        // Short form
4650        assert_eq!(parse_font_style("Helvetica-B"), (true, false));
4651    }
4652
4653    #[test]
4654    fn test_parse_font_style_italic() {
4655        // PostScript style
4656        assert_eq!(parse_font_style("Helvetica-Italic"), (false, true));
4657        assert_eq!(parse_font_style("Times-Oblique"), (false, true));
4658
4659        // TrueType style
4660        assert_eq!(parse_font_style("Arial Italic"), (false, true));
4661        assert_eq!(parse_font_style("Courier Oblique"), (false, true));
4662
4663        // Short form
4664        assert_eq!(parse_font_style("Helvetica-I"), (false, true));
4665    }
4666
4667    #[test]
4668    fn test_parse_font_style_bold_italic() {
4669        assert_eq!(parse_font_style("Helvetica-BoldItalic"), (true, true));
4670        assert_eq!(parse_font_style("Times-BoldOblique"), (true, true));
4671        assert_eq!(parse_font_style("Arial Bold Italic"), (true, true));
4672    }
4673
4674    #[test]
4675    fn test_parse_font_style_regular() {
4676        assert_eq!(parse_font_style("Helvetica"), (false, false));
4677        assert_eq!(parse_font_style("Times-Roman"), (false, false));
4678        assert_eq!(parse_font_style("Courier"), (false, false));
4679        assert_eq!(parse_font_style("Arial"), (false, false));
4680    }
4681
4682    #[test]
4683    fn test_parse_font_style_edge_cases() {
4684        // Empty and unusual cases
4685        assert_eq!(parse_font_style(""), (false, false));
4686        assert_eq!(parse_font_style("UnknownFont"), (false, false));
4687
4688        // Case insensitive
4689        assert_eq!(parse_font_style("HELVETICA-BOLD"), (true, false));
4690        assert_eq!(parse_font_style("times-ITALIC"), (false, true));
4691    }
4692
4693    #[test]
4694    fn test_text_fragment() {
4695        let fragment = TextFragment {
4696            text: "Hello".to_string(),
4697            x: 100.0,
4698            y: 200.0,
4699            width: 50.0,
4700            height: 12.0,
4701            font_size: 10.0,
4702            font_name: None,
4703            is_bold: false,
4704            is_italic: false,
4705            color: None,
4706            space_decisions: Vec::new(),
4707            mcid: None,
4708            struct_tag: None,
4709        };
4710        assert_eq!(fragment.text, "Hello");
4711        assert_eq!(fragment.x, 100.0);
4712        assert_eq!(fragment.y, 200.0);
4713        assert_eq!(fragment.width, 50.0);
4714        assert_eq!(fragment.height, 12.0);
4715        assert_eq!(fragment.font_size, 10.0);
4716    }
4717
4718    #[test]
4719    fn test_extracted_text() {
4720        let fragments = vec![
4721            TextFragment {
4722                text: "Hello".to_string(),
4723                x: 100.0,
4724                y: 200.0,
4725                width: 50.0,
4726                height: 12.0,
4727                font_size: 10.0,
4728                font_name: None,
4729                is_bold: false,
4730                is_italic: false,
4731                color: None,
4732                space_decisions: Vec::new(),
4733                mcid: None,
4734                struct_tag: None,
4735            },
4736            TextFragment {
4737                text: "World".to_string(),
4738                x: 160.0,
4739                y: 200.0,
4740                width: 50.0,
4741                height: 12.0,
4742                font_size: 10.0,
4743                font_name: None,
4744                is_bold: false,
4745                is_italic: false,
4746                color: None,
4747                space_decisions: Vec::new(),
4748                mcid: None,
4749                struct_tag: None,
4750            },
4751        ];
4752
4753        let extracted = ExtractedText {
4754            text: "Hello World".to_string(),
4755            fragments: fragments,
4756            truncated: false,
4757        };
4758
4759        assert_eq!(extracted.text, "Hello World");
4760        assert_eq!(extracted.fragments.len(), 2);
4761        assert_eq!(extracted.fragments[0].text, "Hello");
4762        assert_eq!(extracted.fragments[1].text, "World");
4763    }
4764
4765    #[test]
4766    fn test_text_state_default() {
4767        let state = TextState::default();
4768        assert_eq!(state.text_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4769        assert_eq!(state.text_line_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4770        assert_eq!(state.ctm, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4771        assert_eq!(state.leading, 0.0);
4772        assert_eq!(state.char_space, 0.0);
4773        assert_eq!(state.word_space, 0.0);
4774        assert_eq!(state.horizontal_scale, 100.0);
4775        assert_eq!(state.text_rise, 0.0);
4776        assert_eq!(state.font_size, 0.0);
4777        assert!(state.font_name.is_none());
4778        assert_eq!(state.render_mode, 0);
4779    }
4780
4781    #[test]
4782    fn test_matrix_operations() {
4783        // Test rotation matrix
4784        let rotation = [0.0, 1.0, -1.0, 0.0, 0.0, 0.0]; // 90 degree rotation
4785        let (x, y) = transform_point(1.0, 0.0, &rotation);
4786        assert_eq!(x, 0.0);
4787        assert_eq!(y, 1.0);
4788
4789        // Test scaling matrix
4790        let scale = [2.0, 0.0, 0.0, 3.0, 0.0, 0.0];
4791        let (x, y) = transform_point(5.0, 5.0, &scale);
4792        assert_eq!(x, 10.0);
4793        assert_eq!(y, 15.0);
4794
4795        // Test complex transformation
4796        let complex = [2.0, 1.0, 1.0, 2.0, 10.0, 20.0];
4797        let (x, y) = transform_point(1.0, 1.0, &complex);
4798        assert_eq!(x, 13.0); // 2*1 + 1*1 + 10
4799        assert_eq!(y, 23.0); // 1*1 + 2*1 + 20
4800    }
4801
4802    #[test]
4803    fn test_text_extractor_new() {
4804        let extractor = TextExtractor::new();
4805        let options = extractor.options;
4806        assert!(!options.preserve_layout);
4807        assert_eq!(options.space_threshold, 0.3);
4808        assert_eq!(options.newline_threshold, 10.0);
4809        assert!(options.sort_by_position);
4810        assert!(!options.detect_columns);
4811        assert_eq!(options.column_threshold, 50.0);
4812        assert!(options.merge_hyphenated);
4813    }
4814
4815    #[test]
4816    fn test_text_extractor_with_options() {
4817        let options = ExtractionOptions {
4818            preserve_layout: true,
4819            space_threshold: 0.3,
4820            tj_space_threshold: 0.2,
4821            newline_threshold: 12.0,
4822            sort_by_position: false,
4823            detect_columns: true,
4824            column_threshold: 60.0,
4825            merge_hyphenated: false,
4826            track_space_decisions: false,
4827            reconstruct_paragraphs: false,
4828            include_artifacts: false,
4829            reorder_columns: false,
4830            max_extracted_bytes: None,
4831        };
4832        let extractor = TextExtractor::with_options(options.clone());
4833        assert_eq!(extractor.options.preserve_layout, options.preserve_layout);
4834        assert_eq!(extractor.options.space_threshold, options.space_threshold);
4835        assert_eq!(
4836            extractor.options.newline_threshold,
4837            options.newline_threshold
4838        );
4839        assert_eq!(extractor.options.sort_by_position, options.sort_by_position);
4840        assert_eq!(extractor.options.detect_columns, options.detect_columns);
4841        assert_eq!(extractor.options.column_threshold, options.column_threshold);
4842        assert_eq!(extractor.options.merge_hyphenated, options.merge_hyphenated);
4843    }
4844
4845    // =========================================================================
4846    // RIGOROUS TESTS FOR FONT METRICS TEXT WIDTH CALCULATION
4847    // =========================================================================
4848
4849    #[test]
4850    fn test_calculate_text_width_with_no_font_info() {
4851        // Test fallback: should use simplified calculation
4852        let width = calculate_text_width("Hello", 12.0, None);
4853
4854        // Expected: 5 chars * 12.0 * 0.5 = 30.0
4855        assert_eq!(
4856            width, 30.0,
4857            "Without font info, should use simplified calculation: len * font_size * 0.5"
4858        );
4859    }
4860
4861    #[test]
4862    fn test_calculate_text_width_with_empty_metrics() {
4863        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
4864
4865        // Font with no widths array
4866        let font_info = FontInfo {
4867            name: "TestFont".to_string(),
4868            font_type: "Type1".to_string(),
4869            encoding: None,
4870            to_unicode: None,
4871            differences: None,
4872            descendant_font: None,
4873            cid_ordering: None,
4874            metrics: FontMetrics {
4875                first_char: None,
4876                last_char: None,
4877                widths: None,
4878                missing_width: Some(500.0),
4879                kerning: None,
4880                cid_widths: None,
4881            },
4882            cid_encoding: None,
4883        };
4884
4885        let width = calculate_text_width("Hello", 12.0, Some(&font_info));
4886
4887        // Should fall back to simplified calculation
4888        assert_eq!(
4889            width, 30.0,
4890            "Without widths array, should fall back to simplified calculation"
4891        );
4892    }
4893
4894    #[test]
4895    fn standard_14_no_widths_uses_effective_encoding_for_pen_advance() {
4896        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
4897        use std::collections::HashMap;
4898
4899        let font = |name: &str, encoding: Option<&str>, differences| FontInfo {
4900            name: name.to_string(),
4901            font_type: "Type1".to_string(),
4902            encoding: encoding.map(str::to_string),
4903            to_unicode: None,
4904            differences,
4905            descendant_font: None,
4906            cid_ordering: None,
4907            metrics: FontMetrics::default(),
4908            cid_encoding: None,
4909        };
4910        let width = |code: u8, info: &FontInfo| {
4911            calculate_text_width_from_codes(&[code], "", 10.0, Some(info), 0.0, 0.0)
4912        };
4913        let assert_width = |actual: f64, expected: f64| {
4914            assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
4915        };
4916
4917        assert_width(
4918            width(39, &font("Helvetica", Some("StandardEncoding"), None)),
4919            2.22,
4920        );
4921        assert_width(
4922            width(39, &font("Helvetica", Some("WinAnsiEncoding"), None)),
4923            1.91,
4924        );
4925        assert_width(
4926            width(0xDB, &font("Helvetica", Some("MacRomanEncoding"), None)),
4927            5.56,
4928        );
4929
4930        let differences = HashMap::from([(b'A', "fi".to_string())]);
4931        assert_width(
4932            width(
4933                b'A',
4934                &font("Helvetica", Some("WinAnsiEncoding"), Some(differences)),
4935            ),
4936            5.0,
4937        );
4938        assert_width(width(b'a', &font("Symbol", None, None)), 6.31);
4939        assert_width(width(b'!', &font("ZapfDingbats", None, None)), 9.74);
4940
4941        let unknown = HashMap::from([(b'A', "not-a-glyph".to_string())]);
4942        let unresolved = width(
4943            b'A',
4944            &font("Helvetica", Some("WinAnsiEncoding"), Some(unknown)),
4945        );
4946        assert!(
4947            (unresolved - 5.0).abs() < 1e-12,
4948            "unresolved glyphs retain the legacy 0.5em fallback"
4949        );
4950    }
4951
4952    #[test]
4953    fn test_calculate_text_width_with_complete_metrics() {
4954        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
4955
4956        // Font with complete metrics for ASCII range 32-126
4957        // Simulate typical Helvetica widths (in 1/1000 units)
4958        let mut widths = vec![0.0; 95]; // 95 chars from 32 to 126
4959
4960        // Set specific widths for "Hello" (H=722, e=556, l=278, o=611)
4961        widths[72 - 32] = 722.0; // 'H' is ASCII 72
4962        widths[101 - 32] = 556.0; // 'e' is ASCII 101
4963        widths[108 - 32] = 278.0; // 'l' is ASCII 108
4964        widths[111 - 32] = 611.0; // 'o' is ASCII 111
4965
4966        let font_info = FontInfo {
4967            name: "Helvetica".to_string(),
4968            font_type: "Type1".to_string(),
4969            encoding: None,
4970            to_unicode: None,
4971            differences: None,
4972            descendant_font: None,
4973            cid_ordering: None,
4974            metrics: FontMetrics {
4975                first_char: Some(32),
4976                last_char: Some(126),
4977                widths: Some(widths),
4978                missing_width: Some(500.0),
4979                kerning: None,
4980                cid_widths: None,
4981            },
4982            cid_encoding: None,
4983        };
4984
4985        let width = calculate_text_width("Hello", 12.0, Some(&font_info));
4986
4987        // Expected calculation (widths in glyph space / 1000 * font_size):
4988        // H: 722/1000 * 12 = 8.664
4989        // e: 556/1000 * 12 = 6.672
4990        // l: 278/1000 * 12 = 3.336
4991        // l: 278/1000 * 12 = 3.336
4992        // o: 611/1000 * 12 = 7.332
4993        // Total: 29.34
4994        let expected = (722.0 + 556.0 + 278.0 + 278.0 + 611.0) / 1000.0 * 12.0;
4995        let tolerance = 0.0001; // Floating point tolerance
4996        assert!(
4997            (width - expected).abs() < tolerance,
4998            "Should calculate width using actual character metrics: expected {}, got {}, diff {}",
4999            expected,
5000            width,
5001            (width - expected).abs()
5002        );
5003
5004        // Verify it's different from simplified calculation
5005        let simplified = 5.0 * 12.0 * 0.5; // 30.0
5006        assert_ne!(
5007            width, simplified,
5008            "Metrics-based calculation should differ from simplified (30.0)"
5009        );
5010    }
5011
5012    #[test]
5013    fn width_from_codes_uses_char_code_not_decoded_unicode() {
5014        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5015
5016        // Simple Type1 font with a code-indexed Widths array: code 1 -> 1000,
5017        // code 2 -> 100. A custom encoding decodes code 1 -> 'm' (U+006D) and
5018        // code 2 -> 'i' (U+0069), so the decoded Unicode codepoints (109, 105)
5019        // are far from the codes (1, 2). The advance width MUST come from the
5020        // codes; indexing the Widths array by the decoded Unicode codepoint
5021        // reads out-of-range -> missing_width, desyncing glyph advance on
5022        // custom-encoded fonts (issue #302, Higgs/Computer-Modern scramble).
5023        let font_info = FontInfo {
5024            name: "F1".to_string(),
5025            font_type: "Type1".to_string(),
5026            encoding: None,
5027            to_unicode: None,
5028            differences: None,
5029            descendant_font: None,
5030            cid_ordering: None,
5031            metrics: FontMetrics {
5032                first_char: Some(1),
5033                last_char: Some(2),
5034                widths: Some(vec![1000.0, 100.0]),
5035                missing_width: Some(500.0),
5036                kerning: None,
5037                cid_widths: None,
5038            },
5039            cid_encoding: None,
5040        };
5041
5042        let codes = [1u8, 2u8];
5043        let decoded = "mi"; // what decode_text produced for these codes
5044        let width =
5045            calculate_text_width_from_codes(&codes, decoded, 10.0, Some(&font_info), 0.0, 0.0);
5046        let expected = (1000.0 + 100.0) / 1000.0 * 10.0; // 11.0
5047        assert!(
5048            (width - expected).abs() < 1e-6,
5049            "width must come from char codes: expected {expected}, got {width}"
5050        );
5051
5052        // The decoded-Unicode-indexed path is the bug: 109 and 105 are outside
5053        // [1,2] so both fall back to missing_width -> (500+500)/1000*10 = 10.0.
5054        let buggy = calculate_text_width(decoded, 10.0, Some(&font_info));
5055        assert_eq!(buggy, 10.0);
5056        assert_ne!(
5057            width, buggy,
5058            "code-based width must differ from the Unicode-indexed bug"
5059        );
5060    }
5061
5062    #[test]
5063    fn test_calculate_text_width_character_outside_range() {
5064        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5065
5066        // Font with narrow range (only covers 'A'-'Z')
5067        let widths = vec![722.0; 26]; // All uppercase letters same width
5068
5069        let font_info = FontInfo {
5070            name: "TestFont".to_string(),
5071            font_type: "Type1".to_string(),
5072            encoding: None,
5073            to_unicode: None,
5074            differences: None,
5075            descendant_font: None,
5076            cid_ordering: None,
5077            metrics: FontMetrics {
5078                first_char: Some(65), // 'A'
5079                last_char: Some(90),  // 'Z'
5080                widths: Some(widths),
5081                missing_width: Some(500.0),
5082                kerning: None,
5083                cid_widths: None,
5084            },
5085            cid_encoding: None,
5086        };
5087
5088        // Test with character outside range
5089        let width = calculate_text_width("A1", 10.0, Some(&font_info));
5090
5091        // Expected:
5092        // 'A' (65) is in range: 722/1000 * 10 = 7.22
5093        // '1' (49) is outside range: missing_width 500/1000 * 10 = 5.0
5094        // Total: 12.22
5095        let expected = (722.0 / 1000.0 * 10.0) + (500.0 / 1000.0 * 10.0);
5096        assert_eq!(
5097            width, expected,
5098            "Should use missing_width for characters outside range"
5099        );
5100    }
5101
5102    #[test]
5103    fn test_calculate_text_width_missing_width_in_array() {
5104        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5105
5106        // Font with incomplete widths array (some characters have 0.0)
5107        let mut widths = vec![500.0; 95]; // Default width
5108        widths[10] = 0.0; // Character at index 10 has no width defined
5109
5110        let font_info = FontInfo {
5111            name: "TestFont".to_string(),
5112            font_type: "Type1".to_string(),
5113            encoding: None,
5114            to_unicode: None,
5115            differences: None,
5116            descendant_font: None,
5117            cid_ordering: None,
5118            metrics: FontMetrics {
5119                first_char: Some(32),
5120                last_char: Some(126),
5121                widths: Some(widths),
5122                missing_width: Some(600.0),
5123                kerning: None,
5124                cid_widths: None,
5125            },
5126            cid_encoding: None,
5127        };
5128
5129        // Character 42 (index 10 from first_char 32)
5130        let char_code = 42u8 as char; // '*'
5131        let text = char_code.to_string();
5132        let width = calculate_text_width(&text, 10.0, Some(&font_info));
5133
5134        // Character is in range but width is 0.0, should NOT fall back to missing_width
5135        // (0.0 is a valid width for zero-width characters)
5136        assert_eq!(
5137            width, 0.0,
5138            "Should use 0.0 width from array, not missing_width"
5139        );
5140    }
5141
5142    #[test]
5143    fn test_calculate_text_width_empty_string() {
5144        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5145
5146        let font_info = FontInfo {
5147            name: "TestFont".to_string(),
5148            font_type: "Type1".to_string(),
5149            encoding: None,
5150            to_unicode: None,
5151            differences: None,
5152            descendant_font: None,
5153            cid_ordering: None,
5154            metrics: FontMetrics {
5155                first_char: Some(32),
5156                last_char: Some(126),
5157                widths: Some(vec![500.0; 95]),
5158                missing_width: Some(500.0),
5159                kerning: None,
5160                cid_widths: None,
5161            },
5162            cid_encoding: None,
5163        };
5164
5165        let width = calculate_text_width("", 12.0, Some(&font_info));
5166        assert_eq!(width, 0.0, "Empty string should have zero width");
5167
5168        // Also test without font info
5169        let width_no_font = calculate_text_width("", 12.0, None);
5170        assert_eq!(
5171            width_no_font, 0.0,
5172            "Empty string should have zero width (no font)"
5173        );
5174    }
5175
5176    #[test]
5177    fn test_calculate_text_width_unicode_characters() {
5178        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5179
5180        // Font with limited ASCII range
5181        let font_info = FontInfo {
5182            name: "TestFont".to_string(),
5183            font_type: "Type1".to_string(),
5184            encoding: None,
5185            to_unicode: None,
5186            differences: None,
5187            descendant_font: None,
5188            cid_ordering: None,
5189            metrics: FontMetrics {
5190                first_char: Some(32),
5191                last_char: Some(126),
5192                widths: Some(vec![500.0; 95]),
5193                missing_width: Some(600.0),
5194                kerning: None,
5195                cid_widths: None,
5196            },
5197            cid_encoding: None,
5198        };
5199
5200        // Test with Unicode characters outside ASCII range
5201        let width = calculate_text_width("Ñ", 10.0, Some(&font_info));
5202
5203        // 'Ñ' (U+00D1, code 209) is outside range, should use missing_width
5204        // Expected: 600/1000 * 10 = 6.0
5205        assert_eq!(
5206            width, 6.0,
5207            "Unicode character outside range should use missing_width"
5208        );
5209    }
5210
5211    #[test]
5212    fn test_calculate_text_width_different_font_sizes() {
5213        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5214
5215        let font_info = FontInfo {
5216            name: "TestFont".to_string(),
5217            font_type: "Type1".to_string(),
5218            encoding: None,
5219            to_unicode: None,
5220            differences: None,
5221            descendant_font: None,
5222            cid_ordering: None,
5223            metrics: FontMetrics {
5224                first_char: Some(65), // 'A'
5225                last_char: Some(65),  // 'A'
5226                widths: Some(vec![722.0]),
5227                missing_width: Some(500.0),
5228                kerning: None,
5229                cid_widths: None,
5230            },
5231            cid_encoding: None,
5232        };
5233
5234        // Test same character with different font sizes
5235        let width_10 = calculate_text_width("A", 10.0, Some(&font_info));
5236        let width_20 = calculate_text_width("A", 20.0, Some(&font_info));
5237
5238        // Widths should scale linearly with font size
5239        assert_eq!(width_10, 722.0 / 1000.0 * 10.0);
5240        assert_eq!(width_20, 722.0 / 1000.0 * 20.0);
5241        assert_eq!(
5242            width_20,
5243            width_10 * 2.0,
5244            "Width should scale linearly with font size"
5245        );
5246    }
5247
5248    #[test]
5249    fn test_calculate_text_width_proportional_vs_monospace() {
5250        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5251
5252        // Simulate proportional font (different widths)
5253        let proportional_widths = vec![278.0, 556.0, 722.0]; // i, m, W
5254        let proportional_font = FontInfo {
5255            name: "Helvetica".to_string(),
5256            font_type: "Type1".to_string(),
5257            encoding: None,
5258            to_unicode: None,
5259            differences: None,
5260            descendant_font: None,
5261            cid_ordering: None,
5262            metrics: FontMetrics {
5263                first_char: Some(105), // 'i'
5264                last_char: Some(107),  // covers i, j, k
5265                widths: Some(proportional_widths),
5266                missing_width: Some(500.0),
5267                kerning: None,
5268                cid_widths: None,
5269            },
5270            cid_encoding: None,
5271        };
5272
5273        // Simulate monospace font (same width)
5274        let monospace_widths = vec![600.0, 600.0, 600.0];
5275        let monospace_font = FontInfo {
5276            name: "Courier".to_string(),
5277            font_type: "Type1".to_string(),
5278            encoding: None,
5279            to_unicode: None,
5280            differences: None,
5281            descendant_font: None,
5282            cid_ordering: None,
5283            metrics: FontMetrics {
5284                first_char: Some(105),
5285                last_char: Some(107),
5286                widths: Some(monospace_widths),
5287                missing_width: Some(600.0),
5288                kerning: None,
5289                cid_widths: None,
5290            },
5291            cid_encoding: None,
5292        };
5293
5294        let prop_width = calculate_text_width("i", 12.0, Some(&proportional_font));
5295        let mono_width = calculate_text_width("i", 12.0, Some(&monospace_font));
5296
5297        // Proportional 'i' should be narrower than monospace 'i'
5298        assert!(
5299            prop_width < mono_width,
5300            "Proportional 'i' ({}) should be narrower than monospace 'i' ({})",
5301            prop_width,
5302            mono_width
5303        );
5304    }
5305
5306    // =========================================================================
5307    // CRITICAL KERNING TESTS (Issue #87 - Quality Agent Required)
5308    // =========================================================================
5309
5310    #[test]
5311    fn test_calculate_text_width_with_kerning() {
5312        use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5313        use std::collections::HashMap;
5314
5315        // Create a font with kerning pairs
5316        let mut widths = vec![500.0; 95]; // ASCII 32-126
5317        widths[65 - 32] = 722.0; // 'A'
5318        widths[86 - 32] = 722.0; // 'V'
5319        widths[87 - 32] = 944.0; // 'W'
5320
5321        let mut kerning = HashMap::new();
5322        // Typical kerning pairs (in FUnits, 1/1000)
5323        kerning.insert((65, 86), -50.0); // 'A' + 'V' → tighten by 50 FUnits
5324        kerning.insert((65, 87), -40.0); // 'A' + 'W' → tighten by 40 FUnits
5325
5326        let font_info = FontInfo {
5327            name: "Helvetica".to_string(),
5328            font_type: "Type1".to_string(),
5329            encoding: None,
5330            to_unicode: None,
5331            differences: None,
5332            descendant_font: None,
5333            cid_ordering: None,
5334            metrics: FontMetrics {
5335                first_char: Some(32),
5336                last_char: Some(126),
5337                widths: Some(widths),
5338                missing_width: Some(500.0),
5339                kerning: Some(kerning),
5340                cid_widths: None,
5341            },
5342            cid_encoding: None,
5343        };
5344
5345        // Test "AV" with kerning
5346        let width_av = calculate_text_width("AV", 12.0, Some(&font_info));
5347        // Expected: (722 + 722)/1000 * 12 + (-50/1000 * 12)
5348        //         = 17.328 - 0.6 = 16.728
5349        let expected_av = (722.0 + 722.0) / 1000.0 * 12.0 + (-50.0 / 1000.0 * 12.0);
5350        let tolerance = 0.0001;
5351        assert!(
5352            (width_av - expected_av).abs() < tolerance,
5353            "AV with kerning: expected {}, got {}, diff {}",
5354            expected_av,
5355            width_av,
5356            (width_av - expected_av).abs()
5357        );
5358
5359        // Test "AW" with different kerning value
5360        let width_aw = calculate_text_width("AW", 12.0, Some(&font_info));
5361        // Expected: (722 + 944)/1000 * 12 + (-40/1000 * 12)
5362        //         = 19.992 - 0.48 = 19.512
5363        let expected_aw = (722.0 + 944.0) / 1000.0 * 12.0 + (-40.0 / 1000.0 * 12.0);
5364        assert!(
5365            (width_aw - expected_aw).abs() < tolerance,
5366            "AW with kerning: expected {}, got {}, diff {}",
5367            expected_aw,
5368            width_aw,
5369            (width_aw - expected_aw).abs()
5370        );
5371
5372        // Test "VA" with NO kerning (pair not in HashMap)
5373        let width_va = calculate_text_width("VA", 12.0, Some(&font_info));
5374        // Expected: (722 + 722)/1000 * 12 = 17.328 (no kerning adjustment)
5375        let expected_va = (722.0 + 722.0) / 1000.0 * 12.0;
5376        assert!(
5377            (width_va - expected_va).abs() < tolerance,
5378            "VA without kerning: expected {}, got {}, diff {}",
5379            expected_va,
5380            width_va,
5381            (width_va - expected_va).abs()
5382        );
5383
5384        // Verify kerning makes a measurable difference
5385        assert!(
5386            width_av < width_va,
5387            "AV with kerning ({}) should be narrower than VA without kerning ({})",
5388            width_av,
5389            width_va
5390        );
5391    }
5392
5393    #[test]
5394    fn test_parse_truetype_kern_table_minimal() {
5395        use crate::text::extraction_cmap::parse_truetype_kern_table;
5396
5397        // Complete TrueType font with kern table (Format 0, 2 kerning pairs)
5398        // Structure:
5399        // 1. Offset table (12 bytes)
5400        // 2. Table directory (2 tables: 'head' and 'kern', each 16 bytes = 32 total)
5401        // 3. 'head' table data (54 bytes)
5402        // 4. 'kern' table data (30 bytes)
5403        // Total: 128 bytes
5404        let mut ttf_data = vec![
5405            // Offset table
5406            0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
5407            0x00, 0x02, // numTables: 2
5408            0x00, 0x20, // searchRange: 32
5409            0x00, 0x01, // entrySelector: 1
5410            0x00, 0x00, // rangeShift: 0
5411        ];
5412
5413        // Table directory entry 1: 'head' table
5414        ttf_data.extend_from_slice(b"head"); // tag
5415        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
5416        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x2C]); // offset: 44 (12 + 32)
5417        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x36]); // length: 54
5418
5419        // Table directory entry 2: 'kern' table
5420        ttf_data.extend_from_slice(b"kern"); // tag
5421        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
5422        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x62]); // offset: 98 (44 + 54)
5423        ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x1E]); // length: 30 (actual kern table size)
5424
5425        // 'head' table data (54 bytes of zeros - minimal valid head table)
5426        ttf_data.extend_from_slice(&[0u8; 54]);
5427
5428        // 'kern' table data (34 bytes)
5429        ttf_data.extend_from_slice(&[
5430            // Kern table header
5431            0x00, 0x00, // version: 0
5432            0x00, 0x01, // nTables: 1
5433            // Subtable header
5434            0x00, 0x00, // version: 0
5435            0x00, 0x1A, // length: 26 bytes (header 6 + nPairs data 8 + pairs 2*6=12)
5436            0x00, 0x00, // coverage: 0x0000 (Format 0 in lower byte, horizontal)
5437            0x00, 0x02, // nPairs: 2
5438            0x00, 0x08, // searchRange: 8
5439            0x00, 0x00, // entrySelector: 0
5440            0x00, 0x04, // rangeShift: 4
5441            // Kerning pair 1: A + V → -50
5442            0x00, 0x41, // left glyph: 65 ('A')
5443            0x00, 0x56, // right glyph: 86 ('V')
5444            0xFF, 0xCE, // value: -50 (signed 16-bit big-endian)
5445            // Kerning pair 2: A + W → -40
5446            0x00, 0x41, // left glyph: 65 ('A')
5447            0x00, 0x57, // right glyph: 87 ('W')
5448            0xFF, 0xD8, // value: -40 (signed 16-bit big-endian)
5449        ]);
5450
5451        let result = parse_truetype_kern_table(&ttf_data);
5452        assert!(
5453            result.is_ok(),
5454            "Should parse minimal kern table successfully: {:?}",
5455            result.err()
5456        );
5457
5458        let kerning_map = result.unwrap();
5459        assert_eq!(kerning_map.len(), 2, "Should extract 2 kerning pairs");
5460
5461        // Verify pair 1: A + V → -50
5462        assert_eq!(
5463            kerning_map.get(&(65, 86)),
5464            Some(&-50.0),
5465            "Should have A+V kerning pair with value -50"
5466        );
5467
5468        // Verify pair 2: A + W → -40
5469        assert_eq!(
5470            kerning_map.get(&(65, 87)),
5471            Some(&-40.0),
5472            "Should have A+W kerning pair with value -40"
5473        );
5474    }
5475
5476    #[test]
5477    fn test_parse_kern_table_no_kern_table() {
5478        use crate::text::extraction_cmap::parse_truetype_kern_table;
5479
5480        // TrueType font data WITHOUT a 'kern' table
5481        // Structure:
5482        // - Offset table: scaler type + numTables + searchRange + entrySelector + rangeShift
5483        // - Table directory: 1 entry for 'head' table (not 'kern')
5484        let ttf_data = vec![
5485            // Offset table
5486            0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
5487            0x00, 0x01, // numTables: 1
5488            0x00, 0x10, // searchRange: 16
5489            0x00, 0x00, // entrySelector: 0
5490            0x00, 0x00, // rangeShift: 0
5491            // Table directory entry: 'head' table (not 'kern')
5492            b'h', b'e', b'a', b'd', // tag: 'head'
5493            0x00, 0x00, 0x00, 0x00, // checksum
5494            0x00, 0x00, 0x00, 0x1C, // offset: 28
5495            0x00, 0x00, 0x00, 0x36, // length: 54
5496            // Mock 'head' table data (54 bytes of zeros)
5497            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
5498            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
5499            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
5500            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
5501        ];
5502
5503        let result = parse_truetype_kern_table(&ttf_data);
5504        assert!(
5505            result.is_ok(),
5506            "Should gracefully handle missing kern table"
5507        );
5508
5509        let kerning_map = result.unwrap();
5510        assert!(
5511            kerning_map.is_empty(),
5512            "Should return empty HashMap when no kern table exists"
5513        );
5514    }
5515
5516    // Helper for paragraph-reconstruction unit tests. TextFragment has 11
5517    // fields so a helper keeps the test bodies focused on geometry.
5518    fn tf(text: &str, x: f64, y: f64, width: f64, font_size: f64) -> TextFragment {
5519        TextFragment {
5520            text: text.to_string(),
5521            x,
5522            y,
5523            width,
5524            height: font_size,
5525            font_size,
5526            font_name: None,
5527            is_bold: false,
5528            is_italic: false,
5529            color: None,
5530            space_decisions: Vec::new(),
5531            mcid: None,
5532            struct_tag: None,
5533        }
5534    }
5535
5536    #[test]
5537    fn merge_into_lines_groups_same_baseline_fragments() {
5538        let extractor = TextExtractor::with_options(ExtractionOptions {
5539            reconstruct_paragraphs: true,
5540            ..Default::default()
5541        });
5542        let input = vec![
5543            tf("Hello", 50.0, 400.0, 30.0, 12.0),
5544            tf("world", 90.0, 400.0, 30.0, 12.0),
5545            tf("now.", 130.0, 400.0, 25.0, 12.0),
5546            tf("Next", 50.0, 386.0, 30.0, 12.0),
5547            tf("line.", 90.0, 386.0, 25.0, 12.0),
5548        ];
5549        let lines = extractor.merge_into_lines(&input);
5550        assert_eq!(
5551            lines.len(),
5552            2,
5553            "two distinct baselines must produce two line fragments"
5554        );
5555        assert_eq!(
5556            lines[0].text, "Hello world now.",
5557            "first line concatenated with spaces"
5558        );
5559        assert_eq!(lines[1].text, "Next line.", "second line concatenated");
5560    }
5561
5562    #[test]
5563    fn merge_into_lines_inserts_space_only_when_gap_exceeds_threshold() {
5564        let extractor = TextExtractor::with_options(ExtractionOptions {
5565            reconstruct_paragraphs: true,
5566            space_threshold: 0.3,
5567            ..Default::default()
5568        });
5569        // Gap of 4pt at font_size 12 = 0.33x — above threshold 0.3
5570        let with_gap = vec![
5571            tf("AB", 50.0, 400.0, 10.0, 12.0),
5572            tf("CD", 64.0, 400.0, 10.0, 12.0),
5573        ];
5574        let lines = extractor.merge_into_lines(&with_gap);
5575        assert_eq!(
5576            lines[0].text, "AB CD",
5577            "gap above threshold must insert space"
5578        );
5579
5580        // Gap of 1pt = 0.083x — below threshold
5581        let tight = vec![
5582            tf("AB", 50.0, 400.0, 10.0, 12.0),
5583            tf("CD", 61.0, 400.0, 10.0, 12.0),
5584        ];
5585        let lines = extractor.merge_into_lines(&tight);
5586        assert_eq!(lines[0].text, "ABCD", "tight gap must NOT insert space");
5587    }
5588
5589    #[test]
5590    fn standard_14_space_width_maps_base_fonts_and_substitutes() {
5591        // Adobe Core-14 AFM space advances, with subset prefixes stripped and
5592        // metric-compatible substitutes folded in (#302 symptom 2).
5593        assert_eq!(super::standard_14_space_width("Times-Roman"), Some(250.0));
5594        assert_eq!(
5595            super::standard_14_space_width("Times-BoldItalic"),
5596            Some(250.0)
5597        );
5598        assert_eq!(super::standard_14_space_width("Helvetica"), Some(278.0));
5599        assert_eq!(super::standard_14_space_width("Courier-Bold"), Some(600.0));
5600        assert_eq!(super::standard_14_space_width("Symbol"), Some(250.0));
5601        assert_eq!(super::standard_14_space_width("ZapfDingbats"), Some(278.0));
5602        // subset prefix stripped
5603        assert_eq!(
5604            super::standard_14_space_width("ABCDEF+Times-Roman"),
5605            Some(250.0)
5606        );
5607        // metric-compatible substitutes
5608        assert_eq!(super::standard_14_space_width("Arial-BoldMT"), Some(278.0));
5609        assert_eq!(
5610            super::standard_14_space_width("TimesNewRomanPSMT"),
5611            Some(250.0)
5612        );
5613        assert_eq!(
5614            super::standard_14_space_width("CourierNewPSMT"),
5615            Some(600.0)
5616        );
5617        // unknown / embedded fonts fall through to the caller's fallback
5618        assert_eq!(super::standard_14_space_width("Poppins-Regular"), None);
5619        assert_eq!(super::standard_14_space_width("VUNXGH+Calibri"), None);
5620    }
5621
5622    #[test]
5623    fn merge_into_lines_keeps_emission_order_for_font_switch_overlap() {
5624        // #302 symptom 1: a font-switched glyph (e.g. the italic particle
5625        // symbol "Z" in "to the Z boson") is positioned by the producer with
5626        // an x-origin that falls INSIDE the x-span of the preceding roman run
5627        // ("to the"). The content stream still delivers it in correct reading
5628        // order. Sorting a row purely by x-origin interleaves the overlapping
5629        // fragment, yielding "Zto the" instead of "to theZ". When a row's only
5630        // backward emission steps are span overlaps (not disjoint jumps),
5631        // emission order is the authoritative reading order.
5632        let extractor = TextExtractor::with_options(ExtractionOptions {
5633            reconstruct_paragraphs: true,
5634            ..Default::default()
5635        });
5636        // emission order = reading order; "Z" overlaps "to t" + "he" in x.
5637        let row = vec![
5638            tf("to t", 455.5, 400.0, 12.0, 10.0), // 455.5 .. 467.5
5639            tf("he", 467.5, 400.0, 10.0, 10.0),   // 467.5 .. 477.5
5640            tf("Z", 455.3, 400.0, 23.0, 10.0),    // 455.3 .. 478.3 (overlaps both)
5641        ];
5642        let lines = extractor.merge_into_lines(&row);
5643        assert_eq!(lines.len(), 1);
5644        assert_eq!(
5645            lines[0].text, "to theZ",
5646            "overlapping font-switch fragment must keep emission (reading) order"
5647        );
5648    }
5649
5650    #[test]
5651    fn merge_into_lines_keeps_emission_when_run_backfills_covered_span() {
5652        // #305: dense justified body text is split into sub-word fragments by
5653        // the font's arbitrary glyph runs. A later word ("described", x 492..537)
5654        // is emitted with a backward x-origin that lands INSIDE the span already
5655        // covered by the line ("...selections", 479..521), but does NOT overlap
5656        // the short immediately-preceding fragment ("s", 517..521). Emission is
5657        // still the reading order, so the line must keep it — the overlap test
5658        // has to consider the line's running extent, not just the previous
5659        // fragment. (Real case: Higgs p5 "kinematic selections described in".)
5660        let extractor = TextExtractor::with_options(ExtractionOptions {
5661            reconstruct_paragraphs: true,
5662            ..Default::default()
5663        });
5664        let row = vec![
5665            tf("selection", 479.0, 400.0, 38.0, 8.0), // 479..517
5666            tf("s", 517.0, 400.0, 4.0, 8.0),          // 517..521  short predecessor
5667            tf("d", 492.0, 400.0, 4.0, 8.0),          // 492..496  backfill, no overlap with "s"
5668            tf("escribed", 496.0, 400.0, 41.0, 8.0),  // 496..537
5669        ];
5670        let lines = extractor.merge_into_lines(&row);
5671        assert_eq!(
5672            lines[0].text, "selectionsdescribed",
5673            "a run that backfills the line's covered span must keep emission order"
5674        );
5675    }
5676
5677    #[test]
5678    fn merge_into_lines_uses_x_order_for_disjoint_backward_jump() {
5679        // Guard: a genuinely scrambled non-tagged stream (fragments emitted
5680        // out of x-order at DISJOINT positions, e.g. right-to-left or random
5681        // generators) must still be reordered by x. Here "the" is emitted
5682        // after "boson" with no span overlap, so x-order is authoritative.
5683        let extractor = TextExtractor::with_options(ExtractionOptions {
5684            reconstruct_paragraphs: true,
5685            ..Default::default()
5686        });
5687        let row = vec![
5688            tf("boson", 100.0, 400.0, 28.0, 10.0), // 100 .. 128
5689            tf("the", 80.0, 400.0, 15.0, 10.0),    // 80 .. 95 (disjoint, left of boson)
5690        ];
5691        let lines = extractor.merge_into_lines(&row);
5692        assert_eq!(lines.len(), 1);
5693        assert_eq!(
5694            lines[0].text, "the boson",
5695            "disjoint backward emission jump must be reordered by x"
5696        );
5697    }
5698
5699    #[test]
5700    fn merge_into_lines_unioned_bounding_box() {
5701        let extractor = TextExtractor::with_options(ExtractionOptions {
5702            reconstruct_paragraphs: true,
5703            ..Default::default()
5704        });
5705        let input = vec![
5706            tf("A", 50.0, 400.0, 10.0, 12.0),
5707            tf("B", 100.0, 400.0, 10.0, 12.0),
5708        ];
5709        let lines = extractor.merge_into_lines(&input);
5710        assert_eq!(lines.len(), 1);
5711        assert!((lines[0].x - 50.0).abs() < 0.01);
5712        assert!(
5713            (lines[0].width - 60.0).abs() < 0.01,
5714            "width must span 50->110"
5715        );
5716    }
5717
5718    #[test]
5719    fn assign_row_ids_monotone_y_descending_keeps_zero() {
5720        let frags = vec![
5721            tf("A", 50.0, 400.0, 10.0, 9.0),
5722            tf("B", 50.0, 395.0, 10.0, 9.0),
5723            tf("C", 50.0, 390.0, 10.0, 9.0),
5724        ];
5725        let row_ids = super::assign_row_ids(&frags);
5726        assert_eq!(row_ids, vec![0u32, 0, 0]);
5727    }
5728
5729    #[test]
5730    fn assign_row_ids_increments_on_y_up_jump_above_threshold() {
5731        // font_size=9 → threshold = max(4.5, 2.0) = 4.5
5732        // deltas: 395-400=-5, 420-395=+25 (>4.5)
5733        let frags = vec![
5734            tf("A", 50.0, 400.0, 10.0, 9.0),
5735            tf("B", 50.0, 395.0, 10.0, 9.0),
5736            tf("C", 50.0, 420.0, 10.0, 9.0),
5737        ];
5738        let row_ids = super::assign_row_ids(&frags);
5739        assert_eq!(row_ids, vec![0u32, 0, 1]);
5740    }
5741
5742    #[test]
5743    fn assign_row_ids_ignores_superscript_within_threshold() {
5744        // font_size=9 → threshold 4.5. delta 2.5 must NOT trigger.
5745        let frags = vec![
5746            tf("A", 50.0, 400.0, 10.0, 9.0),
5747            tf("^2", 60.0, 402.5, 5.0, 9.0),
5748            tf("B", 65.0, 395.0, 10.0, 9.0),
5749        ];
5750        let row_ids = super::assign_row_ids(&frags);
5751        assert_eq!(row_ids, vec![0u32, 0, 0]);
5752    }
5753
5754    #[test]
5755    fn assign_row_ids_floor_2pt_for_small_fonts() {
5756        // font_size=3 → font_size*0.5 = 1.5; floor lifts threshold to 2.0
5757        // delta = +2.5 > 2.0 must trigger.
5758        let frags = vec![
5759            tf("A", 50.0, 100.0, 10.0, 3.0),
5760            tf("B", 50.0, 102.5, 10.0, 3.0),
5761        ];
5762        let row_ids = super::assign_row_ids(&frags);
5763        assert_eq!(row_ids, vec![0u32, 1]);
5764    }
5765
5766    #[test]
5767    fn assign_row_ids_empty_slice_returns_empty() {
5768        let frags: Vec<TextFragment> = vec![];
5769        let row_ids = super::assign_row_ids(&frags);
5770        assert!(row_ids.is_empty(), "empty input must yield empty output");
5771    }
5772
5773    #[test]
5774    fn merge_into_lines_splits_two_columns_emitted_sequentially() {
5775        let extractor = TextExtractor::with_options(ExtractionOptions {
5776            reconstruct_paragraphs: true,
5777            ..Default::default()
5778        });
5779        // Emission order: col1.l1, col1.l2 (Y monotone down), then col2.l1
5780        // (Y jumps UP by 10 > threshold 5 for font 10pt), col2.l2.
5781        let input = vec![
5782            tf("col1-top", 50.0, 400.0, 80.0, 10.0),
5783            tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
5784            tf("col2-top", 200.0, 405.0, 80.0, 10.0),
5785            tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
5786        ];
5787        let lines = extractor.merge_into_lines(&input);
5788        assert_eq!(
5789            lines.len(),
5790            4,
5791            "two columns at near-identical Y must split into 4 lines"
5792        );
5793        // row_id=0 batch first (col1), then row_id=1 (col2). Within each batch, Y desc.
5794        assert_eq!(lines[0].text, "col1-top");
5795        assert_eq!(lines[0].y, 400.0);
5796        assert_eq!(lines[1].text, "col1-bot");
5797        assert_eq!(lines[1].y, 395.0);
5798        assert_eq!(lines[2].text, "col2-top");
5799        assert_eq!(lines[2].y, 405.0);
5800        assert_eq!(lines[3].text, "col2-bot");
5801        assert_eq!(lines[3].y, 400.0);
5802    }
5803
5804    #[test]
5805    fn merge_into_lines_preserves_single_column_continuation() {
5806        let extractor = TextExtractor::with_options(ExtractionOptions {
5807            reconstruct_paragraphs: true,
5808            ..Default::default()
5809        });
5810        // Single column: same Y continuation (X grows), then next line down.
5811        let input = vec![
5812            tf("Hello", 50.0, 400.0, 30.0, 10.0),
5813            tf("world", 90.0, 400.0, 30.0, 10.0),
5814            tf("next-line", 50.0, 395.0, 70.0, 10.0),
5815        ];
5816        let lines = extractor.merge_into_lines(&input);
5817        assert_eq!(
5818            lines.len(),
5819            2,
5820            "single column continuation must collapse to 2 lines"
5821        );
5822        assert!(lines[0].text.contains("Hello"));
5823        assert!(lines[0].text.contains("world"));
5824        assert_eq!(lines[1].text, "next-line");
5825    }
5826
5827    #[test]
5828    fn merge_into_lines_splits_columns_with_uniform_mcid() {
5829        // Regression guard for #265 root cause: NCSC page 12 has a single
5830        // outer BDC, so every fragment has mcid=Some(0). Column separation
5831        // must come from row_id alone, not from mcid.
5832        let extractor = TextExtractor::with_options(ExtractionOptions {
5833            reconstruct_paragraphs: true,
5834            ..Default::default()
5835        });
5836        let mut frags = vec![
5837            tf("col1-top", 50.0, 400.0, 80.0, 10.0),
5838            tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
5839            tf("col2-top", 200.0, 405.0, 80.0, 10.0),
5840            tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
5841        ];
5842        for f in &mut frags {
5843            f.mcid = Some(0);
5844        }
5845        let lines = extractor.merge_into_lines(&frags);
5846        assert_eq!(
5847            lines.len(),
5848            4,
5849            "uniform mcid must not prevent row_id-based column split (NCSC root cause)"
5850        );
5851        assert_eq!(lines[0].text, "col1-top");
5852        assert_eq!(lines[1].text, "col1-bot");
5853        assert_eq!(lines[2].text, "col2-top");
5854        assert_eq!(lines[3].text, "col2-bot");
5855    }
5856
5857    #[test]
5858    fn merge_close_fragments_superscript_merges_when_reconstruct_paragraphs() {
5859        let extractor = TextExtractor::with_options(ExtractionOptions {
5860            reconstruct_paragraphs: true,
5861            ..Default::default()
5862        });
5863        // Citation superscript: body text at y=400, raised digit at y=403.5
5864        // (3.5pt above baseline for 10pt font). y_tol = 0.5 * 10 = 5.0 > 3.5
5865        // and x_gap = 4pt < 10*0.5 = 5pt, so the superscript must merge into
5866        // the body fragment.
5867        let frags = vec![
5868            tf("body-text", 50.0, 400.0, 25.0, 10.0),
5869            tf("1", 79.0, 403.5, 4.0, 10.0),
5870        ];
5871        let merged = extractor.merge_close_fragments(&frags);
5872        assert_eq!(
5873            merged.len(),
5874            1,
5875            "superscript within 5pt of baseline must merge in reconstruct path"
5876        );
5877        assert!(merged[0].text.contains("body-text"));
5878        assert!(merged[0].text.contains("1"));
5879    }
5880
5881    #[test]
5882    fn merge_close_fragments_superscript_does_not_merge_in_legacy_path() {
5883        let extractor = TextExtractor::with_options(ExtractionOptions {
5884            reconstruct_paragraphs: false,
5885            ..Default::default()
5886        });
5887        // Legacy path: y_tol=1.0 fixed. A 3.5pt delta must NOT merge.
5888        let frags = vec![
5889            tf("body-text", 50.0, 400.0, 25.0, 10.0),
5890            tf("1", 79.0, 403.5, 4.0, 10.0),
5891        ];
5892        let merged = extractor.merge_close_fragments(&frags);
5893        assert_eq!(
5894            merged.len(),
5895            2,
5896            "3.5pt Y delta exceeds legacy 1.0pt threshold; superscript stays separate"
5897        );
5898    }
5899
5900    #[test]
5901    fn merge_into_paragraphs_groups_consecutive_lines() {
5902        let extractor = TextExtractor::with_options(ExtractionOptions {
5903            reconstruct_paragraphs: true,
5904            ..Default::default()
5905        });
5906        // Three lines, 14pt leading (line height 12pt, gap 2pt)
5907        let lines = vec![
5908            tf("Line one.", 50.0, 400.0, 60.0, 12.0),
5909            tf("Line two.", 50.0, 386.0, 60.0, 12.0),
5910            tf("Line three.", 50.0, 372.0, 70.0, 12.0),
5911        ];
5912        let paragraphs = extractor.merge_into_paragraphs(&lines);
5913        assert_eq!(paragraphs.len(), 1);
5914        assert_eq!(paragraphs[0].text, "Line one.\nLine two.\nLine three.");
5915    }
5916
5917    #[test]
5918    fn merge_into_paragraphs_splits_on_large_vertical_gap() {
5919        let extractor = TextExtractor::with_options(ExtractionOptions {
5920            reconstruct_paragraphs: true,
5921            ..Default::default()
5922        });
5923        let lines = vec![
5924            tf("P1L1.", 50.0, 400.0, 40.0, 12.0),
5925            tf("P1L2.", 50.0, 386.0, 40.0, 12.0),
5926            tf("P2L1.", 50.0, 300.0, 40.0, 12.0),
5927        ];
5928        let paragraphs = extractor.merge_into_paragraphs(&lines);
5929        assert_eq!(paragraphs.len(), 2);
5930        assert_eq!(paragraphs[0].text, "P1L1.\nP1L2.");
5931        assert_eq!(paragraphs[1].text, "P2L1.");
5932    }
5933
5934    /// A heading is a different block from the body that follows it, even when
5935    /// the vertical gap is small enough to look like line spacing. Merging them
5936    /// destroys the two signals `partition` uses to classify a `Title`
5937    /// (font-size ratio and bold-short), so the heading text is never
5938    /// recoverable downstream (issue #436).
5939    #[test]
5940    fn merge_into_paragraphs_splits_on_font_size_change() {
5941        let extractor = TextExtractor::with_options(ExtractionOptions {
5942            reconstruct_paragraphs: true,
5943            ..Default::default()
5944        });
5945        // 20pt title at y=760, 10pt body line 40pt below: gap = 30pt, which is
5946        // exactly the 1.5 * median(20, 10) = 30pt vertical threshold, so only
5947        // the style change can separate them.
5948        let lines = vec![
5949            tf("Section Heading", 72.0, 760.0, 120.0, 20.0),
5950            tf("Body text of this section.", 72.0, 720.0, 150.0, 10.0),
5951        ];
5952        let paragraphs = extractor.merge_into_paragraphs(&lines);
5953        assert_eq!(
5954            paragraphs.len(),
5955            2,
5956            "font-size change must end the paragraph"
5957        );
5958        assert_eq!(paragraphs[0].text, "Section Heading");
5959        assert_eq!(paragraphs[0].font_size, 20.0);
5960        assert_eq!(paragraphs[1].text, "Body text of this section.");
5961    }
5962
5963    /// Same size, different weight: the classic run-in bold heading. `partition`
5964    /// classifies it through `bold_short_title`, which needs the heading to
5965    /// survive extraction as its own fragment (issue #436).
5966    #[test]
5967    fn merge_into_paragraphs_splits_on_weight_change() {
5968        let extractor = TextExtractor::with_options(ExtractionOptions {
5969            reconstruct_paragraphs: true,
5970            ..Default::default()
5971        });
5972        let mut heading = tf("Overview", 72.0, 400.0, 60.0, 12.0);
5973        heading.is_bold = true;
5974        let lines = vec![heading, tf("Body line.", 72.0, 386.0, 60.0, 12.0)];
5975        let paragraphs = extractor.merge_into_paragraphs(&lines);
5976        assert_eq!(paragraphs.len(), 2, "weight change must end the paragraph");
5977        assert_eq!(paragraphs[0].text, "Overview");
5978        assert!(paragraphs[0].is_bold);
5979        assert_eq!(paragraphs[1].text, "Body line.");
5980    }
5981
5982    /// Sub-point rounding (11.96pt vs 12pt from a scaled text matrix) is not a
5983    /// style change: the paragraph must stay whole.
5984    #[test]
5985    fn merge_into_paragraphs_tolerates_subpoint_font_size_jitter() {
5986        let extractor = TextExtractor::with_options(ExtractionOptions {
5987            reconstruct_paragraphs: true,
5988            ..Default::default()
5989        });
5990        let lines = vec![
5991            tf("Line one.", 50.0, 400.0, 60.0, 12.0),
5992            tf("Line two.", 50.0, 386.0, 60.0, 11.96),
5993        ];
5994        let paragraphs = extractor.merge_into_paragraphs(&lines);
5995        assert_eq!(
5996            paragraphs.len(),
5997            1,
5998            "0.3% size jitter is not a style change"
5999        );
6000        assert_eq!(paragraphs[0].text, "Line one.\nLine two.");
6001    }
6002
6003    #[test]
6004    fn merge_into_paragraphs_drops_hyphen_when_merge_hyphenated() {
6005        let extractor = TextExtractor::with_options(ExtractionOptions {
6006            reconstruct_paragraphs: true,
6007            merge_hyphenated: true,
6008            ..Default::default()
6009        });
6010        let lines = vec![
6011            tf("Kryp-", 50.0, 400.0, 30.0, 12.0),
6012            tf("tographie", 50.0, 386.0, 60.0, 12.0),
6013        ];
6014        let paragraphs = extractor.merge_into_paragraphs(&lines);
6015        assert_eq!(paragraphs.len(), 1);
6016        assert_eq!(
6017            paragraphs[0].text, "Kryptographie",
6018            "hyphen elided, no newline inserted"
6019        );
6020    }
6021
6022    #[test]
6023    fn decode_pdf_string_utf16be_bom_decodes_fi_ligature() {
6024        let bytes = [0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69];
6025        assert_eq!(super::decode_pdf_string(&bytes), "fi");
6026    }
6027
6028    #[test]
6029    fn decode_pdf_string_ascii_pdfdocencoding_passthrough() {
6030        let bytes = b"page 12";
6031        assert_eq!(super::decode_pdf_string(bytes), "page 12");
6032    }
6033
6034    #[test]
6035    fn decode_pdf_string_empty_input_returns_empty() {
6036        assert_eq!(super::decode_pdf_string(&[]), "");
6037    }
6038
6039    #[test]
6040    fn decode_pdf_string_lone_bom_returns_empty() {
6041        // BOM only, no code units after.
6042        assert_eq!(super::decode_pdf_string(&[0xFE, 0xFF]), "");
6043    }
6044
6045    #[test]
6046    fn resolve_props_extracts_integer_mcid() {
6047        use crate::parser::content::{MarkedContentProps, MarkedContentValue};
6048        use std::collections::HashMap;
6049        let mut map = HashMap::new();
6050        map.insert("MCID".to_string(), MarkedContentValue::Integer(7));
6051        let props = MarkedContentProps::Inline(map);
6052
6053        let (mcid, actual) = super::resolve_props(&props, None);
6054        assert_eq!(mcid, Some(7));
6055        assert_eq!(actual, None);
6056    }
6057
6058    #[test]
6059    fn resolve_props_decodes_utf16be_actualtext() {
6060        use crate::parser::content::{MarkedContentProps, MarkedContentValue};
6061        use std::collections::HashMap;
6062        let mut map = HashMap::new();
6063        map.insert(
6064            "ActualText".to_string(),
6065            MarkedContentValue::String(vec![0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69]),
6066        );
6067        let props = MarkedContentProps::Inline(map);
6068
6069        let (mcid, actual) = super::resolve_props(&props, None);
6070        assert_eq!(mcid, None);
6071        assert_eq!(actual.as_deref(), Some("fi"));
6072    }
6073
6074    #[test]
6075    fn resolve_props_returns_none_for_unresolvable_resource_ref() {
6076        use crate::parser::content::MarkedContentProps;
6077        let props = MarkedContentProps::ResourceRef("PropsName".to_string());
6078        let (mcid, actual) = super::resolve_props(&props, None);
6079        assert_eq!((mcid, actual), (None, None));
6080    }
6081
6082    #[test]
6083    fn resolve_props_negative_mcid_rejected() {
6084        use crate::parser::content::{MarkedContentProps, MarkedContentValue};
6085        use std::collections::HashMap;
6086        // MCID is unsigned per ISO 32000-1; negative integer is malformed.
6087        let mut map = HashMap::new();
6088        map.insert("MCID".to_string(), MarkedContentValue::Integer(-1));
6089        let props = MarkedContentProps::Inline(map);
6090
6091        let (mcid, _) = super::resolve_props(&props, None);
6092        assert_eq!(mcid, None);
6093    }
6094
6095    #[test]
6096    fn resolve_props_resource_ref_overflow_mcid_rejected() {
6097        // ISO 32000-1 §14.7.4: MCID is an unsigned 32-bit integer. A
6098        // PdfObject::Integer holds an i64, so a malformed PDF can carry an
6099        // out-of-range MCID. The ResourceRef path must reject those rather
6100        // than wrap silently via `as u32`. Mirrors the Inline-path guard
6101        // already covered by `resolve_props_negative_mcid_rejected`.
6102        use crate::parser::content::MarkedContentProps;
6103        use crate::parser::objects::{PdfDictionary, PdfObject};
6104
6105        let mut inner = PdfDictionary::new();
6106        inner.insert("MCID".to_string(), PdfObject::Integer(i64::MAX));
6107
6108        let mut properties = PdfDictionary::new();
6109        properties.insert("PropsName".to_string(), PdfObject::Dictionary(inner));
6110
6111        let props = MarkedContentProps::ResourceRef("PropsName".to_string());
6112        let (mcid, _) = super::resolve_props(&props, Some(&properties));
6113        assert_eq!(mcid, None);
6114    }
6115
6116    #[test]
6117    fn sort_and_merge_fragments_nan_y_does_not_swallow_other_lines() {
6118        // A fragment with a non-finite Y (reachable from a degenerate text
6119        // matrix in a malformed PDF) must not chain every remaining fragment
6120        // into one pseudo-line. The tolerance filter compares with `< tol`; a
6121        // `>= tol` phrasing would let a NaN anchor never terminate the line,
6122        // collapsing the whole page into a single X-sorted "line".
6123        let extractor = TextExtractor::with_options(ExtractionOptions::default());
6124
6125        // Four well-separated lines whose X order is the reverse of their Y
6126        // (reading) order: if the NaN anchor swallows the rest, they get
6127        // re-sorted purely by X into D,C,B,A instead of the reading order.
6128        let mut fragments = vec![
6129            tf("A", 400.0, f64::NAN, 10.0, 12.0),
6130            tf("B", 300.0, 500.0, 10.0, 12.0),
6131            tf("C", 200.0, 300.0, 10.0, 12.0),
6132            tf("D", 100.0, 100.0, 10.0, 12.0),
6133        ];
6134        extractor.sort_and_merge_fragments(&mut fragments);
6135
6136        let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
6137        assert_eq!(
6138            order,
6139            vec!["A", "B", "C", "D"],
6140            "NaN-Y fragment must stay its own line; the finite lines keep \
6141             top-to-bottom reading order instead of collapsing to X order"
6142        );
6143    }
6144
6145    #[test]
6146    fn sort_and_merge_fragments_keeps_emission_regions_atomic() {
6147        let extractor = TextExtractor::with_options(ExtractionOptions::default());
6148
6149        // Region 0 is a normal top-to-bottom footer. Region 1 is an overlay
6150        // emitted later: its first line jumps back up the page, while its
6151        // second line falls numerically between the footer's two lines. A
6152        // page-wide Y-sort would produce body-1, overlay-1, overlay-2, body-2.
6153        let mut fragments = vec![
6154            tf("body-1", 50.0, 45.0, 40.0, 10.0),
6155            tf("body-2", 50.0, 30.0, 40.0, 10.0),
6156            tf("overlay-1", 250.0, 50.0, 60.0, 10.0),
6157            tf("overlay-2", 250.0, 35.0, 60.0, 10.0),
6158        ];
6159
6160        extractor.sort_and_merge_fragments(&mut fragments);
6161
6162        let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
6163        assert_eq!(
6164            order,
6165            vec!["body-1", "body-2", "overlay-1", "overlay-2"],
6166            "positional sorting must not interleave independent emission regions"
6167        );
6168    }
6169
6170    #[test]
6171    fn sort_and_merge_fragments_uses_mcid_as_a_region_boundary() {
6172        let extractor = TextExtractor::with_options(ExtractionOptions::default());
6173        let mut fragments = vec![
6174            tf("body-1", 50.0, 45.0, 40.0, 10.0),
6175            tf("body-2", 50.0, 30.0, 40.0, 10.0),
6176            // The small Y increase is below assign_row_ids' reset threshold;
6177            // MCID ownership must still keep this overlay independent.
6178            tf("overlay", 250.0, 33.0, 60.0, 10.0),
6179        ];
6180        fragments[0].mcid = Some(7);
6181        fragments[1].mcid = Some(7);
6182        fragments[2].mcid = Some(8);
6183
6184        extractor.sort_and_merge_fragments(&mut fragments);
6185
6186        let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
6187        assert_eq!(order, vec!["body-1", "body-2", "overlay"]);
6188    }
6189
6190    #[test]
6191    fn column_detection_does_not_join_independent_layout_regions() {
6192        let extractor = TextExtractor::with_options(ExtractionOptions {
6193            detect_columns: true,
6194            ..Default::default()
6195        });
6196        let mut fragments = vec![
6197            tf("a1", 0.0, 100.0, 10.0, 10.0),
6198            tf("b1", 100.0, 100.0, 10.0, 10.0),
6199            tf("a2", 0.0, 80.0, 10.0, 10.0),
6200            tf("b2", 100.0, 80.0, 10.0, 10.0),
6201            // A later overlay repeats the same column corridor and overlaps
6202            // the first region's Y span. Column detection must not combine
6203            // both into one column-major block.
6204            tf("c1", 0.0, 103.0, 10.0, 10.0),
6205            tf("d1", 100.0, 103.0, 10.0, 10.0),
6206            tf("c2", 0.0, 83.0, 10.0, 10.0),
6207            tf("d2", 100.0, 83.0, 10.0, 10.0),
6208        ];
6209
6210        extractor.sort_and_merge_fragments(&mut fragments);
6211
6212        let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
6213        assert_eq!(order, vec!["a1", "a2", "b1", "b2", "c1", "c2", "d1", "d2"]);
6214    }
6215}