Skip to main content

renamite_text/
lib.rs

1//! Text shaping for renamite: string -> `kurbo::BezPath` outlines.
2//!
3//! Deliberately simple for v1: left-to-right horizontal layout, per-glyph
4//! advances, `\n` line breaks. No bidi, no complex-script shaping, no
5//! ligatures - documented limits, not silent wrongness. Deterministic: the
6//! same input always yields the same path, so goldens and CLI renders match
7//! the editor exactly.
8//!
9//! A process-wide family-name registry (like repose's `repose_text`) maps
10//! logical family names to raw font bytes: [`register_font_data`] stores a
11//! font keyed by the name its own name table reports, [`font_family_name`]
12//! extracts that name, and [`FontRef::for_family`] resolves a
13//! `TextNode.font` value to a face, falling back to the bundled default.
14
15use std::collections::HashMap;
16use std::sync::{Arc, OnceLock};
17use web_workers::sync::Mutex;
18
19use kurbo::BezPath;
20use ttf_parser::name::name_id;
21use ttf_parser::{Face, GlyphId, OutlineBuilder};
22
23/// Bundled fallback face (OFL-licensed; see `assets/OFL.txt`).
24static DEFAULT_FONT: &[u8] = include_bytes!("../assets/default.ttf");
25
26#[derive(Debug, thiserror::Error)]
27pub enum TextError {
28    #[error("font failed to parse")]
29    BadFont,
30}
31
32/// Horizontal alignment of each line within the text block.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
34pub enum TextAlign {
35    #[default]
36    Left,
37    Center,
38    Right,
39}
40
41/// A parsed font face, owning its bytes. Cheap to clone (an `Arc` bump); the
42/// face table scan happens once per `face()` call.
43#[derive(Clone)]
44pub struct FontRef {
45    data: Arc<[u8]>,
46}
47
48impl FontRef {
49    /// Parse and own a copy of `data`. Validates the face up front so
50    /// [`FontRef::face`] can be infallible afterward.
51    pub fn parse(data: &[u8]) -> Result<Self, TextError> {
52        Face::parse(data, 0).map_err(|_| TextError::BadFont)?;
53        Ok(Self {
54            data: Arc::from(data),
55        })
56    }
57
58    /// The bundled default face (registered under its family name too, so
59    /// `for_family(Some("Noto Sans"))` and `for_family(None)` agree). Shares
60    /// the default's single allocated copy of the bytes.
61    pub fn default_font() -> Self {
62        Self {
63            data: default_font_data().clone(),
64        }
65    }
66
67    /// Resolve the font for a logical family name (`TextNode.font`), falling
68    /// back to the bundled default when the name is absent or unknown.
69    pub fn for_family(name: Option<&str>) -> Self {
70        match name.and_then(|n| registry().lock_sync().fonts.get(n).cloned()) {
71            Some(data) => Self { data },
72            None => Self::default_font(),
73        }
74    }
75
76    /// The parsed face, borrowing this instance. Never fails: every
77    /// constructor validates the data.
78    pub fn face(&self) -> Face<'_> {
79        Face::parse(&self.data, 0).expect("font data validated at construction")
80    }
81}
82
83fn default_font_data() -> &'static Arc<[u8]> {
84    static DEFAULT: OnceLock<Arc<[u8]>> = OnceLock::new();
85    DEFAULT.get_or_init(|| Arc::from(DEFAULT_FONT))
86}
87
88/// Raw bytes of the bundled default face, for callers that need to register
89/// the font with their own machinery (e.g. `usvg::Options::fontdb_mut`).
90pub fn default_font_bytes() -> &'static [u8] {
91    DEFAULT_FONT
92}
93
94struct Registry {
95    fonts: HashMap<String, Arc<[u8]>>,
96}
97
98fn registry() -> &'static Mutex<Registry> {
99    static REGISTRY: OnceLock<Mutex<Registry>> = OnceLock::new();
100    REGISTRY.get_or_init(|| {
101        let mut fonts = HashMap::new();
102        let default = default_font_data().clone();
103        if let Some(name) = font_family_name(&default) {
104            fonts.insert(name, default);
105        }
106        Mutex::new(Registry { fonts })
107    })
108}
109
110/// Extract the family name from raw font bytes: typographic family
111/// (`name id 16`) first, standard family (`name id 1`) as fallback. Mirrors
112/// `repose_text::font_family_name`.
113pub fn font_family_name(bytes: &[u8]) -> Option<String> {
114    let face = Face::parse(bytes, 0).ok()?;
115    let mut fallback = None;
116    for name in face.names() {
117        match name.name_id {
118            name_id::TYPOGRAPHIC_FAMILY => {
119                if let Some(s) = name.to_string() {
120                    return Some(s);
121                }
122            }
123            name_id::FAMILY if fallback.is_none() => {
124                fallback = name.to_string();
125            }
126            _ => {}
127        }
128    }
129    fallback
130}
131
132/// Register raw font bytes (`ttf`/`otf`) into the process-wide registry,
133/// keyed by the family name the font reports. Returns that name, or `None`
134/// if the bytes are not a parseable font.
135pub fn register_font_data(bytes: Vec<u8>) -> Option<String> {
136    let family = font_family_name(&bytes)?;
137    registry()
138        .lock_sync()
139        .fonts
140        .insert(family.clone(), Arc::from(bytes));
141    Some(family)
142}
143
144/// All registered family names (including the bundled default), sorted.
145pub fn registered_families() -> Vec<String> {
146    let mut names: Vec<String> = registry().lock_sync().fonts.keys().cloned().collect();
147    names.sort();
148    names
149}
150
151/// Collects glyph outline segments into a `BezPath`. Font coordinates are
152/// y-up with the baseline at y = 0; the canvas is y-down, so `dy` holds the
153/// baseline and the y axis is flipped.
154struct PathSink {
155    path: BezPath,
156    scale: f64,
157    dx: f64,
158    dy: f64,
159}
160
161impl OutlineBuilder for PathSink {
162    fn move_to(&mut self, x: f32, y: f32) {
163        self.path.move_to((
164            self.dx + x as f64 * self.scale,
165            self.dy - y as f64 * self.scale,
166        ));
167    }
168    fn line_to(&mut self, x: f32, y: f32) {
169        self.path.line_to((
170            self.dx + x as f64 * self.scale,
171            self.dy - y as f64 * self.scale,
172        ));
173    }
174    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
175        self.path.quad_to(
176            (
177                self.dx + x1 as f64 * self.scale,
178                self.dy - y1 as f64 * self.scale,
179            ),
180            (
181                self.dx + x as f64 * self.scale,
182                self.dy - y as f64 * self.scale,
183            ),
184        );
185    }
186    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
187        self.path.curve_to(
188            (
189                self.dx + x1 as f64 * self.scale,
190                self.dy - y1 as f64 * self.scale,
191            ),
192            (
193                self.dx + x2 as f64 * self.scale,
194                self.dy - y2 as f64 * self.scale,
195            ),
196            (
197                self.dx + x as f64 * self.scale,
198                self.dy - y as f64 * self.scale,
199            ),
200        );
201    }
202    fn close(&mut self) {
203        self.path.close_path();
204    }
205}
206
207/// Shape `text` from raw font bytes (TTF/OTF). Errors if the bytes are not a
208/// parseable font; callers should fall back to the bundled default.
209pub fn shape_text_from_bytes(
210    bytes: &[u8],
211    text: &str,
212    size: f64,
213    align: TextAlign,
214    tracking: f64,
215    leading: f64,
216) -> Result<BezPath, TextError> {
217    let font = FontRef::parse(bytes)?;
218    Ok(shape_text(&font, text, size, align, tracking, leading))
219}
220
221/// Shape `text` with the bundled default face.
222pub fn shape_text_default(
223    text: &str,
224    size: f64,
225    align: TextAlign,
226    tracking: f64,
227    leading: f64,
228) -> BezPath {
229    shape_text(
230        &FontRef::default_font(),
231        text,
232        size,
233        align,
234        tracking,
235        leading,
236    )
237}
238
239/// Shape `text` at `size` (px per em) into one combined outline path.
240///
241/// Origin: (0, 0) is the first line's baseline start; lines advance downward.
242/// `tracking` is extra advance per glyph in px. `leading` is extra line spacing in px added to the face's default line height.
243pub fn shape_text(
244    font: &FontRef,
245    text: &str,
246    size: f64,
247    align: TextAlign,
248    tracking: f64,
249    leading: f64,
250) -> BezPath {
251    let face = font.face();
252    let upem = face.units_per_em() as f64;
253    let size = if size.is_finite() { size.max(0.0) } else { 0.0 };
254    if size <= 1e-9 {
255        return BezPath::new();
256    }
257    let tracking = if tracking.is_finite() { tracking } else { 0.0 };
258    let leading = if leading.is_finite() { leading } else { 0.0 };
259    let scale = size / upem.max(1.0);
260    let line_height = ((face.ascender() as f64 - face.descender() as f64 + face.line_gap() as f64)
261        * scale
262        + leading)
263        .max(size * 0.2);
264    let mut out = BezPath::new();
265    for (line_idx, line) in text
266        .split('\n')
267        .map(|l| l.strip_suffix('\r').unwrap_or(l))
268        .enumerate()
269    {
270        let baseline = line_idx as f64 * line_height;
271        let width = line_advance(&face, line) * scale
272            + tracking * line.chars().count().saturating_sub(1) as f64;
273        let start_x = match align {
274            TextAlign::Left => 0.0,
275            TextAlign::Center => -width / 2.0,
276            TextAlign::Right => -width,
277        };
278        let mut pen = start_x;
279        for ch in line.chars() {
280            let gid = face.glyph_index(ch).unwrap_or(GlyphId(0));
281            let mut sink = PathSink {
282                path: BezPath::new(),
283                scale,
284                dx: pen,
285                dy: baseline,
286            };
287            let _ = face.outline_glyph(gid, &mut sink);
288            out.extend(sink.path);
289            pen += face.glyph_hor_advance(gid).unwrap_or(0) as f64 * scale + tracking;
290        }
291    }
292    out
293}
294
295/// Advance width of one line in font units.
296fn line_advance(face: &Face, line: &str) -> f64 {
297    line.chars()
298        .map(|ch| {
299            let gid = face.glyph_index(ch).unwrap_or(GlyphId(0));
300            face.glyph_hor_advance(gid).unwrap_or(0) as f64
301        })
302        .sum()
303}