xberg_native_pdf/layout/text_block.rs
1//! Text block representation for layout analysis.
2//!
3//! This module defines structures for representing text elements in a PDF document
4//! with their geometric and styling information.
5
6use crate::extractors::text::ArtifactType;
7use crate::geometry::{Point, Rect};
8use crate::structure::McidScope;
9use std::collections::HashMap;
10
11/// A text span (complete string from a Tj/TJ operator).
12///
13/// This represents text as the PDF specification provides it - complete strings
14/// from text showing operators, not individual characters. This is the correct
15/// approach per PDF spec ISO 32000-1:2008.
16///
17/// Extracting complete strings instead of individual characters:
18/// - Avoids overlapping character issues
19/// - Preserves PDF's text positioning intent
20/// - Matches industry best practices
21/// - More robust for complex layouts
22#[derive(Debug, Clone, serde::Serialize)]
23pub struct TextSpan {
24 /// The complete text string
25 pub text: String,
26 /// Bounding box of the entire span in PDF coordinates (points)
27 pub bbox: Rect,
28 /// Font name/family
29 pub font_name: String,
30 /// Font size in points
31 pub font_size: f32,
32 /// Font weight (normal or bold)
33 pub font_weight: FontWeight,
34 /// Font style: italic or normal
35 pub is_italic: bool,
36 /// Whether the font is monospaced (from PDF font descriptor FixedPitch flag)
37 pub is_monospace: bool,
38 /// Text color
39 pub color: Color,
40 /// Marked Content ID (for Tagged PDFs)
41 pub mcid: Option<u32>,
42 /// Content-stream scope of [`Self::mcid`] (ISO 32000-1:2008 §14.7.4.3).
43 ///
44 /// MCIDs are scoped to a single content stream — page, Form
45 /// XObject, or Tiling Pattern — not to a page globally. When this
46 /// span's `mcid` was emitted inside a Form XObject's content
47 /// stream, `mcid_scope` is `Form(<form_ref>)`; inside a Tiling
48 /// Pattern, `Pattern(<pattern_ref>)`; otherwise `Page(page_index)`
49 /// for the page that owns the top-level content stream the span
50 /// came from.
51 ///
52 /// The struct-tree `/ActualText` applier keys lookups by
53 /// `(mcid_scope, mcid)` so two Form XObjects on the same page that
54 /// each carry MCID 0 do not collide and overwrite each other's
55 /// replacements.
56 ///
57 /// `None` for spans extracted before page-index attribution
58 /// completes (e.g. mid-extraction internal spans) or for synthetic
59 /// test fixtures.
60 #[serde(skip_serializing_if = "Option::is_none", default)]
61 pub mcid_scope: Option<McidScope>,
62 /// Extraction sequence number
63 pub sequence: usize,
64 /// If true, this span was created by splitting fused words
65 pub split_boundary_before: bool,
66 /// If true, this span was created by the TJ processor as a space
67 pub offset_semantic: bool,
68 /// Character spacing (Tc parameter)
69 pub char_spacing: f32,
70 /// Word spacing (Tw parameter)
71 pub word_spacing: f32,
72 /// Horizontal scaling (Tz parameter)
73 pub horizontal_scaling: f32,
74 /// If true, was created by WordBoundaryDetector primary detection.
75 pub primary_detected: bool,
76 /// Artifact type classification for filtered content (PDF Spec Section 14.8.2.2)
77 pub artifact_type: Option<ArtifactType>,
78 /// Per-character advance widths in user-space points.
79 /// When non-empty and matching text length, to_chars() uses these
80 /// for accurate per-glyph bounding boxes instead of uniform division.
81 #[serde(skip_serializing_if = "Vec::is_empty")]
82 pub char_widths: Vec<f32>,
83 /// Accurate per-glyph baseline x-origins (user-space points), sourced from
84 /// the spec-aligned char-level extractor that `extract_chars` uses.
85 ///
86 /// When non-empty and matching `text.chars().count()`, [`Self::to_chars`]
87 /// uses these directly for each glyph's `origin_x` / `bbox.x` instead of
88 /// prefix-summing the nominal `char_widths` from `bbox.x`. The nominal
89 /// widths omit ISO 32000-1:2008 §9.4.3 TJ-array kerning adjustments and the
90 /// full §9.4.4 text-space displacement, so prefix-summing them drifts
91 /// cumulatively along a line; these offsets carry the real positions and so
92 /// do not drift. Empty (the default) preserves the legacy prefix-sum path
93 /// byte-for-byte.
94 #[serde(skip_serializing_if = "Vec::is_empty", default)]
95 pub char_x_offsets: Vec<f32>,
96 /// Heading level (1-6) when this span belongs to a document heading.
97 /// Populated either from the source PDF's structure tree
98 /// (`StructRole::Heading(n)`) or from a font-size-ratio heuristic when
99 /// the PDF is untagged. Layout-preserving DOCX export uses this to
100 /// emit `<w:pStyle w:val="HeadingN"/>` so the output document
101 /// preserves heading semantics for accessibility, navigation, and
102 /// outline panes.
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub heading_level: Option<u8>,
105 /// Display rotation of the run in degrees, from `atan2(b, a)` of the composed
106 /// text rendering matrix (`T_m × CTM`, ISO 32000-1 §9.4.4), normalised so a
107 /// near-quadrant angle snaps to `0` / `90` / `180` / `-90`. `0.0` for ordinary
108 /// horizontal text. Reading order segregates non-zero-rotation runs out of the
109 /// horizontal flow so they are ordered as their own blocks rather than
110 /// interleaved (the axis-aligned assumptions in the row-band / XY-cut sort do
111 /// not hold for rotated text).
112 #[serde(skip_serializing_if = "is_zero_f32", default)]
113 pub rotation_degrees: f32,
114 /// Writing mode under which the glyphs in this span were emitted.
115 ///
116 /// `0` = horizontal (the overwhelming default), `1` = vertical (tategaki
117 /// / lateral CJK). Set from `GraphicsState::text_wmode` when the span
118 /// is constructed, so each span carries its own writing-mode metadata
119 /// even on mixed-mode pages (e.g. horizontal headings above vertical
120 /// body copy). The reading-order sort consults this to advance
121 /// downward-then-right-to-left within blocks of vertical spans while
122 /// leaving horizontal spans on their existing top-to-bottom,
123 /// left-to-right path.
124 #[serde(skip_serializing_if = "is_zero_u8", default)]
125 pub wmode: u8,
126 /// Baseline shift of this run as a ratio of font size (`Ts ÷ font_size`),
127 /// from the text-rise text-state parameter (ISO 32000-1 §9.3.7). `Ts > 0`
128 /// raises the baseline (superscript), `Ts < 0` lowers it (subscript); `0.0`
129 /// for ordinary on-baseline text. Stored as a ratio (rather than raw points)
130 /// so it is independent of the text/CTM scale and directly comparable to the
131 /// font-size-ratio used by the sub/superscript rejoin. Reading order uses a
132 /// non-zero value as the authoritative, untagged-available signal that the
133 /// run is an off-baseline super/subscript to be rejoined inline rather than
134 /// split onto its own line by the row-band sort.
135 #[serde(skip_serializing_if = "is_zero_f32", default)]
136 pub text_rise: f32,
137 /// True when this span's glyphs were drawn **right-to-left** — successive
138 /// glyphs placed at decreasing x (the producer stored RTL text in LOGICAL
139 /// order and positioned each glyph individually, ISO 32000-1 §14.8.2.3.3
140 /// method 1). Such a span's characters are already in logical order, so the
141 /// structure-path `push_span_text_bidi` must NOT apply its visual→logical
142 /// character reversal to it. Detected from raw draw geometry before any
143 /// reading-order sort (`detect_rtl_draw_direction`) and OR-ed through
144 /// `merge_adjacent_spans`. Default `false` = VISUAL storage (glyphs drawn
145 /// left-to-right, the common case), kept byte-identical. The draw direction
146 /// is the only signal that separates logical- from visual-stored RTL when
147 /// both use base-form characters (no presentation forms, no `/ReversedChars`).
148 #[serde(skip_serializing_if = "is_false", default)]
149 pub rtl_draw_logical: bool,
150 /// True when the composed text rendering matrix has a negative
151 /// determinant — the run is mirrored, so its glyph-up axis is the writing
152 /// axis *reflected*, not rotated (`snap_run_rotation` likewise refuses to
153 /// snap these so they are never confused with a clean rotation).
154 /// [`Self::page_bbox`] reflects the across-axis for such runs instead of
155 /// rotating it. Runtime metadata — deliberately not serialized.
156 #[serde(skip)]
157 pub mirrored: bool,
158 /// Clockwise page `/Rotate` (`0`/`90`/`180`/`270`) already folded into
159 /// [`Self::bbox`] by span postprocessing; `0` means `bbox` is raw user
160 /// space. On a `/Rotate`d page rotated-content bboxes are rewritten into
161 /// the displayed frame while `rotation_degrees` keeps the raw content
162 /// angle (it is the marker the reading-order passes select on), so
163 /// [`Self::page_bbox`] needs this to reconstruct the run frame without
164 /// double-transforming. Runtime metadata — deliberately not
165 /// serialized.
166 ///
167 /// Written only by `map_span_into_rotated_frame` in
168 /// `xberg_native_pdf::document`, reached only via `extract_spans` /
169 /// `extract_spans_filtered`. The reading-order extraction path
170 /// (`extract_spans_filtered_with_reading_order`) never calls it, so spans
171 /// from that path always carry `0` here, even on a rotated page — see its
172 /// doc comment for why that is intentional, not a gap.
173 #[serde(skip)]
174 pub page_rotation_applied: i32,
175 /// Provenance of this span's Unicode text — which ISO 32000-1 §9.10.2
176 /// mapping tier the font offered, as a
177 /// [`MappingProvenance`](crate::fonts::MappingProvenance). `None` when the
178 /// span was not produced by the extractor (synthetic/test spans). A
179 /// [`Fallback`](crate::fonts::MappingProvenance::Fallback) value means the
180 /// font carried no mapping resource, so the text is a fabricated glyph-index
181 /// echo, not read from the file. Runtime metadata only — deliberately not
182 /// serialized (kept out of span JSON so existing output is byte-identical);
183 /// bindings surface it through explicit accessors.
184 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub provenance: Option<crate::fonts::MappingProvenance>,
186}
187
188/// serde skip helper: omit a `false` flag (the common case) from serialized output.
189pub(crate) fn is_false(v: &bool) -> bool {
190 !*v
191}
192
193/// serde skip helper: omit a `0` writing mode (horizontal, the common case)
194/// from serialized output so existing fixtures stay unchanged.
195pub(crate) fn is_zero_u8(v: &u8) -> bool {
196 *v == 0
197}
198
199/// serde skip helper: omit a `0.0` rotation (the overwhelming common case) from
200/// serialized output so existing fixtures stay unchanged.
201pub(crate) fn is_zero_f32(v: &f32) -> bool {
202 *v == 0.0
203}
204
205impl Default for TextSpan {
206 fn default() -> Self {
207 Self {
208 text: String::new(),
209 bbox: Rect::default(),
210 font_name: "Helvetica".to_string(),
211 font_size: 12.0,
212 font_weight: FontWeight::Normal,
213 is_italic: false,
214 is_monospace: false,
215 color: Color::black(),
216 mcid: None,
217 mcid_scope: None,
218 sequence: 0,
219 split_boundary_before: false,
220 offset_semantic: false,
221 char_spacing: 0.0,
222 word_spacing: 0.0,
223 horizontal_scaling: 100.0,
224 primary_detected: false,
225 artifact_type: None,
226 char_widths: Vec::new(),
227 char_x_offsets: Vec::new(),
228 heading_level: None,
229 rotation_degrees: 0.0,
230 wmode: 0,
231 text_rise: 0.0,
232 rtl_draw_logical: false,
233 mirrored: false,
234 page_rotation_applied: 0,
235 provenance: None,
236 }
237 }
238}
239
240impl TextSpan {
241 /// Where the run physically sits on the page.
242 ///
243 /// [`Self::bbox`] carries the run's own extents: `width` is the advance
244 /// along the writing axis and `height` the font size, whatever the
245 /// rotation. That is the right frame for measuring the text, and it is the
246 /// frame every existing consumer already reads, so it is left alone — but
247 /// it means a run drawn with `Tm [0 1 -1 0]` reports a wide, short box for
248 /// text that is physically tall and narrow.
249 ///
250 /// This rotates those extents about the run origin into the same frame
251 /// `bbox` lives in. At `rotation_degrees == 0` it returns `bbox`
252 /// unchanged, so upright pages cannot move; free angles get the
253 /// axis-aligned hull of the rotated box. On a `/Rotate`d page, where span
254 /// postprocessing already rewrote `bbox` into the displayed frame
255 /// ([`Self::page_rotation_applied`]), the hull is built in that displayed
256 /// frame rather than re-rotating the already-mapped rect.
257 ///
258 /// Mirrors [`crate::elements::PathContent::rendered_bbox`]: a derived accessor
259 /// rather than a second stored rectangle that could drift out of sync.
260 pub fn page_bbox(&self) -> Rect {
261 if self.rotation_degrees == 0.0 {
262 return self.bbox;
263 }
264 // Conjugate the run frame by the clockwise page rotation already
265 // applied to `bbox`: the run origin's image is a fixed, known corner
266 // of the mapped rect; a rotation conjugates to itself, a mirror to
267 // `θ - 2·rot`. ~keep
268 let rot = self.page_rotation_applied.rem_euclid(360);
269 let theta = if self.mirrored {
270 self.rotation_degrees - 2.0 * rot as f32
271 } else {
272 self.rotation_degrees
273 }
274 .to_radians();
275 let (sin, cos) = theta.sin_cos();
276 // Writing axis and the across-line axis, the same pair the assembler
277 // resolves displacements onto (ISO 32000-1 §9.4.4). A mirrored run
278 // (negative determinant) carries its across-axis on the clockwise
279 // side of the baseline: v is reflected, not rotated. ~keep
280 let (ux, uy) = (cos, sin);
281 let (vx, vy) = if self.mirrored { (sin, -cos) } else { (-sin, cos) };
282 let b = self.bbox;
283 // Image of the run origin under the applied page rotation — the
284 // anchor the run frame pivots about in `bbox`'s current frame. ~keep
285 let (px, py) = match rot {
286 90 => (b.x, b.y + b.height),
287 180 => (b.x + b.width, b.y + b.height),
288 270 => (b.x + b.width, b.y),
289 _ => (b.x, b.y),
290 };
291 let corners = [
292 (b.x, b.y),
293 (b.x + b.width, b.y),
294 (b.x, b.y + b.height),
295 (b.x + b.width, b.y + b.height),
296 ]
297 .map(|(cx, cy)| {
298 let (dx, dy) = (cx - px, cy - py);
299 (px + dx * ux + dy * vx, py + dx * uy + dy * vy)
300 });
301
302 let min_x = corners.iter().map(|c| c.0).fold(f32::INFINITY, f32::min);
303 let max_x = corners.iter().map(|c| c.0).fold(f32::NEG_INFINITY, f32::max);
304 let min_y = corners.iter().map(|c| c.1).fold(f32::INFINITY, f32::min);
305 let max_y = corners.iter().map(|c| c.1).fold(f32::NEG_INFINITY, f32::max);
306 Rect::new(min_x, min_y, max_x - min_x, max_y - min_y)
307 }
308
309 /// Decompose the span into individual characters.
310 pub fn to_chars(&self) -> Vec<TextChar> {
311 let char_count = self.text.chars().count();
312 if char_count == 0 {
313 return Vec::new();
314 }
315
316 // Preferred path: accurate per-glyph x-origins captured from the
317 // spec-aligned char extractor (ISO 32000-1:2008 §9.4.3 / §9.4.4). These
318 // carry the real text-space positions, so they do not accumulate the
319 // TJ-kerning drift that prefix-summing `char_widths` from `bbox.x`
320 // produces. Only taken when the offsets cover every glyph exactly. ~keep
321 const OFFSET_BBOX_TOLERANCE: f32 = 0.5;
322 let offsets_fit_bbox = self.char_x_offsets.iter().all(|&x| {
323 x.is_finite()
324 && x >= self.bbox.x - OFFSET_BBOX_TOLERANCE
325 && x <= self.bbox.x + self.bbox.width + OFFSET_BBOX_TOLERANCE
326 });
327 if self.char_x_offsets.len() == char_count && offsets_fit_bbox {
328 let has_widths = self.char_widths.len() == char_count;
329 let offsets = &self.char_x_offsets;
330 return self
331 .text
332 .chars()
333 .enumerate()
334 .map(|(i, c)| {
335 let char_x = offsets[i];
336 let w = if has_widths {
337 self.char_widths[i]
338 } else if i + 1 < char_count {
339 (offsets[i + 1] - char_x).max(0.0)
340 } else {
341 (self.bbox.x + self.bbox.width - char_x).max(0.0)
342 };
343 self.text_char_at(c, char_x, w)
344 })
345 .collect();
346 }
347
348 if self.char_widths.len() == char_count {
349 let mut x = self.bbox.x;
350 self.text
351 .chars()
352 .enumerate()
353 .map(|(i, c)| {
354 let w = self.char_widths[i];
355 let char_x = x;
356 x += w;
357 self.text_char_at(c, char_x, w)
358 })
359 .collect()
360 } else {
361 let char_width = self.bbox.width / (char_count as f32);
362 self.text
363 .chars()
364 .enumerate()
365 .map(|(i, c)| self.text_char_at(c, self.bbox.x + (i as f32) * char_width, char_width))
366 .collect()
367 }
368 }
369
370 /// Builds a single [`TextChar`] at `char_x` with `width`, inheriting every other
371 /// styling and geometry field from this span. Split out of [`Self::to_chars`]'s
372 /// three branches, which otherwise built an identical [`TextChar`] shape from
373 /// differently-derived `(char_x, width)` pairs.
374 fn text_char_at(&self, c: char, char_x: f32, width: f32) -> TextChar {
375 TextChar {
376 char: c,
377 bbox: Rect::new(char_x, self.bbox.y, width, self.bbox.height),
378 font_name: self.font_name.clone(),
379 font_size: self.font_size,
380 font_weight: self.font_weight,
381 is_italic: self.is_italic,
382 is_monospace: self.is_monospace,
383 color: self.color,
384 mcid: self.mcid,
385 origin_x: char_x,
386 origin_y: self.bbox.y,
387 rotation_degrees: self.rotation_degrees,
388 advance_width: width,
389 rendered_advance: width,
390 ascent: 0.95 * self.font_size,
391 descent: -0.35 * self.font_size,
392 matrix: Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
393 }
394 }
395}
396
397/// A single character with its position and styling.
398///
399/// NOTE: This is kept for backward compatibility and special use cases.
400/// For normal text extraction, prefer TextSpan which represents complete
401/// text strings as the PDF provides them.
402///
403/// ## Transformation Properties
404///
405/// TextChar now includes transformation information for precise text positioning:
406/// - `origin_x`, `origin_y`: Baseline position (where the character sits)
407/// - `rotation_degrees`: Text rotation angle
408/// - `advance_width`: Horizontal distance to next character
409/// - `matrix`: Full 6-element transformation matrix for advanced use cases
410///
411/// These properties match industry standards.
412#[derive(Debug, Clone, serde::Serialize)]
413pub struct TextChar {
414 /// The character itself
415 pub char: char,
416 /// Bounding box of the character
417 pub bbox: Rect,
418 /// Font name/family
419 pub font_name: String,
420 /// Font size in points
421 pub font_size: f32,
422 /// Font weight (normal or bold)
423 pub font_weight: FontWeight,
424 /// Font style: italic or normal
425 pub is_italic: bool,
426 /// Whether the font is monospaced (from PDF font descriptor FixedPitch flag)
427 pub is_monospace: bool,
428 /// Text color
429 pub color: Color,
430 /// Marked Content ID (for Tagged PDFs)
431 ///
432 /// This field stores the MCID if this character was extracted within
433 /// a marked content sequence in a Tagged PDF.
434 pub mcid: Option<u32>,
435
436 /// Baseline origin X coordinate.
437 ///
438 /// This is the X position where the character's baseline starts,
439 /// which is the standard reference point for text positioning in PDFs.
440 /// Unlike bbox.x which is the left edge of the glyph, origin_x is
441 /// the typographic origin point.
442 pub origin_x: f32,
443
444 /// Baseline origin Y coordinate.
445 ///
446 /// This is the Y position of the character's baseline. For horizontal
447 /// text, this is where the bottom of letters like 'a', 'x' sit, while
448 /// letters with descenders like 'g', 'y' extend below this line.
449 pub origin_y: f32,
450
451 /// Rotation angle in degrees (0-360, clockwise from horizontal).
452 ///
453 /// Calculated from the text transformation matrix using atan2(b, a).
454 /// - 0° = normal horizontal text (left to right)
455 /// - 90° = vertical text (top to bottom)
456 /// - 180° = upside down text
457 /// - 270° = vertical text (bottom to top)
458 pub rotation_degrees: f32,
459
460 /// Horizontal advance width (distance to next character position).
461 ///
462 /// Glyph advance width from font metrics (device space).
463 ///
464 /// This is the advance for the glyph shape only — it does **not** include
465 /// character spacing (Tc), word spacing (Tw), or TJ array adjustments.
466 /// For word-boundary detection and the full cursor advance including all
467 /// spacing, use [`Self::rendered_advance`].
468 pub advance_width: f32,
469
470 /// Actual rendered advance to the next character's origin (device space).
471 ///
472 /// This is the per-glyph cursor advance including character spacing (Tc)
473 /// and word spacing (Tw for U+0020), per the PDF spec Tx formula:
474 /// `(w0 × Tfs / 1000 + Tc + Tw) × Th` converted to device space.
475 ///
476 /// TJ array adjustments between strings are **not** folded into this
477 /// field. They are emitted as separate synthetic-space [`TextChar`]s
478 /// inserted between the glyphs they affect, so the overall cursor
479 /// displacement is correctly represented by walking the full char list.
480 ///
481 /// Equivalent to Poppler's `dx` argument in `drawChar`.
482 ///
483 /// For the last character on a line this falls back to `advance_width`.
484 /// Use this field (not `advance_width`) to detect word boundaries:
485 /// a gap `next.origin_x − (this.origin_x + this.rendered_advance) > threshold`
486 /// reliably identifies inter-word spacing.
487 pub rendered_advance: f32,
488
489 /// Distance from the baseline to the top of the typographic glyph box (device space).
490 ///
491 /// From the font descriptor `/Ascent`; falls back to Adobe AFM values for the 14
492 /// standard PDF fonts, then to 0.95 × font_size (Poppler's default).
493 ///
494 /// `bbox.height` is the full em square and does not reflect the font's actual cap
495 /// height. Use `origin_y + ascent` for the glyph's true top edge.
496 pub ascent: f32,
497
498 /// Distance from the baseline to the bottom of the typographic glyph box (device space, negative).
499 ///
500 /// From the font descriptor `/Descent`; falls back to Adobe AFM values for the 14
501 /// standard PDF fonts, then to −0.35 × font_size (Poppler's default).
502 ///
503 /// `bbox` does not represent the descender region at all (its origin is the
504 /// baseline). Use `origin_y + descent` for the glyph's true bottom edge.
505 pub descent: f32,
506
507 /// Full transformation matrix [a, b, c, d, e, f].
508 ///
509 /// The composed text matrix (CTM × Tm) that transforms this character
510 /// from text space to device space. Provides complete transformation
511 /// info for advanced use cases like re-rendering or precise positioning.
512 ///
513 /// Matrix layout:
514 /// ```text
515 /// [ a b 0 ]
516 /// [ c d 0 ]
517 /// [ e f 1 ]
518 /// ```
519 /// Where (a,d) = scaling, (b,c) = rotation/skew, (e,f) = translation.
520 pub matrix: Option<[f32; 6]>,
521}
522
523impl Default for TextChar {
524 fn default() -> Self {
525 Self {
526 char: ' ',
527 bbox: Rect::default(),
528 font_name: "Helvetica".to_string(),
529 font_size: 12.0,
530 font_weight: FontWeight::Normal,
531 is_italic: false,
532 is_monospace: false,
533 color: Color::black(),
534 mcid: None,
535 origin_x: 0.0,
536 origin_y: 0.0,
537 rotation_degrees: 0.0,
538 advance_width: 0.0,
539 rendered_advance: 0.0,
540 ascent: 0.95 * 12.0,
541 descent: -0.35 * 12.0,
542 matrix: Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
543 }
544 }
545}
546
547impl TextChar {
548 /// Get the rotation angle in radians.
549 pub fn rotation_radians(&self) -> f32 {
550 self.rotation_degrees.to_radians()
551 }
552
553 /// Check if this character is rotated (non-zero rotation).
554 pub fn is_rotated(&self) -> bool {
555 self.rotation_degrees.abs() > 0.01
556 }
557
558 /// Set the transformation matrix and update derived values.
559 ///
560 /// This method sets the full transformation matrix and automatically
561 /// calculates the rotation angle and origin from the matrix components.
562 ///
563 /// # Arguments
564 ///
565 /// * `matrix` - A 6-element transformation matrix [a, b, c, d, e, f]
566 pub fn with_matrix(mut self, matrix: [f32; 6]) -> Self {
567 self.matrix = Some(matrix);
568 self.origin_x = matrix[4];
569 self.origin_y = matrix[5];
570 self.rotation_degrees = matrix[1].atan2(matrix[0]).to_degrees();
571 self
572 }
573
574 /// Get the transformation matrix, computing from basic values if not stored.
575 ///
576 /// If the matrix was stored during extraction, returns it directly.
577 /// Otherwise, reconstructs a basic matrix from origin and rotation.
578 ///
579 /// # Returns
580 ///
581 /// A 6-element transformation matrix [a, b, c, d, e, f]
582 pub fn get_matrix(&self) -> [f32; 6] {
583 if let Some(m) = self.matrix {
584 m
585 } else {
586 let rad = self.rotation_radians();
587 let cos_r = rad.cos();
588 let sin_r = rad.sin();
589 [cos_r, sin_r, -sin_r, cos_r, self.origin_x, self.origin_y]
590 }
591 }
592
593 /// Create a simple TextChar with default transformation values.
594 ///
595 /// This is a convenience constructor for creating TextChar instances
596 /// when transformation data is not available (e.g., programmatic creation).
597 /// The origin defaults to the bbox position, rotation to 0, and
598 /// advance_width to the bbox width.
599 pub fn simple(char: char, bbox: Rect, font_name: String, font_size: f32) -> Self {
600 Self {
601 char,
602 bbox,
603 font_name,
604 font_size,
605 font_weight: FontWeight::Normal,
606 is_italic: false,
607 is_monospace: false,
608 color: Color::black(),
609 mcid: None,
610 origin_x: bbox.x,
611 origin_y: bbox.y,
612 rotation_degrees: 0.0,
613 advance_width: bbox.width,
614 rendered_advance: bbox.width,
615 ascent: 0.95 * font_size,
616 descent: -0.35 * font_size,
617 matrix: None,
618 }
619 }
620}
621
622/// Font weight classification following PDF spec numeric scale.
623///
624/// PDF Spec: ISO 32000-1:2008, Table 122 - FontDescriptor
625/// Values: 100-900 where 400 = normal, 700 = bold
626#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, Default)]
627#[repr(u16)]
628pub enum FontWeight {
629 /// Thin (100)
630 Thin = 100,
631 /// Extra Light (200)
632 ExtraLight = 200,
633 /// Light (300)
634 Light = 300,
635 /// Normal (400) - default weight
636 #[default]
637 Normal = 400,
638 /// Medium (500)
639 Medium = 500,
640 /// Semi Bold (600)
641 SemiBold = 600,
642 /// Bold (700) - standard bold weight
643 Bold = 700,
644 /// Extra Bold (800)
645 ExtraBold = 800,
646 /// Black (900) - heaviest weight
647 Black = 900,
648}
649
650impl FontWeight {
651 /// Check if this weight is considered bold (>= 600).
652 ///
653 /// Per PDF spec, weights 600+ are semi-bold or bolder.
654 pub fn is_bold(&self) -> bool {
655 *self as u16 >= 600
656 }
657
658 /// Create FontWeight from PDF numeric value.
659 ///
660 /// Rounds to nearest standard weight value.
661 pub fn from_pdf_value(value: i32) -> Self {
662 match value {
663 ..=150 => FontWeight::Thin,
664 151..=250 => FontWeight::ExtraLight,
665 251..=350 => FontWeight::Light,
666 351..=450 => FontWeight::Normal,
667 451..=550 => FontWeight::Medium,
668 551..=650 => FontWeight::SemiBold,
669 651..=750 => FontWeight::Bold,
670 751..=850 => FontWeight::ExtraBold,
671 851.. => FontWeight::Black,
672 }
673 }
674
675 /// Get the numeric PDF value for this weight.
676 pub fn to_pdf_value(&self) -> u16 {
677 *self as u16
678 }
679}
680
681/// RGB color representation.
682#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, Default)]
683pub struct Color {
684 /// Red channel (0.0 - 1.0)
685 pub r: f32,
686 /// Green channel (0.0 - 1.0)
687 pub g: f32,
688 /// Blue channel (0.0 - 1.0)
689 pub b: f32,
690}
691
692impl Color {
693 /// Create a new color.
694 pub fn new(r: f32, g: f32, b: f32) -> Self {
695 Self { r, g, b }
696 }
697
698 /// Create a black color.
699 pub fn black() -> Self {
700 Self::new(0.0, 0.0, 0.0)
701 }
702
703 /// Create a white color.
704 pub fn white() -> Self {
705 Self::new(1.0, 1.0, 1.0)
706 }
707}
708
709/// Complete text extraction result for a single page.
710///
711/// Single-call API that provides spans, per-character data, and page dimensions.
712/// The `chars` field is derived from spans via `TextSpan::to_chars()`, using
713/// font-metric widths when available for accurate per-glyph bounding boxes.
714#[derive(Debug, Clone, serde::Serialize)]
715pub struct PageText {
716 /// Text spans in reading order.
717 pub spans: Vec<TextSpan>,
718 /// Per-character data derived from spans (uses font metric widths when available).
719 pub chars: Vec<TextChar>,
720 /// Page width in PDF points.
721 pub page_width: f32,
722 /// Page height in PDF points.
723 pub page_height: f32,
724}
725
726/// A text block (word, line, or paragraph).
727#[derive(Debug, Clone, serde::Serialize)]
728pub struct TextBlock {
729 /// Characters in this block
730 pub chars: Vec<TextChar>,
731 /// Bounding box of the entire block
732 pub bbox: Rect,
733 /// Text content
734 pub text: String,
735 /// Average font size
736 pub avg_font_size: f32,
737 /// Dominant font name
738 pub dominant_font: String,
739 /// Whether the block contains bold text
740 pub is_bold: bool,
741 /// Whether the block contains italic text
742 pub is_italic: bool,
743 /// Marked Content ID (for Tagged PDFs)
744 pub mcid: Option<u32>,
745 /// Content stream the `mcid` is numbered within (ISO 32000-1 §14.7.4.3).
746 /// An MCID is unique only inside its own stream, so a page and a Form
747 /// XObject drawn on it may both number a sequence 0. Consumers that look
748 /// a sequence up must key by `(mcid_scope, mcid)`, never the number alone.
749 pub mcid_scope: Option<crate::structure::McidScope>,
750 /// Content-stream emission order of the originating span(s) — lets
751 /// callers tell "drawn consecutively in the content stream" apart
752 /// from "merely spatially close" (e.g. table cells vs. overlays).
753 /// `from_chars` has no span to draw this from, so it defaults to 0;
754 /// word-assembly call sites that build a block from a single span
755 /// set it explicitly from that span's `sequence`.
756 pub sequence: usize,
757 /// Rotation of the block's glyph run in degrees, snapped to a quadrant
758 /// (`0` / `90` / `180` / `-90`), from the composed text-rendering
759 /// matrix (ISO 32000-1:2008 §9.4.4). `90` means the text reads
760 /// bottom-to-top on an unrotated page — a landscape table typeset on a
761 /// portrait page. Lets consumers transform coordinates into the
762 /// reading frame instead of guessing from bbox aspect.
763 /// Derived in `from_chars` from the constituent chars.
764 #[serde(skip_serializing_if = "is_zero_f32", default)]
765 pub rotation_degrees: f32,
766}
767
768impl TextBlock {
769 /// Create a text block from a collection of characters.
770 ///
771 /// This computes the bounding box, text content, average font size,
772 /// and dominant font from the character data.
773 ///
774 /// # Panics
775 ///
776 /// Panics if the `chars` vector is empty.
777 pub fn from_chars(chars: Vec<TextChar>) -> Self {
778 assert!(!chars.is_empty(), "Cannot create TextBlock from empty chars");
779
780 let text: String = chars.iter().map(|c| c.char).collect();
781
782 let bbox = chars.iter().map(|c| c.bbox).fold(chars[0].bbox, |acc, r| acc.union(&r));
783
784 let avg_font_size = chars.iter().map(|c| c.font_size).sum::<f32>() / chars.len() as f32;
785
786 let mut font_counts = HashMap::new();
787 for c in &chars {
788 *font_counts.entry(c.font_name.clone()).or_insert(0) += 1;
789 }
790 // Explicit tie-break, not `max_by_key`: it returns the LAST maximal
791 // element, and over a `HashMap` that is the per-process-randomized
792 // iteration order. A block whose two fonts carry the same character
793 // count would otherwise pick a different dominant font per process.
794 // Ties resolve to the lexicographically smaller font name. ~keep
795 let dominant_font = font_counts
796 .iter()
797 .max_by(|(a_font, a_count), (b_font, b_count)| a_count.cmp(b_count).then_with(|| b_font.cmp(a_font)))
798 .map(|(font, _)| font.clone())
799 .unwrap_or_default();
800
801 let is_bold = chars.iter().any(|c| c.font_weight.is_bold());
802 let is_italic = chars.iter().any(|c| c.is_italic);
803
804 let mcid = chars
805 .first()
806 .and_then(|c| c.mcid)
807 .filter(|&first_mcid| chars.iter().all(|c| c.mcid == Some(first_mcid)));
808
809 // A word's glyphs share one text-rendering matrix in practice; take
810 // the rotation all chars agree on, and fall back to upright when a
811 // block mixes rotations (no single frame describes it). ~keep
812 let rotation_degrees = chars
813 .first()
814 .map(|c| c.rotation_degrees)
815 .filter(|&r| chars.iter().all(|c| c.rotation_degrees == r))
816 .unwrap_or(0.0);
817
818 Self {
819 chars,
820 bbox,
821 text,
822 avg_font_size,
823 dominant_font,
824 is_bold,
825 is_italic,
826 mcid,
827 mcid_scope: None,
828 sequence: 0,
829 rotation_degrees,
830 }
831 }
832
833 /// Append `other`'s glyphs to this block, growing the bbox to their union
834 /// and re-deriving the aggregate attributes.
835 ///
836 /// Incremental by design: O(len(other)) per call, so folding a run of `k`
837 /// blocks costs O(total_chars) instead of the O(n²) of rebuilding through
838 /// [`Self::from_chars`] at every step.
839 ///
840 /// Attribute rules, which are NOT the same as rebuilding from the
841 /// concatenated chars: `avg_font_size` becomes the glyph-count-weighted
842 /// mean, `dominant_font` follows the larger side, the style flags OR
843 /// together, and a differing `mcid` collapses to `None` (the union no
844 /// longer belongs to one marked-content sequence). `sequence` and
845 /// `rotation_degrees` are left as this block's — callers fold blocks from
846 /// a single run, where both already agree.
847 pub(crate) fn absorb(&mut self, other: TextBlock) {
848 let self_n = self.chars.len() as f32;
849 let other_n = other.chars.len() as f32;
850 self.bbox = self.bbox.union(&other.bbox);
851 // Both sides can be chars-empty (several call sites build blocks with
852 // no chars); a plain weighted mean would divide 0.0 by 0.0 and poison
853 // the font size with NaN, which then propagates into every downstream
854 // gap threshold. ~keep
855 if self_n + other_n > 0.0 {
856 self.avg_font_size = (self.avg_font_size * self_n + other.avg_font_size * other_n) / (self_n + other_n);
857 }
858 if other_n > self_n {
859 self.dominant_font = other.dominant_font;
860 }
861 self.is_bold |= other.is_bold;
862 self.is_italic |= other.is_italic;
863 if self.mcid != other.mcid {
864 self.mcid = None;
865 }
866 self.text.push_str(&other.text);
867 self.chars.extend(other.chars);
868 }
869
870 /// Get the center point of the text block.
871 pub fn center(&self) -> Point {
872 self.bbox.center()
873 }
874
875 /// Check if this block is horizontally aligned with another block.
876 pub fn is_horizontally_aligned(&self, other: &TextBlock, tolerance: f32) -> bool {
877 (self.bbox.y - other.bbox.y).abs() < tolerance
878 }
879
880 /// Check if this block is vertically aligned with another block.
881 pub fn is_vertically_aligned(&self, other: &TextBlock, tolerance: f32) -> bool {
882 (self.bbox.x - other.bbox.x).abs() < tolerance
883 }
884}
885
886/// A word is a semantic unit of text (alias for TextBlock).
887pub type Word = TextBlock;
888
889/// A line of text containing multiple words.
890#[derive(Debug, Clone, serde::Serialize)]
891pub struct TextLine {
892 /// Words in this line
893 pub words: Vec<Word>,
894 /// Bounding box of the entire line
895 pub bbox: Rect,
896 /// Complete text content of the line (words joined by spaces)
897 pub text: String,
898}
899
900impl TextLine {
901 /// Create a new TextLine from a list of words.
902 ///
903 /// # Panics
904 ///
905 /// Panics if the `words` vector is empty.
906 pub fn new(words: Vec<Word>) -> Self {
907 assert!(!words.is_empty(), "Cannot create TextLine from empty words");
908
909 let bbox = words.iter().map(|w| w.bbox).fold(words[0].bbox, |acc, r| acc.union(&r));
910
911 let text = words.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" ");
912
913 Self { words, bbox, text }
914 }
915}
916
917#[cfg(test)]
918mod tests;