Skip to main content

pdf_engine/
text_info.rs

1//! Canonical, serializable wire form of an extracted text span.
2//!
3//! [`TextSpanInfo`] is the **single source of truth** for the shape of a
4//! positioned text span as delivered to every SDK binding (Tauri/serde,
5//! Node/napi, WASM). Bindings construct it from a [`TextSpan`] via [`From`]
6//! and then serialize or re-shape it for their target runtime, instead of each
7//! maintaining a separate field list and conversion logic.
8//!
9//! The `serde` derive is feature-gated so the core engine stays
10//! serialization-agnostic; the struct and the `From` conversion are always
11//! available, so non-serde bindings (e.g. napi) consume the same source.
12
13// Copyright (c) 2026 Innovation Trigger B.V.
14//
15// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
16// the PDFluent Commercial Licence. See the LICENSE file in this repository --
17// that file travels with the copy you received, which a URL does not.
18
19use crate::text::{FontMetrics, TextSpan, WidthSource};
20
21/// A positioned text span in PDF user space (origin bottom-left, y up).
22///
23/// JSON key renames mirror the established wire contract consumed by the
24/// editor; all optional metadata is omitted from JSON when absent, so existing
25/// clients are unaffected.
26#[derive(Debug, Clone)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize))]
28pub struct TextSpanInfo {
29    /// The extracted text of the span.
30    pub text: String,
31    /// X position in PDF user space.
32    pub x: f64,
33    /// Y position in PDF user space (baseline origin).
34    pub y: f64,
35    /// Span width in user space. Falls back to an estimate (½ em per character)
36    /// when no measured width is available.
37    pub width: f64,
38    /// Span height in user space.
39    ///
40    /// Mirrors the existing wire contract, which reports the font size (ascent
41    /// extent) here. NOTE: this preserves prior behaviour exactly; richer
42    /// ascent/descent metrics arrive in a later milestone.
43    pub height: f64,
44    /// Font size in points.
45    pub font_size: f64,
46
47    /// PostScript font name with any 6-character subset prefix stripped.
48    /// Omitted from JSON when unknown.
49    #[cfg_attr(
50        feature = "serde",
51        serde(rename = "fontName", skip_serializing_if = "Option::is_none")
52    )]
53    pub font_name: Option<String>,
54    /// Inferred bold style.
55    #[cfg_attr(feature = "serde", serde(rename = "isBold"))]
56    pub is_bold: bool,
57    /// Inferred italic style.
58    #[cfg_attr(feature = "serde", serde(rename = "isItalic"))]
59    pub is_italic: bool,
60    /// Fill colour as sRGB `[r, g, b]` normalised to 0.0–1.0. Omitted from JSON
61    /// for pattern/shading paints.
62    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
63    pub color: Option<[f32; 3]>,
64
65    /// Whether glyph widths came from real font metrics or an estimate.
66    /// Serialised as `"Metric"` / `"Estimate"`.
67    #[cfg_attr(feature = "serde", serde(rename = "widthSource"))]
68    pub width_source: WidthSource,
69    /// Per-glyph bounding boxes `[x0, y0, x1, y1]` (y up). Omitted when empty.
70    #[cfg_attr(
71        feature = "serde",
72        serde(rename = "charBounds", skip_serializing_if = "Vec::is_empty")
73    )]
74    pub char_bounds: Vec<[f64; 4]>,
75
76    // ---- Golf 1 typographic metadata ----
77    /// Full affine transform `[a, b, c, d, e, f]` of the span's first glyph.
78    /// Captures rotation/shear discarded by `(x, y, fontSize)`.
79    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
80    pub transform: Option<[f64; 6]>,
81    /// Numeric font weight (~100–900) from embedded font data, if available.
82    #[cfg_attr(
83        feature = "serde",
84        serde(rename = "fontWeight", skip_serializing_if = "Option::is_none")
85    )]
86    pub font_weight: Option<u16>,
87    /// Serif flag from embedded font data, if available.
88    #[cfg_attr(
89        feature = "serde",
90        serde(rename = "isSerif", skip_serializing_if = "Option::is_none")
91    )]
92    pub is_serif: Option<bool>,
93    /// Monospace flag from embedded font data, if available.
94    #[cfg_attr(
95        feature = "serde",
96        serde(rename = "isMonospace", skip_serializing_if = "Option::is_none")
97    )]
98    pub is_monospace: Option<bool>,
99    /// Coarse PDF text render mode: `0` fill, `1` stroke, `3` invisible.
100    #[cfg_attr(
101        feature = "serde",
102        serde(rename = "renderMode", skip_serializing_if = "Option::is_none")
103    )]
104    pub render_mode: Option<u8>,
105
106    // ---- Golf 2 font metrics ----
107    /// Vertical font metrics (/1000 em) from the embedded font, if available.
108    #[cfg_attr(
109        feature = "serde",
110        serde(rename = "fontMetrics", skip_serializing_if = "Option::is_none")
111    )]
112    pub font_metrics: Option<FontMetrics>,
113}
114
115impl From<TextSpan> for TextSpanInfo {
116    /// Build the canonical wire form from an owned engine [`TextSpan`].
117    ///
118    /// Centralises the conversions that previously lived in each binding: the
119    /// ½-em width fallback, the RGBA-u8 → RGB-f32 colour normalisation, and the
120    /// height-reports-font-size contract.
121    fn from(s: TextSpan) -> Self {
122        let char_count = s.text.chars().count() as f64;
123        let width = if s.width > 0.0 {
124            s.width
125        } else {
126            s.font_size * 0.5 * char_count
127        };
128        let color = s
129            .color
130            .map(|[r, g, b, _a]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]);
131        Self {
132            x: s.x,
133            y: s.y,
134            width,
135            height: s.font_size,
136            font_size: s.font_size,
137            font_name: s.font_name,
138            is_bold: s.is_bold,
139            is_italic: s.is_italic,
140            color,
141            width_source: s.width_source,
142            char_bounds: s.char_bounds,
143            transform: s.transform,
144            font_weight: s.font_weight,
145            is_serif: s.is_serif,
146            is_monospace: s.is_monospace,
147            render_mode: s.render_mode,
148            font_metrics: s.font_metrics,
149            text: s.text,
150        }
151    }
152}
153
154impl From<&TextSpan> for TextSpanInfo {
155    /// Build the canonical wire form from a borrowed [`TextSpan`] (clones).
156    fn from(s: &TextSpan) -> Self {
157        TextSpanInfo::from(s.clone())
158    }
159}