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