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