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 for (font_name, entry) in
2315 crate::text::extraction_cmap::resolve_font_entries(resources, document)
2316 {
2317 match entry {
2318 crate::text::extraction_cmap::FontEntry::Indirect(num, gen) => {
2319 self.cache_font_by_ref::<R>(&font_name, (num, gen), document);
2320 }
2321 crate::text::extraction_cmap::FontEntry::Inline(font_dict) => {
2322 self.cache_inline_font::<R>(&font_name, &font_dict, document);
2323 }
2324 }
2325 }
2326 }
2327
2328 /// Cache a font written directly into the page's resources.
2329 ///
2330 /// Unlike [`Self::cache_font_by_ref`] this cannot touch the persistent
2331 /// cache: an inline dictionary has no object id to key on, and two pages
2332 /// may write different fonts under the same name. It is parsed per page.
2333 fn cache_inline_font<R: Read + Seek>(
2334 &mut self,
2335 font_name: &str,
2336 font_dict: &PdfDictionary,
2337 document: &PdfDocument<R>,
2338 ) {
2339 let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
2340 if let Ok(font_info) = cmap_extractor.extract_font_info(font_dict, document) {
2341 tracing::debug!(
2342 "Parsed inline font {} (ToUnicode: {})",
2343 font_name,
2344 font_info.to_unicode.is_some()
2345 );
2346 self.font_cache.insert(font_name.to_string(), font_info);
2347 }
2348 }
2349
2350 /// Cache a font, reusing the persistent object cache when possible.
2351 fn cache_font_by_ref<R: Read + Seek>(
2352 &mut self,
2353 font_name: &str,
2354 font_ref: (u32, u16),
2355 document: &PdfDocument<R>,
2356 ) {
2357 // Check persistent object cache first — avoids re-parsing across pages
2358 if let Some(cached) = self.font_object_cache.get(&font_ref) {
2359 self.font_cache
2360 .insert(font_name.to_string(), cached.clone());
2361 tracing::debug!(
2362 "Reused cached font object ({}, {}): {} (ToUnicode: {})",
2363 font_ref.0,
2364 font_ref.1,
2365 font_name,
2366 cached.to_unicode.is_some()
2367 );
2368 return;
2369 }
2370
2371 // Parse font object
2372 if let Ok(PdfObject::Dictionary(font_dict)) = document.get_object(font_ref.0, font_ref.1) {
2373 let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
2374 if let Ok(font_info) = cmap_extractor.extract_font_info(&font_dict, document) {
2375 let has_to_unicode = font_info.to_unicode.is_some();
2376 // Store in persistent cache
2377 self.font_object_cache.insert(font_ref, font_info.clone());
2378 // Store in per-page name cache
2379 self.font_cache.insert(font_name.to_string(), font_info);
2380 tracing::debug!(
2381 "Parsed and cached font ({}, {}): {} (ToUnicode: {})",
2382 font_ref.0,
2383 font_ref.1,
2384 font_name,
2385 has_to_unicode
2386 );
2387 }
2388 }
2389 }
2390
2391 /// Decode text using the current font encoding and ToUnicode mapping
2392 fn decode_text(&self, text: &[u8], state: &TextState) -> ParseResult<String> {
2393 use crate::text::encoding::TextEncoding;
2394
2395 // First, try to use cached font information with ToUnicode CMap
2396 if let Some(ref font_name) = state.font_name {
2397 if let Some(font_info) = self.font_cache.get(font_name) {
2398 // Try CMap-based decoding first (free function — no allocation)
2399 if let Ok(decoded) =
2400 crate::text::extraction_cmap::decode_text_with_font(text, font_info)
2401 {
2402 // Only accept if we got meaningful text (not all null bytes
2403 // or garbage). Whitespace counts as meaningful: a decode
2404 // that is exactly a space is a space, not a failed decode
2405 // (#438). See `decode_is_usable`.
2406 if crate::text::extraction_cmap::decode_is_usable(&decoded) {
2407 // Apply sanitization to remove control characters (Issue #116)
2408 let sanitized = sanitize_extracted_text(&decoded);
2409 tracing::debug!(
2410 "Successfully decoded text using CMap for font {}: {:?} -> \"{}\"",
2411 font_name,
2412 text,
2413 sanitized
2414 );
2415 return Ok(sanitized);
2416 }
2417 }
2418
2419 tracing::debug!(
2420 "CMap decoding failed or produced garbage for font {}, falling back to encoding",
2421 font_name
2422 );
2423 }
2424 }
2425
2426 // Fall back to encoding-based decoding
2427 let encoding = if let Some(ref font_name) = state.font_name {
2428 match font_name.to_lowercase().as_str() {
2429 name if name.contains("macroman") => TextEncoding::MacRomanEncoding,
2430 name if name.contains("winansi") => TextEncoding::WinAnsiEncoding,
2431 name if name.contains("standard") => TextEncoding::StandardEncoding,
2432 name if name.contains("pdfdoc") => TextEncoding::PdfDocEncoding,
2433 _ => {
2434 // Default based on common patterns
2435 if font_name.starts_with("Times")
2436 || font_name.starts_with("Helvetica")
2437 || font_name.starts_with("Courier")
2438 {
2439 TextEncoding::WinAnsiEncoding // Most common for standard fonts
2440 } else {
2441 TextEncoding::PdfDocEncoding // Safe default
2442 }
2443 }
2444 }
2445 } else {
2446 TextEncoding::WinAnsiEncoding // Default for most PDFs
2447 };
2448
2449 let fallback_result = encoding.decode(text);
2450 // Apply sanitization to remove control characters (Issue #116)
2451 let sanitized = sanitize_extracted_text(&fallback_result);
2452 tracing::debug!(
2453 "Fallback encoding decoding: {:?} -> \"{}\"",
2454 text,
2455 sanitized
2456 );
2457 Ok(sanitized)
2458 }
2459}
2460
2461impl Default for TextExtractor {
2462 fn default() -> Self {
2463 Self::new()
2464 }
2465}
2466
2467/// Emit a `TextFragment` for one decoded text-show event under `preserve_layout`.
2468///
2469/// Encapsulates the style-derivation + push sequence shared by every
2470/// text-show operator handler in `extract_from_page` (`Tj`, `TJ`, `'`,
2471/// `"`). The caller supplies the pen origin `(x, y)` already mapped to
2472/// user space (typically via `text_origin(&state)`); doing so avoids the
2473/// double `multiply_matrix + transform_point` that prior versions did
2474/// (handler computed it for `last_x`/`last_y`, then this fn recomputed
2475/// it on the same `state`).
2476///
2477/// Skips emission when an ancestor in the marked-content stack is `/Artifact`
2478/// and `include_artifacts` is false. When a pending ActualText run is
2479/// active in the current scope, accumulates the text-width contribution and
2480/// records the first origin instead of pushing a fragment (the run is flushed
2481/// once on EMC, see Task 8's EndMarkedContent handler).
2482///
2483/// `mcid` and `struct_tag` come from the innermost ancestor on the stack that
2484/// declared `/MCID`; non-tagged content leaves both as `None`.
2485/// Whether the current marked-content stack should suppress text emission.
2486///
2487/// Mirrors the gate inside [`emit_text_fragment`]: when an ancestor in the
2488/// stack is `/Artifact` and the caller has not opted into artifact content
2489/// via `include_artifacts`, neither `.text` nor `.fragments` should receive
2490/// the run. Used by the four show-text operator arms to keep `extracted_text`
2491/// and `fragments` symmetric — a page whose entire content is an
2492/// `/Artifact BMC … EMC` scope (the common pattern for screen-reader-skipped
2493/// disclaimers / footers / decorative tagged-PDF content) used to surface
2494/// text in `.text` while leaving `.fragments` empty, silently dropping the
2495/// page from `partition_with(...)` / `rag_chunks(...)` (issue #330).
2496fn skip_artifact_text(state: &TextState, include_artifacts: bool) -> bool {
2497 !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact)
2498}
2499
2500/// Append an optional `separator` plus `decoded` to `acc`, honouring the
2501/// per-page byte budget `limit` (issue #382).
2502///
2503/// Returns `true` when the run was appended. Returns `false` — appending
2504/// nothing and setting `*truncated` — when the combined bytes would exceed
2505/// `limit`. The separator is counted against the budget so the invariant
2506/// `acc.len() <= limit` holds *exactly*, and because whole runs are the unit of
2507/// truncation a multi-byte UTF-8 character is never split (undershoot
2508/// semantics). A `None` limit always appends and never truncates, keeping the
2509/// no-limit path byte-identical to before. Once `*truncated` is set the helper
2510/// is a no-op, so a caller that keeps calling it after the budget is reached
2511/// simply accumulates nothing further.
2512fn append_bounded(
2513 acc: &mut String,
2514 separator: Option<char>,
2515 decoded: &str,
2516 limit: Option<usize>,
2517 truncated: &mut bool,
2518) -> bool {
2519 if *truncated {
2520 return false;
2521 }
2522 if let Some(max) = limit {
2523 let add = separator.map_or(0, char::len_utf8) + decoded.len();
2524 if acc.len() + add > max {
2525 *truncated = true;
2526 return false;
2527 }
2528 }
2529 if let Some(sep) = separator {
2530 acc.push(sep);
2531 }
2532 acc.push_str(decoded);
2533 true
2534}
2535
2536/// Defensive final clamp of a page's text to the byte budget (issue #382).
2537///
2538/// The `preserve_layout` / `reorder_columns` paths rebuild `.text` from the
2539/// already-bounded fragment set via `reconstruct_text_from_fragments`, which
2540/// reorders fragments and inserts its own separators — so the reconstructed
2541/// length is not provably `<= limit` from the accumulation-time accounting
2542/// alone. This clamps the result to `limit` at a UTF-8 char boundary (never
2543/// splitting a character) and sets `*truncated` if it had to cut, making the
2544/// `text.len() <= max_extracted_bytes` invariant hold for *every* path. A no-op
2545/// when there is no limit or the text already fits.
2546fn clamp_to_budget(text: &mut String, limit: Option<usize>, truncated: &mut bool) {
2547 if let Some(max) = limit {
2548 if text.len() > max {
2549 let mut cut = max;
2550 while cut > 0 && !text.is_char_boundary(cut) {
2551 cut -= 1;
2552 }
2553 text.truncate(cut);
2554 *truncated = true;
2555 }
2556 }
2557}
2558
2559fn emit_text_fragment(
2560 fragments: &mut Vec<TextFragment>,
2561 decoded: &str,
2562 text_width: f64,
2563 x: f64,
2564 y: f64,
2565 state: &mut TextState,
2566 include_artifacts: bool,
2567) {
2568 if decoded.is_empty() {
2569 return;
2570 }
2571
2572 // Artifact filter (default: skip emission for Artifact subtrees).
2573 if !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact) {
2574 return;
2575 }
2576
2577 let (is_bold, is_italic) = state
2578 .font_name
2579 .as_ref()
2580 .map(|name| parse_font_style(name))
2581 .unwrap_or((false, false));
2582
2583 // Issue #262: font_size, height, and width must be in page space so that
2584 // downstream heuristics (line/paragraph reconstruction, header/footer zone
2585 // detection, table detection) reason about real geometry. `x` and `y` are
2586 // already page-space (caller transforms via `text_origin`); we still need
2587 // to scale the size/width fields by the combined `text_matrix × CTM`.
2588 let combined = multiply_matrix(&state.text_matrix, &state.ctm);
2589 let x_scale = (combined[0] * combined[0] + combined[1] * combined[1]).sqrt();
2590 let y_scale = (combined[2] * combined[2] + combined[3] * combined[3]).sqrt();
2591 let effective_width = text_width * x_scale;
2592 let effective_size = state.font_size * y_scale;
2593
2594 // If a pending ActualText run is active in the current scope, accumulate
2595 // into it instead of emitting a fragment now. The run is flushed on the
2596 // matching EMC by the EndMarkedContent arm (Task 8).
2597 // Hoist font_name/fill_color reads before taking &mut on pending_actualtext
2598 // to avoid borrow-checker conflicts with the disjoint fields.
2599 let local_font_name = state.font_name.clone();
2600 let local_fill_color = state.fill_color;
2601 if let Some(pending) = state.pending_actualtext.as_mut() {
2602 if !pending.populated {
2603 pending.first_x = x;
2604 pending.first_y = y;
2605 pending.font_size = effective_size;
2606 pending.font_name = local_font_name;
2607 pending.is_bold = is_bold;
2608 pending.is_italic = is_italic;
2609 pending.color = local_fill_color;
2610 pending.populated = true;
2611 }
2612 pending.width += effective_width;
2613 return;
2614 }
2615
2616 let (mcid, struct_tag) = innermost_mc_tag(&state.mc_stack);
2617
2618 fragments.push(TextFragment {
2619 text: decoded.to_owned(),
2620 x,
2621 y,
2622 width: effective_width,
2623 height: effective_size,
2624 font_size: effective_size,
2625 font_name: state.font_name.clone(),
2626 is_bold,
2627 is_italic,
2628 color: state.fill_color,
2629 space_decisions: Vec::new(),
2630 mcid,
2631 struct_tag,
2632 });
2633}
2634
2635/// Pen origin (user-space coordinates) of the next glyph in the current
2636/// text state.
2637///
2638/// Per ISO 32000-1 §8.3.4, the text rendering matrix is `Tm × CTM` (row-vector
2639/// convention). `multiply_matrix(a, b)` returns the matrix that applies `a`
2640/// first and then `b`, so the correct composition is
2641/// `multiply_matrix(text_matrix, ctm)`. Prior to issue #262 this used the
2642/// reverse order which gave correct results only when the CTM was an identity
2643/// or pure-translation matrix; non-uniform CTM scaling produced wrong origins.
2644fn text_origin(state: &TextState) -> (f64, f64) {
2645 let combined = multiply_matrix(&state.text_matrix, &state.ctm);
2646 transform_point(0.0, 0.0, &combined)
2647}
2648
2649/// Advance the text matrix by one shown glyph run of unscaled width
2650/// `text_width` and return the pen's new x in user space.
2651///
2652/// The advance applied to the text matrix is `text_width * Tz/100`
2653/// (`state.horizontal_scale`), and the resulting user-space displacement also
2654/// folds in the CTM's x-scale. The caller's `last_x` (used for `dx`-based
2655/// space decisions) must therefore come from the post-advance pen origin, not
2656/// from `origin_x + text_width`, which ignores both factors and trails the
2657/// real pen whenever `Tz != 100` or the CTM scales x (issue #386).
2658fn advance_pen(state: &mut TextState, text_width: f64) -> (f64, f64) {
2659 let tx = text_width * state.horizontal_scale / 100.0;
2660 state.text_matrix = multiply_matrix(&[1.0, 0.0, 0.0, 1.0, tx, 0.0], &state.text_matrix);
2661 text_origin(state)
2662}
2663
2664/// Projection-noise floor for the perpendicular pen delta. Same-baseline
2665/// glyph runs produce a `dy` that is exactly 0 in real arithmetic but can
2666/// carry ~1e-13 of float rounding after the baseline projection; anything
2667/// below this epsilon is "the same baseline". The smallest meaningful
2668/// leading in real documents is orders of magnitude above it.
2669const SAME_LINE_EPS: f64 = 1e-6;
2670
2671/// Backward-jump magnitude, in multiples of the font size, above which a
2672/// same-baseline (`dy == 0`) backward pen jump is a line wrap rather than a
2673/// glyph reposition (issue #447).
2674///
2675/// At `dy == 0` a backward jump is ambiguous: a same-line reposition
2676/// (justification, kerned overlay, out-of-order emission — issue #441) and a
2677/// real wrap whose two lines happen to land on the same content-stream Y
2678/// (issue #447) both produce it. They separate by MAGNITUDE: a reposition is
2679/// local (a word/phrase — a few em), while a wrap returns across the whole
2680/// text column (many em). This bound sits in that gap, scaled to font size
2681/// because the reposition scale is the glyph/word scale, not the fixed
2682/// paragraph-break `newline_threshold`. Scaled to `font_size.abs()`: `Tf`
2683/// accepts negative sizes (mirrored text), and the sign must not flip the
2684/// threshold's sense — otherwise a negative size makes every backward jump a
2685/// "wrap" and resurrects the #441 defect.
2686///
2687/// Accepted, documented limitation (the #417/#422 trade-off): a same-line
2688/// reposition that jumps back more than this many em is misread as a wrap, and
2689/// a same-Y wrap of a line shorter than this is glued. Both are rare and
2690/// neither loses a glyph — only the separator is wrong. A wrap with any
2691/// nonzero leading (the common case, issue #390) is unaffected: it breaks on
2692/// the `dy`-aware gate regardless of magnitude.
2693const SAME_Y_WRAP_EM: f64 = 10.0;
2694
2695/// Pen movement from the previous post-advance pen point `last` to the
2696/// current glyph origin `cur` (both user space), measured in the frame of the
2697/// current text baseline (issue #443): `dx` along the baseline direction,
2698/// `dy` perpendicular to it (signed; callers take `.abs()` for line
2699/// detection).
2700///
2701/// The baseline direction is the image of the text-space x-axis under the
2702/// text rendering matrix `Tm × CTM`. For an axis-aligned matrix
2703/// (identity/translation/positive scale — the overwhelming majority of
2704/// content) the baseline IS the user-space x-axis and this returns exactly
2705/// `(Δx, Δy)`, the pre-#443 behavior. Under a rotated CTM (and any
2706/// similarity transform) the projection recovers the text's own line
2707/// geometry exactly, which raw user-space deltas conflate: a plain forward
2708/// advance along a rotated baseline changes the user-space y, which the
2709/// separator heuristics misread as a line change. Axis-aligned shear
2710/// (`b == 0`, `c != 0`) also projects exactly (the perpendicular reduces to
2711/// the y-axis); a shear COMBINED with a rotated baseline is approximated —
2712/// the perpendicular is built by rotating the baseline 90°, not from the
2713/// true image of the text-space y-axis.
2714///
2715/// A mirrored baseline (negative x-scale) measures `dx` along the text's own
2716/// advance direction, so a forward advance is positive `dx` — the spacing
2717/// and wrap gates apply as for unmirrored text (pre-#443 they saw a raw
2718/// negative `dx` and misfired the wrap gate on plain advances).
2719///
2720/// A degenerate baseline (zero-length or non-finite) falls back to the raw
2721/// user-space deltas, preserving pre-#443 behavior for malformed matrices.
2722fn pen_delta(state: &TextState, last: (f64, f64), cur: (f64, f64)) -> (f64, f64) {
2723 let dxu = cur.0 - last.0;
2724 let dyu = cur.1 - last.1;
2725 let m = multiply_matrix(&state.text_matrix, &state.ctm);
2726 let (bx, by) = (m[0], m[1]);
2727 let norm = (bx * bx + by * by).sqrt();
2728 if !norm.is_finite() || norm <= f64::EPSILON {
2729 return (dxu, dyu);
2730 }
2731 let (ux, uy) = (bx / norm, by / norm);
2732 (dxu * ux + dyu * uy, -dxu * uy + dyu * ux)
2733}
2734
2735/// Multiply two transformation matrices
2736fn multiply_matrix(a: &[f64; 6], b: &[f64; 6]) -> [f64; 6] {
2737 [
2738 a[0] * b[0] + a[1] * b[2],
2739 a[0] * b[1] + a[1] * b[3],
2740 a[2] * b[0] + a[3] * b[2],
2741 a[2] * b[1] + a[3] * b[3],
2742 a[4] * b[0] + a[5] * b[2] + b[4],
2743 a[4] * b[1] + a[5] * b[3] + b[5],
2744 ]
2745}
2746
2747/// Decode a PDF string operand into Rust `String`.
2748///
2749/// A string inside marked-content properties (notably `/ActualText`) is a PDF
2750/// text string like any other, so this is
2751/// [`PdfString::to_text`](crate::parser::objects::PdfString::to_text): UTF-16BE
2752/// when a byte order mark is present — the canonical encoding for non-ASCII
2753/// `/ActualText`, e.g. an `fi` ligature or a Greek symbol — and the WinAnsi
2754/// reading of PDFDocEncoding otherwise. Before that helper existed this mapped
2755/// non-BOM bytes to `char` one by one, which is Latin-1 and wrong for the
2756/// typographic punctuation WinAnsi puts in `0x80..=0x9F`.
2757fn decode_pdf_string(bytes: &[u8]) -> String {
2758 crate::parser::objects::decode_text_string(bytes)
2759}
2760
2761/// Resolve a `MarkedContentProps` to `(mcid, actual_text)`.
2762///
2763/// For `Inline` props, walk the map: `/MCID` (Integer, must fit in `u32`)
2764/// becomes `mcid`; `/ActualText` (String) is decoded via `decode_pdf_string`.
2765///
2766/// For `ResourceRef(name)`, look up `properties.get(name)`. If found and
2767/// it's a Dictionary, extract `/MCID` and `/ActualText` from there. If
2768/// not found (or the named entry is not a dict), return `(None, None)`
2769/// — a malformed reference must not abort extraction.
2770fn resolve_props(
2771 props: &crate::parser::content::MarkedContentProps,
2772 properties: Option<&crate::parser::objects::PdfDictionary>,
2773) -> (Option<u32>, Option<String>) {
2774 use crate::parser::content::{MarkedContentProps, MarkedContentValue};
2775
2776 let map_mcid_actual =
2777 |map: &std::collections::HashMap<String, MarkedContentValue>| -> (Option<u32>, Option<String>) {
2778 let mcid = match map.get("MCID") {
2779 Some(MarkedContentValue::Integer(n)) if *n >= 0 && *n <= u32::MAX as i64 => {
2780 Some(*n as u32)
2781 }
2782 _ => None,
2783 };
2784 let actual = match map.get("ActualText") {
2785 Some(MarkedContentValue::String(bytes)) => Some(decode_pdf_string(bytes)),
2786 _ => None,
2787 };
2788 (mcid, actual)
2789 };
2790
2791 match props {
2792 MarkedContentProps::Inline(map) => map_mcid_actual(map),
2793 MarkedContentProps::ResourceRef(name) => {
2794 let Some(properties) = properties else {
2795 return (None, None);
2796 };
2797 let Some(entry) = properties.get(name) else {
2798 return (None, None);
2799 };
2800 let crate::parser::objects::PdfObject::Dictionary(dict) = entry else {
2801 return (None, None);
2802 };
2803 let mcid = dict.get("MCID").and_then(|o| match o {
2804 crate::parser::objects::PdfObject::Integer(n)
2805 if *n >= 0 && *n <= u32::MAX as i64 =>
2806 {
2807 Some(*n as u32)
2808 }
2809 _ => None,
2810 });
2811 let actual_text = dict.get("ActualText").and_then(|o| match o {
2812 crate::parser::objects::PdfObject::String(s) => {
2813 Some(decode_pdf_string(s.as_bytes()))
2814 }
2815 _ => None,
2816 });
2817 (mcid, actual_text)
2818 }
2819 }
2820}
2821
2822/// Walk the marked-content stack from innermost (top) outward, returning the
2823/// first entry's `(mcid, tag)` pair whose `mcid` is `Some`. Returns
2824/// `(None, None)` when no ancestor declared an MCID — typical of non-tagged
2825/// PDFs, in which case the `None == None` grouping-key invariant preserves
2826/// legacy behaviour.
2827fn innermost_mc_tag(stack: &[MarkedContentEntry]) -> (Option<u32>, Option<String>) {
2828 stack
2829 .iter()
2830 .rev()
2831 .find(|e| e.mcid.is_some())
2832 .map_or((None, None), |e| (e.mcid, Some(e.tag.clone())))
2833}
2834
2835/// Transform a point using a transformation matrix
2836fn transform_point(x: f64, y: f64, matrix: &[f64; 6]) -> (f64, f64) {
2837 let tx = matrix[0] * x + matrix[2] * y + matrix[4];
2838 let ty = matrix[1] * x + matrix[3] * y + matrix[5];
2839 (tx, ty)
2840}
2841
2842/// Calculate text width using actual font metrics (including kerning)
2843fn calculate_text_width(text: &str, font_size: f64, font_info: Option<&FontInfo>) -> f64 {
2844 // If we have font metrics, use them for accurate width calculation
2845 if let Some(font) = font_info {
2846 if let Some(ref widths) = font.metrics.widths {
2847 let first_char = font.metrics.first_char.unwrap_or(0);
2848 let last_char = font.metrics.last_char.unwrap_or(255);
2849 let missing_width = font.metrics.missing_width.unwrap_or(500.0);
2850
2851 let mut total_width = 0.0;
2852 let mut chars = text.chars().peekable();
2853
2854 while let Some(ch) = chars.next() {
2855 let char_code = ch as u32;
2856
2857 // Get width from Widths array or use missing_width
2858 let width = if char_code >= first_char && char_code <= last_char {
2859 let index = (char_code - first_char) as usize;
2860 widths.get(index).copied().unwrap_or(missing_width)
2861 } else {
2862 missing_width
2863 };
2864
2865 // Convert from glyph space (1/1000 units) to user space
2866 total_width += width / 1000.0 * font_size;
2867
2868 // Apply kerning if available (for character pairs)
2869 if let Some(ref kerning) = font.metrics.kerning {
2870 if let Some(&next_ch) = chars.peek() {
2871 let next_char = next_ch as u32;
2872 if let Some(&kern_value) = kerning.get(&(char_code, next_char)) {
2873 // Kerning is in FUnits (1/1000), convert to user space
2874 total_width += kern_value / 1000.0 * font_size;
2875 }
2876 }
2877 }
2878 }
2879
2880 return total_width;
2881 }
2882 }
2883
2884 // Fallback to simplified calculation if no metrics available
2885 text.len() as f64 * font_size * 0.5
2886}
2887
2888/// Compute advance width from the original character **codes**, not the decoded
2889/// Unicode text.
2890///
2891/// A simple font's `Widths` array is indexed by character code (`first_char..=
2892/// last_char`), i.e. the byte value in the content stream — not by the Unicode
2893/// codepoint the code decodes to. [`calculate_text_width`] indexes by the decoded
2894/// codepoint (`ch as u32`), which is correct only when code == codepoint (ASCII /
2895/// WinAnsi fonts). For custom-encoded fonts (Type1 with `Differences`, embedded
2896/// Computer Modern in LaTeX PDFs, ToUnicode remaps) the codepoint diverges from
2897/// the code, so the wrong slot — or `missing_width` — is read, desyncing glyph
2898/// advance and scrambling word order once fragments are sorted by position
2899/// (issue #302).
2900///
2901/// `decoded` is the already-decoded text for this run; it is only consulted for
2902/// composite (Type0) fonts, whose multi-byte codes cannot be indexed byte-wise
2903/// and whose width path is unchanged here to avoid regressing CJK extraction.
2904fn calculate_text_width_from_codes(
2905 codes: &[u8],
2906 decoded: &str,
2907 font_size: f64,
2908 font_info: Option<&FontInfo>,
2909) -> f64 {
2910 // Composite (Type0) fonts use multi-byte codes; a single byte is not a code,
2911 // so byte-indexed width lookup is invalid. Preserve the existing decoded-based
2912 // behavior for them.
2913 let is_composite =
2914 font_info.is_some_and(|f| f.font_type == "Type0" || f.descendant_font.is_some());
2915 if is_composite {
2916 return calculate_text_width(decoded, font_size, font_info);
2917 }
2918
2919 if let Some(font) = font_info {
2920 if let Some(ref widths) = font.metrics.widths {
2921 let first_char = font.metrics.first_char.unwrap_or(0);
2922 let last_char = font.metrics.last_char.unwrap_or(255);
2923 let missing_width = font.metrics.missing_width.unwrap_or(500.0);
2924
2925 let mut total_width = 0.0;
2926 let mut iter = codes.iter().peekable();
2927 while let Some(&byte) = iter.next() {
2928 let code = byte as u32;
2929 let width = if code >= first_char && code <= last_char {
2930 widths
2931 .get((code - first_char) as usize)
2932 .copied()
2933 .unwrap_or(missing_width)
2934 } else {
2935 missing_width
2936 };
2937 total_width += width / 1000.0 * font_size;
2938
2939 // Kerning is keyed by code pair, consistent with code-based widths.
2940 if let Some(ref kerning) = font.metrics.kerning {
2941 if let Some(&next_byte) = iter.peek() {
2942 if let Some(&kern_value) = kerning.get(&(code, *next_byte as u32)) {
2943 total_width += kern_value / 1000.0 * font_size;
2944 }
2945 }
2946 }
2947 }
2948
2949 return total_width;
2950 }
2951 }
2952
2953 // No metrics: one fallback width per code (byte), the simple-font glyph count.
2954 codes.len() as f64 * font_size * 0.5
2955}
2956
2957/// Sanitize extracted text by removing or replacing control characters.
2958///
2959/// This function addresses Issue #116 where extracted text contains NUL bytes (`\0`)
2960/// and ETX characters (`\u{3}`) where spaces should appear.
2961///
2962/// # Behavior
2963///
2964/// - Replaces `\0\u{3}` sequences with a single space (common word separator pattern)
2965/// - Replaces standalone `\0` (NUL) with space
2966/// - Removes other ASCII control characters (0x01-0x1F) except:
2967/// - `\t` (0x09) - Tab
2968/// - `\n` (0x0A) - Line feed
2969/// - `\r` (0x0D) - Carriage return
2970/// - Collapses multiple consecutive spaces into a single space
2971///
2972/// # Examples
2973///
2974/// ```
2975/// use oxidize_pdf::text::extraction::sanitize_extracted_text;
2976///
2977/// // Issue #116 pattern: NUL+ETX as word separator
2978/// let dirty = "a\0\u{3}sergeant\0\u{3}and";
2979/// assert_eq!(sanitize_extracted_text(dirty), "a sergeant and");
2980///
2981/// // Standalone NUL becomes space
2982/// let with_nul = "word\0another";
2983/// assert_eq!(sanitize_extracted_text(with_nul), "word another");
2984///
2985/// // Clean text passes through unchanged
2986/// let clean = "Normal text";
2987/// assert_eq!(sanitize_extracted_text(clean), "Normal text");
2988/// ```
2989pub fn sanitize_extracted_text(text: &str) -> String {
2990 if text.is_empty() {
2991 return String::new();
2992 }
2993
2994 // Pre-allocate with same capacity (result will be <= input length)
2995 let mut result = String::with_capacity(text.len());
2996 let mut chars = text.chars().peekable();
2997 let mut last_was_space = false;
2998
2999 while let Some(ch) = chars.next() {
3000 match ch {
3001 // NUL byte - check if followed by ETX for the \0\u{3} pattern
3002 '\0' => {
3003 // Peek at next char to detect \0\u{3} sequence
3004 if chars.peek() == Some(&'\u{3}') {
3005 chars.next(); // consume the ETX
3006 }
3007 // In both cases (standalone NUL or NUL+ETX), emit space
3008 if !last_was_space {
3009 result.push(' ');
3010 last_was_space = true;
3011 }
3012 }
3013
3014 // ETX alone (not preceded by NUL) - remove it
3015 '\u{3}' => {
3016 // Don't emit anything, just skip
3017 }
3018
3019 // Preserve allowed whitespace
3020 '\t' | '\n' | '\r' => {
3021 result.push(ch);
3022 // Reset space tracking on newlines but not tabs
3023 last_was_space = ch == '\t';
3024 }
3025
3026 // Regular space - collapse multiples
3027 ' ' => {
3028 if !last_was_space {
3029 result.push(' ');
3030 last_was_space = true;
3031 }
3032 }
3033
3034 // Other control characters (0x01-0x1F except tab/newline/CR) - remove
3035 c if c.is_ascii_control() => {
3036 // Skip control characters
3037 }
3038
3039 // Normal characters - keep them
3040 _ => {
3041 result.push(ch);
3042 last_was_space = false;
3043 }
3044 }
3045 }
3046
3047 result
3048}
3049
3050/// Assign a logical row identifier to each fragment based on Y-up-jumps in
3051/// emission order. Used by `merge_into_lines` to distinguish columns in
3052/// multi-column layouts where a single outer BDC scope makes mcid uniform.
3053///
3054/// Increments `row_id` whenever the next fragment's Y exceeds the previous
3055/// by more than `max(font_size * 0.5, 2.0)`. Superscripts (small positive
3056/// deltas) and normal line descents (negative deltas) leave `row_id`
3057/// unchanged. See `docs/superpowers/specs/2026-05-23-issue-265-line-interleaving-design.md`.
3058///
3059/// # Invariants
3060/// Returns a `Vec<u32>` with exactly `fragments.len()` elements — one
3061/// row id per input fragment, in input order. Callers may safely `.zip(fragments)`.
3062fn assign_row_ids(fragments: &[TextFragment]) -> Vec<u32> {
3063 let mut result = Vec::with_capacity(fragments.len());
3064 let mut row_id: u32 = 0;
3065 let mut prev_y: Option<f64> = None;
3066 for frag in fragments {
3067 if let Some(py) = prev_y {
3068 let delta = frag.y - py;
3069 // Threshold anchored to the arriving fragment's font_size; for the
3070 // symmetric same-font case (body→body, same font) this is equivalent
3071 // to anchoring to the previous fragment.
3072 let threshold = (frag.font_size * 0.5).max(2.0);
3073 if delta > threshold {
3074 row_id += 1;
3075 }
3076 }
3077 result.push(row_id);
3078 prev_y = Some(frag.y);
3079 }
3080 debug_assert_eq!(
3081 result.len(),
3082 fragments.len(),
3083 "assign_row_ids: output length must equal input length"
3084 );
3085 result
3086}
3087
3088/// Decide whether a single visual line should be read in emission order.
3089///
3090/// `line` holds `(emission_index, fragment)` pairs for one visual line in any
3091/// order. Returns `true` when, walked in emission order, the line has no
3092/// DISJOINT backward x-step — i.e. no fragment lands entirely to the LEFT of
3093/// everything emitted so far on the line. Such a left jump is the signature of
3094/// a genuinely scrambled stream (right-to-left / random generators), for which
3095/// x-order is authoritative.
3096///
3097/// The comparison is against the line's running left edge, not the immediately
3098/// preceding fragment: dense bodies are split into sub-word glyph runs, so a
3099/// run that legitimately backfills the line (a font-switched math symbol, or a
3100/// word whose run starts left of the previous short run — #302 symptom 1 /
3101/// #305) overlaps the *covered span* even when it does not overlap the single
3102/// fragment right before it. As long as it does not jump past the line's left
3103/// edge, emission order is preserved. Lines that are already x-monotone in
3104/// emission satisfy this trivially and decode identically under either policy.
3105fn line_prefers_emission_order(line: &[(usize, &TextFragment)]) -> bool {
3106 if line.len() < 2 {
3107 return true;
3108 }
3109 let mut em: Vec<&(usize, &TextFragment)> = line.iter().collect();
3110 em.sort_by_key(|&&(idx, _)| idx);
3111 let mut min_start = em[0].1.x;
3112 for &&(_, f) in &em[1..] {
3113 let end = f.x + f.width;
3114 // A fragment whose right edge is at or left of the leftmost glyph seen
3115 // so far is a true backward jump — emission order is not reading order.
3116 if end <= min_start {
3117 return false;
3118 }
3119 min_start = min_start.min(f.x);
3120 }
3121 true
3122}
3123
3124/// Space-glyph advance width (1000-em units) for the Adobe Core-14 base fonts,
3125/// keyed by `/BaseFont`. Subset prefixes (`ABCDEF+`) are stripped; common
3126/// substitute names (Arial→Helvetica, TimesNewRoman→Times, CourierNew→Courier)
3127/// map to their metric-compatible base. Returns `None` for unknown fonts, which
3128/// leaves the caller on its fixed-fraction fallback. These fonts legitimately
3129/// ship no `/Widths` array, so their space metric is only available here.
3130fn standard_14_space_width(base_font: &str) -> Option<f64> {
3131 let name = base_font.rsplit('+').next().unwrap_or(base_font);
3132 let lower = name.to_ascii_lowercase();
3133 if lower.contains("courier") {
3134 Some(600.0)
3135 } else if lower.contains("helvetica") || lower.contains("arial") {
3136 Some(278.0)
3137 } else if lower.contains("times") {
3138 Some(250.0)
3139 } else if lower == "symbol" {
3140 Some(250.0)
3141 } else if lower.contains("zapfdingbats") || lower.contains("dingbats") {
3142 Some(278.0)
3143 } else {
3144 None
3145 }
3146}
3147
3148#[cfg(test)]
3149mod tests {
3150 use super::*;
3151
3152 // ── issue #443: baseline-frame pen deltas ────────────────────────────────
3153
3154 fn state_with_ctm(ctm: [f64; 6]) -> TextState {
3155 TextState {
3156 ctm,
3157 ..Default::default()
3158 }
3159 }
3160
3161 #[test]
3162 fn pen_delta_identity_matrix_returns_raw_deltas() {
3163 let state = state_with_ctm([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3164 let (dx, dy) = pen_delta(&state, (10.0, 20.0), (14.5, 17.0));
3165 assert_eq!((dx, dy), (4.5, -3.0), "axis-aligned = raw Δx/Δy exactly");
3166 }
3167
3168 #[test]
3169 fn pen_delta_rotation_recovers_text_space_advance() {
3170 // 30° rotation; the pen advances 5 units along the rotated baseline.
3171 let (s30, c30) = 30f64.to_radians().sin_cos();
3172 let state = state_with_ctm([c30, s30, -s30, c30, 0.0, 0.0]);
3173 let (dx, dy) = pen_delta(&state, (0.0, 0.0), (5.0 * c30, 5.0 * s30));
3174 assert!((dx - 5.0).abs() < 1e-12, "advance recovered: {dx}");
3175 assert!(dy.abs() < 1e-12, "same baseline → dy ≈ 0: {dy}");
3176 assert!(
3177 dy.abs() < SAME_LINE_EPS,
3178 "noise below the same-line epsilon"
3179 );
3180 }
3181
3182 #[test]
3183 fn pen_delta_mirrored_baseline_measures_advance_direction() {
3184 // Horizontal mirror: a forward text-space advance moves the pen LEFT
3185 // in user space. dx must still be positive (the text's own advance
3186 // direction), so the wrap gate does not misfire on plain advances.
3187 let state = state_with_ctm([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3188 let (dx, dy) = pen_delta(&state, (100.0, 50.0), (95.0, 50.0));
3189 assert_eq!(dx, 5.0, "forward advance is positive along the baseline");
3190 assert_eq!(dy.abs(), 0.0, "same baseline");
3191 }
3192
3193 #[test]
3194 fn pen_delta_degenerate_matrix_falls_back_to_raw_deltas() {
3195 // Zero baseline (a=b=0): projection impossible → raw user-space
3196 // deltas, the pre-#443 behavior.
3197 let state = state_with_ctm([0.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3198 assert_eq!(pen_delta(&state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
3199 // Non-finite baseline: same fallback.
3200 let nan_state = state_with_ctm([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]);
3201 assert_eq!(pen_delta(&nan_state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
3202 }
3203
3204 // ── issue #382: per-page byte-budget helper ──────────────────────────────
3205
3206 #[test]
3207 fn test_append_bounded_no_limit_always_appends() {
3208 let mut s = String::new();
3209 let mut trunc = false;
3210 assert!(append_bounded(&mut s, None, "hello", None, &mut trunc));
3211 assert!(append_bounded(&mut s, Some(' '), "world", None, &mut trunc));
3212 assert_eq!(s, "hello world");
3213 assert!(!trunc, "no limit never truncates");
3214 }
3215
3216 #[test]
3217 fn test_append_bounded_undershoot_counts_separator() {
3218 // "abcd" (4) is at budget 5; a Some('\n') + "x" would need 2 more → over.
3219 let mut s = String::from("abcd");
3220 let mut trunc = false;
3221 assert!(!append_bounded(
3222 &mut s,
3223 Some('\n'),
3224 "x",
3225 Some(5),
3226 &mut trunc
3227 ));
3228 assert_eq!(s, "abcd", "nothing appended when it would overshoot");
3229 assert!(trunc, "budget hit sets truncated");
3230 // Exactly-fits case: "e" alone (1 byte, no separator) reaches 5.
3231 let mut s2 = String::from("abcd");
3232 let mut t2 = false;
3233 assert!(append_bounded(&mut s2, None, "e", Some(5), &mut t2));
3234 assert_eq!(s2, "abcde");
3235 assert!(!t2);
3236 assert!(s2.len() <= 5, "invariant: len <= limit exactly");
3237 }
3238
3239 #[test]
3240 fn test_append_bounded_zero_limit_truncates_immediately() {
3241 let mut s = String::new();
3242 let mut trunc = false;
3243 assert!(!append_bounded(&mut s, None, "a", Some(0), &mut trunc));
3244 assert!(s.is_empty());
3245 assert!(trunc);
3246 }
3247
3248 #[test]
3249 fn test_append_bounded_is_noop_once_truncated() {
3250 let mut s = String::from("kept");
3251 let mut trunc = true; // already truncated
3252 assert!(!append_bounded(
3253 &mut s,
3254 None,
3255 "more",
3256 Some(1_000),
3257 &mut trunc
3258 ));
3259 assert_eq!(s, "kept", "no further accumulation after truncation");
3260 }
3261
3262 #[test]
3263 fn test_clamp_to_budget_no_limit_or_fits_is_noop() {
3264 let mut a = String::from("hello");
3265 let mut t = false;
3266 clamp_to_budget(&mut a, None, &mut t);
3267 assert_eq!(a, "hello");
3268 assert!(!t, "no limit never truncates");
3269
3270 let mut b = String::from("hi");
3271 clamp_to_budget(&mut b, Some(10), &mut t);
3272 assert_eq!(b, "hi", "already fits");
3273 assert!(!t);
3274 }
3275
3276 #[test]
3277 fn test_clamp_to_budget_cuts_and_flags() {
3278 let mut s = String::from("abcdefgh");
3279 let mut t = false;
3280 clamp_to_budget(&mut s, Some(3), &mut t);
3281 assert_eq!(s, "abc");
3282 assert!(t, "clamp that cut must set truncated");
3283 }
3284
3285 #[test]
3286 fn test_clamp_to_budget_never_splits_utf8() {
3287 // "é" is 2 bytes (0xC3 0xA9). A 3-byte budget on "éé" (4 bytes) must cut
3288 // back to the char boundary at 2, keeping one whole "é".
3289 let mut s = String::from("éé");
3290 let mut t = false;
3291 clamp_to_budget(&mut s, Some(3), &mut t);
3292 assert_eq!(s, "é", "must retreat to a char boundary, not split 'é'");
3293 assert!(s.len() <= 3);
3294 assert!(t);
3295 }
3296
3297 #[test]
3298 fn test_matrix_multiplication() {
3299 let identity = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
3300 let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
3301
3302 let result = multiply_matrix(&identity, &translation);
3303 assert_eq!(result, translation);
3304
3305 let result2 = multiply_matrix(&translation, &identity);
3306 assert_eq!(result2, translation);
3307 }
3308
3309 #[test]
3310 fn test_transform_point() {
3311 let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
3312 let (x, y) = transform_point(5.0, 5.0, &translation);
3313 assert_eq!(x, 15.0);
3314 assert_eq!(y, 25.0);
3315 }
3316
3317 #[test]
3318 fn test_extraction_options_default() {
3319 let options = ExtractionOptions::default();
3320 assert!(!options.preserve_layout);
3321 assert_eq!(options.space_threshold, 0.3);
3322 assert_eq!(options.newline_threshold, 10.0);
3323 assert!(options.sort_by_position);
3324 assert!(!options.detect_columns);
3325 assert_eq!(options.column_threshold, 50.0);
3326 assert!(options.merge_hyphenated);
3327 }
3328
3329 #[test]
3330 fn test_extraction_options_custom() {
3331 let options = ExtractionOptions {
3332 preserve_layout: true,
3333 space_threshold: 0.5,
3334 tj_space_threshold: 0.15,
3335 newline_threshold: 15.0,
3336 sort_by_position: false,
3337 detect_columns: true,
3338 column_threshold: 75.0,
3339 merge_hyphenated: false,
3340 track_space_decisions: false,
3341 reconstruct_paragraphs: false,
3342 include_artifacts: false,
3343 reorder_columns: false,
3344 max_extracted_bytes: None,
3345 };
3346 assert!(options.preserve_layout);
3347 assert_eq!(options.space_threshold, 0.5);
3348 assert_eq!(options.tj_space_threshold, 0.15);
3349 assert_eq!(options.newline_threshold, 15.0);
3350 assert!(!options.sort_by_position);
3351 assert!(options.detect_columns);
3352 assert_eq!(options.column_threshold, 75.0);
3353 assert!(!options.merge_hyphenated);
3354 }
3355
3356 #[test]
3357 fn test_parse_font_style_bold() {
3358 // PostScript style
3359 assert_eq!(parse_font_style("Helvetica-Bold"), (true, false));
3360 assert_eq!(parse_font_style("TimesNewRoman-Bold"), (true, false));
3361
3362 // TrueType style
3363 assert_eq!(parse_font_style("Arial Bold"), (true, false));
3364 assert_eq!(parse_font_style("Calibri Bold"), (true, false));
3365
3366 // Short form
3367 assert_eq!(parse_font_style("Helvetica-B"), (true, false));
3368 }
3369
3370 #[test]
3371 fn test_parse_font_style_italic() {
3372 // PostScript style
3373 assert_eq!(parse_font_style("Helvetica-Italic"), (false, true));
3374 assert_eq!(parse_font_style("Times-Oblique"), (false, true));
3375
3376 // TrueType style
3377 assert_eq!(parse_font_style("Arial Italic"), (false, true));
3378 assert_eq!(parse_font_style("Courier Oblique"), (false, true));
3379
3380 // Short form
3381 assert_eq!(parse_font_style("Helvetica-I"), (false, true));
3382 }
3383
3384 #[test]
3385 fn test_parse_font_style_bold_italic() {
3386 assert_eq!(parse_font_style("Helvetica-BoldItalic"), (true, true));
3387 assert_eq!(parse_font_style("Times-BoldOblique"), (true, true));
3388 assert_eq!(parse_font_style("Arial Bold Italic"), (true, true));
3389 }
3390
3391 #[test]
3392 fn test_parse_font_style_regular() {
3393 assert_eq!(parse_font_style("Helvetica"), (false, false));
3394 assert_eq!(parse_font_style("Times-Roman"), (false, false));
3395 assert_eq!(parse_font_style("Courier"), (false, false));
3396 assert_eq!(parse_font_style("Arial"), (false, false));
3397 }
3398
3399 #[test]
3400 fn test_parse_font_style_edge_cases() {
3401 // Empty and unusual cases
3402 assert_eq!(parse_font_style(""), (false, false));
3403 assert_eq!(parse_font_style("UnknownFont"), (false, false));
3404
3405 // Case insensitive
3406 assert_eq!(parse_font_style("HELVETICA-BOLD"), (true, false));
3407 assert_eq!(parse_font_style("times-ITALIC"), (false, true));
3408 }
3409
3410 #[test]
3411 fn test_text_fragment() {
3412 let fragment = TextFragment {
3413 text: "Hello".to_string(),
3414 x: 100.0,
3415 y: 200.0,
3416 width: 50.0,
3417 height: 12.0,
3418 font_size: 10.0,
3419 font_name: None,
3420 is_bold: false,
3421 is_italic: false,
3422 color: None,
3423 space_decisions: Vec::new(),
3424 mcid: None,
3425 struct_tag: None,
3426 };
3427 assert_eq!(fragment.text, "Hello");
3428 assert_eq!(fragment.x, 100.0);
3429 assert_eq!(fragment.y, 200.0);
3430 assert_eq!(fragment.width, 50.0);
3431 assert_eq!(fragment.height, 12.0);
3432 assert_eq!(fragment.font_size, 10.0);
3433 }
3434
3435 #[test]
3436 fn test_extracted_text() {
3437 let fragments = vec![
3438 TextFragment {
3439 text: "Hello".to_string(),
3440 x: 100.0,
3441 y: 200.0,
3442 width: 50.0,
3443 height: 12.0,
3444 font_size: 10.0,
3445 font_name: None,
3446 is_bold: false,
3447 is_italic: false,
3448 color: None,
3449 space_decisions: Vec::new(),
3450 mcid: None,
3451 struct_tag: None,
3452 },
3453 TextFragment {
3454 text: "World".to_string(),
3455 x: 160.0,
3456 y: 200.0,
3457 width: 50.0,
3458 height: 12.0,
3459 font_size: 10.0,
3460 font_name: None,
3461 is_bold: false,
3462 is_italic: false,
3463 color: None,
3464 space_decisions: Vec::new(),
3465 mcid: None,
3466 struct_tag: None,
3467 },
3468 ];
3469
3470 let extracted = ExtractedText {
3471 text: "Hello World".to_string(),
3472 fragments: fragments,
3473 truncated: false,
3474 };
3475
3476 assert_eq!(extracted.text, "Hello World");
3477 assert_eq!(extracted.fragments.len(), 2);
3478 assert_eq!(extracted.fragments[0].text, "Hello");
3479 assert_eq!(extracted.fragments[1].text, "World");
3480 }
3481
3482 #[test]
3483 fn test_text_state_default() {
3484 let state = TextState::default();
3485 assert_eq!(state.text_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3486 assert_eq!(state.text_line_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3487 assert_eq!(state.ctm, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
3488 assert_eq!(state.leading, 0.0);
3489 assert_eq!(state.char_space, 0.0);
3490 assert_eq!(state.word_space, 0.0);
3491 assert_eq!(state.horizontal_scale, 100.0);
3492 assert_eq!(state.text_rise, 0.0);
3493 assert_eq!(state.font_size, 0.0);
3494 assert!(state.font_name.is_none());
3495 assert_eq!(state.render_mode, 0);
3496 }
3497
3498 #[test]
3499 fn test_matrix_operations() {
3500 // Test rotation matrix
3501 let rotation = [0.0, 1.0, -1.0, 0.0, 0.0, 0.0]; // 90 degree rotation
3502 let (x, y) = transform_point(1.0, 0.0, &rotation);
3503 assert_eq!(x, 0.0);
3504 assert_eq!(y, 1.0);
3505
3506 // Test scaling matrix
3507 let scale = [2.0, 0.0, 0.0, 3.0, 0.0, 0.0];
3508 let (x, y) = transform_point(5.0, 5.0, &scale);
3509 assert_eq!(x, 10.0);
3510 assert_eq!(y, 15.0);
3511
3512 // Test complex transformation
3513 let complex = [2.0, 1.0, 1.0, 2.0, 10.0, 20.0];
3514 let (x, y) = transform_point(1.0, 1.0, &complex);
3515 assert_eq!(x, 13.0); // 2*1 + 1*1 + 10
3516 assert_eq!(y, 23.0); // 1*1 + 2*1 + 20
3517 }
3518
3519 #[test]
3520 fn test_text_extractor_new() {
3521 let extractor = TextExtractor::new();
3522 let options = extractor.options;
3523 assert!(!options.preserve_layout);
3524 assert_eq!(options.space_threshold, 0.3);
3525 assert_eq!(options.newline_threshold, 10.0);
3526 assert!(options.sort_by_position);
3527 assert!(!options.detect_columns);
3528 assert_eq!(options.column_threshold, 50.0);
3529 assert!(options.merge_hyphenated);
3530 }
3531
3532 #[test]
3533 fn test_text_extractor_with_options() {
3534 let options = ExtractionOptions {
3535 preserve_layout: true,
3536 space_threshold: 0.3,
3537 tj_space_threshold: 0.2,
3538 newline_threshold: 12.0,
3539 sort_by_position: false,
3540 detect_columns: true,
3541 column_threshold: 60.0,
3542 merge_hyphenated: false,
3543 track_space_decisions: false,
3544 reconstruct_paragraphs: false,
3545 include_artifacts: false,
3546 reorder_columns: false,
3547 max_extracted_bytes: None,
3548 };
3549 let extractor = TextExtractor::with_options(options.clone());
3550 assert_eq!(extractor.options.preserve_layout, options.preserve_layout);
3551 assert_eq!(extractor.options.space_threshold, options.space_threshold);
3552 assert_eq!(
3553 extractor.options.newline_threshold,
3554 options.newline_threshold
3555 );
3556 assert_eq!(extractor.options.sort_by_position, options.sort_by_position);
3557 assert_eq!(extractor.options.detect_columns, options.detect_columns);
3558 assert_eq!(extractor.options.column_threshold, options.column_threshold);
3559 assert_eq!(extractor.options.merge_hyphenated, options.merge_hyphenated);
3560 }
3561
3562 // =========================================================================
3563 // RIGOROUS TESTS FOR FONT METRICS TEXT WIDTH CALCULATION
3564 // =========================================================================
3565
3566 #[test]
3567 fn test_calculate_text_width_with_no_font_info() {
3568 // Test fallback: should use simplified calculation
3569 let width = calculate_text_width("Hello", 12.0, None);
3570
3571 // Expected: 5 chars * 12.0 * 0.5 = 30.0
3572 assert_eq!(
3573 width, 30.0,
3574 "Without font info, should use simplified calculation: len * font_size * 0.5"
3575 );
3576 }
3577
3578 #[test]
3579 fn test_calculate_text_width_with_empty_metrics() {
3580 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3581
3582 // Font with no widths array
3583 let font_info = FontInfo {
3584 name: "TestFont".to_string(),
3585 font_type: "Type1".to_string(),
3586 encoding: None,
3587 to_unicode: None,
3588 differences: None,
3589 descendant_font: None,
3590 cid_ordering: None,
3591 metrics: FontMetrics {
3592 first_char: None,
3593 last_char: None,
3594 widths: None,
3595 missing_width: Some(500.0),
3596 kerning: None,
3597 },
3598 cid_encoding: None,
3599 };
3600
3601 let width = calculate_text_width("Hello", 12.0, Some(&font_info));
3602
3603 // Should fall back to simplified calculation
3604 assert_eq!(
3605 width, 30.0,
3606 "Without widths array, should fall back to simplified calculation"
3607 );
3608 }
3609
3610 #[test]
3611 fn test_calculate_text_width_with_complete_metrics() {
3612 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3613
3614 // Font with complete metrics for ASCII range 32-126
3615 // Simulate typical Helvetica widths (in 1/1000 units)
3616 let mut widths = vec![0.0; 95]; // 95 chars from 32 to 126
3617
3618 // Set specific widths for "Hello" (H=722, e=556, l=278, o=611)
3619 widths[72 - 32] = 722.0; // 'H' is ASCII 72
3620 widths[101 - 32] = 556.0; // 'e' is ASCII 101
3621 widths[108 - 32] = 278.0; // 'l' is ASCII 108
3622 widths[111 - 32] = 611.0; // 'o' is ASCII 111
3623
3624 let font_info = FontInfo {
3625 name: "Helvetica".to_string(),
3626 font_type: "Type1".to_string(),
3627 encoding: None,
3628 to_unicode: None,
3629 differences: None,
3630 descendant_font: None,
3631 cid_ordering: None,
3632 metrics: FontMetrics {
3633 first_char: Some(32),
3634 last_char: Some(126),
3635 widths: Some(widths),
3636 missing_width: Some(500.0),
3637 kerning: None,
3638 },
3639 cid_encoding: None,
3640 };
3641
3642 let width = calculate_text_width("Hello", 12.0, Some(&font_info));
3643
3644 // Expected calculation (widths in glyph space / 1000 * font_size):
3645 // H: 722/1000 * 12 = 8.664
3646 // e: 556/1000 * 12 = 6.672
3647 // l: 278/1000 * 12 = 3.336
3648 // l: 278/1000 * 12 = 3.336
3649 // o: 611/1000 * 12 = 7.332
3650 // Total: 29.34
3651 let expected = (722.0 + 556.0 + 278.0 + 278.0 + 611.0) / 1000.0 * 12.0;
3652 let tolerance = 0.0001; // Floating point tolerance
3653 assert!(
3654 (width - expected).abs() < tolerance,
3655 "Should calculate width using actual character metrics: expected {}, got {}, diff {}",
3656 expected,
3657 width,
3658 (width - expected).abs()
3659 );
3660
3661 // Verify it's different from simplified calculation
3662 let simplified = 5.0 * 12.0 * 0.5; // 30.0
3663 assert_ne!(
3664 width, simplified,
3665 "Metrics-based calculation should differ from simplified (30.0)"
3666 );
3667 }
3668
3669 #[test]
3670 fn width_from_codes_uses_char_code_not_decoded_unicode() {
3671 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3672
3673 // Simple Type1 font with a code-indexed Widths array: code 1 -> 1000,
3674 // code 2 -> 100. A custom encoding decodes code 1 -> 'm' (U+006D) and
3675 // code 2 -> 'i' (U+0069), so the decoded Unicode codepoints (109, 105)
3676 // are far from the codes (1, 2). The advance width MUST come from the
3677 // codes; indexing the Widths array by the decoded Unicode codepoint
3678 // reads out-of-range -> missing_width, desyncing glyph advance on
3679 // custom-encoded fonts (issue #302, Higgs/Computer-Modern scramble).
3680 let font_info = FontInfo {
3681 name: "F1".to_string(),
3682 font_type: "Type1".to_string(),
3683 encoding: None,
3684 to_unicode: None,
3685 differences: None,
3686 descendant_font: None,
3687 cid_ordering: None,
3688 metrics: FontMetrics {
3689 first_char: Some(1),
3690 last_char: Some(2),
3691 widths: Some(vec![1000.0, 100.0]),
3692 missing_width: Some(500.0),
3693 kerning: None,
3694 },
3695 cid_encoding: None,
3696 };
3697
3698 let codes = [1u8, 2u8];
3699 let decoded = "mi"; // what decode_text produced for these codes
3700 let width = calculate_text_width_from_codes(&codes, decoded, 10.0, Some(&font_info));
3701 let expected = (1000.0 + 100.0) / 1000.0 * 10.0; // 11.0
3702 assert!(
3703 (width - expected).abs() < 1e-6,
3704 "width must come from char codes: expected {expected}, got {width}"
3705 );
3706
3707 // The decoded-Unicode-indexed path is the bug: 109 and 105 are outside
3708 // [1,2] so both fall back to missing_width -> (500+500)/1000*10 = 10.0.
3709 let buggy = calculate_text_width(decoded, 10.0, Some(&font_info));
3710 assert_eq!(buggy, 10.0);
3711 assert_ne!(
3712 width, buggy,
3713 "code-based width must differ from the Unicode-indexed bug"
3714 );
3715 }
3716
3717 #[test]
3718 fn test_calculate_text_width_character_outside_range() {
3719 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3720
3721 // Font with narrow range (only covers 'A'-'Z')
3722 let widths = vec![722.0; 26]; // All uppercase letters same width
3723
3724 let font_info = FontInfo {
3725 name: "TestFont".to_string(),
3726 font_type: "Type1".to_string(),
3727 encoding: None,
3728 to_unicode: None,
3729 differences: None,
3730 descendant_font: None,
3731 cid_ordering: None,
3732 metrics: FontMetrics {
3733 first_char: Some(65), // 'A'
3734 last_char: Some(90), // 'Z'
3735 widths: Some(widths),
3736 missing_width: Some(500.0),
3737 kerning: None,
3738 },
3739 cid_encoding: None,
3740 };
3741
3742 // Test with character outside range
3743 let width = calculate_text_width("A1", 10.0, Some(&font_info));
3744
3745 // Expected:
3746 // 'A' (65) is in range: 722/1000 * 10 = 7.22
3747 // '1' (49) is outside range: missing_width 500/1000 * 10 = 5.0
3748 // Total: 12.22
3749 let expected = (722.0 / 1000.0 * 10.0) + (500.0 / 1000.0 * 10.0);
3750 assert_eq!(
3751 width, expected,
3752 "Should use missing_width for characters outside range"
3753 );
3754 }
3755
3756 #[test]
3757 fn test_calculate_text_width_missing_width_in_array() {
3758 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3759
3760 // Font with incomplete widths array (some characters have 0.0)
3761 let mut widths = vec![500.0; 95]; // Default width
3762 widths[10] = 0.0; // Character at index 10 has no width defined
3763
3764 let font_info = FontInfo {
3765 name: "TestFont".to_string(),
3766 font_type: "Type1".to_string(),
3767 encoding: None,
3768 to_unicode: None,
3769 differences: None,
3770 descendant_font: None,
3771 cid_ordering: None,
3772 metrics: FontMetrics {
3773 first_char: Some(32),
3774 last_char: Some(126),
3775 widths: Some(widths),
3776 missing_width: Some(600.0),
3777 kerning: None,
3778 },
3779 cid_encoding: None,
3780 };
3781
3782 // Character 42 (index 10 from first_char 32)
3783 let char_code = 42u8 as char; // '*'
3784 let text = char_code.to_string();
3785 let width = calculate_text_width(&text, 10.0, Some(&font_info));
3786
3787 // Character is in range but width is 0.0, should NOT fall back to missing_width
3788 // (0.0 is a valid width for zero-width characters)
3789 assert_eq!(
3790 width, 0.0,
3791 "Should use 0.0 width from array, not missing_width"
3792 );
3793 }
3794
3795 #[test]
3796 fn test_calculate_text_width_empty_string() {
3797 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3798
3799 let font_info = FontInfo {
3800 name: "TestFont".to_string(),
3801 font_type: "Type1".to_string(),
3802 encoding: None,
3803 to_unicode: None,
3804 differences: None,
3805 descendant_font: None,
3806 cid_ordering: None,
3807 metrics: FontMetrics {
3808 first_char: Some(32),
3809 last_char: Some(126),
3810 widths: Some(vec![500.0; 95]),
3811 missing_width: Some(500.0),
3812 kerning: None,
3813 },
3814 cid_encoding: None,
3815 };
3816
3817 let width = calculate_text_width("", 12.0, Some(&font_info));
3818 assert_eq!(width, 0.0, "Empty string should have zero width");
3819
3820 // Also test without font info
3821 let width_no_font = calculate_text_width("", 12.0, None);
3822 assert_eq!(
3823 width_no_font, 0.0,
3824 "Empty string should have zero width (no font)"
3825 );
3826 }
3827
3828 #[test]
3829 fn test_calculate_text_width_unicode_characters() {
3830 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3831
3832 // Font with limited ASCII range
3833 let font_info = FontInfo {
3834 name: "TestFont".to_string(),
3835 font_type: "Type1".to_string(),
3836 encoding: None,
3837 to_unicode: None,
3838 differences: None,
3839 descendant_font: None,
3840 cid_ordering: None,
3841 metrics: FontMetrics {
3842 first_char: Some(32),
3843 last_char: Some(126),
3844 widths: Some(vec![500.0; 95]),
3845 missing_width: Some(600.0),
3846 kerning: None,
3847 },
3848 cid_encoding: None,
3849 };
3850
3851 // Test with Unicode characters outside ASCII range
3852 let width = calculate_text_width("Ñ", 10.0, Some(&font_info));
3853
3854 // 'Ñ' (U+00D1, code 209) is outside range, should use missing_width
3855 // Expected: 600/1000 * 10 = 6.0
3856 assert_eq!(
3857 width, 6.0,
3858 "Unicode character outside range should use missing_width"
3859 );
3860 }
3861
3862 #[test]
3863 fn test_calculate_text_width_different_font_sizes() {
3864 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3865
3866 let font_info = FontInfo {
3867 name: "TestFont".to_string(),
3868 font_type: "Type1".to_string(),
3869 encoding: None,
3870 to_unicode: None,
3871 differences: None,
3872 descendant_font: None,
3873 cid_ordering: None,
3874 metrics: FontMetrics {
3875 first_char: Some(65), // 'A'
3876 last_char: Some(65), // 'A'
3877 widths: Some(vec![722.0]),
3878 missing_width: Some(500.0),
3879 kerning: None,
3880 },
3881 cid_encoding: None,
3882 };
3883
3884 // Test same character with different font sizes
3885 let width_10 = calculate_text_width("A", 10.0, Some(&font_info));
3886 let width_20 = calculate_text_width("A", 20.0, Some(&font_info));
3887
3888 // Widths should scale linearly with font size
3889 assert_eq!(width_10, 722.0 / 1000.0 * 10.0);
3890 assert_eq!(width_20, 722.0 / 1000.0 * 20.0);
3891 assert_eq!(
3892 width_20,
3893 width_10 * 2.0,
3894 "Width should scale linearly with font size"
3895 );
3896 }
3897
3898 #[test]
3899 fn test_calculate_text_width_proportional_vs_monospace() {
3900 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3901
3902 // Simulate proportional font (different widths)
3903 let proportional_widths = vec![278.0, 556.0, 722.0]; // i, m, W
3904 let proportional_font = FontInfo {
3905 name: "Helvetica".to_string(),
3906 font_type: "Type1".to_string(),
3907 encoding: None,
3908 to_unicode: None,
3909 differences: None,
3910 descendant_font: None,
3911 cid_ordering: None,
3912 metrics: FontMetrics {
3913 first_char: Some(105), // 'i'
3914 last_char: Some(107), // covers i, j, k
3915 widths: Some(proportional_widths),
3916 missing_width: Some(500.0),
3917 kerning: None,
3918 },
3919 cid_encoding: None,
3920 };
3921
3922 // Simulate monospace font (same width)
3923 let monospace_widths = vec![600.0, 600.0, 600.0];
3924 let monospace_font = FontInfo {
3925 name: "Courier".to_string(),
3926 font_type: "Type1".to_string(),
3927 encoding: None,
3928 to_unicode: None,
3929 differences: None,
3930 descendant_font: None,
3931 cid_ordering: None,
3932 metrics: FontMetrics {
3933 first_char: Some(105),
3934 last_char: Some(107),
3935 widths: Some(monospace_widths),
3936 missing_width: Some(600.0),
3937 kerning: None,
3938 },
3939 cid_encoding: None,
3940 };
3941
3942 let prop_width = calculate_text_width("i", 12.0, Some(&proportional_font));
3943 let mono_width = calculate_text_width("i", 12.0, Some(&monospace_font));
3944
3945 // Proportional 'i' should be narrower than monospace 'i'
3946 assert!(
3947 prop_width < mono_width,
3948 "Proportional 'i' ({}) should be narrower than monospace 'i' ({})",
3949 prop_width,
3950 mono_width
3951 );
3952 }
3953
3954 // =========================================================================
3955 // CRITICAL KERNING TESTS (Issue #87 - Quality Agent Required)
3956 // =========================================================================
3957
3958 #[test]
3959 fn test_calculate_text_width_with_kerning() {
3960 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
3961 use std::collections::HashMap;
3962
3963 // Create a font with kerning pairs
3964 let mut widths = vec![500.0; 95]; // ASCII 32-126
3965 widths[65 - 32] = 722.0; // 'A'
3966 widths[86 - 32] = 722.0; // 'V'
3967 widths[87 - 32] = 944.0; // 'W'
3968
3969 let mut kerning = HashMap::new();
3970 // Typical kerning pairs (in FUnits, 1/1000)
3971 kerning.insert((65, 86), -50.0); // 'A' + 'V' → tighten by 50 FUnits
3972 kerning.insert((65, 87), -40.0); // 'A' + 'W' → tighten by 40 FUnits
3973
3974 let font_info = FontInfo {
3975 name: "Helvetica".to_string(),
3976 font_type: "Type1".to_string(),
3977 encoding: None,
3978 to_unicode: None,
3979 differences: None,
3980 descendant_font: None,
3981 cid_ordering: None,
3982 metrics: FontMetrics {
3983 first_char: Some(32),
3984 last_char: Some(126),
3985 widths: Some(widths),
3986 missing_width: Some(500.0),
3987 kerning: Some(kerning),
3988 },
3989 cid_encoding: None,
3990 };
3991
3992 // Test "AV" with kerning
3993 let width_av = calculate_text_width("AV", 12.0, Some(&font_info));
3994 // Expected: (722 + 722)/1000 * 12 + (-50/1000 * 12)
3995 // = 17.328 - 0.6 = 16.728
3996 let expected_av = (722.0 + 722.0) / 1000.0 * 12.0 + (-50.0 / 1000.0 * 12.0);
3997 let tolerance = 0.0001;
3998 assert!(
3999 (width_av - expected_av).abs() < tolerance,
4000 "AV with kerning: expected {}, got {}, diff {}",
4001 expected_av,
4002 width_av,
4003 (width_av - expected_av).abs()
4004 );
4005
4006 // Test "AW" with different kerning value
4007 let width_aw = calculate_text_width("AW", 12.0, Some(&font_info));
4008 // Expected: (722 + 944)/1000 * 12 + (-40/1000 * 12)
4009 // = 19.992 - 0.48 = 19.512
4010 let expected_aw = (722.0 + 944.0) / 1000.0 * 12.0 + (-40.0 / 1000.0 * 12.0);
4011 assert!(
4012 (width_aw - expected_aw).abs() < tolerance,
4013 "AW with kerning: expected {}, got {}, diff {}",
4014 expected_aw,
4015 width_aw,
4016 (width_aw - expected_aw).abs()
4017 );
4018
4019 // Test "VA" with NO kerning (pair not in HashMap)
4020 let width_va = calculate_text_width("VA", 12.0, Some(&font_info));
4021 // Expected: (722 + 722)/1000 * 12 = 17.328 (no kerning adjustment)
4022 let expected_va = (722.0 + 722.0) / 1000.0 * 12.0;
4023 assert!(
4024 (width_va - expected_va).abs() < tolerance,
4025 "VA without kerning: expected {}, got {}, diff {}",
4026 expected_va,
4027 width_va,
4028 (width_va - expected_va).abs()
4029 );
4030
4031 // Verify kerning makes a measurable difference
4032 assert!(
4033 width_av < width_va,
4034 "AV with kerning ({}) should be narrower than VA without kerning ({})",
4035 width_av,
4036 width_va
4037 );
4038 }
4039
4040 #[test]
4041 fn test_parse_truetype_kern_table_minimal() {
4042 use crate::text::extraction_cmap::parse_truetype_kern_table;
4043
4044 // Complete TrueType font with kern table (Format 0, 2 kerning pairs)
4045 // Structure:
4046 // 1. Offset table (12 bytes)
4047 // 2. Table directory (2 tables: 'head' and 'kern', each 16 bytes = 32 total)
4048 // 3. 'head' table data (54 bytes)
4049 // 4. 'kern' table data (30 bytes)
4050 // Total: 128 bytes
4051 let mut ttf_data = vec![
4052 // Offset table
4053 0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
4054 0x00, 0x02, // numTables: 2
4055 0x00, 0x20, // searchRange: 32
4056 0x00, 0x01, // entrySelector: 1
4057 0x00, 0x00, // rangeShift: 0
4058 ];
4059
4060 // Table directory entry 1: 'head' table
4061 ttf_data.extend_from_slice(b"head"); // tag
4062 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
4063 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x2C]); // offset: 44 (12 + 32)
4064 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x36]); // length: 54
4065
4066 // Table directory entry 2: 'kern' table
4067 ttf_data.extend_from_slice(b"kern"); // tag
4068 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
4069 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x62]); // offset: 98 (44 + 54)
4070 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x1E]); // length: 30 (actual kern table size)
4071
4072 // 'head' table data (54 bytes of zeros - minimal valid head table)
4073 ttf_data.extend_from_slice(&[0u8; 54]);
4074
4075 // 'kern' table data (34 bytes)
4076 ttf_data.extend_from_slice(&[
4077 // Kern table header
4078 0x00, 0x00, // version: 0
4079 0x00, 0x01, // nTables: 1
4080 // Subtable header
4081 0x00, 0x00, // version: 0
4082 0x00, 0x1A, // length: 26 bytes (header 6 + nPairs data 8 + pairs 2*6=12)
4083 0x00, 0x00, // coverage: 0x0000 (Format 0 in lower byte, horizontal)
4084 0x00, 0x02, // nPairs: 2
4085 0x00, 0x08, // searchRange: 8
4086 0x00, 0x00, // entrySelector: 0
4087 0x00, 0x04, // rangeShift: 4
4088 // Kerning pair 1: A + V → -50
4089 0x00, 0x41, // left glyph: 65 ('A')
4090 0x00, 0x56, // right glyph: 86 ('V')
4091 0xFF, 0xCE, // value: -50 (signed 16-bit big-endian)
4092 // Kerning pair 2: A + W → -40
4093 0x00, 0x41, // left glyph: 65 ('A')
4094 0x00, 0x57, // right glyph: 87 ('W')
4095 0xFF, 0xD8, // value: -40 (signed 16-bit big-endian)
4096 ]);
4097
4098 let result = parse_truetype_kern_table(&ttf_data);
4099 assert!(
4100 result.is_ok(),
4101 "Should parse minimal kern table successfully: {:?}",
4102 result.err()
4103 );
4104
4105 let kerning_map = result.unwrap();
4106 assert_eq!(kerning_map.len(), 2, "Should extract 2 kerning pairs");
4107
4108 // Verify pair 1: A + V → -50
4109 assert_eq!(
4110 kerning_map.get(&(65, 86)),
4111 Some(&-50.0),
4112 "Should have A+V kerning pair with value -50"
4113 );
4114
4115 // Verify pair 2: A + W → -40
4116 assert_eq!(
4117 kerning_map.get(&(65, 87)),
4118 Some(&-40.0),
4119 "Should have A+W kerning pair with value -40"
4120 );
4121 }
4122
4123 #[test]
4124 fn test_parse_kern_table_no_kern_table() {
4125 use crate::text::extraction_cmap::parse_truetype_kern_table;
4126
4127 // TrueType font data WITHOUT a 'kern' table
4128 // Structure:
4129 // - Offset table: scaler type + numTables + searchRange + entrySelector + rangeShift
4130 // - Table directory: 1 entry for 'head' table (not 'kern')
4131 let ttf_data = vec![
4132 // Offset table
4133 0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
4134 0x00, 0x01, // numTables: 1
4135 0x00, 0x10, // searchRange: 16
4136 0x00, 0x00, // entrySelector: 0
4137 0x00, 0x00, // rangeShift: 0
4138 // Table directory entry: 'head' table (not 'kern')
4139 b'h', b'e', b'a', b'd', // tag: 'head'
4140 0x00, 0x00, 0x00, 0x00, // checksum
4141 0x00, 0x00, 0x00, 0x1C, // offset: 28
4142 0x00, 0x00, 0x00, 0x36, // length: 54
4143 // Mock 'head' table data (54 bytes of zeros)
4144 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4145 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4146 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4147 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
4148 ];
4149
4150 let result = parse_truetype_kern_table(&ttf_data);
4151 assert!(
4152 result.is_ok(),
4153 "Should gracefully handle missing kern table"
4154 );
4155
4156 let kerning_map = result.unwrap();
4157 assert!(
4158 kerning_map.is_empty(),
4159 "Should return empty HashMap when no kern table exists"
4160 );
4161 }
4162
4163 // Helper for paragraph-reconstruction unit tests. TextFragment has 11
4164 // fields so a helper keeps the test bodies focused on geometry.
4165 fn tf(text: &str, x: f64, y: f64, width: f64, font_size: f64) -> TextFragment {
4166 TextFragment {
4167 text: text.to_string(),
4168 x,
4169 y,
4170 width,
4171 height: font_size,
4172 font_size,
4173 font_name: None,
4174 is_bold: false,
4175 is_italic: false,
4176 color: None,
4177 space_decisions: Vec::new(),
4178 mcid: None,
4179 struct_tag: None,
4180 }
4181 }
4182
4183 #[test]
4184 fn merge_into_lines_groups_same_baseline_fragments() {
4185 let extractor = TextExtractor::with_options(ExtractionOptions {
4186 reconstruct_paragraphs: true,
4187 ..Default::default()
4188 });
4189 let input = vec![
4190 tf("Hello", 50.0, 400.0, 30.0, 12.0),
4191 tf("world", 90.0, 400.0, 30.0, 12.0),
4192 tf("now.", 130.0, 400.0, 25.0, 12.0),
4193 tf("Next", 50.0, 386.0, 30.0, 12.0),
4194 tf("line.", 90.0, 386.0, 25.0, 12.0),
4195 ];
4196 let lines = extractor.merge_into_lines(&input);
4197 assert_eq!(
4198 lines.len(),
4199 2,
4200 "two distinct baselines must produce two line fragments"
4201 );
4202 assert_eq!(
4203 lines[0].text, "Hello world now.",
4204 "first line concatenated with spaces"
4205 );
4206 assert_eq!(lines[1].text, "Next line.", "second line concatenated");
4207 }
4208
4209 #[test]
4210 fn merge_into_lines_inserts_space_only_when_gap_exceeds_threshold() {
4211 let extractor = TextExtractor::with_options(ExtractionOptions {
4212 reconstruct_paragraphs: true,
4213 space_threshold: 0.3,
4214 ..Default::default()
4215 });
4216 // Gap of 4pt at font_size 12 = 0.33x — above threshold 0.3
4217 let with_gap = vec![
4218 tf("AB", 50.0, 400.0, 10.0, 12.0),
4219 tf("CD", 64.0, 400.0, 10.0, 12.0),
4220 ];
4221 let lines = extractor.merge_into_lines(&with_gap);
4222 assert_eq!(
4223 lines[0].text, "AB CD",
4224 "gap above threshold must insert space"
4225 );
4226
4227 // Gap of 1pt = 0.083x — below threshold
4228 let tight = vec![
4229 tf("AB", 50.0, 400.0, 10.0, 12.0),
4230 tf("CD", 61.0, 400.0, 10.0, 12.0),
4231 ];
4232 let lines = extractor.merge_into_lines(&tight);
4233 assert_eq!(lines[0].text, "ABCD", "tight gap must NOT insert space");
4234 }
4235
4236 #[test]
4237 fn standard_14_space_width_maps_base_fonts_and_substitutes() {
4238 // Adobe Core-14 AFM space advances, with subset prefixes stripped and
4239 // metric-compatible substitutes folded in (#302 symptom 2).
4240 assert_eq!(super::standard_14_space_width("Times-Roman"), Some(250.0));
4241 assert_eq!(
4242 super::standard_14_space_width("Times-BoldItalic"),
4243 Some(250.0)
4244 );
4245 assert_eq!(super::standard_14_space_width("Helvetica"), Some(278.0));
4246 assert_eq!(super::standard_14_space_width("Courier-Bold"), Some(600.0));
4247 assert_eq!(super::standard_14_space_width("Symbol"), Some(250.0));
4248 assert_eq!(super::standard_14_space_width("ZapfDingbats"), Some(278.0));
4249 // subset prefix stripped
4250 assert_eq!(
4251 super::standard_14_space_width("ABCDEF+Times-Roman"),
4252 Some(250.0)
4253 );
4254 // metric-compatible substitutes
4255 assert_eq!(super::standard_14_space_width("Arial-BoldMT"), Some(278.0));
4256 assert_eq!(
4257 super::standard_14_space_width("TimesNewRomanPSMT"),
4258 Some(250.0)
4259 );
4260 assert_eq!(
4261 super::standard_14_space_width("CourierNewPSMT"),
4262 Some(600.0)
4263 );
4264 // unknown / embedded fonts fall through to the caller's fallback
4265 assert_eq!(super::standard_14_space_width("Poppins-Regular"), None);
4266 assert_eq!(super::standard_14_space_width("VUNXGH+Calibri"), None);
4267 }
4268
4269 #[test]
4270 fn merge_into_lines_keeps_emission_order_for_font_switch_overlap() {
4271 // #302 symptom 1: a font-switched glyph (e.g. the italic particle
4272 // symbol "Z" in "to the Z boson") is positioned by the producer with
4273 // an x-origin that falls INSIDE the x-span of the preceding roman run
4274 // ("to the"). The content stream still delivers it in correct reading
4275 // order. Sorting a row purely by x-origin interleaves the overlapping
4276 // fragment, yielding "Zto the" instead of "to theZ". When a row's only
4277 // backward emission steps are span overlaps (not disjoint jumps),
4278 // emission order is the authoritative reading order.
4279 let extractor = TextExtractor::with_options(ExtractionOptions {
4280 reconstruct_paragraphs: true,
4281 ..Default::default()
4282 });
4283 // emission order = reading order; "Z" overlaps "to t" + "he" in x.
4284 let row = vec![
4285 tf("to t", 455.5, 400.0, 12.0, 10.0), // 455.5 .. 467.5
4286 tf("he", 467.5, 400.0, 10.0, 10.0), // 467.5 .. 477.5
4287 tf("Z", 455.3, 400.0, 23.0, 10.0), // 455.3 .. 478.3 (overlaps both)
4288 ];
4289 let lines = extractor.merge_into_lines(&row);
4290 assert_eq!(lines.len(), 1);
4291 assert_eq!(
4292 lines[0].text, "to theZ",
4293 "overlapping font-switch fragment must keep emission (reading) order"
4294 );
4295 }
4296
4297 #[test]
4298 fn merge_into_lines_keeps_emission_when_run_backfills_covered_span() {
4299 // #305: dense justified body text is split into sub-word fragments by
4300 // the font's arbitrary glyph runs. A later word ("described", x 492..537)
4301 // is emitted with a backward x-origin that lands INSIDE the span already
4302 // covered by the line ("...selections", 479..521), but does NOT overlap
4303 // the short immediately-preceding fragment ("s", 517..521). Emission is
4304 // still the reading order, so the line must keep it — the overlap test
4305 // has to consider the line's running extent, not just the previous
4306 // fragment. (Real case: Higgs p5 "kinematic selections described in".)
4307 let extractor = TextExtractor::with_options(ExtractionOptions {
4308 reconstruct_paragraphs: true,
4309 ..Default::default()
4310 });
4311 let row = vec![
4312 tf("selection", 479.0, 400.0, 38.0, 8.0), // 479..517
4313 tf("s", 517.0, 400.0, 4.0, 8.0), // 517..521 short predecessor
4314 tf("d", 492.0, 400.0, 4.0, 8.0), // 492..496 backfill, no overlap with "s"
4315 tf("escribed", 496.0, 400.0, 41.0, 8.0), // 496..537
4316 ];
4317 let lines = extractor.merge_into_lines(&row);
4318 assert_eq!(
4319 lines[0].text, "selectionsdescribed",
4320 "a run that backfills the line's covered span must keep emission order"
4321 );
4322 }
4323
4324 #[test]
4325 fn merge_into_lines_uses_x_order_for_disjoint_backward_jump() {
4326 // Guard: a genuinely scrambled non-tagged stream (fragments emitted
4327 // out of x-order at DISJOINT positions, e.g. right-to-left or random
4328 // generators) must still be reordered by x. Here "the" is emitted
4329 // after "boson" with no span overlap, so x-order is authoritative.
4330 let extractor = TextExtractor::with_options(ExtractionOptions {
4331 reconstruct_paragraphs: true,
4332 ..Default::default()
4333 });
4334 let row = vec![
4335 tf("boson", 100.0, 400.0, 28.0, 10.0), // 100 .. 128
4336 tf("the", 80.0, 400.0, 15.0, 10.0), // 80 .. 95 (disjoint, left of boson)
4337 ];
4338 let lines = extractor.merge_into_lines(&row);
4339 assert_eq!(lines.len(), 1);
4340 assert_eq!(
4341 lines[0].text, "the boson",
4342 "disjoint backward emission jump must be reordered by x"
4343 );
4344 }
4345
4346 #[test]
4347 fn merge_into_lines_unioned_bounding_box() {
4348 let extractor = TextExtractor::with_options(ExtractionOptions {
4349 reconstruct_paragraphs: true,
4350 ..Default::default()
4351 });
4352 let input = vec![
4353 tf("A", 50.0, 400.0, 10.0, 12.0),
4354 tf("B", 100.0, 400.0, 10.0, 12.0),
4355 ];
4356 let lines = extractor.merge_into_lines(&input);
4357 assert_eq!(lines.len(), 1);
4358 assert!((lines[0].x - 50.0).abs() < 0.01);
4359 assert!(
4360 (lines[0].width - 60.0).abs() < 0.01,
4361 "width must span 50->110"
4362 );
4363 }
4364
4365 #[test]
4366 fn assign_row_ids_monotone_y_descending_keeps_zero() {
4367 let frags = vec![
4368 tf("A", 50.0, 400.0, 10.0, 9.0),
4369 tf("B", 50.0, 395.0, 10.0, 9.0),
4370 tf("C", 50.0, 390.0, 10.0, 9.0),
4371 ];
4372 let row_ids = super::assign_row_ids(&frags);
4373 assert_eq!(row_ids, vec![0u32, 0, 0]);
4374 }
4375
4376 #[test]
4377 fn assign_row_ids_increments_on_y_up_jump_above_threshold() {
4378 // font_size=9 → threshold = max(4.5, 2.0) = 4.5
4379 // deltas: 395-400=-5, 420-395=+25 (>4.5)
4380 let frags = vec![
4381 tf("A", 50.0, 400.0, 10.0, 9.0),
4382 tf("B", 50.0, 395.0, 10.0, 9.0),
4383 tf("C", 50.0, 420.0, 10.0, 9.0),
4384 ];
4385 let row_ids = super::assign_row_ids(&frags);
4386 assert_eq!(row_ids, vec![0u32, 0, 1]);
4387 }
4388
4389 #[test]
4390 fn assign_row_ids_ignores_superscript_within_threshold() {
4391 // font_size=9 → threshold 4.5. delta 2.5 must NOT trigger.
4392 let frags = vec![
4393 tf("A", 50.0, 400.0, 10.0, 9.0),
4394 tf("^2", 60.0, 402.5, 5.0, 9.0),
4395 tf("B", 65.0, 395.0, 10.0, 9.0),
4396 ];
4397 let row_ids = super::assign_row_ids(&frags);
4398 assert_eq!(row_ids, vec![0u32, 0, 0]);
4399 }
4400
4401 #[test]
4402 fn assign_row_ids_floor_2pt_for_small_fonts() {
4403 // font_size=3 → font_size*0.5 = 1.5; floor lifts threshold to 2.0
4404 // delta = +2.5 > 2.0 must trigger.
4405 let frags = vec![
4406 tf("A", 50.0, 100.0, 10.0, 3.0),
4407 tf("B", 50.0, 102.5, 10.0, 3.0),
4408 ];
4409 let row_ids = super::assign_row_ids(&frags);
4410 assert_eq!(row_ids, vec![0u32, 1]);
4411 }
4412
4413 #[test]
4414 fn assign_row_ids_empty_slice_returns_empty() {
4415 let frags: Vec<TextFragment> = vec![];
4416 let row_ids = super::assign_row_ids(&frags);
4417 assert!(row_ids.is_empty(), "empty input must yield empty output");
4418 }
4419
4420 #[test]
4421 fn merge_into_lines_splits_two_columns_emitted_sequentially() {
4422 let extractor = TextExtractor::with_options(ExtractionOptions {
4423 reconstruct_paragraphs: true,
4424 ..Default::default()
4425 });
4426 // Emission order: col1.l1, col1.l2 (Y monotone down), then col2.l1
4427 // (Y jumps UP by 10 > threshold 5 for font 10pt), col2.l2.
4428 let input = vec![
4429 tf("col1-top", 50.0, 400.0, 80.0, 10.0),
4430 tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
4431 tf("col2-top", 200.0, 405.0, 80.0, 10.0),
4432 tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
4433 ];
4434 let lines = extractor.merge_into_lines(&input);
4435 assert_eq!(
4436 lines.len(),
4437 4,
4438 "two columns at near-identical Y must split into 4 lines"
4439 );
4440 // row_id=0 batch first (col1), then row_id=1 (col2). Within each batch, Y desc.
4441 assert_eq!(lines[0].text, "col1-top");
4442 assert_eq!(lines[0].y, 400.0);
4443 assert_eq!(lines[1].text, "col1-bot");
4444 assert_eq!(lines[1].y, 395.0);
4445 assert_eq!(lines[2].text, "col2-top");
4446 assert_eq!(lines[2].y, 405.0);
4447 assert_eq!(lines[3].text, "col2-bot");
4448 assert_eq!(lines[3].y, 400.0);
4449 }
4450
4451 #[test]
4452 fn merge_into_lines_preserves_single_column_continuation() {
4453 let extractor = TextExtractor::with_options(ExtractionOptions {
4454 reconstruct_paragraphs: true,
4455 ..Default::default()
4456 });
4457 // Single column: same Y continuation (X grows), then next line down.
4458 let input = vec![
4459 tf("Hello", 50.0, 400.0, 30.0, 10.0),
4460 tf("world", 90.0, 400.0, 30.0, 10.0),
4461 tf("next-line", 50.0, 395.0, 70.0, 10.0),
4462 ];
4463 let lines = extractor.merge_into_lines(&input);
4464 assert_eq!(
4465 lines.len(),
4466 2,
4467 "single column continuation must collapse to 2 lines"
4468 );
4469 assert!(lines[0].text.contains("Hello"));
4470 assert!(lines[0].text.contains("world"));
4471 assert_eq!(lines[1].text, "next-line");
4472 }
4473
4474 #[test]
4475 fn merge_into_lines_splits_columns_with_uniform_mcid() {
4476 // Regression guard for #265 root cause: NCSC page 12 has a single
4477 // outer BDC, so every fragment has mcid=Some(0). Column separation
4478 // must come from row_id alone, not from mcid.
4479 let extractor = TextExtractor::with_options(ExtractionOptions {
4480 reconstruct_paragraphs: true,
4481 ..Default::default()
4482 });
4483 let mut frags = vec![
4484 tf("col1-top", 50.0, 400.0, 80.0, 10.0),
4485 tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
4486 tf("col2-top", 200.0, 405.0, 80.0, 10.0),
4487 tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
4488 ];
4489 for f in &mut frags {
4490 f.mcid = Some(0);
4491 }
4492 let lines = extractor.merge_into_lines(&frags);
4493 assert_eq!(
4494 lines.len(),
4495 4,
4496 "uniform mcid must not prevent row_id-based column split (NCSC root cause)"
4497 );
4498 assert_eq!(lines[0].text, "col1-top");
4499 assert_eq!(lines[1].text, "col1-bot");
4500 assert_eq!(lines[2].text, "col2-top");
4501 assert_eq!(lines[3].text, "col2-bot");
4502 }
4503
4504 #[test]
4505 fn merge_close_fragments_superscript_merges_when_reconstruct_paragraphs() {
4506 let extractor = TextExtractor::with_options(ExtractionOptions {
4507 reconstruct_paragraphs: true,
4508 ..Default::default()
4509 });
4510 // Citation superscript: body text at y=400, raised digit at y=403.5
4511 // (3.5pt above baseline for 10pt font). y_tol = 0.5 * 10 = 5.0 > 3.5
4512 // and x_gap = 4pt < 10*0.5 = 5pt, so the superscript must merge into
4513 // the body fragment.
4514 let frags = vec![
4515 tf("body-text", 50.0, 400.0, 25.0, 10.0),
4516 tf("1", 79.0, 403.5, 4.0, 10.0),
4517 ];
4518 let merged = extractor.merge_close_fragments(&frags);
4519 assert_eq!(
4520 merged.len(),
4521 1,
4522 "superscript within 5pt of baseline must merge in reconstruct path"
4523 );
4524 assert!(merged[0].text.contains("body-text"));
4525 assert!(merged[0].text.contains("1"));
4526 }
4527
4528 #[test]
4529 fn merge_close_fragments_superscript_does_not_merge_in_legacy_path() {
4530 let extractor = TextExtractor::with_options(ExtractionOptions {
4531 reconstruct_paragraphs: false,
4532 ..Default::default()
4533 });
4534 // Legacy path: y_tol=1.0 fixed. A 3.5pt delta must NOT merge.
4535 let frags = vec![
4536 tf("body-text", 50.0, 400.0, 25.0, 10.0),
4537 tf("1", 79.0, 403.5, 4.0, 10.0),
4538 ];
4539 let merged = extractor.merge_close_fragments(&frags);
4540 assert_eq!(
4541 merged.len(),
4542 2,
4543 "3.5pt Y delta exceeds legacy 1.0pt threshold; superscript stays separate"
4544 );
4545 }
4546
4547 #[test]
4548 fn merge_into_paragraphs_groups_consecutive_lines() {
4549 let extractor = TextExtractor::with_options(ExtractionOptions {
4550 reconstruct_paragraphs: true,
4551 ..Default::default()
4552 });
4553 // Three lines, 14pt leading (line height 12pt, gap 2pt)
4554 let lines = vec![
4555 tf("Line one.", 50.0, 400.0, 60.0, 12.0),
4556 tf("Line two.", 50.0, 386.0, 60.0, 12.0),
4557 tf("Line three.", 50.0, 372.0, 70.0, 12.0),
4558 ];
4559 let paragraphs = extractor.merge_into_paragraphs(&lines);
4560 assert_eq!(paragraphs.len(), 1);
4561 assert_eq!(paragraphs[0].text, "Line one.\nLine two.\nLine three.");
4562 }
4563
4564 #[test]
4565 fn merge_into_paragraphs_splits_on_large_vertical_gap() {
4566 let extractor = TextExtractor::with_options(ExtractionOptions {
4567 reconstruct_paragraphs: true,
4568 ..Default::default()
4569 });
4570 let lines = vec![
4571 tf("P1L1.", 50.0, 400.0, 40.0, 12.0),
4572 tf("P1L2.", 50.0, 386.0, 40.0, 12.0),
4573 tf("P2L1.", 50.0, 300.0, 40.0, 12.0),
4574 ];
4575 let paragraphs = extractor.merge_into_paragraphs(&lines);
4576 assert_eq!(paragraphs.len(), 2);
4577 assert_eq!(paragraphs[0].text, "P1L1.\nP1L2.");
4578 assert_eq!(paragraphs[1].text, "P2L1.");
4579 }
4580
4581 /// A heading is a different block from the body that follows it, even when
4582 /// the vertical gap is small enough to look like line spacing. Merging them
4583 /// destroys the two signals `partition` uses to classify a `Title`
4584 /// (font-size ratio and bold-short), so the heading text is never
4585 /// recoverable downstream (issue #436).
4586 #[test]
4587 fn merge_into_paragraphs_splits_on_font_size_change() {
4588 let extractor = TextExtractor::with_options(ExtractionOptions {
4589 reconstruct_paragraphs: true,
4590 ..Default::default()
4591 });
4592 // 20pt title at y=760, 10pt body line 40pt below: gap = 30pt, which is
4593 // exactly the 1.5 * median(20, 10) = 30pt vertical threshold, so only
4594 // the style change can separate them.
4595 let lines = vec![
4596 tf("Section Heading", 72.0, 760.0, 120.0, 20.0),
4597 tf("Body text of this section.", 72.0, 720.0, 150.0, 10.0),
4598 ];
4599 let paragraphs = extractor.merge_into_paragraphs(&lines);
4600 assert_eq!(
4601 paragraphs.len(),
4602 2,
4603 "font-size change must end the paragraph"
4604 );
4605 assert_eq!(paragraphs[0].text, "Section Heading");
4606 assert_eq!(paragraphs[0].font_size, 20.0);
4607 assert_eq!(paragraphs[1].text, "Body text of this section.");
4608 }
4609
4610 /// Same size, different weight: the classic run-in bold heading. `partition`
4611 /// classifies it through `bold_short_title`, which needs the heading to
4612 /// survive extraction as its own fragment (issue #436).
4613 #[test]
4614 fn merge_into_paragraphs_splits_on_weight_change() {
4615 let extractor = TextExtractor::with_options(ExtractionOptions {
4616 reconstruct_paragraphs: true,
4617 ..Default::default()
4618 });
4619 let mut heading = tf("Overview", 72.0, 400.0, 60.0, 12.0);
4620 heading.is_bold = true;
4621 let lines = vec![heading, tf("Body line.", 72.0, 386.0, 60.0, 12.0)];
4622 let paragraphs = extractor.merge_into_paragraphs(&lines);
4623 assert_eq!(paragraphs.len(), 2, "weight change must end the paragraph");
4624 assert_eq!(paragraphs[0].text, "Overview");
4625 assert!(paragraphs[0].is_bold);
4626 assert_eq!(paragraphs[1].text, "Body line.");
4627 }
4628
4629 /// Sub-point rounding (11.96pt vs 12pt from a scaled text matrix) is not a
4630 /// style change: the paragraph must stay whole.
4631 #[test]
4632 fn merge_into_paragraphs_tolerates_subpoint_font_size_jitter() {
4633 let extractor = TextExtractor::with_options(ExtractionOptions {
4634 reconstruct_paragraphs: true,
4635 ..Default::default()
4636 });
4637 let lines = vec![
4638 tf("Line one.", 50.0, 400.0, 60.0, 12.0),
4639 tf("Line two.", 50.0, 386.0, 60.0, 11.96),
4640 ];
4641 let paragraphs = extractor.merge_into_paragraphs(&lines);
4642 assert_eq!(
4643 paragraphs.len(),
4644 1,
4645 "0.3% size jitter is not a style change"
4646 );
4647 assert_eq!(paragraphs[0].text, "Line one.\nLine two.");
4648 }
4649
4650 #[test]
4651 fn merge_into_paragraphs_drops_hyphen_when_merge_hyphenated() {
4652 let extractor = TextExtractor::with_options(ExtractionOptions {
4653 reconstruct_paragraphs: true,
4654 merge_hyphenated: true,
4655 ..Default::default()
4656 });
4657 let lines = vec![
4658 tf("Kryp-", 50.0, 400.0, 30.0, 12.0),
4659 tf("tographie", 50.0, 386.0, 60.0, 12.0),
4660 ];
4661 let paragraphs = extractor.merge_into_paragraphs(&lines);
4662 assert_eq!(paragraphs.len(), 1);
4663 assert_eq!(
4664 paragraphs[0].text, "Kryptographie",
4665 "hyphen elided, no newline inserted"
4666 );
4667 }
4668
4669 #[test]
4670 fn decode_pdf_string_utf16be_bom_decodes_fi_ligature() {
4671 let bytes = [0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69];
4672 assert_eq!(super::decode_pdf_string(&bytes), "fi");
4673 }
4674
4675 #[test]
4676 fn decode_pdf_string_ascii_pdfdocencoding_passthrough() {
4677 let bytes = b"page 12";
4678 assert_eq!(super::decode_pdf_string(bytes), "page 12");
4679 }
4680
4681 #[test]
4682 fn decode_pdf_string_empty_input_returns_empty() {
4683 assert_eq!(super::decode_pdf_string(&[]), "");
4684 }
4685
4686 #[test]
4687 fn decode_pdf_string_lone_bom_returns_empty() {
4688 // BOM only, no code units after.
4689 assert_eq!(super::decode_pdf_string(&[0xFE, 0xFF]), "");
4690 }
4691
4692 #[test]
4693 fn resolve_props_extracts_integer_mcid() {
4694 use crate::parser::content::{MarkedContentProps, MarkedContentValue};
4695 use std::collections::HashMap;
4696 let mut map = HashMap::new();
4697 map.insert("MCID".to_string(), MarkedContentValue::Integer(7));
4698 let props = MarkedContentProps::Inline(map);
4699
4700 let (mcid, actual) = super::resolve_props(&props, None);
4701 assert_eq!(mcid, Some(7));
4702 assert_eq!(actual, None);
4703 }
4704
4705 #[test]
4706 fn resolve_props_decodes_utf16be_actualtext() {
4707 use crate::parser::content::{MarkedContentProps, MarkedContentValue};
4708 use std::collections::HashMap;
4709 let mut map = HashMap::new();
4710 map.insert(
4711 "ActualText".to_string(),
4712 MarkedContentValue::String(vec![0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69]),
4713 );
4714 let props = MarkedContentProps::Inline(map);
4715
4716 let (mcid, actual) = super::resolve_props(&props, None);
4717 assert_eq!(mcid, None);
4718 assert_eq!(actual.as_deref(), Some("fi"));
4719 }
4720
4721 #[test]
4722 fn resolve_props_returns_none_for_unresolvable_resource_ref() {
4723 use crate::parser::content::MarkedContentProps;
4724 let props = MarkedContentProps::ResourceRef("PropsName".to_string());
4725 let (mcid, actual) = super::resolve_props(&props, None);
4726 assert_eq!((mcid, actual), (None, None));
4727 }
4728
4729 #[test]
4730 fn resolve_props_negative_mcid_rejected() {
4731 use crate::parser::content::{MarkedContentProps, MarkedContentValue};
4732 use std::collections::HashMap;
4733 // MCID is unsigned per ISO 32000-1; negative integer is malformed.
4734 let mut map = HashMap::new();
4735 map.insert("MCID".to_string(), MarkedContentValue::Integer(-1));
4736 let props = MarkedContentProps::Inline(map);
4737
4738 let (mcid, _) = super::resolve_props(&props, None);
4739 assert_eq!(mcid, None);
4740 }
4741
4742 #[test]
4743 fn resolve_props_resource_ref_overflow_mcid_rejected() {
4744 // ISO 32000-1 §14.7.4: MCID is an unsigned 32-bit integer. A
4745 // PdfObject::Integer holds an i64, so a malformed PDF can carry an
4746 // out-of-range MCID. The ResourceRef path must reject those rather
4747 // than wrap silently via `as u32`. Mirrors the Inline-path guard
4748 // already covered by `resolve_props_negative_mcid_rejected`.
4749 use crate::parser::content::MarkedContentProps;
4750 use crate::parser::objects::{PdfDictionary, PdfObject};
4751
4752 let mut inner = PdfDictionary::new();
4753 inner.insert("MCID".to_string(), PdfObject::Integer(i64::MAX));
4754
4755 let mut properties = PdfDictionary::new();
4756 properties.insert("PropsName".to_string(), PdfObject::Dictionary(inner));
4757
4758 let props = MarkedContentProps::ResourceRef("PropsName".to_string());
4759 let (mcid, _) = super::resolve_props(&props, Some(&properties));
4760 assert_eq!(mcid, None);
4761 }
4762
4763 #[test]
4764 fn sort_and_merge_fragments_nan_y_does_not_swallow_other_lines() {
4765 // A fragment with a non-finite Y (reachable from a degenerate text
4766 // matrix in a malformed PDF) must not chain every remaining fragment
4767 // into one pseudo-line. The tolerance filter compares with `< tol`; a
4768 // `>= tol` phrasing would let a NaN anchor never terminate the line,
4769 // collapsing the whole page into a single X-sorted "line".
4770 let extractor = TextExtractor::with_options(ExtractionOptions::default());
4771
4772 // Four well-separated lines whose X order is the reverse of their Y
4773 // (reading) order: if the NaN anchor swallows the rest, they get
4774 // re-sorted purely by X into D,C,B,A instead of the reading order.
4775 let mut fragments = vec![
4776 tf("A", 400.0, f64::NAN, 10.0, 12.0),
4777 tf("B", 300.0, 500.0, 10.0, 12.0),
4778 tf("C", 200.0, 300.0, 10.0, 12.0),
4779 tf("D", 100.0, 100.0, 10.0, 12.0),
4780 ];
4781 extractor.sort_and_merge_fragments(&mut fragments);
4782
4783 let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
4784 assert_eq!(
4785 order,
4786 vec!["A", "B", "C", "D"],
4787 "NaN-Y fragment must stay its own line; the finite lines keep \
4788 top-to-bottom reading order instead of collapsing to X order"
4789 );
4790 }
4791}