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}