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 // `x_gap >= 0.0` treats a "touching" pair (the next run starting
2954 // exactly where the previous one's pen advance ended, e.g. two
2955 // runs of the same word/sentence with no positioning operator
2956 // between them) as mergeable. But `current.width` and
2957 // `fragment.x` are usually derived from independent floating-point
2958 // paths (accumulated per-glyph AFM/Widths sums vs. the text
2959 // matrix's absolute origin) that are mathematically identical but
2960 // not bit-identical, so a genuinely zero gap can land a few ULPs
2961 // on either side of 0.0 (issue #521 follow-up). Tolerate that
2962 // rounding noise (see `SAME_LINE_EPS`) without loosening the check
2963 // enough to treat a real, visible overlap as adjacent.
2964 let should_merge = y_diff < y_tol
2965 && x_gap >= -SAME_LINE_EPS // Fragment is to the right (within FP rounding noise)
2966 && x_gap < fragment.font_size * 0.5 // Gap less than 50% of font size
2967 && current.mcid == fragment.mcid;
2968
2969 if should_merge {
2970 // Merge this fragment into current, preserving word boundaries
2971 // when the gap exceeds the font-anchored space threshold.
2972 if x_gap > self.space_gap_threshold(fragment) {
2973 current.text.push(' ');
2974 }
2975 current.text.push_str(&fragment.text);
2976 current.width = (fragment.x + fragment.width) - current.x;
2977 } else {
2978 // Start a new fragment
2979 merged.push(current);
2980 current = fragment.clone();
2981 }
2982 }
2983
2984 merged.push(current);
2985 merged
2986 }
2987
2988 /// Extract font resources from page
2989 ///
2990 /// Clears the per-page name cache (font names are page-local in PDF), but
2991 /// reuses previously parsed font objects via `font_object_cache` to avoid
2992 /// re-parsing the same font object across multiple pages.
2993 fn extract_font_resources<R: Read + Seek>(
2994 &mut self,
2995 page: &ParsedPage,
2996 document: &PdfDocument<R>,
2997 ) -> ParseResult<()> {
2998 // Clear per-page name mapping (font names like /F1 are page-local)
2999 self.font_cache.clear();
3000 self.type0_implicit_space_widths.clear();
3001
3002 // Try to get resources manually from page dictionary first
3003 // This is necessary because ParsedPage.get_resources() may not always work
3004 if let Some(res_ref) = page.dict.get("Resources").and_then(|o| o.as_reference()) {
3005 if let Ok(PdfObject::Dictionary(resources)) = document.get_object(res_ref.0, res_ref.1)
3006 {
3007 self.cache_fonts_from_resources::<R>(&resources, document);
3008 }
3009 } else if let Some(resources) = page.get_resources() {
3010 // Fallback to get_resources() if Resources is not a reference
3011 self.cache_fonts_from_resources::<R>(resources, document);
3012 }
3013
3014 Ok(())
3015 }
3016
3017 /// Cache every font declared in a page's `/Resources` `/Font` dictionary.
3018 ///
3019 /// `/Font` itself may be either an inline dictionary or an indirect
3020 /// reference (`/Font 191 0 R`); both are common in real PDFs (e.g. the
3021 /// ATLAS Higgs paper references it). Resolving the reference is required —
3022 /// otherwise the font cache stays empty, decoding loses ToUnicode, and
3023 /// glyph widths fall back to a flat estimate that scrambles multi-column
3024 /// layout (issue #302).
3025 fn cache_fonts_from_resources<R: Read + Seek>(
3026 &mut self,
3027 resources: &PdfDictionary,
3028 document: &PdfDocument<R>,
3029 ) {
3030 for (font_name, entry) in
3031 crate::text::extraction_cmap::resolve_font_entries(resources, document)
3032 {
3033 match entry {
3034 crate::text::extraction_cmap::FontEntry::Indirect(num, gen) => {
3035 self.cache_font_by_ref::<R>(&font_name, (num, gen), document);
3036 }
3037 crate::text::extraction_cmap::FontEntry::Inline(font_dict) => {
3038 self.cache_inline_font::<R>(&font_name, &font_dict, document);
3039 }
3040 }
3041 }
3042 }
3043
3044 /// Cache a font written directly into the page's resources.
3045 ///
3046 /// Unlike [`Self::cache_font_by_ref`] this cannot touch the persistent
3047 /// cache: an inline dictionary has no object id to key on, and two pages
3048 /// may write different fonts under the same name. It is parsed per page.
3049 fn cache_inline_font<R: Read + Seek>(
3050 &mut self,
3051 font_name: &str,
3052 font_dict: &PdfDictionary,
3053 document: &PdfDocument<R>,
3054 ) {
3055 let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
3056 if let Ok(font_info) = cmap_extractor.extract_font_info(font_dict, document) {
3057 tracing::debug!(
3058 "Parsed inline font {} (ToUnicode: {})",
3059 font_name,
3060 font_info.to_unicode.is_some()
3061 );
3062 self.cache_page_font(font_name, font_info);
3063 }
3064 }
3065
3066 /// Cache a font, reusing the persistent object cache when possible.
3067 fn cache_font_by_ref<R: Read + Seek>(
3068 &mut self,
3069 font_name: &str,
3070 font_ref: (u32, u16),
3071 document: &PdfDocument<R>,
3072 ) {
3073 // Check persistent object cache first — avoids re-parsing across pages
3074 if let Some(cached) = self.font_object_cache.get(&font_ref) {
3075 let cached = cached.clone();
3076 tracing::debug!(
3077 "Reused cached font object ({}, {}): {} (ToUnicode: {})",
3078 font_ref.0,
3079 font_ref.1,
3080 font_name,
3081 cached.to_unicode.is_some()
3082 );
3083 self.cache_page_font(font_name, cached);
3084 return;
3085 }
3086
3087 // Parse font object
3088 if let Ok(PdfObject::Dictionary(font_dict)) = document.get_object(font_ref.0, font_ref.1) {
3089 let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
3090 if let Ok(font_info) = cmap_extractor.extract_font_info(&font_dict, document) {
3091 let has_to_unicode = font_info.to_unicode.is_some();
3092 // Store in persistent cache
3093 self.font_object_cache.insert(font_ref, font_info.clone());
3094 // Store in per-page name cache
3095 self.cache_page_font(font_name, font_info);
3096 tracing::debug!(
3097 "Parsed and cached font ({}, {}): {} (ToUnicode: {})",
3098 font_ref.0,
3099 font_ref.1,
3100 font_name,
3101 has_to_unicode
3102 );
3103 }
3104 }
3105 }
3106
3107 /// Add a parsed font to the page-local caches, deriving the Type0 fallback
3108 /// width once rather than on every text-showing boundary.
3109 fn cache_page_font(&mut self, font_name: &str, font_info: FontInfo) {
3110 if font_info.font_type == "Type0" || font_info.descendant_font.is_some() {
3111 let cid_font = font_info.descendant_font.as_deref().unwrap_or(&font_info);
3112 if let Some(width) = cid_font
3113 .metrics
3114 .cid_widths
3115 .as_ref()
3116 .and_then(|widths| widths.narrowest_positive_width())
3117 {
3118 self.type0_implicit_space_widths
3119 .insert(font_name.to_string(), width);
3120 }
3121 }
3122 self.font_cache.insert(font_name.to_string(), font_info);
3123 }
3124
3125 /// Decode text using the current font encoding and ToUnicode mapping
3126 fn decode_text(&self, text: &[u8], state: &TextState) -> ParseResult<String> {
3127 use crate::text::encoding::TextEncoding;
3128
3129 // First, try to use cached font information with ToUnicode CMap
3130 if let Some(ref font_name) = state.font_name {
3131 if let Some(font_info) = self.font_cache.get(font_name) {
3132 // Try CMap-based decoding first (free function — no allocation)
3133 if let Ok(decoded) =
3134 crate::text::extraction_cmap::decode_text_with_font(text, font_info)
3135 {
3136 // Only accept if we got meaningful text (not all null bytes
3137 // or garbage). Whitespace counts as meaningful: a decode
3138 // that is exactly a space is a space, not a failed decode
3139 // (#438). See `decode_is_usable`.
3140 let sanitized = sanitize_extracted_text_with_policy(
3141 &decoded,
3142 self.carriage_return_handling,
3143 );
3144 if crate::text::extraction_cmap::decode_is_usable(&sanitized) {
3145 tracing::debug!(
3146 "Successfully decoded text using CMap for font {}: {:?} -> \"{}\"",
3147 font_name,
3148 text,
3149 sanitized
3150 );
3151 return Ok(sanitized);
3152 }
3153 }
3154
3155 tracing::debug!(
3156 "CMap decoding failed or produced garbage for font {}, falling back to encoding",
3157 font_name
3158 );
3159 }
3160 }
3161
3162 // Fall back to encoding-based decoding
3163 let encoding = if let Some(ref font_name) = state.font_name {
3164 match font_name.to_lowercase().as_str() {
3165 name if name.contains("macroman") => TextEncoding::MacRomanEncoding,
3166 name if name.contains("winansi") => TextEncoding::WinAnsiEncoding,
3167 name if name.contains("standard") => TextEncoding::StandardEncoding,
3168 name if name.contains("pdfdoc") => TextEncoding::PdfDocEncoding,
3169 _ => {
3170 // Default based on common patterns
3171 if font_name.starts_with("Times")
3172 || font_name.starts_with("Helvetica")
3173 || font_name.starts_with("Courier")
3174 {
3175 TextEncoding::WinAnsiEncoding // Most common for standard fonts
3176 } else {
3177 TextEncoding::PdfDocEncoding // Safe default
3178 }
3179 }
3180 }
3181 } else {
3182 TextEncoding::WinAnsiEncoding // Default for most PDFs
3183 };
3184
3185 let fallback_result = encoding.decode(text);
3186 // Apply sanitization to remove control characters (Issue #116)
3187 let sanitized =
3188 sanitize_extracted_text_with_policy(&fallback_result, self.carriage_return_handling);
3189 tracing::debug!(
3190 "Fallback encoding decoding: {:?} -> \"{}\"",
3191 text,
3192 sanitized
3193 );
3194 Ok(sanitized)
3195 }
3196}
3197
3198impl Default for TextExtractor {
3199 fn default() -> Self {
3200 Self::new()
3201 }
3202}
3203
3204/// Emit a `TextFragment` for one decoded text-show event under `preserve_layout`.
3205///
3206/// Encapsulates the style-derivation + push sequence shared by every
3207/// text-show operator handler in `extract_from_page` (`Tj`, `TJ`, `'`,
3208/// `"`). The caller supplies the pen origin `(x, y)` already mapped to
3209/// user space (typically via `text_origin(&state)`); doing so avoids the
3210/// double `multiply_matrix + transform_point` that prior versions did
3211/// (handler computed it for `last_x`/`last_y`, then this fn recomputed
3212/// it on the same `state`).
3213///
3214/// Skips emission when an ancestor in the marked-content stack is `/Artifact`
3215/// and `include_artifacts` is false. When a pending ActualText run is
3216/// active in the current scope, accumulates the text-width contribution and
3217/// records the first origin instead of pushing a fragment (the run is flushed
3218/// once on EMC, see Task 8's EndMarkedContent handler).
3219///
3220/// `mcid` and `struct_tag` come from the innermost ancestor on the stack that
3221/// declared `/MCID`; non-tagged content leaves both as `None`.
3222/// Whether the current marked-content stack should suppress text emission.
3223///
3224/// Mirrors the gate inside [`emit_text_fragment`]: when an ancestor in the
3225/// stack is `/Artifact` and the caller has not opted into artifact content
3226/// via `include_artifacts`, neither `.text` nor `.fragments` should receive
3227/// the run. Used by the four show-text operator arms to keep `extracted_text`
3228/// and `fragments` symmetric — a page whose entire content is an
3229/// `/Artifact BMC … EMC` scope (the common pattern for screen-reader-skipped
3230/// disclaimers / footers / decorative tagged-PDF content) used to surface
3231/// text in `.text` while leaving `.fragments` empty, silently dropping the
3232/// page from `partition_with(...)` / `rag_chunks(...)` (issue #330).
3233fn skip_artifact_text(state: &TextState, include_artifacts: bool) -> bool {
3234 !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact)
3235}
3236
3237/// Page-space scale factors `(x_scale, y_scale)` of the current text/CTM
3238/// combination (issue #262). Converts a text-space width/size into page space,
3239/// mirroring the scaling [`emit_text_fragment`] applies, so the reading-order
3240/// boxes and the median-font unit that judges their gaps share the page-space
3241/// scale of the `x`/`y` origins.
3242fn combined_text_scale(state: &TextState) -> (f64, f64) {
3243 let combined = multiply_matrix(&state.text_matrix, &state.ctm);
3244 let x_scale = (combined[0] * combined[0] + combined[1] * combined[1]).sqrt();
3245 let y_scale = (combined[2] * combined[2] + combined[3] * combined[3]).sqrt();
3246 (x_scale, y_scale)
3247}
3248
3249/// Record a just-emitted glyph run into the flat-path line groups (issue #448).
3250///
3251/// Called only when `ExtractionOptions::reading_order` is on, right after a
3252/// successful [`append_bounded`], with `text_len` = `extracted_text.len()` after
3253/// the append. `width` and `font_size` must already be page-space (scaled via
3254/// [`combined_text_scale`]) so the box matches the page-space `x`/`y` origin. A
3255/// run whose separator is a newline opens a new group; anything else extends the
3256/// current one. The group's byte range excludes the leading newline (a group's
3257/// text starts at `text_len - decoded_len`, which is past the separator), so
3258/// rejoining slices with `'\n'` reproduces the original exactly.
3259#[allow(clippy::too_many_arguments)]
3260fn record_line_group(
3261 line_groups: &mut Vec<LineGroupGeom>,
3262 cur_group: &mut Option<LineGroupGeom>,
3263 text_len: usize,
3264 decoded_len: usize,
3265 separator: Option<char>,
3266 x: f64,
3267 y: f64,
3268 text_width: f64,
3269 state: &TextState,
3270) {
3271 // Convert the text-space advance and font size to page space so the box
3272 // matches the page-space `x`/`y` and the median-font unit is on the same
3273 // scale as the gaps it judges (issue #262).
3274 let (x_scale, y_scale) = combined_text_scale(state);
3275 let width = text_width * x_scale;
3276 let font_size = state.font_size * y_scale;
3277 let run_start = text_len.saturating_sub(decoded_len);
3278 let (rx0, rx1) = (x.min(x + width), x.max(x + width));
3279 let (ry0, ry1) = (y.min(y + font_size), y.max(y + font_size));
3280 let opens = matches!(separator, Some('\n')) || cur_group.is_none();
3281 if opens {
3282 if let Some(g) = cur_group.take() {
3283 line_groups.push(g);
3284 }
3285 *cur_group = Some(LineGroupGeom {
3286 start: run_start,
3287 end: text_len,
3288 min_x: rx0,
3289 max_x: rx1,
3290 min_y: ry0,
3291 max_y: ry1,
3292 font_size,
3293 });
3294 } else if let Some(g) = cur_group.as_mut() {
3295 g.end = text_len;
3296 g.min_x = g.min_x.min(rx0);
3297 g.max_x = g.max_x.max(rx1);
3298 g.min_y = g.min_y.min(ry0);
3299 g.max_y = g.max_y.max(ry1);
3300 g.font_size = g.font_size.max(font_size);
3301 }
3302}
3303
3304/// Outcome of [`append_bounded`]: whether the run was appended, and — when it
3305/// was — the separator actually applied. The applied separator can differ
3306/// from the one the caller requested when hyphen-wrap fusion (issue #486)
3307/// consumes a trailing `-` instead of inserting the requested `\n`; callers
3308/// that feed the separator into reading-order line grouping (`record_line_group`)
3309/// must use `applied_separator`, not the separator they originally computed,
3310/// so a fused run correctly extends its line group instead of opening a new one.
3311struct AppendOutcome {
3312 appended: bool,
3313 applied_separator: Option<char>,
3314}
3315
3316/// Append an optional `separator` plus `decoded` to `acc`, honouring the
3317/// per-page byte budget `limit` (issue #382), with optional hyphen-wrap
3318/// fusion (issue #486).
3319///
3320/// Returns [`AppendOutcome`] with `appended: true` when the run was appended.
3321/// Returns `appended: false` — appending nothing and setting `*truncated` —
3322/// when the combined bytes would exceed `limit`. The separator is counted
3323/// against the budget so the invariant `acc.len() <= limit` holds *exactly*,
3324/// and because whole runs are the unit of truncation a multi-byte UTF-8
3325/// character is never split (undershoot semantics). A `None` limit always
3326/// appends and never truncates, keeping the no-limit path byte-identical to
3327/// before. Once `*truncated` is set the helper is a no-op, so a caller that
3328/// keeps calling it after the budget is reached simply accumulates nothing
3329/// further.
3330///
3331/// When `merge_hyphenated` is set and the caller requests a `'\n'` separator
3332/// (a genuine line wrap) while `acc` already ends with `-`, the hyphen is
3333/// producer noise from a hyphenated word/number wrapping across two lines,
3334/// not a real word boundary (issue #486: `merge_hyphenated` had no effect on
3335/// this flat/default extraction path, unlike `preserve_layout`'s
3336/// `reconstruct_text_from_fragments` and `reconstruct_paragraphs`'s
3337/// `merge_into_paragraphs`, both of which already apply this same rule). The
3338/// trailing hyphen is popped and `decoded` is appended directly with no
3339/// separator, fusing the wrapped token into one word instead of splitting it
3340/// on a newline — e.g. `"...3016-"` + `"0900"` becomes `"...30160900"`
3341/// instead of `"...3016-\n0900"`. `separator` is only ever `'\n'` here when
3342/// `acc` is already non-empty (every call site gates on that), so the pop is
3343/// always into at least one existing byte.
3344fn append_bounded(
3345 acc: &mut String,
3346 separator: Option<char>,
3347 decoded: &str,
3348 limit: Option<usize>,
3349 truncated: &mut bool,
3350 merge_hyphenated: bool,
3351) -> AppendOutcome {
3352 if *truncated {
3353 return AppendOutcome {
3354 appended: false,
3355 applied_separator: None,
3356 };
3357 }
3358
3359 let hyphen_fusion = merge_hyphenated && separator == Some('\n') && acc.ends_with('-');
3360 let separator = if hyphen_fusion { None } else { separator };
3361
3362 if let Some(max) = limit {
3363 // Popping the hyphen frees one byte before the new run is added, so
3364 // account against the post-pop length — otherwise a run that fits
3365 // once the hyphen is dropped could be wrongly rejected as
3366 // over-budget by one byte.
3367 let base_len = if hyphen_fusion {
3368 acc.len() - 1
3369 } else {
3370 acc.len()
3371 };
3372 let add = separator.map_or(0, char::len_utf8) + decoded.len();
3373 if base_len + add > max {
3374 *truncated = true;
3375 return AppendOutcome {
3376 appended: false,
3377 applied_separator: None,
3378 };
3379 }
3380 }
3381
3382 if hyphen_fusion {
3383 acc.pop();
3384 }
3385 if let Some(sep) = separator {
3386 acc.push(sep);
3387 }
3388 acc.push_str(decoded);
3389 AppendOutcome {
3390 appended: true,
3391 applied_separator: separator,
3392 }
3393}
3394
3395/// Defensive final clamp of a page's text to the byte budget (issue #382).
3396///
3397/// The `preserve_layout` / `reorder_columns` paths rebuild `.text` from the
3398/// already-bounded fragment set via `reconstruct_text_from_fragments`, which
3399/// reorders fragments and inserts its own separators — so the reconstructed
3400/// length is not provably `<= limit` from the accumulation-time accounting
3401/// alone. This clamps the result to `limit` at a UTF-8 char boundary (never
3402/// splitting a character) and sets `*truncated` if it had to cut, making the
3403/// `text.len() <= max_extracted_bytes` invariant hold for *every* path. A no-op
3404/// when there is no limit or the text already fits.
3405fn clamp_to_budget(text: &mut String, limit: Option<usize>, truncated: &mut bool) {
3406 if let Some(max) = limit {
3407 if text.len() > max {
3408 let mut cut = max;
3409 while cut > 0 && !text.is_char_boundary(cut) {
3410 cut -= 1;
3411 }
3412 text.truncate(cut);
3413 *truncated = true;
3414 }
3415 }
3416}
3417
3418fn emit_text_fragment(
3419 fragments: &mut Vec<TextFragment>,
3420 decoded: &str,
3421 text_width: f64,
3422 x: f64,
3423 y: f64,
3424 state: &mut TextState,
3425 include_artifacts: bool,
3426) {
3427 if decoded.is_empty() {
3428 return;
3429 }
3430
3431 // Artifact filter (default: skip emission for Artifact subtrees).
3432 if !include_artifacts && state.mc_stack.iter().any(|e| e.is_artifact) {
3433 return;
3434 }
3435
3436 let (is_bold, is_italic) = state
3437 .font_name
3438 .as_ref()
3439 .map(|name| parse_font_style(name))
3440 .unwrap_or((false, false));
3441
3442 // Issue #262: font_size, height, and width must be in page space so that
3443 // downstream heuristics (line/paragraph reconstruction, header/footer zone
3444 // detection, table detection) reason about real geometry. `x` and `y` are
3445 // already page-space (caller transforms via `text_origin`); we still need
3446 // to scale the size/width fields by the combined `text_matrix × CTM`.
3447 let combined = multiply_matrix(&state.text_matrix, &state.ctm);
3448 let x_scale = (combined[0] * combined[0] + combined[1] * combined[1]).sqrt();
3449 let y_scale = (combined[2] * combined[2] + combined[3] * combined[3]).sqrt();
3450 let effective_width = text_width * x_scale;
3451 let effective_size = state.font_size * y_scale;
3452
3453 // If a pending ActualText run is active in the current scope, accumulate
3454 // into it instead of emitting a fragment now. The run is flushed on the
3455 // matching EMC by the EndMarkedContent arm (Task 8).
3456 // Hoist font_name/fill_color reads before taking &mut on pending_actualtext
3457 // to avoid borrow-checker conflicts with the disjoint fields.
3458 let local_font_name = state.font_name.clone();
3459 let local_fill_color = state.fill_color;
3460 if let Some(pending) = state.pending_actualtext.as_mut() {
3461 if !pending.populated {
3462 pending.first_x = x;
3463 pending.first_y = y;
3464 pending.font_size = effective_size;
3465 pending.font_name = local_font_name;
3466 pending.is_bold = is_bold;
3467 pending.is_italic = is_italic;
3468 pending.color = local_fill_color;
3469 pending.populated = true;
3470 }
3471 pending.width += effective_width;
3472 return;
3473 }
3474
3475 let (mcid, struct_tag) = innermost_mc_tag(&state.mc_stack);
3476
3477 fragments.push(TextFragment {
3478 text: decoded.to_owned(),
3479 x,
3480 y,
3481 width: effective_width,
3482 height: effective_size,
3483 font_size: effective_size,
3484 font_name: state.font_name.clone(),
3485 is_bold,
3486 is_italic,
3487 color: state.fill_color,
3488 space_decisions: Vec::new(),
3489 mcid,
3490 struct_tag,
3491 });
3492}
3493
3494/// Pen origin (user-space coordinates) of the next glyph in the current
3495/// text state.
3496///
3497/// Per ISO 32000-1 §8.3.4, the text rendering matrix is `Tm × CTM` (row-vector
3498/// convention). `multiply_matrix(a, b)` returns the matrix that applies `a`
3499/// first and then `b`, so the correct composition is
3500/// `multiply_matrix(text_matrix, ctm)`. Prior to issue #262 this used the
3501/// reverse order which gave correct results only when the CTM was an identity
3502/// or pure-translation matrix; non-uniform CTM scaling produced wrong origins.
3503fn text_origin(state: &TextState) -> (f64, f64) {
3504 let combined = multiply_matrix(&state.text_matrix, &state.ctm);
3505 // Text rise (`Ts`) shifts the glyph origin up the text-space y-axis before
3506 // the text/CTM transform (ISO 32000-1 §9.4.4). For an axis-aligned matrix
3507 // this moves the user-space y by exactly `Ts`.
3508 transform_point(0.0, state.text_rise, &combined)
3509}
3510
3511/// Advance the text matrix by one shown glyph run of unscaled width
3512/// `text_width` and return the pen's new x in user space.
3513///
3514/// The advance applied to the text matrix is `text_width * Tz/100`
3515/// (`state.horizontal_scale`), and the resulting user-space displacement also
3516/// folds in the CTM's x-scale. The caller's `last_x` (used for `dx`-based
3517/// space decisions) must therefore come from the post-advance pen origin, not
3518/// from `origin_x + text_width`, which ignores both factors and trails the
3519/// real pen whenever `Tz != 100` or the CTM scales x (issue #386).
3520fn advance_pen(state: &mut TextState, text_width: f64) -> (f64, f64) {
3521 let tx = text_width * state.horizontal_scale / 100.0;
3522 state.text_matrix = multiply_matrix(&[1.0, 0.0, 0.0, 1.0, tx, 0.0], &state.text_matrix);
3523 text_origin(state)
3524}
3525
3526/// Projection-noise floor for the perpendicular pen delta. Same-baseline
3527/// glyph runs produce a `dy` that is exactly 0 in real arithmetic but can
3528/// carry ~1e-13 of float rounding after the baseline projection; anything
3529/// below this epsilon is "the same baseline". The smallest meaningful
3530/// leading in real documents is orders of magnitude above it.
3531const SAME_LINE_EPS: f64 = 1e-6;
3532
3533/// Scale-relative cut thresholds for the flat-path reading-order option
3534/// (issue #448), in multiples of a region's median glyph size: a horizontal gap
3535/// is a column gutter past `horizontal_k`, a vertical gap a section break past
3536/// `vertical_k`.
3537///
3538/// Validated against the opt-in differential order gate on the full `t3-stress`
3539/// corpus (`t3-stress-reading-order` baseline): with the option on, the
3540/// misplaced-word rate drops 0.2486 → 0.2255 (−9.3%) versus the default flat
3541/// path, at identical alignment coverage — the gain is reordering, not dropped
3542/// text. That clears the design probe's ceiling estimate (~0.2375), so these
3543/// values are kept rather than sweeping for a marginal further gain. (An
3544/// earlier, geometrically wrong build that fed the cut un-CTM-scaled boxes
3545/// scored a hair better here, 0.2226, purely because the corpus is
3546/// identity-CTM-dominated; the correct page-space geometry is kept.)
3547const READING_ORDER_CFG: flat_reading_order::CutConfig = flat_reading_order::CutConfig {
3548 horizontal_k: 1.0,
3549 vertical_k: 1.5,
3550};
3551
3552/// Minimum forward pen jump, in em, that reads as a word break at the boundary
3553/// between two show-text operators — the first element of a `TJ` array whose
3554/// pen jumped forward from the previous operator (a `Tm` reposition, or the
3555/// prior operator's advance). Without it, two `TJ` operators drawn side by side
3556/// on the same line come out glued: a multi-column table cell reads as
3557/// `CellOneCellTwo` (issue #458), and a list bullet 0.75 em from its item text
3558/// reads as `vlarge` (found on preserve_027613.pdf, an IBM manual whose every
3559/// bullet is a separate `TJ`).
3560///
3561/// Calibrated on the full `t3-stress` corpus against poppler, with the
3562/// reading-order (misplaced) rate as the objective and alignment coverage as
3563/// the guard. Recalibrated on the Tc/Tw-corrected pen advance (#456), which
3564/// feeds the `dx` this threshold judges:
3565///
3566/// | em | 0.0 | 0.3 | 0.7 | 1.0 | 2.0 | 3.0 | 6.0 | off |
3567/// |---|---|---|---|---|---|---|---|---|
3568/// | misplaced rate | .2806 | .2485 | **.2486** | .2486 | .2486 | .2512 | .2702 | .2766 |
3569///
3570/// Below ~0.3 em the rule splits words a producer draws as several positioned
3571/// runs — the pen advance is only as accurate as the font widths, so a short
3572/// run inflates the apparent gap, and em=0.0 lands *worse* than not firing at
3573/// all. From 0.3 to 2.0 the corpus cannot discriminate (a flat plateau within
3574/// 1e-4 of the minimum); above 3 em the rule stops firing on genuine column
3575/// gaps and converges back on the un-fixed number.
3576///
3577/// 0.7 sits inside that plateau with margin on both sides: comfortably above
3578/// the word-splitting floor, and below 0.748 em — the narrowest real word gap
3579/// verified by hand (the bullet above), which the threshold must stay under to
3580/// keep separating. On the corrected advance the fix moves the rate .2766 →
3581/// .2486 (vs .2874 → .2714 before #456: an accurate advance lets the boundary
3582/// fire more cleanly).
3583const TJ_BOUNDARY_SPACE_EM: f64 = 0.7;
3584
3585/// Backward-jump magnitude, in multiples of the font size, above which a
3586/// same-baseline (`dy == 0`) backward pen jump is a line wrap rather than a
3587/// glyph reposition (issue #447).
3588///
3589/// At `dy == 0` a backward jump is ambiguous: a same-line reposition
3590/// (justification, kerned overlay, out-of-order emission — issue #441) and a
3591/// real wrap whose two lines happen to land on the same content-stream Y
3592/// (issue #447) both produce it. They separate by MAGNITUDE: a reposition is
3593/// local (a word/phrase — a few em), while a wrap returns across the whole
3594/// text column (many em). This bound sits in that gap, scaled to font size
3595/// because the reposition scale is the glyph/word scale, not the fixed
3596/// paragraph-break `newline_threshold`. Scaled to `font_size.abs()`: `Tf`
3597/// accepts negative sizes (mirrored text), and the sign must not flip the
3598/// threshold's sense — otherwise a negative size makes every backward jump a
3599/// "wrap" and resurrects the #441 defect.
3600///
3601/// Accepted, documented limitation (the #417/#422 trade-off) within one text
3602/// object: a same-line reposition that jumps back more than this many em is
3603/// misread as a wrap, and a same-Y wrap shorter than this is not recognized as
3604/// a wrap. A new `BT`/`ET` object supplies enough evidence to separate #495's
3605/// runs with whitespace, but not enough to promote the separator to a newline.
3606/// A wrap with any nonzero leading (the common case, issue #390) is unaffected:
3607/// it breaks on the `dy`-aware gate.
3608const SAME_Y_WRAP_EM: f64 = 10.0;
3609
3610/// Pen movement from the previous post-advance pen point `last` to the
3611/// current glyph origin `cur` (both user space), measured in the frame of the
3612/// current text baseline (issue #443): `dx` along the baseline direction,
3613/// `dy` perpendicular to it (signed; callers take `.abs()` for line
3614/// detection).
3615///
3616/// The baseline direction is the image of the text-space x-axis under the
3617/// text rendering matrix `Tm × CTM`. For an axis-aligned matrix
3618/// (identity/translation/positive scale — the overwhelming majority of
3619/// content) the baseline IS the user-space x-axis and this returns exactly
3620/// `(Δx, Δy)`, the pre-#443 behavior. Under a rotated CTM (and any
3621/// similarity transform) the projection recovers the text's own line
3622/// geometry exactly, which raw user-space deltas conflate: a plain forward
3623/// advance along a rotated baseline changes the user-space y, which the
3624/// separator heuristics misread as a line change. Axis-aligned shear
3625/// (`b == 0`, `c != 0`) also projects exactly (the perpendicular reduces to
3626/// the y-axis); a shear COMBINED with a rotated baseline is approximated —
3627/// the perpendicular is built by rotating the baseline 90°, not from the
3628/// true image of the text-space y-axis.
3629///
3630/// A mirrored baseline (negative x-scale) measures `dx` along the text's own
3631/// advance direction, so a forward advance is positive `dx` — the spacing
3632/// and wrap gates apply as for unmirrored text (pre-#443 they saw a raw
3633/// negative `dx` and misfired the wrap gate on plain advances).
3634///
3635/// A degenerate baseline (zero-length or non-finite) falls back to the raw
3636/// user-space deltas, preserving pre-#443 behavior for malformed matrices.
3637fn pen_delta(state: &TextState, last: (f64, f64), cur: (f64, f64)) -> (f64, f64) {
3638 let dxu = cur.0 - last.0;
3639 let dyu = cur.1 - last.1;
3640 let m = multiply_matrix(&state.text_matrix, &state.ctm);
3641 let (bx, by) = (m[0], m[1]);
3642 let norm = (bx * bx + by * by).sqrt();
3643 if !norm.is_finite() || norm <= f64::EPSILON {
3644 return (dxu, dyu);
3645 }
3646 let (ux, uy) = (bx / norm, by / norm);
3647 (dxu * ux + dyu * uy, -dxu * uy + dyu * ux)
3648}
3649
3650/// Multiply two transformation matrices
3651fn multiply_matrix(a: &[f64; 6], b: &[f64; 6]) -> [f64; 6] {
3652 [
3653 a[0] * b[0] + a[1] * b[2],
3654 a[0] * b[1] + a[1] * b[3],
3655 a[2] * b[0] + a[3] * b[2],
3656 a[2] * b[1] + a[3] * b[3],
3657 a[4] * b[0] + a[5] * b[2] + b[4],
3658 a[4] * b[1] + a[5] * b[3] + b[5],
3659 ]
3660}
3661
3662/// Decode a PDF string operand into Rust `String`.
3663///
3664/// A string inside marked-content properties (notably `/ActualText`) is a PDF
3665/// text string like any other, so this is
3666/// [`PdfString::to_text`](crate::parser::objects::PdfString::to_text): UTF-16BE
3667/// when a byte order mark is present — the canonical encoding for non-ASCII
3668/// `/ActualText`, e.g. an `fi` ligature or a Greek symbol — and the WinAnsi
3669/// reading of PDFDocEncoding otherwise. Before that helper existed this mapped
3670/// non-BOM bytes to `char` one by one, which is Latin-1 and wrong for the
3671/// typographic punctuation WinAnsi puts in `0x80..=0x9F`.
3672fn decode_pdf_string(bytes: &[u8]) -> String {
3673 crate::parser::objects::decode_text_string(bytes)
3674}
3675
3676/// Build the page-local MCID -> structure-element `/ActualText` map.
3677///
3678/// The parent tree is a PDF number tree, so it may store `/Nums` directly or
3679/// split them across indirect `/Kids`. Every failure is deliberately reduced
3680/// to an empty/partial map: structure metadata must not make text extraction
3681/// fail. Depth, visited-reference, and node-count limits keep malformed trees
3682/// from causing cycles or unbounded traversal.
3683fn resolve_structure_actual_text<R: Read + Seek>(
3684 container: &crate::parser::objects::PdfDictionary,
3685 document: &PdfDocument<R>,
3686) -> StructureActualText {
3687 use crate::parser::objects::PdfObject;
3688
3689 const MAX_NUMBER_TREE_DEPTH: usize = 32;
3690 const MAX_NUMBER_TREE_NODES: usize = 4096;
3691
3692 fn number_tree_value<R: Read + Seek>(
3693 object: &PdfObject,
3694 key: i64,
3695 document: &PdfDocument<R>,
3696 depth: usize,
3697 visited: &mut std::collections::HashSet<(u32, u16)>,
3698 nodes: &mut usize,
3699 ) -> Option<PdfObject> {
3700 if depth > MAX_NUMBER_TREE_DEPTH || *nodes >= MAX_NUMBER_TREE_NODES {
3701 return None;
3702 }
3703 *nodes += 1;
3704
3705 let resolved = match object {
3706 PdfObject::Reference(id, generation) => {
3707 if !visited.insert((*id, *generation)) {
3708 return None;
3709 }
3710 document.get_object(*id, *generation).ok()?
3711 }
3712 other => other.clone(),
3713 };
3714 let dict = resolved.as_dict()?;
3715
3716 if let Some(PdfObject::Array(nums)) = dict.get("Nums") {
3717 for pair in nums.0.chunks_exact(2) {
3718 if pair[0] == PdfObject::Integer(key) {
3719 return Some(pair[1].clone());
3720 }
3721 }
3722 }
3723
3724 let PdfObject::Array(kids) = dict.get("Kids")? else {
3725 return None;
3726 };
3727 for kid in &kids.0 {
3728 if let Some(value) = number_tree_value(kid, key, document, depth + 1, visited, nodes) {
3729 return Some(value);
3730 }
3731 }
3732 None
3733 }
3734
3735 let Some(PdfObject::Integer(struct_parent_key)) = container.get("StructParents") else {
3736 return StructureActualText::default();
3737 };
3738 if *struct_parent_key < 0 {
3739 return StructureActualText::default();
3740 }
3741
3742 let Ok(catalog) = document.catalog_dictionary() else {
3743 return StructureActualText::default();
3744 };
3745 let Some(struct_root_object) = catalog.get("StructTreeRoot") else {
3746 return StructureActualText::default();
3747 };
3748 let Ok(struct_root_object) = document.resolve(struct_root_object) else {
3749 return StructureActualText::default();
3750 };
3751 let Some(struct_root) = struct_root_object.as_dict() else {
3752 return StructureActualText::default();
3753 };
3754 let Some(parent_tree) = struct_root.get("ParentTree") else {
3755 return StructureActualText::default();
3756 };
3757
3758 let Some(owner_array_object) = number_tree_value(
3759 parent_tree,
3760 *struct_parent_key,
3761 document,
3762 0,
3763 &mut std::collections::HashSet::new(),
3764 &mut 0,
3765 ) else {
3766 return StructureActualText::default();
3767 };
3768 let Ok(owner_array_object) = document.resolve(&owner_array_object) else {
3769 return StructureActualText::default();
3770 };
3771 let PdfObject::Array(owners) = owner_array_object else {
3772 return StructureActualText::default();
3773 };
3774 StructureActualText {
3775 owners: owners.0,
3776 cache: HashMap::new(),
3777 }
3778}
3779
3780/// Resolve a `MarkedContentProps` to `(mcid, actual_text)`.
3781///
3782/// For `Inline` props, walk the map: `/MCID` (Integer, must fit in `u32`)
3783/// becomes `mcid`; `/ActualText` (String) is decoded via `decode_pdf_string`.
3784///
3785/// For `ResourceRef(name)`, look up `properties.get(name)`. If found and
3786/// it's a Dictionary, extract `/MCID` and `/ActualText` from there. If
3787/// not found (or the named entry is not a dict), return `(None, None)`
3788/// — a malformed reference must not abort extraction.
3789fn resolve_props(
3790 props: &crate::parser::content::MarkedContentProps,
3791 properties: Option<&crate::parser::objects::PdfDictionary>,
3792) -> (Option<u32>, Option<String>) {
3793 use crate::parser::content::{MarkedContentProps, MarkedContentValue};
3794
3795 let map_mcid_actual =
3796 |map: &std::collections::HashMap<String, MarkedContentValue>| -> (Option<u32>, Option<String>) {
3797 let mcid = match map.get("MCID") {
3798 Some(MarkedContentValue::Integer(n)) if *n >= 0 && *n <= u32::MAX as i64 => {
3799 Some(*n as u32)
3800 }
3801 _ => None,
3802 };
3803 let actual = match map.get("ActualText") {
3804 Some(MarkedContentValue::String(bytes)) => Some(decode_pdf_string(bytes)),
3805 _ => None,
3806 };
3807 (mcid, actual)
3808 };
3809
3810 match props {
3811 MarkedContentProps::Inline(map) => map_mcid_actual(map),
3812 MarkedContentProps::ResourceRef(name) => {
3813 let Some(properties) = properties else {
3814 return (None, None);
3815 };
3816 let Some(entry) = properties.get(name) else {
3817 return (None, None);
3818 };
3819 let crate::parser::objects::PdfObject::Dictionary(dict) = entry else {
3820 return (None, None);
3821 };
3822 let mcid = dict.get("MCID").and_then(|o| match o {
3823 crate::parser::objects::PdfObject::Integer(n)
3824 if *n >= 0 && *n <= u32::MAX as i64 =>
3825 {
3826 Some(*n as u32)
3827 }
3828 _ => None,
3829 });
3830 let actual_text = dict.get("ActualText").and_then(|o| match o {
3831 crate::parser::objects::PdfObject::String(s) => {
3832 Some(decode_pdf_string(s.as_bytes()))
3833 }
3834 _ => None,
3835 });
3836 (mcid, actual_text)
3837 }
3838 }
3839}
3840
3841/// Walk the marked-content stack from innermost (top) outward, returning the
3842/// first entry's `(mcid, tag)` pair whose `mcid` is `Some`. Returns
3843/// `(None, None)` when no ancestor declared an MCID — typical of non-tagged
3844/// PDFs, in which case the `None == None` grouping-key invariant preserves
3845/// legacy behaviour.
3846fn innermost_mc_tag(stack: &[MarkedContentEntry]) -> (Option<u32>, Option<String>) {
3847 stack
3848 .iter()
3849 .rev()
3850 .find(|e| e.mcid.is_some())
3851 .map_or((None, None), |e| (e.mcid, Some(e.tag.clone())))
3852}
3853
3854/// Transform a point using a transformation matrix
3855fn transform_point(x: f64, y: f64, matrix: &[f64; 6]) -> (f64, f64) {
3856 let tx = matrix[0] * x + matrix[2] * y + matrix[4];
3857 let ty = matrix[1] * x + matrix[3] * y + matrix[5];
3858 (tx, ty)
3859}
3860
3861/// Calculate text width using actual font metrics (including kerning)
3862fn calculate_text_width(text: &str, font_size: f64, font_info: Option<&FontInfo>) -> f64 {
3863 // If we have font metrics, use them for accurate width calculation
3864 if let Some(font) = font_info {
3865 if let Some(ref widths) = font.metrics.widths {
3866 let first_char = font.metrics.first_char.unwrap_or(0);
3867 let last_char = font.metrics.last_char.unwrap_or(255);
3868 let missing_width = font.metrics.missing_width.unwrap_or(500.0);
3869
3870 let mut total_width = 0.0;
3871 let mut chars = text.chars().peekable();
3872
3873 while let Some(ch) = chars.next() {
3874 let char_code = ch as u32;
3875
3876 // Get width from Widths array or use missing_width
3877 let width = if char_code >= first_char && char_code <= last_char {
3878 let index = (char_code - first_char) as usize;
3879 widths.get(index).copied().unwrap_or(missing_width)
3880 } else {
3881 missing_width
3882 };
3883
3884 // Convert from glyph space (1/1000 units) to user space
3885 total_width += width / 1000.0 * font_size;
3886
3887 // Apply kerning if available (for character pairs)
3888 if let Some(ref kerning) = font.metrics.kerning {
3889 if let Some(&next_ch) = chars.peek() {
3890 let next_char = next_ch as u32;
3891 if let Some(&kern_value) = kerning.get(&(char_code, next_char)) {
3892 // Kerning is in FUnits (1/1000), convert to user space
3893 total_width += kern_value / 1000.0 * font_size;
3894 }
3895 }
3896 }
3897 }
3898
3899 return total_width;
3900 }
3901 }
3902
3903 // Fallback to simplified calculation if no metrics available
3904 text.len() as f64 * font_size * 0.5
3905}
3906
3907/// Compute advance width from the original character **codes**, not the decoded
3908/// Unicode text.
3909///
3910/// A simple font's `Widths` array is indexed by character code (`first_char..=
3911/// last_char`), i.e. the byte value in the content stream — not by the Unicode
3912/// codepoint the code decodes to. [`calculate_text_width`] indexes by the decoded
3913/// codepoint (`ch as u32`), which is correct only when code == codepoint (ASCII /
3914/// WinAnsi fonts). For custom-encoded fonts (Type1 with `Differences`, embedded
3915/// Computer Modern in LaTeX PDFs, ToUnicode remaps) the codepoint diverges from
3916/// the code, so the wrong slot — or `missing_width` — is read, desyncing glyph
3917/// advance and scrambling word order once fragments are sorted by position
3918/// (issue #302).
3919///
3920/// `decoded` is the already-decoded text for this run; it is only consulted for
3921/// composite (Type0) fonts, whose multi-byte codes cannot be indexed byte-wise
3922/// and whose width path is unchanged here to avoid regressing CJK extraction.
3923/// Unscaled text-space advance of a run (before `Th`), including the text-state
3924/// spacing parameters (ISO 32000-1 §9.4.4): the glyph displacement is
3925/// `w0/1000 * Tfs + Tc + Tw`, so `char_space` (`Tc`) is added once per glyph and
3926/// `word_space` (`Tw`) once per *single-byte* space (code 32, §9.3.3). Both are
3927/// unscaled text-space units, added directly (not multiplied by the font size);
3928/// the caller's `advance_pen` applies `Th`.
3929/// Decode a Type0 text-showing string's raw bytes into CIDs, for CID-indexed
3930/// `/W` width lookup (issue #496). Mirrors the code-length/lookup rules
3931/// `decode_text_with_font` already uses for Unicode decoding, but stops at
3932/// the CID rather than continuing on to a Unicode codepoint.
3933///
3934/// Returns `None` when the code→CID relation is unavailable or vertical: in
3935/// those cases guessing would select unrelated `/W` entries, so the caller
3936/// retains the legacy fallback.
3937fn cids_for_codes<'a>(codes: &'a [u8], font: &FontInfo) -> Option<Vec<(&'a [u8], u32)>> {
3938 use crate::text::encoding_cmap::CidEncoding;
3939
3940 if matches!(font.cid_encoding, Some(CidEncoding::Utf16Be))
3941 || font
3942 .encoding
3943 .as_deref()
3944 .is_some_and(|name| name.ends_with("-V"))
3945 || matches!(&font.cid_encoding, Some(CidEncoding::Cmap(cmap)) if cmap.wmode == 1)
3946 {
3947 return None;
3948 }
3949 let identity = font.encoding.as_deref() == Some("Identity-H");
3950 if font.cid_encoding.is_none() && !identity {
3951 return None;
3952 }
3953
3954 let mut result = Vec::new();
3955 let mut offset = 0;
3956 while offset < codes.len() {
3957 let code_len = match &font.cid_encoding {
3958 Some(CidEncoding::Cmap(cmap)) => cmap.code_len_at(codes, offset),
3959 Some(CidEncoding::Utf16Be) => unreachable!(),
3960 None => 2,
3961 }
3962 .max(1)
3963 .min(codes.len() - offset);
3964 let code = &codes[offset..offset + code_len];
3965 let cid = match &font.cid_encoding {
3966 Some(CidEncoding::Cmap(cmap)) => cmap
3967 .map_code_to_cid(code)
3968 .or_else(|| cmap.map_notdef(code))
3969 .map(u32::from)?,
3970 Some(CidEncoding::Utf16Be) => unreachable!(),
3971 None => code
3972 .iter()
3973 .fold(0u32, |value, byte| (value << 8) | u32::from(*byte)),
3974 };
3975 result.push((code, cid));
3976 offset += code_len;
3977 }
3978 Some(result)
3979}
3980
3981fn calculate_text_width_from_codes(
3982 codes: &[u8],
3983 decoded: &str,
3984 font_size: f64,
3985 font_info: Option<&FontInfo>,
3986 char_space: f64,
3987 word_space: f64,
3988) -> f64 {
3989 // Composite (Type0) fonts use multi-byte codes; a single byte is not a code,
3990 // so byte-indexed width lookup is invalid. `Tc` is added per glyph below.
3991 // `Tw` applies only to the single-byte code 32 (§9.3.3), which a
3992 // multi-byte code can never be, so it does not apply here.
3993 let is_composite =
3994 font_info.is_some_and(|f| f.font_type == "Type0" || f.descendant_font.is_some());
3995 if is_composite {
3996 // Prefer the descendant CIDFont's `/W`/`/DW` (CID-indexed, per
3997 // §9.7.4.3) over the decoded-Unicode-indexed fallback below: a CID
3998 // is an arbitrary font-internal identifier with no relationship to
3999 // the character it decodes to, so indexing by decoded codepoint
4000 // reads the wrong table slot whenever CID != codepoint (any
4001 // subset/reordered CID font). Without `/W`/`/DW` at all, fall back
4002 // to the previous decoded-text heuristic (issue #496).
4003 if let Some(cid_widths) = font_info
4004 .and_then(|f| f.descendant_font.as_deref().or(Some(f)))
4005 .and_then(|f| f.metrics.cid_widths.as_ref())
4006 {
4007 if let Some(font) = font_info {
4008 if let Some(cid_codes) = cids_for_codes(codes, font) {
4009 let mut total = 0.0;
4010 for (code, cid) in cid_codes {
4011 total += cid_widths.width_for(cid) / 1000.0 * font_size + char_space;
4012 if code.len() == 1 && code[0] == b' ' {
4013 total += word_space;
4014 }
4015 }
4016 return total;
4017 }
4018 }
4019 }
4020 let glyphs = decoded.chars().count() as f64;
4021 return calculate_text_width(decoded, font_size, font_info) + char_space * glyphs;
4022 }
4023
4024 // `Tc` on every byte-code, `Tw` on every space byte. Shared by the metric
4025 // and no-metric branches below.
4026 let spacing = |codes: &[u8]| -> f64 {
4027 char_space * codes.len() as f64
4028 + word_space * codes.iter().filter(|&&b| b == b' ').count() as f64
4029 };
4030
4031 if let Some(font) = font_info {
4032 if let Some(ref widths) = font.metrics.widths {
4033 let first_char = font.metrics.first_char.unwrap_or(0);
4034 let last_char = font.metrics.last_char.unwrap_or(255);
4035 let missing_width = font.metrics.missing_width.unwrap_or(500.0);
4036
4037 let mut total_width = 0.0;
4038 let mut iter = codes.iter().peekable();
4039 while let Some(&byte) = iter.next() {
4040 let code = byte as u32;
4041 let width = if code >= first_char && code <= last_char {
4042 widths
4043 .get((code - first_char) as usize)
4044 .copied()
4045 .unwrap_or(missing_width)
4046 } else {
4047 missing_width
4048 };
4049 total_width += width / 1000.0 * font_size;
4050
4051 // Kerning is keyed by code pair, consistent with code-based widths.
4052 if let Some(ref kerning) = font.metrics.kerning {
4053 if let Some(&next_byte) = iter.peek() {
4054 if let Some(&kern_value) = kerning.get(&(code, *next_byte as u32)) {
4055 total_width += kern_value / 1000.0 * font_size;
4056 }
4057 }
4058 }
4059 }
4060
4061 return total_width + spacing(codes);
4062 }
4063
4064 // Standard-14 simple fonts may legally omit `/Widths`. Resolve each
4065 // original character code through the effective base encoding and
4066 // `/Differences`, then look up the resulting PostScript glyph name in
4067 // the font's AFM metrics (#523). An unresolved code retains the legacy
4068 // 0.5em estimate instead of borrowing a width from the wrong encoding.
4069 if let Some(metrics) =
4070 crate::text::fonts::standard::get_standard_font_metrics_by_name(&font.name)
4071 {
4072 let total_width = codes
4073 .iter()
4074 .map(|&code| {
4075 metrics
4076 .encoded_char_width(
4077 font.encoding.as_deref(),
4078 font.differences.as_ref(),
4079 code,
4080 )
4081 .unwrap_or(500) as f64
4082 / 1000.0
4083 * font_size
4084 })
4085 .sum::<f64>();
4086 return total_width + spacing(codes);
4087 }
4088 }
4089
4090 // No metrics: one fallback width per code (byte), the simple-font glyph count.
4091 codes.len() as f64 * font_size * 0.5 + spacing(codes)
4092}
4093
4094/// Sanitize extracted text by removing or replacing control characters.
4095///
4096/// This function addresses Issue #116 where extracted text contains NUL bytes (`\0`)
4097/// and ETX characters (`\u{3}`) where spaces should appear.
4098///
4099/// # Behavior
4100///
4101/// - Replaces `\0\u{3}` sequences with a single space (common word separator pattern)
4102/// - Replaces standalone `\0` (NUL) with space
4103/// - Removes other ASCII control characters (0x01-0x1F) except:
4104/// - `\t` (0x09) - Tab
4105/// - `\n` (0x0A) - Line feed
4106/// - Normalizes `\r` and `\r\n` to `\n`
4107/// - Collapses multiple consecutive spaces into a single space
4108///
4109/// # Examples
4110///
4111/// ```
4112/// use oxidize_pdf::text::extraction::sanitize_extracted_text;
4113///
4114/// // Issue #116 pattern: NUL+ETX as word separator
4115/// let dirty = "a\0\u{3}sergeant\0\u{3}and";
4116/// assert_eq!(sanitize_extracted_text(dirty), "a sergeant and");
4117///
4118/// // Standalone NUL becomes space
4119/// let with_nul = "word\0another";
4120/// assert_eq!(sanitize_extracted_text(with_nul), "word another");
4121///
4122/// // Clean text passes through unchanged
4123/// let clean = "Normal text";
4124/// assert_eq!(sanitize_extracted_text(clean), "Normal text");
4125/// ```
4126pub fn sanitize_extracted_text(text: &str) -> String {
4127 sanitize_extracted_text_with_policy(text, CarriageReturnHandling::default())
4128}
4129
4130/// Sanitize extracted text using an explicit carriage-return policy.
4131pub fn sanitize_extracted_text_with_policy(
4132 text: &str,
4133 carriage_return_handling: CarriageReturnHandling,
4134) -> String {
4135 if text.is_empty() {
4136 return String::new();
4137 }
4138
4139 // Pre-allocate with same capacity (result will be <= input length)
4140 let mut result = String::with_capacity(text.len());
4141 let mut chars = text.chars().peekable();
4142 let mut last_was_space = false;
4143
4144 while let Some(ch) = chars.next() {
4145 match ch {
4146 // NUL byte - check if followed by ETX for the \0\u{3} pattern
4147 '\0' => {
4148 // Peek at next char to detect \0\u{3} sequence
4149 if chars.peek() == Some(&'\u{3}') {
4150 chars.next(); // consume the ETX
4151 }
4152 // In both cases (standalone NUL or NUL+ETX), emit space
4153 if !last_was_space {
4154 result.push(' ');
4155 last_was_space = true;
4156 }
4157 }
4158
4159 // ETX alone (not preceded by NUL) - remove it
4160 '\u{3}' => {
4161 // Don't emit anything, just skip
4162 }
4163
4164 '\r' => {
4165 // CRLF is unambiguously one line ending under every policy.
4166 // Ignore controls that sanitization would remove between the
4167 // pair, otherwise a first pass could create CRLF and a second
4168 // pass would change it again (for example `"\r\u{1}\n"`).
4169 let removed_controls_before_lf = chars
4170 .clone()
4171 .take_while(|next| {
4172 next.is_ascii_control() && !matches!(next, '\0' | '\t' | '\n' | '\r')
4173 })
4174 .count();
4175 let followed_by_lf = chars.clone().nth(removed_controls_before_lf) == Some('\n');
4176
4177 if followed_by_lf {
4178 for _ in 0..=removed_controls_before_lf {
4179 chars.next();
4180 }
4181 result.push('\n');
4182 last_was_space = false;
4183 } else {
4184 match carriage_return_handling {
4185 CarriageReturnHandling::Remove => {}
4186 CarriageReturnHandling::ReplaceWithSpace => {
4187 if !last_was_space {
4188 result.push(' ');
4189 last_was_space = true;
4190 }
4191 }
4192 CarriageReturnHandling::NormalizeLineEnding => {
4193 // A standalone CR is valid input and is not
4194 // equivalent to LF. Only the CRLF sequence above
4195 // is normalized as a line ending.
4196 result.push('\r');
4197 last_was_space = false;
4198 }
4199 }
4200 }
4201 }
4202
4203 // Preserve allowed whitespace
4204 '\t' | '\n' => {
4205 result.push(ch);
4206 // Reset space tracking on newlines but not tabs.
4207 last_was_space = ch == '\t';
4208 }
4209
4210 // Regular space - collapse multiples
4211 ' ' => {
4212 if !last_was_space {
4213 result.push(' ');
4214 last_was_space = true;
4215 }
4216 }
4217
4218 // Other control characters (0x01-0x1F except tab/newline) - remove
4219 c if c.is_ascii_control() => {
4220 // Skip control characters
4221 }
4222
4223 // Normal characters - keep them
4224 _ => {
4225 result.push(ch);
4226 last_was_space = false;
4227 }
4228 }
4229 }
4230
4231 result
4232}
4233
4234/// Assign a logical row identifier to each fragment based on Y-up-jumps in
4235/// emission order. Used by `merge_into_lines` to distinguish columns in
4236/// multi-column layouts where a single outer BDC scope makes mcid uniform.
4237///
4238/// Increments `row_id` whenever the next fragment's Y exceeds the previous
4239/// by more than `max(font_size * 0.5, 2.0)`. Superscripts (small positive
4240/// deltas) and normal line descents (negative deltas) leave `row_id`
4241/// unchanged. See `docs/superpowers/specs/2026-05-23-issue-265-line-interleaving-design.md`.
4242///
4243/// # Invariants
4244/// Returns a `Vec<u32>` with exactly `fragments.len()` elements — one
4245/// row id per input fragment, in input order. Callers may safely `.zip(fragments)`.
4246fn assign_row_ids(fragments: &[TextFragment]) -> Vec<u32> {
4247 let mut result = Vec::with_capacity(fragments.len());
4248 let mut row_id: u32 = 0;
4249 let mut prev_y: Option<f64> = None;
4250 for frag in fragments {
4251 if let Some(py) = prev_y {
4252 let delta = frag.y - py;
4253 // Threshold anchored to the arriving fragment's font_size; for the
4254 // symmetric same-font case (body→body, same font) this is equivalent
4255 // to anchoring to the previous fragment.
4256 let threshold = (frag.font_size * 0.5).max(2.0);
4257 if delta > threshold {
4258 row_id += 1;
4259 }
4260 }
4261 result.push(row_id);
4262 prev_y = Some(frag.y);
4263 }
4264 debug_assert_eq!(
4265 result.len(),
4266 fragments.len(),
4267 "assign_row_ids: output length must equal input length"
4268 );
4269 result
4270}
4271
4272/// Assign stable layout-region ids in content-stream emission order.
4273///
4274/// A region ends when the geometric flow restarts (`assign_row_ids`) or when
4275/// marked-content ownership changes. The former covers untagged columns and
4276/// overlays; the latter preserves author-supplied logical structure even when
4277/// two regions occupy overlapping Y ranges (#482).
4278fn assign_layout_region_ids(fragments: &[TextFragment]) -> Vec<u32> {
4279 let mut regions = Vec::with_capacity(fragments.len());
4280 let mut region = 0u32;
4281
4282 for i in 0..fragments.len() {
4283 if i > 0 {
4284 let prev = &fragments[i - 1];
4285 let current = &fragments[i];
4286 // A new flow may restart only a few points above the preceding
4287 // baseline (the real #482 footer/annotation gap is ~2pt), well
4288 // below assign_row_ids' superscript-friendly 0.5em threshold.
4289 // For layout ordering the relevant boundary is the same visual-line
4290 // tolerance used by sorting: an upward move beyond 0.2 line height
4291 // starts a new monotonic emission region.
4292 let line_tol = prev.height.min(current.height) * 0.2;
4293 let flow_restarted = current.y - prev.y > line_tol;
4294 let mcid_changed = fragments[i].mcid != fragments[i - 1].mcid
4295 && (fragments[i].mcid.is_some() || fragments[i - 1].mcid.is_some());
4296 if flow_restarted || mcid_changed {
4297 region = region.saturating_add(1);
4298 }
4299 }
4300 regions.push(region);
4301 }
4302 regions
4303}
4304
4305/// Decide whether a single visual line should be read in emission order.
4306///
4307/// `line` holds `(emission_index, fragment)` pairs for one visual line in any
4308/// order. Returns `true` when, walked in emission order, the line has no
4309/// DISJOINT backward x-step — i.e. no fragment lands entirely to the LEFT of
4310/// everything emitted so far on the line. Such a left jump is the signature of
4311/// a genuinely scrambled stream (right-to-left / random generators), for which
4312/// x-order is authoritative.
4313///
4314/// The comparison is against the line's running left edge, not the immediately
4315/// preceding fragment: dense bodies are split into sub-word glyph runs, so a
4316/// run that legitimately backfills the line (a font-switched math symbol, or a
4317/// word whose run starts left of the previous short run — #302 symptom 1 /
4318/// #305) overlaps the *covered span* even when it does not overlap the single
4319/// fragment right before it. As long as it does not jump past the line's left
4320/// edge, emission order is preserved. Lines that are already x-monotone in
4321/// emission satisfy this trivially and decode identically under either policy.
4322fn line_prefers_emission_order(line: &[(usize, &TextFragment)]) -> bool {
4323 if line.len() < 2 {
4324 return true;
4325 }
4326 let mut em: Vec<&(usize, &TextFragment)> = line.iter().collect();
4327 em.sort_by_key(|&&(idx, _)| idx);
4328 let mut min_start = em[0].1.x;
4329 for &&(_, f) in &em[1..] {
4330 let end = f.x + f.width;
4331 // A fragment whose right edge is at or left of the leftmost glyph seen
4332 // so far is a true backward jump — emission order is not reading order.
4333 if end <= min_start {
4334 return false;
4335 }
4336 min_start = min_start.min(f.x);
4337 }
4338 true
4339}
4340
4341/// Space-glyph advance width (1000-em units) for the Adobe Core-14 base fonts,
4342/// keyed by `/BaseFont`. Subset prefixes (`ABCDEF+`) are stripped; common
4343/// substitute names (Arial→Helvetica, TimesNewRoman→Times, CourierNew→Courier)
4344/// map to their metric-compatible base. Returns `None` for unknown fonts, which
4345/// leaves the caller on its fixed-fraction fallback. These fonts legitimately
4346/// ship no `/Widths` array, so their space metric is only available here.
4347fn standard_14_space_width(base_font: &str) -> Option<f64> {
4348 crate::text::fonts::standard::get_standard_font_metrics_by_name(base_font)
4349 .map(|metrics| f64::from(metrics.get_char_width(b' ')))
4350}
4351
4352#[cfg(test)]
4353mod tests {
4354 use super::*;
4355
4356 // ── issue #443: baseline-frame pen deltas ────────────────────────────────
4357
4358 fn state_with_ctm(ctm: [f64; 6]) -> TextState {
4359 TextState {
4360 ctm,
4361 ..Default::default()
4362 }
4363 }
4364
4365 #[test]
4366 fn pen_delta_identity_matrix_returns_raw_deltas() {
4367 let state = state_with_ctm([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4368 let (dx, dy) = pen_delta(&state, (10.0, 20.0), (14.5, 17.0));
4369 assert_eq!((dx, dy), (4.5, -3.0), "axis-aligned = raw Δx/Δy exactly");
4370 }
4371
4372 #[test]
4373 fn pen_delta_rotation_recovers_text_space_advance() {
4374 // 30° rotation; the pen advances 5 units along the rotated baseline.
4375 let (s30, c30) = 30f64.to_radians().sin_cos();
4376 let state = state_with_ctm([c30, s30, -s30, c30, 0.0, 0.0]);
4377 let (dx, dy) = pen_delta(&state, (0.0, 0.0), (5.0 * c30, 5.0 * s30));
4378 assert!((dx - 5.0).abs() < 1e-12, "advance recovered: {dx}");
4379 assert!(dy.abs() < 1e-12, "same baseline → dy ≈ 0: {dy}");
4380 assert!(
4381 dy.abs() < SAME_LINE_EPS,
4382 "noise below the same-line epsilon"
4383 );
4384 }
4385
4386 #[test]
4387 fn pen_delta_mirrored_baseline_measures_advance_direction() {
4388 // Horizontal mirror: a forward text-space advance moves the pen LEFT
4389 // in user space. dx must still be positive (the text's own advance
4390 // direction), so the wrap gate does not misfire on plain advances.
4391 let state = state_with_ctm([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4392 let (dx, dy) = pen_delta(&state, (100.0, 50.0), (95.0, 50.0));
4393 assert_eq!(dx, 5.0, "forward advance is positive along the baseline");
4394 assert_eq!(dy.abs(), 0.0, "same baseline");
4395 }
4396
4397 #[test]
4398 fn pen_delta_degenerate_matrix_falls_back_to_raw_deltas() {
4399 // Zero baseline (a=b=0): projection impossible → raw user-space
4400 // deltas, the pre-#443 behavior.
4401 let state = state_with_ctm([0.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4402 assert_eq!(pen_delta(&state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
4403 // Non-finite baseline: same fallback.
4404 let nan_state = state_with_ctm([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]);
4405 assert_eq!(pen_delta(&nan_state, (1.0, 2.0), (4.0, 6.0)), (3.0, 4.0));
4406 }
4407
4408 #[test]
4409 fn flat_space_threshold_uses_unscaled_fallback_for_tiny_baseline() {
4410 let mut state = TextState::default();
4411 state.font_size = 10.0;
4412 state.text_matrix = [f64::EPSILON, 0.0, 0.0, 1.0, 0.0, 0.0];
4413 let extractor = TextExtractor::new();
4414
4415 assert_eq!(
4416 extractor.flat_space_gap_threshold(&state),
4417 extractor.options.space_threshold * state.font_size
4418 );
4419 }
4420
4421 // ── issue #382: per-page byte-budget helper ──────────────────────────────
4422
4423 #[test]
4424 fn test_append_bounded_no_limit_always_appends() {
4425 let mut s = String::new();
4426 let mut trunc = false;
4427 assert!(append_bounded(&mut s, None, "hello", None, &mut trunc, true).appended);
4428 assert!(append_bounded(&mut s, Some(' '), "world", None, &mut trunc, true).appended);
4429 assert_eq!(s, "hello world");
4430 assert!(!trunc, "no limit never truncates");
4431 }
4432
4433 #[test]
4434 fn test_append_bounded_undershoot_counts_separator() {
4435 // "abcd" (4) is at budget 5; a Some('\n') + "x" would need 2 more → over.
4436 let mut s = String::from("abcd");
4437 let mut trunc = false;
4438 assert!(!append_bounded(&mut s, Some('\n'), "x", Some(5), &mut trunc, true).appended);
4439 assert_eq!(s, "abcd", "nothing appended when it would overshoot");
4440 assert!(trunc, "budget hit sets truncated");
4441 // Exactly-fits case: "e" alone (1 byte, no separator) reaches 5.
4442 let mut s2 = String::from("abcd");
4443 let mut t2 = false;
4444 assert!(append_bounded(&mut s2, None, "e", Some(5), &mut t2, true).appended);
4445 assert_eq!(s2, "abcde");
4446 assert!(!t2);
4447 assert!(s2.len() <= 5, "invariant: len <= limit exactly");
4448 }
4449
4450 #[test]
4451 fn test_append_bounded_zero_limit_truncates_immediately() {
4452 let mut s = String::new();
4453 let mut trunc = false;
4454 assert!(!append_bounded(&mut s, None, "a", Some(0), &mut trunc, true).appended);
4455 assert!(s.is_empty());
4456 assert!(trunc);
4457 }
4458
4459 #[test]
4460 fn test_append_bounded_is_noop_once_truncated() {
4461 let mut s = String::from("kept");
4462 let mut trunc = true; // already truncated
4463 assert!(!append_bounded(&mut s, None, "more", Some(1_000), &mut trunc, true).appended);
4464 assert_eq!(s, "kept", "no further accumulation after truncation");
4465 }
4466
4467 // ── issue #486: flat-path hyphen-wrap fusion ─────────────────────────────
4468
4469 #[test]
4470 fn test_append_bounded_fuses_hyphen_wrap_when_enabled() {
4471 // Real-world shape: a hyphen-wrapped phone number split across two
4472 // lines, e.g. "...3016-" / "0900" must reconstruct as "...30160900".
4473 let mut s = String::from("+55 11 3016-");
4474 let mut trunc = false;
4475 let outcome = append_bounded(&mut s, Some('\n'), "0900", None, &mut trunc, true);
4476 assert!(outcome.appended);
4477 assert_eq!(
4478 outcome.applied_separator, None,
4479 "hyphen fusion applies no separator, not the requested '\\n'"
4480 );
4481 assert_eq!(s, "+55 11 30160900", "hyphen popped, halves fused");
4482 }
4483
4484 #[test]
4485 fn test_append_bounded_no_fusion_without_a_trailing_hyphen() {
4486 let mut s = String::from("hello world");
4487 let mut trunc = false;
4488 let outcome = append_bounded(&mut s, Some('\n'), "next line", None, &mut trunc, true);
4489 assert!(outcome.appended);
4490 assert_eq!(
4491 outcome.applied_separator,
4492 Some('\n'),
4493 "no trailing hyphen: requested separator applies unchanged"
4494 );
4495 assert_eq!(s, "hello world\nnext line");
4496 }
4497
4498 #[test]
4499 fn test_append_bounded_does_not_fuse_when_merge_hyphenated_disabled() {
4500 let mut s = String::from("rating-");
4501 let mut trunc = false;
4502 let outcome = append_bounded(&mut s, Some('\n'), "aa-exp-sf", None, &mut trunc, false);
4503 assert!(outcome.appended);
4504 assert_eq!(outcome.applied_separator, Some('\n'));
4505 assert_eq!(s, "rating-\naa-exp-sf", "no fusion: split as requested");
4506 }
4507
4508 #[test]
4509 fn test_append_bounded_does_not_fuse_a_space_separator() {
4510 // Only a requested '\n' is a wrap candidate; a same-line space must
4511 // never trigger hyphen fusion even if the accumulator ends in '-'.
4512 let mut s = String::from("well-");
4513 let mut trunc = false;
4514 let outcome = append_bounded(&mut s, Some(' '), "known", None, &mut trunc, true);
4515 assert!(outcome.appended);
4516 assert_eq!(outcome.applied_separator, Some(' '));
4517 assert_eq!(s, "well- known");
4518 }
4519
4520 #[test]
4521 fn test_append_bounded_hyphen_fusion_respects_budget() {
4522 // "rating-" (7 bytes, trailing hyphen) minus the popped hyphen (6)
4523 // plus fused "aa-exp" (6 bytes, no separator) = 12.
4524 // Budget 12 must fit; budget 11 must not (would need to drop the
4525 // hyphen-adjusted run, not silently truncate mid-word).
4526 let mut s = String::from("rating-");
4527 let mut trunc = false;
4528 let outcome = append_bounded(&mut s, Some('\n'), "aa-exp", Some(12), &mut trunc, true);
4529 assert!(outcome.appended);
4530 assert_eq!(s, "ratingaa-exp");
4531 assert!(!trunc);
4532
4533 let mut s2 = String::from("rating-");
4534 let mut trunc2 = false;
4535 let outcome2 = append_bounded(&mut s2, Some('\n'), "aa-exp", Some(11), &mut trunc2, true);
4536 assert!(!outcome2.appended);
4537 assert_eq!(s2, "rating-", "nothing appended when over budget");
4538 assert!(trunc2);
4539 }
4540
4541 #[test]
4542 fn test_clamp_to_budget_no_limit_or_fits_is_noop() {
4543 let mut a = String::from("hello");
4544 let mut t = false;
4545 clamp_to_budget(&mut a, None, &mut t);
4546 assert_eq!(a, "hello");
4547 assert!(!t, "no limit never truncates");
4548
4549 let mut b = String::from("hi");
4550 clamp_to_budget(&mut b, Some(10), &mut t);
4551 assert_eq!(b, "hi", "already fits");
4552 assert!(!t);
4553 }
4554
4555 #[test]
4556 fn test_clamp_to_budget_cuts_and_flags() {
4557 let mut s = String::from("abcdefgh");
4558 let mut t = false;
4559 clamp_to_budget(&mut s, Some(3), &mut t);
4560 assert_eq!(s, "abc");
4561 assert!(t, "clamp that cut must set truncated");
4562 }
4563
4564 #[test]
4565 fn test_clamp_to_budget_never_splits_utf8() {
4566 // "é" is 2 bytes (0xC3 0xA9). A 3-byte budget on "éé" (4 bytes) must cut
4567 // back to the char boundary at 2, keeping one whole "é".
4568 let mut s = String::from("éé");
4569 let mut t = false;
4570 clamp_to_budget(&mut s, Some(3), &mut t);
4571 assert_eq!(s, "é", "must retreat to a char boundary, not split 'é'");
4572 assert!(s.len() <= 3);
4573 assert!(t);
4574 }
4575
4576 #[test]
4577 fn test_matrix_multiplication() {
4578 let identity = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
4579 let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
4580
4581 let result = multiply_matrix(&identity, &translation);
4582 assert_eq!(result, translation);
4583
4584 let result2 = multiply_matrix(&translation, &identity);
4585 assert_eq!(result2, translation);
4586 }
4587
4588 #[test]
4589 fn test_transform_point() {
4590 let translation = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
4591 let (x, y) = transform_point(5.0, 5.0, &translation);
4592 assert_eq!(x, 15.0);
4593 assert_eq!(y, 25.0);
4594 }
4595
4596 #[test]
4597 fn test_extraction_options_default() {
4598 let options = ExtractionOptions::default();
4599 assert!(!options.preserve_layout);
4600 assert_eq!(options.space_threshold, 0.3);
4601 assert_eq!(options.newline_threshold, 10.0);
4602 assert!(options.sort_by_position);
4603 assert!(!options.detect_columns);
4604 assert_eq!(options.column_threshold, 50.0);
4605 assert!(options.merge_hyphenated);
4606 assert_eq!(
4607 CarriageReturnHandling::default(),
4608 CarriageReturnHandling::Remove
4609 );
4610 }
4611
4612 #[test]
4613 fn test_extraction_options_custom() {
4614 let options = ExtractionOptions {
4615 preserve_layout: true,
4616 space_threshold: 0.5,
4617 tj_space_threshold: 0.15,
4618 newline_threshold: 15.0,
4619 sort_by_position: false,
4620 detect_columns: true,
4621 column_threshold: 75.0,
4622 merge_hyphenated: false,
4623 track_space_decisions: false,
4624 reconstruct_paragraphs: false,
4625 include_artifacts: false,
4626 reorder_columns: false,
4627 max_extracted_bytes: None,
4628 };
4629 assert!(options.preserve_layout);
4630 assert_eq!(options.space_threshold, 0.5);
4631 assert_eq!(options.tj_space_threshold, 0.15);
4632 assert_eq!(options.newline_threshold, 15.0);
4633 assert!(!options.sort_by_position);
4634 assert!(options.detect_columns);
4635 assert_eq!(options.column_threshold, 75.0);
4636 assert!(!options.merge_hyphenated);
4637 }
4638
4639 #[test]
4640 fn test_parse_font_style_bold() {
4641 // PostScript style
4642 assert_eq!(parse_font_style("Helvetica-Bold"), (true, false));
4643 assert_eq!(parse_font_style("TimesNewRoman-Bold"), (true, false));
4644
4645 // TrueType style
4646 assert_eq!(parse_font_style("Arial Bold"), (true, false));
4647 assert_eq!(parse_font_style("Calibri Bold"), (true, false));
4648
4649 // Short form
4650 assert_eq!(parse_font_style("Helvetica-B"), (true, false));
4651 }
4652
4653 #[test]
4654 fn test_parse_font_style_italic() {
4655 // PostScript style
4656 assert_eq!(parse_font_style("Helvetica-Italic"), (false, true));
4657 assert_eq!(parse_font_style("Times-Oblique"), (false, true));
4658
4659 // TrueType style
4660 assert_eq!(parse_font_style("Arial Italic"), (false, true));
4661 assert_eq!(parse_font_style("Courier Oblique"), (false, true));
4662
4663 // Short form
4664 assert_eq!(parse_font_style("Helvetica-I"), (false, true));
4665 }
4666
4667 #[test]
4668 fn test_parse_font_style_bold_italic() {
4669 assert_eq!(parse_font_style("Helvetica-BoldItalic"), (true, true));
4670 assert_eq!(parse_font_style("Times-BoldOblique"), (true, true));
4671 assert_eq!(parse_font_style("Arial Bold Italic"), (true, true));
4672 }
4673
4674 #[test]
4675 fn test_parse_font_style_regular() {
4676 assert_eq!(parse_font_style("Helvetica"), (false, false));
4677 assert_eq!(parse_font_style("Times-Roman"), (false, false));
4678 assert_eq!(parse_font_style("Courier"), (false, false));
4679 assert_eq!(parse_font_style("Arial"), (false, false));
4680 }
4681
4682 #[test]
4683 fn test_parse_font_style_edge_cases() {
4684 // Empty and unusual cases
4685 assert_eq!(parse_font_style(""), (false, false));
4686 assert_eq!(parse_font_style("UnknownFont"), (false, false));
4687
4688 // Case insensitive
4689 assert_eq!(parse_font_style("HELVETICA-BOLD"), (true, false));
4690 assert_eq!(parse_font_style("times-ITALIC"), (false, true));
4691 }
4692
4693 #[test]
4694 fn test_text_fragment() {
4695 let fragment = TextFragment {
4696 text: "Hello".to_string(),
4697 x: 100.0,
4698 y: 200.0,
4699 width: 50.0,
4700 height: 12.0,
4701 font_size: 10.0,
4702 font_name: None,
4703 is_bold: false,
4704 is_italic: false,
4705 color: None,
4706 space_decisions: Vec::new(),
4707 mcid: None,
4708 struct_tag: None,
4709 };
4710 assert_eq!(fragment.text, "Hello");
4711 assert_eq!(fragment.x, 100.0);
4712 assert_eq!(fragment.y, 200.0);
4713 assert_eq!(fragment.width, 50.0);
4714 assert_eq!(fragment.height, 12.0);
4715 assert_eq!(fragment.font_size, 10.0);
4716 }
4717
4718 #[test]
4719 fn test_extracted_text() {
4720 let fragments = vec![
4721 TextFragment {
4722 text: "Hello".to_string(),
4723 x: 100.0,
4724 y: 200.0,
4725 width: 50.0,
4726 height: 12.0,
4727 font_size: 10.0,
4728 font_name: None,
4729 is_bold: false,
4730 is_italic: false,
4731 color: None,
4732 space_decisions: Vec::new(),
4733 mcid: None,
4734 struct_tag: None,
4735 },
4736 TextFragment {
4737 text: "World".to_string(),
4738 x: 160.0,
4739 y: 200.0,
4740 width: 50.0,
4741 height: 12.0,
4742 font_size: 10.0,
4743 font_name: None,
4744 is_bold: false,
4745 is_italic: false,
4746 color: None,
4747 space_decisions: Vec::new(),
4748 mcid: None,
4749 struct_tag: None,
4750 },
4751 ];
4752
4753 let extracted = ExtractedText {
4754 text: "Hello World".to_string(),
4755 fragments: fragments,
4756 truncated: false,
4757 };
4758
4759 assert_eq!(extracted.text, "Hello World");
4760 assert_eq!(extracted.fragments.len(), 2);
4761 assert_eq!(extracted.fragments[0].text, "Hello");
4762 assert_eq!(extracted.fragments[1].text, "World");
4763 }
4764
4765 #[test]
4766 fn test_text_state_default() {
4767 let state = TextState::default();
4768 assert_eq!(state.text_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4769 assert_eq!(state.text_line_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4770 assert_eq!(state.ctm, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
4771 assert_eq!(state.leading, 0.0);
4772 assert_eq!(state.char_space, 0.0);
4773 assert_eq!(state.word_space, 0.0);
4774 assert_eq!(state.horizontal_scale, 100.0);
4775 assert_eq!(state.text_rise, 0.0);
4776 assert_eq!(state.font_size, 0.0);
4777 assert!(state.font_name.is_none());
4778 assert_eq!(state.render_mode, 0);
4779 }
4780
4781 #[test]
4782 fn test_matrix_operations() {
4783 // Test rotation matrix
4784 let rotation = [0.0, 1.0, -1.0, 0.0, 0.0, 0.0]; // 90 degree rotation
4785 let (x, y) = transform_point(1.0, 0.0, &rotation);
4786 assert_eq!(x, 0.0);
4787 assert_eq!(y, 1.0);
4788
4789 // Test scaling matrix
4790 let scale = [2.0, 0.0, 0.0, 3.0, 0.0, 0.0];
4791 let (x, y) = transform_point(5.0, 5.0, &scale);
4792 assert_eq!(x, 10.0);
4793 assert_eq!(y, 15.0);
4794
4795 // Test complex transformation
4796 let complex = [2.0, 1.0, 1.0, 2.0, 10.0, 20.0];
4797 let (x, y) = transform_point(1.0, 1.0, &complex);
4798 assert_eq!(x, 13.0); // 2*1 + 1*1 + 10
4799 assert_eq!(y, 23.0); // 1*1 + 2*1 + 20
4800 }
4801
4802 #[test]
4803 fn test_text_extractor_new() {
4804 let extractor = TextExtractor::new();
4805 let options = extractor.options;
4806 assert!(!options.preserve_layout);
4807 assert_eq!(options.space_threshold, 0.3);
4808 assert_eq!(options.newline_threshold, 10.0);
4809 assert!(options.sort_by_position);
4810 assert!(!options.detect_columns);
4811 assert_eq!(options.column_threshold, 50.0);
4812 assert!(options.merge_hyphenated);
4813 }
4814
4815 #[test]
4816 fn test_text_extractor_with_options() {
4817 let options = ExtractionOptions {
4818 preserve_layout: true,
4819 space_threshold: 0.3,
4820 tj_space_threshold: 0.2,
4821 newline_threshold: 12.0,
4822 sort_by_position: false,
4823 detect_columns: true,
4824 column_threshold: 60.0,
4825 merge_hyphenated: false,
4826 track_space_decisions: false,
4827 reconstruct_paragraphs: false,
4828 include_artifacts: false,
4829 reorder_columns: false,
4830 max_extracted_bytes: None,
4831 };
4832 let extractor = TextExtractor::with_options(options.clone());
4833 assert_eq!(extractor.options.preserve_layout, options.preserve_layout);
4834 assert_eq!(extractor.options.space_threshold, options.space_threshold);
4835 assert_eq!(
4836 extractor.options.newline_threshold,
4837 options.newline_threshold
4838 );
4839 assert_eq!(extractor.options.sort_by_position, options.sort_by_position);
4840 assert_eq!(extractor.options.detect_columns, options.detect_columns);
4841 assert_eq!(extractor.options.column_threshold, options.column_threshold);
4842 assert_eq!(extractor.options.merge_hyphenated, options.merge_hyphenated);
4843 }
4844
4845 // =========================================================================
4846 // RIGOROUS TESTS FOR FONT METRICS TEXT WIDTH CALCULATION
4847 // =========================================================================
4848
4849 #[test]
4850 fn test_calculate_text_width_with_no_font_info() {
4851 // Test fallback: should use simplified calculation
4852 let width = calculate_text_width("Hello", 12.0, None);
4853
4854 // Expected: 5 chars * 12.0 * 0.5 = 30.0
4855 assert_eq!(
4856 width, 30.0,
4857 "Without font info, should use simplified calculation: len * font_size * 0.5"
4858 );
4859 }
4860
4861 #[test]
4862 fn test_calculate_text_width_with_empty_metrics() {
4863 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
4864
4865 // Font with no widths array
4866 let font_info = FontInfo {
4867 name: "TestFont".to_string(),
4868 font_type: "Type1".to_string(),
4869 encoding: None,
4870 to_unicode: None,
4871 differences: None,
4872 descendant_font: None,
4873 cid_ordering: None,
4874 metrics: FontMetrics {
4875 first_char: None,
4876 last_char: None,
4877 widths: None,
4878 missing_width: Some(500.0),
4879 kerning: None,
4880 cid_widths: None,
4881 },
4882 cid_encoding: None,
4883 };
4884
4885 let width = calculate_text_width("Hello", 12.0, Some(&font_info));
4886
4887 // Should fall back to simplified calculation
4888 assert_eq!(
4889 width, 30.0,
4890 "Without widths array, should fall back to simplified calculation"
4891 );
4892 }
4893
4894 #[test]
4895 fn standard_14_no_widths_uses_effective_encoding_for_pen_advance() {
4896 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
4897 use std::collections::HashMap;
4898
4899 let font = |name: &str, encoding: Option<&str>, differences| FontInfo {
4900 name: name.to_string(),
4901 font_type: "Type1".to_string(),
4902 encoding: encoding.map(str::to_string),
4903 to_unicode: None,
4904 differences,
4905 descendant_font: None,
4906 cid_ordering: None,
4907 metrics: FontMetrics::default(),
4908 cid_encoding: None,
4909 };
4910 let width = |code: u8, info: &FontInfo| {
4911 calculate_text_width_from_codes(&[code], "", 10.0, Some(info), 0.0, 0.0)
4912 };
4913 let assert_width = |actual: f64, expected: f64| {
4914 assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
4915 };
4916
4917 assert_width(
4918 width(39, &font("Helvetica", Some("StandardEncoding"), None)),
4919 2.22,
4920 );
4921 assert_width(
4922 width(39, &font("Helvetica", Some("WinAnsiEncoding"), None)),
4923 1.91,
4924 );
4925 assert_width(
4926 width(0xDB, &font("Helvetica", Some("MacRomanEncoding"), None)),
4927 5.56,
4928 );
4929
4930 let differences = HashMap::from([(b'A', "fi".to_string())]);
4931 assert_width(
4932 width(
4933 b'A',
4934 &font("Helvetica", Some("WinAnsiEncoding"), Some(differences)),
4935 ),
4936 5.0,
4937 );
4938 assert_width(width(b'a', &font("Symbol", None, None)), 6.31);
4939 assert_width(width(b'!', &font("ZapfDingbats", None, None)), 9.74);
4940
4941 let unknown = HashMap::from([(b'A', "not-a-glyph".to_string())]);
4942 let unresolved = width(
4943 b'A',
4944 &font("Helvetica", Some("WinAnsiEncoding"), Some(unknown)),
4945 );
4946 assert!(
4947 (unresolved - 5.0).abs() < 1e-12,
4948 "unresolved glyphs retain the legacy 0.5em fallback"
4949 );
4950 }
4951
4952 #[test]
4953 fn test_calculate_text_width_with_complete_metrics() {
4954 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
4955
4956 // Font with complete metrics for ASCII range 32-126
4957 // Simulate typical Helvetica widths (in 1/1000 units)
4958 let mut widths = vec![0.0; 95]; // 95 chars from 32 to 126
4959
4960 // Set specific widths for "Hello" (H=722, e=556, l=278, o=611)
4961 widths[72 - 32] = 722.0; // 'H' is ASCII 72
4962 widths[101 - 32] = 556.0; // 'e' is ASCII 101
4963 widths[108 - 32] = 278.0; // 'l' is ASCII 108
4964 widths[111 - 32] = 611.0; // 'o' is ASCII 111
4965
4966 let font_info = FontInfo {
4967 name: "Helvetica".to_string(),
4968 font_type: "Type1".to_string(),
4969 encoding: None,
4970 to_unicode: None,
4971 differences: None,
4972 descendant_font: None,
4973 cid_ordering: None,
4974 metrics: FontMetrics {
4975 first_char: Some(32),
4976 last_char: Some(126),
4977 widths: Some(widths),
4978 missing_width: Some(500.0),
4979 kerning: None,
4980 cid_widths: None,
4981 },
4982 cid_encoding: None,
4983 };
4984
4985 let width = calculate_text_width("Hello", 12.0, Some(&font_info));
4986
4987 // Expected calculation (widths in glyph space / 1000 * font_size):
4988 // H: 722/1000 * 12 = 8.664
4989 // e: 556/1000 * 12 = 6.672
4990 // l: 278/1000 * 12 = 3.336
4991 // l: 278/1000 * 12 = 3.336
4992 // o: 611/1000 * 12 = 7.332
4993 // Total: 29.34
4994 let expected = (722.0 + 556.0 + 278.0 + 278.0 + 611.0) / 1000.0 * 12.0;
4995 let tolerance = 0.0001; // Floating point tolerance
4996 assert!(
4997 (width - expected).abs() < tolerance,
4998 "Should calculate width using actual character metrics: expected {}, got {}, diff {}",
4999 expected,
5000 width,
5001 (width - expected).abs()
5002 );
5003
5004 // Verify it's different from simplified calculation
5005 let simplified = 5.0 * 12.0 * 0.5; // 30.0
5006 assert_ne!(
5007 width, simplified,
5008 "Metrics-based calculation should differ from simplified (30.0)"
5009 );
5010 }
5011
5012 #[test]
5013 fn width_from_codes_uses_char_code_not_decoded_unicode() {
5014 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5015
5016 // Simple Type1 font with a code-indexed Widths array: code 1 -> 1000,
5017 // code 2 -> 100. A custom encoding decodes code 1 -> 'm' (U+006D) and
5018 // code 2 -> 'i' (U+0069), so the decoded Unicode codepoints (109, 105)
5019 // are far from the codes (1, 2). The advance width MUST come from the
5020 // codes; indexing the Widths array by the decoded Unicode codepoint
5021 // reads out-of-range -> missing_width, desyncing glyph advance on
5022 // custom-encoded fonts (issue #302, Higgs/Computer-Modern scramble).
5023 let font_info = FontInfo {
5024 name: "F1".to_string(),
5025 font_type: "Type1".to_string(),
5026 encoding: None,
5027 to_unicode: None,
5028 differences: None,
5029 descendant_font: None,
5030 cid_ordering: None,
5031 metrics: FontMetrics {
5032 first_char: Some(1),
5033 last_char: Some(2),
5034 widths: Some(vec![1000.0, 100.0]),
5035 missing_width: Some(500.0),
5036 kerning: None,
5037 cid_widths: None,
5038 },
5039 cid_encoding: None,
5040 };
5041
5042 let codes = [1u8, 2u8];
5043 let decoded = "mi"; // what decode_text produced for these codes
5044 let width =
5045 calculate_text_width_from_codes(&codes, decoded, 10.0, Some(&font_info), 0.0, 0.0);
5046 let expected = (1000.0 + 100.0) / 1000.0 * 10.0; // 11.0
5047 assert!(
5048 (width - expected).abs() < 1e-6,
5049 "width must come from char codes: expected {expected}, got {width}"
5050 );
5051
5052 // The decoded-Unicode-indexed path is the bug: 109 and 105 are outside
5053 // [1,2] so both fall back to missing_width -> (500+500)/1000*10 = 10.0.
5054 let buggy = calculate_text_width(decoded, 10.0, Some(&font_info));
5055 assert_eq!(buggy, 10.0);
5056 assert_ne!(
5057 width, buggy,
5058 "code-based width must differ from the Unicode-indexed bug"
5059 );
5060 }
5061
5062 #[test]
5063 fn test_calculate_text_width_character_outside_range() {
5064 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5065
5066 // Font with narrow range (only covers 'A'-'Z')
5067 let widths = vec![722.0; 26]; // All uppercase letters same width
5068
5069 let font_info = FontInfo {
5070 name: "TestFont".to_string(),
5071 font_type: "Type1".to_string(),
5072 encoding: None,
5073 to_unicode: None,
5074 differences: None,
5075 descendant_font: None,
5076 cid_ordering: None,
5077 metrics: FontMetrics {
5078 first_char: Some(65), // 'A'
5079 last_char: Some(90), // 'Z'
5080 widths: Some(widths),
5081 missing_width: Some(500.0),
5082 kerning: None,
5083 cid_widths: None,
5084 },
5085 cid_encoding: None,
5086 };
5087
5088 // Test with character outside range
5089 let width = calculate_text_width("A1", 10.0, Some(&font_info));
5090
5091 // Expected:
5092 // 'A' (65) is in range: 722/1000 * 10 = 7.22
5093 // '1' (49) is outside range: missing_width 500/1000 * 10 = 5.0
5094 // Total: 12.22
5095 let expected = (722.0 / 1000.0 * 10.0) + (500.0 / 1000.0 * 10.0);
5096 assert_eq!(
5097 width, expected,
5098 "Should use missing_width for characters outside range"
5099 );
5100 }
5101
5102 #[test]
5103 fn test_calculate_text_width_missing_width_in_array() {
5104 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5105
5106 // Font with incomplete widths array (some characters have 0.0)
5107 let mut widths = vec![500.0; 95]; // Default width
5108 widths[10] = 0.0; // Character at index 10 has no width defined
5109
5110 let font_info = FontInfo {
5111 name: "TestFont".to_string(),
5112 font_type: "Type1".to_string(),
5113 encoding: None,
5114 to_unicode: None,
5115 differences: None,
5116 descendant_font: None,
5117 cid_ordering: None,
5118 metrics: FontMetrics {
5119 first_char: Some(32),
5120 last_char: Some(126),
5121 widths: Some(widths),
5122 missing_width: Some(600.0),
5123 kerning: None,
5124 cid_widths: None,
5125 },
5126 cid_encoding: None,
5127 };
5128
5129 // Character 42 (index 10 from first_char 32)
5130 let char_code = 42u8 as char; // '*'
5131 let text = char_code.to_string();
5132 let width = calculate_text_width(&text, 10.0, Some(&font_info));
5133
5134 // Character is in range but width is 0.0, should NOT fall back to missing_width
5135 // (0.0 is a valid width for zero-width characters)
5136 assert_eq!(
5137 width, 0.0,
5138 "Should use 0.0 width from array, not missing_width"
5139 );
5140 }
5141
5142 #[test]
5143 fn test_calculate_text_width_empty_string() {
5144 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5145
5146 let font_info = FontInfo {
5147 name: "TestFont".to_string(),
5148 font_type: "Type1".to_string(),
5149 encoding: None,
5150 to_unicode: None,
5151 differences: None,
5152 descendant_font: None,
5153 cid_ordering: None,
5154 metrics: FontMetrics {
5155 first_char: Some(32),
5156 last_char: Some(126),
5157 widths: Some(vec![500.0; 95]),
5158 missing_width: Some(500.0),
5159 kerning: None,
5160 cid_widths: None,
5161 },
5162 cid_encoding: None,
5163 };
5164
5165 let width = calculate_text_width("", 12.0, Some(&font_info));
5166 assert_eq!(width, 0.0, "Empty string should have zero width");
5167
5168 // Also test without font info
5169 let width_no_font = calculate_text_width("", 12.0, None);
5170 assert_eq!(
5171 width_no_font, 0.0,
5172 "Empty string should have zero width (no font)"
5173 );
5174 }
5175
5176 #[test]
5177 fn test_calculate_text_width_unicode_characters() {
5178 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5179
5180 // Font with limited ASCII range
5181 let font_info = FontInfo {
5182 name: "TestFont".to_string(),
5183 font_type: "Type1".to_string(),
5184 encoding: None,
5185 to_unicode: None,
5186 differences: None,
5187 descendant_font: None,
5188 cid_ordering: None,
5189 metrics: FontMetrics {
5190 first_char: Some(32),
5191 last_char: Some(126),
5192 widths: Some(vec![500.0; 95]),
5193 missing_width: Some(600.0),
5194 kerning: None,
5195 cid_widths: None,
5196 },
5197 cid_encoding: None,
5198 };
5199
5200 // Test with Unicode characters outside ASCII range
5201 let width = calculate_text_width("Ñ", 10.0, Some(&font_info));
5202
5203 // 'Ñ' (U+00D1, code 209) is outside range, should use missing_width
5204 // Expected: 600/1000 * 10 = 6.0
5205 assert_eq!(
5206 width, 6.0,
5207 "Unicode character outside range should use missing_width"
5208 );
5209 }
5210
5211 #[test]
5212 fn test_calculate_text_width_different_font_sizes() {
5213 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5214
5215 let font_info = FontInfo {
5216 name: "TestFont".to_string(),
5217 font_type: "Type1".to_string(),
5218 encoding: None,
5219 to_unicode: None,
5220 differences: None,
5221 descendant_font: None,
5222 cid_ordering: None,
5223 metrics: FontMetrics {
5224 first_char: Some(65), // 'A'
5225 last_char: Some(65), // 'A'
5226 widths: Some(vec![722.0]),
5227 missing_width: Some(500.0),
5228 kerning: None,
5229 cid_widths: None,
5230 },
5231 cid_encoding: None,
5232 };
5233
5234 // Test same character with different font sizes
5235 let width_10 = calculate_text_width("A", 10.0, Some(&font_info));
5236 let width_20 = calculate_text_width("A", 20.0, Some(&font_info));
5237
5238 // Widths should scale linearly with font size
5239 assert_eq!(width_10, 722.0 / 1000.0 * 10.0);
5240 assert_eq!(width_20, 722.0 / 1000.0 * 20.0);
5241 assert_eq!(
5242 width_20,
5243 width_10 * 2.0,
5244 "Width should scale linearly with font size"
5245 );
5246 }
5247
5248 #[test]
5249 fn test_calculate_text_width_proportional_vs_monospace() {
5250 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5251
5252 // Simulate proportional font (different widths)
5253 let proportional_widths = vec![278.0, 556.0, 722.0]; // i, m, W
5254 let proportional_font = FontInfo {
5255 name: "Helvetica".to_string(),
5256 font_type: "Type1".to_string(),
5257 encoding: None,
5258 to_unicode: None,
5259 differences: None,
5260 descendant_font: None,
5261 cid_ordering: None,
5262 metrics: FontMetrics {
5263 first_char: Some(105), // 'i'
5264 last_char: Some(107), // covers i, j, k
5265 widths: Some(proportional_widths),
5266 missing_width: Some(500.0),
5267 kerning: None,
5268 cid_widths: None,
5269 },
5270 cid_encoding: None,
5271 };
5272
5273 // Simulate monospace font (same width)
5274 let monospace_widths = vec![600.0, 600.0, 600.0];
5275 let monospace_font = FontInfo {
5276 name: "Courier".to_string(),
5277 font_type: "Type1".to_string(),
5278 encoding: None,
5279 to_unicode: None,
5280 differences: None,
5281 descendant_font: None,
5282 cid_ordering: None,
5283 metrics: FontMetrics {
5284 first_char: Some(105),
5285 last_char: Some(107),
5286 widths: Some(monospace_widths),
5287 missing_width: Some(600.0),
5288 kerning: None,
5289 cid_widths: None,
5290 },
5291 cid_encoding: None,
5292 };
5293
5294 let prop_width = calculate_text_width("i", 12.0, Some(&proportional_font));
5295 let mono_width = calculate_text_width("i", 12.0, Some(&monospace_font));
5296
5297 // Proportional 'i' should be narrower than monospace 'i'
5298 assert!(
5299 prop_width < mono_width,
5300 "Proportional 'i' ({}) should be narrower than monospace 'i' ({})",
5301 prop_width,
5302 mono_width
5303 );
5304 }
5305
5306 // =========================================================================
5307 // CRITICAL KERNING TESTS (Issue #87 - Quality Agent Required)
5308 // =========================================================================
5309
5310 #[test]
5311 fn test_calculate_text_width_with_kerning() {
5312 use crate::text::extraction_cmap::{FontInfo, FontMetrics};
5313 use std::collections::HashMap;
5314
5315 // Create a font with kerning pairs
5316 let mut widths = vec![500.0; 95]; // ASCII 32-126
5317 widths[65 - 32] = 722.0; // 'A'
5318 widths[86 - 32] = 722.0; // 'V'
5319 widths[87 - 32] = 944.0; // 'W'
5320
5321 let mut kerning = HashMap::new();
5322 // Typical kerning pairs (in FUnits, 1/1000)
5323 kerning.insert((65, 86), -50.0); // 'A' + 'V' → tighten by 50 FUnits
5324 kerning.insert((65, 87), -40.0); // 'A' + 'W' → tighten by 40 FUnits
5325
5326 let font_info = FontInfo {
5327 name: "Helvetica".to_string(),
5328 font_type: "Type1".to_string(),
5329 encoding: None,
5330 to_unicode: None,
5331 differences: None,
5332 descendant_font: None,
5333 cid_ordering: None,
5334 metrics: FontMetrics {
5335 first_char: Some(32),
5336 last_char: Some(126),
5337 widths: Some(widths),
5338 missing_width: Some(500.0),
5339 kerning: Some(kerning),
5340 cid_widths: None,
5341 },
5342 cid_encoding: None,
5343 };
5344
5345 // Test "AV" with kerning
5346 let width_av = calculate_text_width("AV", 12.0, Some(&font_info));
5347 // Expected: (722 + 722)/1000 * 12 + (-50/1000 * 12)
5348 // = 17.328 - 0.6 = 16.728
5349 let expected_av = (722.0 + 722.0) / 1000.0 * 12.0 + (-50.0 / 1000.0 * 12.0);
5350 let tolerance = 0.0001;
5351 assert!(
5352 (width_av - expected_av).abs() < tolerance,
5353 "AV with kerning: expected {}, got {}, diff {}",
5354 expected_av,
5355 width_av,
5356 (width_av - expected_av).abs()
5357 );
5358
5359 // Test "AW" with different kerning value
5360 let width_aw = calculate_text_width("AW", 12.0, Some(&font_info));
5361 // Expected: (722 + 944)/1000 * 12 + (-40/1000 * 12)
5362 // = 19.992 - 0.48 = 19.512
5363 let expected_aw = (722.0 + 944.0) / 1000.0 * 12.0 + (-40.0 / 1000.0 * 12.0);
5364 assert!(
5365 (width_aw - expected_aw).abs() < tolerance,
5366 "AW with kerning: expected {}, got {}, diff {}",
5367 expected_aw,
5368 width_aw,
5369 (width_aw - expected_aw).abs()
5370 );
5371
5372 // Test "VA" with NO kerning (pair not in HashMap)
5373 let width_va = calculate_text_width("VA", 12.0, Some(&font_info));
5374 // Expected: (722 + 722)/1000 * 12 = 17.328 (no kerning adjustment)
5375 let expected_va = (722.0 + 722.0) / 1000.0 * 12.0;
5376 assert!(
5377 (width_va - expected_va).abs() < tolerance,
5378 "VA without kerning: expected {}, got {}, diff {}",
5379 expected_va,
5380 width_va,
5381 (width_va - expected_va).abs()
5382 );
5383
5384 // Verify kerning makes a measurable difference
5385 assert!(
5386 width_av < width_va,
5387 "AV with kerning ({}) should be narrower than VA without kerning ({})",
5388 width_av,
5389 width_va
5390 );
5391 }
5392
5393 #[test]
5394 fn test_parse_truetype_kern_table_minimal() {
5395 use crate::text::extraction_cmap::parse_truetype_kern_table;
5396
5397 // Complete TrueType font with kern table (Format 0, 2 kerning pairs)
5398 // Structure:
5399 // 1. Offset table (12 bytes)
5400 // 2. Table directory (2 tables: 'head' and 'kern', each 16 bytes = 32 total)
5401 // 3. 'head' table data (54 bytes)
5402 // 4. 'kern' table data (30 bytes)
5403 // Total: 128 bytes
5404 let mut ttf_data = vec![
5405 // Offset table
5406 0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
5407 0x00, 0x02, // numTables: 2
5408 0x00, 0x20, // searchRange: 32
5409 0x00, 0x01, // entrySelector: 1
5410 0x00, 0x00, // rangeShift: 0
5411 ];
5412
5413 // Table directory entry 1: 'head' table
5414 ttf_data.extend_from_slice(b"head"); // tag
5415 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
5416 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x2C]); // offset: 44 (12 + 32)
5417 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x36]); // length: 54
5418
5419 // Table directory entry 2: 'kern' table
5420 ttf_data.extend_from_slice(b"kern"); // tag
5421 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // checksum
5422 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x62]); // offset: 98 (44 + 54)
5423 ttf_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x1E]); // length: 30 (actual kern table size)
5424
5425 // 'head' table data (54 bytes of zeros - minimal valid head table)
5426 ttf_data.extend_from_slice(&[0u8; 54]);
5427
5428 // 'kern' table data (34 bytes)
5429 ttf_data.extend_from_slice(&[
5430 // Kern table header
5431 0x00, 0x00, // version: 0
5432 0x00, 0x01, // nTables: 1
5433 // Subtable header
5434 0x00, 0x00, // version: 0
5435 0x00, 0x1A, // length: 26 bytes (header 6 + nPairs data 8 + pairs 2*6=12)
5436 0x00, 0x00, // coverage: 0x0000 (Format 0 in lower byte, horizontal)
5437 0x00, 0x02, // nPairs: 2
5438 0x00, 0x08, // searchRange: 8
5439 0x00, 0x00, // entrySelector: 0
5440 0x00, 0x04, // rangeShift: 4
5441 // Kerning pair 1: A + V → -50
5442 0x00, 0x41, // left glyph: 65 ('A')
5443 0x00, 0x56, // right glyph: 86 ('V')
5444 0xFF, 0xCE, // value: -50 (signed 16-bit big-endian)
5445 // Kerning pair 2: A + W → -40
5446 0x00, 0x41, // left glyph: 65 ('A')
5447 0x00, 0x57, // right glyph: 87 ('W')
5448 0xFF, 0xD8, // value: -40 (signed 16-bit big-endian)
5449 ]);
5450
5451 let result = parse_truetype_kern_table(&ttf_data);
5452 assert!(
5453 result.is_ok(),
5454 "Should parse minimal kern table successfully: {:?}",
5455 result.err()
5456 );
5457
5458 let kerning_map = result.unwrap();
5459 assert_eq!(kerning_map.len(), 2, "Should extract 2 kerning pairs");
5460
5461 // Verify pair 1: A + V → -50
5462 assert_eq!(
5463 kerning_map.get(&(65, 86)),
5464 Some(&-50.0),
5465 "Should have A+V kerning pair with value -50"
5466 );
5467
5468 // Verify pair 2: A + W → -40
5469 assert_eq!(
5470 kerning_map.get(&(65, 87)),
5471 Some(&-40.0),
5472 "Should have A+W kerning pair with value -40"
5473 );
5474 }
5475
5476 #[test]
5477 fn test_parse_kern_table_no_kern_table() {
5478 use crate::text::extraction_cmap::parse_truetype_kern_table;
5479
5480 // TrueType font data WITHOUT a 'kern' table
5481 // Structure:
5482 // - Offset table: scaler type + numTables + searchRange + entrySelector + rangeShift
5483 // - Table directory: 1 entry for 'head' table (not 'kern')
5484 let ttf_data = vec![
5485 // Offset table
5486 0x00, 0x01, 0x00, 0x00, // scaler type: TrueType
5487 0x00, 0x01, // numTables: 1
5488 0x00, 0x10, // searchRange: 16
5489 0x00, 0x00, // entrySelector: 0
5490 0x00, 0x00, // rangeShift: 0
5491 // Table directory entry: 'head' table (not 'kern')
5492 b'h', b'e', b'a', b'd', // tag: 'head'
5493 0x00, 0x00, 0x00, 0x00, // checksum
5494 0x00, 0x00, 0x00, 0x1C, // offset: 28
5495 0x00, 0x00, 0x00, 0x36, // length: 54
5496 // Mock 'head' table data (54 bytes of zeros)
5497 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
5498 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
5499 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
5500 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
5501 ];
5502
5503 let result = parse_truetype_kern_table(&ttf_data);
5504 assert!(
5505 result.is_ok(),
5506 "Should gracefully handle missing kern table"
5507 );
5508
5509 let kerning_map = result.unwrap();
5510 assert!(
5511 kerning_map.is_empty(),
5512 "Should return empty HashMap when no kern table exists"
5513 );
5514 }
5515
5516 // Helper for paragraph-reconstruction unit tests. TextFragment has 11
5517 // fields so a helper keeps the test bodies focused on geometry.
5518 fn tf(text: &str, x: f64, y: f64, width: f64, font_size: f64) -> TextFragment {
5519 TextFragment {
5520 text: text.to_string(),
5521 x,
5522 y,
5523 width,
5524 height: font_size,
5525 font_size,
5526 font_name: None,
5527 is_bold: false,
5528 is_italic: false,
5529 color: None,
5530 space_decisions: Vec::new(),
5531 mcid: None,
5532 struct_tag: None,
5533 }
5534 }
5535
5536 #[test]
5537 fn merge_into_lines_groups_same_baseline_fragments() {
5538 let extractor = TextExtractor::with_options(ExtractionOptions {
5539 reconstruct_paragraphs: true,
5540 ..Default::default()
5541 });
5542 let input = vec![
5543 tf("Hello", 50.0, 400.0, 30.0, 12.0),
5544 tf("world", 90.0, 400.0, 30.0, 12.0),
5545 tf("now.", 130.0, 400.0, 25.0, 12.0),
5546 tf("Next", 50.0, 386.0, 30.0, 12.0),
5547 tf("line.", 90.0, 386.0, 25.0, 12.0),
5548 ];
5549 let lines = extractor.merge_into_lines(&input);
5550 assert_eq!(
5551 lines.len(),
5552 2,
5553 "two distinct baselines must produce two line fragments"
5554 );
5555 assert_eq!(
5556 lines[0].text, "Hello world now.",
5557 "first line concatenated with spaces"
5558 );
5559 assert_eq!(lines[1].text, "Next line.", "second line concatenated");
5560 }
5561
5562 #[test]
5563 fn merge_into_lines_inserts_space_only_when_gap_exceeds_threshold() {
5564 let extractor = TextExtractor::with_options(ExtractionOptions {
5565 reconstruct_paragraphs: true,
5566 space_threshold: 0.3,
5567 ..Default::default()
5568 });
5569 // Gap of 4pt at font_size 12 = 0.33x — above threshold 0.3
5570 let with_gap = vec![
5571 tf("AB", 50.0, 400.0, 10.0, 12.0),
5572 tf("CD", 64.0, 400.0, 10.0, 12.0),
5573 ];
5574 let lines = extractor.merge_into_lines(&with_gap);
5575 assert_eq!(
5576 lines[0].text, "AB CD",
5577 "gap above threshold must insert space"
5578 );
5579
5580 // Gap of 1pt = 0.083x — below threshold
5581 let tight = vec![
5582 tf("AB", 50.0, 400.0, 10.0, 12.0),
5583 tf("CD", 61.0, 400.0, 10.0, 12.0),
5584 ];
5585 let lines = extractor.merge_into_lines(&tight);
5586 assert_eq!(lines[0].text, "ABCD", "tight gap must NOT insert space");
5587 }
5588
5589 #[test]
5590 fn standard_14_space_width_maps_base_fonts_and_substitutes() {
5591 // Adobe Core-14 AFM space advances, with subset prefixes stripped and
5592 // metric-compatible substitutes folded in (#302 symptom 2).
5593 assert_eq!(super::standard_14_space_width("Times-Roman"), Some(250.0));
5594 assert_eq!(
5595 super::standard_14_space_width("Times-BoldItalic"),
5596 Some(250.0)
5597 );
5598 assert_eq!(super::standard_14_space_width("Helvetica"), Some(278.0));
5599 assert_eq!(super::standard_14_space_width("Courier-Bold"), Some(600.0));
5600 assert_eq!(super::standard_14_space_width("Symbol"), Some(250.0));
5601 assert_eq!(super::standard_14_space_width("ZapfDingbats"), Some(278.0));
5602 // subset prefix stripped
5603 assert_eq!(
5604 super::standard_14_space_width("ABCDEF+Times-Roman"),
5605 Some(250.0)
5606 );
5607 // metric-compatible substitutes
5608 assert_eq!(super::standard_14_space_width("Arial-BoldMT"), Some(278.0));
5609 assert_eq!(
5610 super::standard_14_space_width("TimesNewRomanPSMT"),
5611 Some(250.0)
5612 );
5613 assert_eq!(
5614 super::standard_14_space_width("CourierNewPSMT"),
5615 Some(600.0)
5616 );
5617 // unknown / embedded fonts fall through to the caller's fallback
5618 assert_eq!(super::standard_14_space_width("Poppins-Regular"), None);
5619 assert_eq!(super::standard_14_space_width("VUNXGH+Calibri"), None);
5620 }
5621
5622 #[test]
5623 fn merge_into_lines_keeps_emission_order_for_font_switch_overlap() {
5624 // #302 symptom 1: a font-switched glyph (e.g. the italic particle
5625 // symbol "Z" in "to the Z boson") is positioned by the producer with
5626 // an x-origin that falls INSIDE the x-span of the preceding roman run
5627 // ("to the"). The content stream still delivers it in correct reading
5628 // order. Sorting a row purely by x-origin interleaves the overlapping
5629 // fragment, yielding "Zto the" instead of "to theZ". When a row's only
5630 // backward emission steps are span overlaps (not disjoint jumps),
5631 // emission order is the authoritative reading order.
5632 let extractor = TextExtractor::with_options(ExtractionOptions {
5633 reconstruct_paragraphs: true,
5634 ..Default::default()
5635 });
5636 // emission order = reading order; "Z" overlaps "to t" + "he" in x.
5637 let row = vec![
5638 tf("to t", 455.5, 400.0, 12.0, 10.0), // 455.5 .. 467.5
5639 tf("he", 467.5, 400.0, 10.0, 10.0), // 467.5 .. 477.5
5640 tf("Z", 455.3, 400.0, 23.0, 10.0), // 455.3 .. 478.3 (overlaps both)
5641 ];
5642 let lines = extractor.merge_into_lines(&row);
5643 assert_eq!(lines.len(), 1);
5644 assert_eq!(
5645 lines[0].text, "to theZ",
5646 "overlapping font-switch fragment must keep emission (reading) order"
5647 );
5648 }
5649
5650 #[test]
5651 fn merge_into_lines_keeps_emission_when_run_backfills_covered_span() {
5652 // #305: dense justified body text is split into sub-word fragments by
5653 // the font's arbitrary glyph runs. A later word ("described", x 492..537)
5654 // is emitted with a backward x-origin that lands INSIDE the span already
5655 // covered by the line ("...selections", 479..521), but does NOT overlap
5656 // the short immediately-preceding fragment ("s", 517..521). Emission is
5657 // still the reading order, so the line must keep it — the overlap test
5658 // has to consider the line's running extent, not just the previous
5659 // fragment. (Real case: Higgs p5 "kinematic selections described in".)
5660 let extractor = TextExtractor::with_options(ExtractionOptions {
5661 reconstruct_paragraphs: true,
5662 ..Default::default()
5663 });
5664 let row = vec![
5665 tf("selection", 479.0, 400.0, 38.0, 8.0), // 479..517
5666 tf("s", 517.0, 400.0, 4.0, 8.0), // 517..521 short predecessor
5667 tf("d", 492.0, 400.0, 4.0, 8.0), // 492..496 backfill, no overlap with "s"
5668 tf("escribed", 496.0, 400.0, 41.0, 8.0), // 496..537
5669 ];
5670 let lines = extractor.merge_into_lines(&row);
5671 assert_eq!(
5672 lines[0].text, "selectionsdescribed",
5673 "a run that backfills the line's covered span must keep emission order"
5674 );
5675 }
5676
5677 #[test]
5678 fn merge_into_lines_uses_x_order_for_disjoint_backward_jump() {
5679 // Guard: a genuinely scrambled non-tagged stream (fragments emitted
5680 // out of x-order at DISJOINT positions, e.g. right-to-left or random
5681 // generators) must still be reordered by x. Here "the" is emitted
5682 // after "boson" with no span overlap, so x-order is authoritative.
5683 let extractor = TextExtractor::with_options(ExtractionOptions {
5684 reconstruct_paragraphs: true,
5685 ..Default::default()
5686 });
5687 let row = vec![
5688 tf("boson", 100.0, 400.0, 28.0, 10.0), // 100 .. 128
5689 tf("the", 80.0, 400.0, 15.0, 10.0), // 80 .. 95 (disjoint, left of boson)
5690 ];
5691 let lines = extractor.merge_into_lines(&row);
5692 assert_eq!(lines.len(), 1);
5693 assert_eq!(
5694 lines[0].text, "the boson",
5695 "disjoint backward emission jump must be reordered by x"
5696 );
5697 }
5698
5699 #[test]
5700 fn merge_into_lines_unioned_bounding_box() {
5701 let extractor = TextExtractor::with_options(ExtractionOptions {
5702 reconstruct_paragraphs: true,
5703 ..Default::default()
5704 });
5705 let input = vec![
5706 tf("A", 50.0, 400.0, 10.0, 12.0),
5707 tf("B", 100.0, 400.0, 10.0, 12.0),
5708 ];
5709 let lines = extractor.merge_into_lines(&input);
5710 assert_eq!(lines.len(), 1);
5711 assert!((lines[0].x - 50.0).abs() < 0.01);
5712 assert!(
5713 (lines[0].width - 60.0).abs() < 0.01,
5714 "width must span 50->110"
5715 );
5716 }
5717
5718 #[test]
5719 fn assign_row_ids_monotone_y_descending_keeps_zero() {
5720 let frags = vec![
5721 tf("A", 50.0, 400.0, 10.0, 9.0),
5722 tf("B", 50.0, 395.0, 10.0, 9.0),
5723 tf("C", 50.0, 390.0, 10.0, 9.0),
5724 ];
5725 let row_ids = super::assign_row_ids(&frags);
5726 assert_eq!(row_ids, vec![0u32, 0, 0]);
5727 }
5728
5729 #[test]
5730 fn assign_row_ids_increments_on_y_up_jump_above_threshold() {
5731 // font_size=9 → threshold = max(4.5, 2.0) = 4.5
5732 // deltas: 395-400=-5, 420-395=+25 (>4.5)
5733 let frags = vec![
5734 tf("A", 50.0, 400.0, 10.0, 9.0),
5735 tf("B", 50.0, 395.0, 10.0, 9.0),
5736 tf("C", 50.0, 420.0, 10.0, 9.0),
5737 ];
5738 let row_ids = super::assign_row_ids(&frags);
5739 assert_eq!(row_ids, vec![0u32, 0, 1]);
5740 }
5741
5742 #[test]
5743 fn assign_row_ids_ignores_superscript_within_threshold() {
5744 // font_size=9 → threshold 4.5. delta 2.5 must NOT trigger.
5745 let frags = vec![
5746 tf("A", 50.0, 400.0, 10.0, 9.0),
5747 tf("^2", 60.0, 402.5, 5.0, 9.0),
5748 tf("B", 65.0, 395.0, 10.0, 9.0),
5749 ];
5750 let row_ids = super::assign_row_ids(&frags);
5751 assert_eq!(row_ids, vec![0u32, 0, 0]);
5752 }
5753
5754 #[test]
5755 fn assign_row_ids_floor_2pt_for_small_fonts() {
5756 // font_size=3 → font_size*0.5 = 1.5; floor lifts threshold to 2.0
5757 // delta = +2.5 > 2.0 must trigger.
5758 let frags = vec![
5759 tf("A", 50.0, 100.0, 10.0, 3.0),
5760 tf("B", 50.0, 102.5, 10.0, 3.0),
5761 ];
5762 let row_ids = super::assign_row_ids(&frags);
5763 assert_eq!(row_ids, vec![0u32, 1]);
5764 }
5765
5766 #[test]
5767 fn assign_row_ids_empty_slice_returns_empty() {
5768 let frags: Vec<TextFragment> = vec![];
5769 let row_ids = super::assign_row_ids(&frags);
5770 assert!(row_ids.is_empty(), "empty input must yield empty output");
5771 }
5772
5773 #[test]
5774 fn merge_into_lines_splits_two_columns_emitted_sequentially() {
5775 let extractor = TextExtractor::with_options(ExtractionOptions {
5776 reconstruct_paragraphs: true,
5777 ..Default::default()
5778 });
5779 // Emission order: col1.l1, col1.l2 (Y monotone down), then col2.l1
5780 // (Y jumps UP by 10 > threshold 5 for font 10pt), col2.l2.
5781 let input = vec![
5782 tf("col1-top", 50.0, 400.0, 80.0, 10.0),
5783 tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
5784 tf("col2-top", 200.0, 405.0, 80.0, 10.0),
5785 tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
5786 ];
5787 let lines = extractor.merge_into_lines(&input);
5788 assert_eq!(
5789 lines.len(),
5790 4,
5791 "two columns at near-identical Y must split into 4 lines"
5792 );
5793 // row_id=0 batch first (col1), then row_id=1 (col2). Within each batch, Y desc.
5794 assert_eq!(lines[0].text, "col1-top");
5795 assert_eq!(lines[0].y, 400.0);
5796 assert_eq!(lines[1].text, "col1-bot");
5797 assert_eq!(lines[1].y, 395.0);
5798 assert_eq!(lines[2].text, "col2-top");
5799 assert_eq!(lines[2].y, 405.0);
5800 assert_eq!(lines[3].text, "col2-bot");
5801 assert_eq!(lines[3].y, 400.0);
5802 }
5803
5804 #[test]
5805 fn merge_into_lines_preserves_single_column_continuation() {
5806 let extractor = TextExtractor::with_options(ExtractionOptions {
5807 reconstruct_paragraphs: true,
5808 ..Default::default()
5809 });
5810 // Single column: same Y continuation (X grows), then next line down.
5811 let input = vec![
5812 tf("Hello", 50.0, 400.0, 30.0, 10.0),
5813 tf("world", 90.0, 400.0, 30.0, 10.0),
5814 tf("next-line", 50.0, 395.0, 70.0, 10.0),
5815 ];
5816 let lines = extractor.merge_into_lines(&input);
5817 assert_eq!(
5818 lines.len(),
5819 2,
5820 "single column continuation must collapse to 2 lines"
5821 );
5822 assert!(lines[0].text.contains("Hello"));
5823 assert!(lines[0].text.contains("world"));
5824 assert_eq!(lines[1].text, "next-line");
5825 }
5826
5827 #[test]
5828 fn merge_into_lines_splits_columns_with_uniform_mcid() {
5829 // Regression guard for #265 root cause: NCSC page 12 has a single
5830 // outer BDC, so every fragment has mcid=Some(0). Column separation
5831 // must come from row_id alone, not from mcid.
5832 let extractor = TextExtractor::with_options(ExtractionOptions {
5833 reconstruct_paragraphs: true,
5834 ..Default::default()
5835 });
5836 let mut frags = vec![
5837 tf("col1-top", 50.0, 400.0, 80.0, 10.0),
5838 tf("col1-bot", 50.0, 395.0, 80.0, 10.0),
5839 tf("col2-top", 200.0, 405.0, 80.0, 10.0),
5840 tf("col2-bot", 200.0, 400.0, 80.0, 10.0),
5841 ];
5842 for f in &mut frags {
5843 f.mcid = Some(0);
5844 }
5845 let lines = extractor.merge_into_lines(&frags);
5846 assert_eq!(
5847 lines.len(),
5848 4,
5849 "uniform mcid must not prevent row_id-based column split (NCSC root cause)"
5850 );
5851 assert_eq!(lines[0].text, "col1-top");
5852 assert_eq!(lines[1].text, "col1-bot");
5853 assert_eq!(lines[2].text, "col2-top");
5854 assert_eq!(lines[3].text, "col2-bot");
5855 }
5856
5857 #[test]
5858 fn merge_close_fragments_superscript_merges_when_reconstruct_paragraphs() {
5859 let extractor = TextExtractor::with_options(ExtractionOptions {
5860 reconstruct_paragraphs: true,
5861 ..Default::default()
5862 });
5863 // Citation superscript: body text at y=400, raised digit at y=403.5
5864 // (3.5pt above baseline for 10pt font). y_tol = 0.5 * 10 = 5.0 > 3.5
5865 // and x_gap = 4pt < 10*0.5 = 5pt, so the superscript must merge into
5866 // the body fragment.
5867 let frags = vec![
5868 tf("body-text", 50.0, 400.0, 25.0, 10.0),
5869 tf("1", 79.0, 403.5, 4.0, 10.0),
5870 ];
5871 let merged = extractor.merge_close_fragments(&frags);
5872 assert_eq!(
5873 merged.len(),
5874 1,
5875 "superscript within 5pt of baseline must merge in reconstruct path"
5876 );
5877 assert!(merged[0].text.contains("body-text"));
5878 assert!(merged[0].text.contains("1"));
5879 }
5880
5881 #[test]
5882 fn merge_close_fragments_superscript_does_not_merge_in_legacy_path() {
5883 let extractor = TextExtractor::with_options(ExtractionOptions {
5884 reconstruct_paragraphs: false,
5885 ..Default::default()
5886 });
5887 // Legacy path: y_tol=1.0 fixed. A 3.5pt delta must NOT merge.
5888 let frags = vec![
5889 tf("body-text", 50.0, 400.0, 25.0, 10.0),
5890 tf("1", 79.0, 403.5, 4.0, 10.0),
5891 ];
5892 let merged = extractor.merge_close_fragments(&frags);
5893 assert_eq!(
5894 merged.len(),
5895 2,
5896 "3.5pt Y delta exceeds legacy 1.0pt threshold; superscript stays separate"
5897 );
5898 }
5899
5900 #[test]
5901 fn merge_into_paragraphs_groups_consecutive_lines() {
5902 let extractor = TextExtractor::with_options(ExtractionOptions {
5903 reconstruct_paragraphs: true,
5904 ..Default::default()
5905 });
5906 // Three lines, 14pt leading (line height 12pt, gap 2pt)
5907 let lines = vec![
5908 tf("Line one.", 50.0, 400.0, 60.0, 12.0),
5909 tf("Line two.", 50.0, 386.0, 60.0, 12.0),
5910 tf("Line three.", 50.0, 372.0, 70.0, 12.0),
5911 ];
5912 let paragraphs = extractor.merge_into_paragraphs(&lines);
5913 assert_eq!(paragraphs.len(), 1);
5914 assert_eq!(paragraphs[0].text, "Line one.\nLine two.\nLine three.");
5915 }
5916
5917 #[test]
5918 fn merge_into_paragraphs_splits_on_large_vertical_gap() {
5919 let extractor = TextExtractor::with_options(ExtractionOptions {
5920 reconstruct_paragraphs: true,
5921 ..Default::default()
5922 });
5923 let lines = vec![
5924 tf("P1L1.", 50.0, 400.0, 40.0, 12.0),
5925 tf("P1L2.", 50.0, 386.0, 40.0, 12.0),
5926 tf("P2L1.", 50.0, 300.0, 40.0, 12.0),
5927 ];
5928 let paragraphs = extractor.merge_into_paragraphs(&lines);
5929 assert_eq!(paragraphs.len(), 2);
5930 assert_eq!(paragraphs[0].text, "P1L1.\nP1L2.");
5931 assert_eq!(paragraphs[1].text, "P2L1.");
5932 }
5933
5934 /// A heading is a different block from the body that follows it, even when
5935 /// the vertical gap is small enough to look like line spacing. Merging them
5936 /// destroys the two signals `partition` uses to classify a `Title`
5937 /// (font-size ratio and bold-short), so the heading text is never
5938 /// recoverable downstream (issue #436).
5939 #[test]
5940 fn merge_into_paragraphs_splits_on_font_size_change() {
5941 let extractor = TextExtractor::with_options(ExtractionOptions {
5942 reconstruct_paragraphs: true,
5943 ..Default::default()
5944 });
5945 // 20pt title at y=760, 10pt body line 40pt below: gap = 30pt, which is
5946 // exactly the 1.5 * median(20, 10) = 30pt vertical threshold, so only
5947 // the style change can separate them.
5948 let lines = vec![
5949 tf("Section Heading", 72.0, 760.0, 120.0, 20.0),
5950 tf("Body text of this section.", 72.0, 720.0, 150.0, 10.0),
5951 ];
5952 let paragraphs = extractor.merge_into_paragraphs(&lines);
5953 assert_eq!(
5954 paragraphs.len(),
5955 2,
5956 "font-size change must end the paragraph"
5957 );
5958 assert_eq!(paragraphs[0].text, "Section Heading");
5959 assert_eq!(paragraphs[0].font_size, 20.0);
5960 assert_eq!(paragraphs[1].text, "Body text of this section.");
5961 }
5962
5963 /// Same size, different weight: the classic run-in bold heading. `partition`
5964 /// classifies it through `bold_short_title`, which needs the heading to
5965 /// survive extraction as its own fragment (issue #436).
5966 #[test]
5967 fn merge_into_paragraphs_splits_on_weight_change() {
5968 let extractor = TextExtractor::with_options(ExtractionOptions {
5969 reconstruct_paragraphs: true,
5970 ..Default::default()
5971 });
5972 let mut heading = tf("Overview", 72.0, 400.0, 60.0, 12.0);
5973 heading.is_bold = true;
5974 let lines = vec![heading, tf("Body line.", 72.0, 386.0, 60.0, 12.0)];
5975 let paragraphs = extractor.merge_into_paragraphs(&lines);
5976 assert_eq!(paragraphs.len(), 2, "weight change must end the paragraph");
5977 assert_eq!(paragraphs[0].text, "Overview");
5978 assert!(paragraphs[0].is_bold);
5979 assert_eq!(paragraphs[1].text, "Body line.");
5980 }
5981
5982 /// Sub-point rounding (11.96pt vs 12pt from a scaled text matrix) is not a
5983 /// style change: the paragraph must stay whole.
5984 #[test]
5985 fn merge_into_paragraphs_tolerates_subpoint_font_size_jitter() {
5986 let extractor = TextExtractor::with_options(ExtractionOptions {
5987 reconstruct_paragraphs: true,
5988 ..Default::default()
5989 });
5990 let lines = vec![
5991 tf("Line one.", 50.0, 400.0, 60.0, 12.0),
5992 tf("Line two.", 50.0, 386.0, 60.0, 11.96),
5993 ];
5994 let paragraphs = extractor.merge_into_paragraphs(&lines);
5995 assert_eq!(
5996 paragraphs.len(),
5997 1,
5998 "0.3% size jitter is not a style change"
5999 );
6000 assert_eq!(paragraphs[0].text, "Line one.\nLine two.");
6001 }
6002
6003 #[test]
6004 fn merge_into_paragraphs_drops_hyphen_when_merge_hyphenated() {
6005 let extractor = TextExtractor::with_options(ExtractionOptions {
6006 reconstruct_paragraphs: true,
6007 merge_hyphenated: true,
6008 ..Default::default()
6009 });
6010 let lines = vec![
6011 tf("Kryp-", 50.0, 400.0, 30.0, 12.0),
6012 tf("tographie", 50.0, 386.0, 60.0, 12.0),
6013 ];
6014 let paragraphs = extractor.merge_into_paragraphs(&lines);
6015 assert_eq!(paragraphs.len(), 1);
6016 assert_eq!(
6017 paragraphs[0].text, "Kryptographie",
6018 "hyphen elided, no newline inserted"
6019 );
6020 }
6021
6022 #[test]
6023 fn decode_pdf_string_utf16be_bom_decodes_fi_ligature() {
6024 let bytes = [0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69];
6025 assert_eq!(super::decode_pdf_string(&bytes), "fi");
6026 }
6027
6028 #[test]
6029 fn decode_pdf_string_ascii_pdfdocencoding_passthrough() {
6030 let bytes = b"page 12";
6031 assert_eq!(super::decode_pdf_string(bytes), "page 12");
6032 }
6033
6034 #[test]
6035 fn decode_pdf_string_empty_input_returns_empty() {
6036 assert_eq!(super::decode_pdf_string(&[]), "");
6037 }
6038
6039 #[test]
6040 fn decode_pdf_string_lone_bom_returns_empty() {
6041 // BOM only, no code units after.
6042 assert_eq!(super::decode_pdf_string(&[0xFE, 0xFF]), "");
6043 }
6044
6045 #[test]
6046 fn resolve_props_extracts_integer_mcid() {
6047 use crate::parser::content::{MarkedContentProps, MarkedContentValue};
6048 use std::collections::HashMap;
6049 let mut map = HashMap::new();
6050 map.insert("MCID".to_string(), MarkedContentValue::Integer(7));
6051 let props = MarkedContentProps::Inline(map);
6052
6053 let (mcid, actual) = super::resolve_props(&props, None);
6054 assert_eq!(mcid, Some(7));
6055 assert_eq!(actual, None);
6056 }
6057
6058 #[test]
6059 fn resolve_props_decodes_utf16be_actualtext() {
6060 use crate::parser::content::{MarkedContentProps, MarkedContentValue};
6061 use std::collections::HashMap;
6062 let mut map = HashMap::new();
6063 map.insert(
6064 "ActualText".to_string(),
6065 MarkedContentValue::String(vec![0xFE, 0xFF, 0x00, 0x66, 0x00, 0x69]),
6066 );
6067 let props = MarkedContentProps::Inline(map);
6068
6069 let (mcid, actual) = super::resolve_props(&props, None);
6070 assert_eq!(mcid, None);
6071 assert_eq!(actual.as_deref(), Some("fi"));
6072 }
6073
6074 #[test]
6075 fn resolve_props_returns_none_for_unresolvable_resource_ref() {
6076 use crate::parser::content::MarkedContentProps;
6077 let props = MarkedContentProps::ResourceRef("PropsName".to_string());
6078 let (mcid, actual) = super::resolve_props(&props, None);
6079 assert_eq!((mcid, actual), (None, None));
6080 }
6081
6082 #[test]
6083 fn resolve_props_negative_mcid_rejected() {
6084 use crate::parser::content::{MarkedContentProps, MarkedContentValue};
6085 use std::collections::HashMap;
6086 // MCID is unsigned per ISO 32000-1; negative integer is malformed.
6087 let mut map = HashMap::new();
6088 map.insert("MCID".to_string(), MarkedContentValue::Integer(-1));
6089 let props = MarkedContentProps::Inline(map);
6090
6091 let (mcid, _) = super::resolve_props(&props, None);
6092 assert_eq!(mcid, None);
6093 }
6094
6095 #[test]
6096 fn resolve_props_resource_ref_overflow_mcid_rejected() {
6097 // ISO 32000-1 §14.7.4: MCID is an unsigned 32-bit integer. A
6098 // PdfObject::Integer holds an i64, so a malformed PDF can carry an
6099 // out-of-range MCID. The ResourceRef path must reject those rather
6100 // than wrap silently via `as u32`. Mirrors the Inline-path guard
6101 // already covered by `resolve_props_negative_mcid_rejected`.
6102 use crate::parser::content::MarkedContentProps;
6103 use crate::parser::objects::{PdfDictionary, PdfObject};
6104
6105 let mut inner = PdfDictionary::new();
6106 inner.insert("MCID".to_string(), PdfObject::Integer(i64::MAX));
6107
6108 let mut properties = PdfDictionary::new();
6109 properties.insert("PropsName".to_string(), PdfObject::Dictionary(inner));
6110
6111 let props = MarkedContentProps::ResourceRef("PropsName".to_string());
6112 let (mcid, _) = super::resolve_props(&props, Some(&properties));
6113 assert_eq!(mcid, None);
6114 }
6115
6116 #[test]
6117 fn sort_and_merge_fragments_nan_y_does_not_swallow_other_lines() {
6118 // A fragment with a non-finite Y (reachable from a degenerate text
6119 // matrix in a malformed PDF) must not chain every remaining fragment
6120 // into one pseudo-line. The tolerance filter compares with `< tol`; a
6121 // `>= tol` phrasing would let a NaN anchor never terminate the line,
6122 // collapsing the whole page into a single X-sorted "line".
6123 let extractor = TextExtractor::with_options(ExtractionOptions::default());
6124
6125 // Four well-separated lines whose X order is the reverse of their Y
6126 // (reading) order: if the NaN anchor swallows the rest, they get
6127 // re-sorted purely by X into D,C,B,A instead of the reading order.
6128 let mut fragments = vec![
6129 tf("A", 400.0, f64::NAN, 10.0, 12.0),
6130 tf("B", 300.0, 500.0, 10.0, 12.0),
6131 tf("C", 200.0, 300.0, 10.0, 12.0),
6132 tf("D", 100.0, 100.0, 10.0, 12.0),
6133 ];
6134 extractor.sort_and_merge_fragments(&mut fragments);
6135
6136 let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
6137 assert_eq!(
6138 order,
6139 vec!["A", "B", "C", "D"],
6140 "NaN-Y fragment must stay its own line; the finite lines keep \
6141 top-to-bottom reading order instead of collapsing to X order"
6142 );
6143 }
6144
6145 #[test]
6146 fn sort_and_merge_fragments_keeps_emission_regions_atomic() {
6147 let extractor = TextExtractor::with_options(ExtractionOptions::default());
6148
6149 // Region 0 is a normal top-to-bottom footer. Region 1 is an overlay
6150 // emitted later: its first line jumps back up the page, while its
6151 // second line falls numerically between the footer's two lines. A
6152 // page-wide Y-sort would produce body-1, overlay-1, overlay-2, body-2.
6153 let mut fragments = vec![
6154 tf("body-1", 50.0, 45.0, 40.0, 10.0),
6155 tf("body-2", 50.0, 30.0, 40.0, 10.0),
6156 tf("overlay-1", 250.0, 50.0, 60.0, 10.0),
6157 tf("overlay-2", 250.0, 35.0, 60.0, 10.0),
6158 ];
6159
6160 extractor.sort_and_merge_fragments(&mut fragments);
6161
6162 let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
6163 assert_eq!(
6164 order,
6165 vec!["body-1", "body-2", "overlay-1", "overlay-2"],
6166 "positional sorting must not interleave independent emission regions"
6167 );
6168 }
6169
6170 #[test]
6171 fn sort_and_merge_fragments_uses_mcid_as_a_region_boundary() {
6172 let extractor = TextExtractor::with_options(ExtractionOptions::default());
6173 let mut fragments = vec![
6174 tf("body-1", 50.0, 45.0, 40.0, 10.0),
6175 tf("body-2", 50.0, 30.0, 40.0, 10.0),
6176 // The small Y increase is below assign_row_ids' reset threshold;
6177 // MCID ownership must still keep this overlay independent.
6178 tf("overlay", 250.0, 33.0, 60.0, 10.0),
6179 ];
6180 fragments[0].mcid = Some(7);
6181 fragments[1].mcid = Some(7);
6182 fragments[2].mcid = Some(8);
6183
6184 extractor.sort_and_merge_fragments(&mut fragments);
6185
6186 let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
6187 assert_eq!(order, vec!["body-1", "body-2", "overlay"]);
6188 }
6189
6190 #[test]
6191 fn column_detection_does_not_join_independent_layout_regions() {
6192 let extractor = TextExtractor::with_options(ExtractionOptions {
6193 detect_columns: true,
6194 ..Default::default()
6195 });
6196 let mut fragments = vec![
6197 tf("a1", 0.0, 100.0, 10.0, 10.0),
6198 tf("b1", 100.0, 100.0, 10.0, 10.0),
6199 tf("a2", 0.0, 80.0, 10.0, 10.0),
6200 tf("b2", 100.0, 80.0, 10.0, 10.0),
6201 // A later overlay repeats the same column corridor and overlaps
6202 // the first region's Y span. Column detection must not combine
6203 // both into one column-major block.
6204 tf("c1", 0.0, 103.0, 10.0, 10.0),
6205 tf("d1", 100.0, 103.0, 10.0, 10.0),
6206 tf("c2", 0.0, 83.0, 10.0, 10.0),
6207 tf("d2", 100.0, 83.0, 10.0, 10.0),
6208 ];
6209
6210 extractor.sort_and_merge_fragments(&mut fragments);
6211
6212 let order: Vec<&str> = fragments.iter().map(|f| f.text.as_str()).collect();
6213 assert_eq!(order, vec!["a1", "a2", "b1", "b2", "c1", "c2", "d1", "d2"]);
6214 }
6215}