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