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