Skip to main content

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;