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