Skip to main content

glyphore_core/
lib.rs

1//! Core MapLibre glyph PBF (SDF) generation for glyphore.
2//!
3//! Pure logic only: no filesystem access, no clocks, no randomness, no
4//! wasm-bindgen. Output follows node-fontnik conventions (24px, 3px buffer,
5//! radius 8, cutoff 0.25).
6//!
7//! Parse font bytes once with [`FontFace::parse`], inspect the sorted range
8//! starts with [`FontFace::covered_ranges`], and pass each start to
9//! [`generate_range`].
10//!
11//! # Example
12//!
13//! ```no_run
14//! use glyphore_core::{FontFace, generate_range};
15//!
16//! fn main() -> Result<(), Box<dyn std::error::Error>> {
17//!     let bytes = std::fs::read("NotoSans-Regular.ttf")?;
18//!     let face = FontFace::parse(&bytes)?;
19//!
20//!     for start in face.covered_ranges() {
21//!         let pbf = generate_range(&face, start)?;
22//!         std::fs::write(format!("{start}-{}.pbf", start + 255), pbf)?;
23//!     }
24//!     Ok(())
25//! }
26//! ```
27
28use std::collections::{BTreeMap, BTreeSet};
29
30use ttf_parser::{GlyphId, name_id};
31
32mod glyph;
33mod pbf;
34
35const FONT_SIZE_PX: u32 = 24;
36
37/// An error produced while parsing a font or generating a glyph range.
38#[derive(Debug, thiserror::Error)]
39pub enum Error {
40	/// The input cannot be parsed as a font.
41	#[error("invalid font: {0}")]
42	InvalidFont(String),
43	/// The input is a valid font, but uses an unsupported feature.
44	#[error("unsupported font: {0}")]
45	UnsupportedFont(String),
46	/// A range must start at a multiple of 256.
47	#[error("range start {0} is not a multiple of 256")]
48	InvalidRangeStart(u32),
49}
50
51/// A parsed font prepared for glyph generation.
52pub struct FontFace {
53	font: fontdue::Font,
54	family_name: String,
55	style_name: String,
56	fontstack_name: String,
57	ascender_round: i32,
58	charmap: BTreeMap<u32, u16>,
59	advances: Vec<u32>,
60}
61
62impl FontFace {
63	/// Parses the first face in a TTF, OTF, or font collection.
64	///
65	/// # Errors
66	///
67	/// Returns [`Error::InvalidFont`] when `bytes` cannot be parsed as a font,
68	/// or [`Error::UnsupportedFont`] when the face uses an unsupported feature
69	/// or lacks a required family or style name.
70	pub fn parse(bytes: &[u8]) -> Result<FontFace, Error> {
71		let face = ttf_parser::Face::parse(bytes, 0)
72			.map_err(|error| Error::InvalidFont(error.to_string()))?;
73
74		if face.tables().colr.is_some() {
75			return Err(Error::UnsupportedFont(
76				"COLR color fonts are not supported".to_owned(),
77			));
78		}
79
80		let family_name = preferred_name(
81			&face,
82			name_id::TYPOGRAPHIC_FAMILY,
83			name_id::FAMILY,
84			"family",
85		)?;
86		let style_name = preferred_name(
87			&face,
88			name_id::TYPOGRAPHIC_SUBFAMILY,
89			name_id::SUBFAMILY,
90			"style",
91		)?;
92		let fontstack_name = format!("{family_name} {style_name}");
93
94		let units_per_em = face.units_per_em();
95		let ascender_round = (f64::from(face.ascender()) * f64::from(FONT_SIZE_PX)
96			/ f64::from(units_per_em))
97		.round() as i32;
98		let charmap = extract_charmap(&face);
99		let advances = (0..face.number_of_glyphs())
100			.map(|glyph_index| {
101				face.glyph_hor_advance(GlyphId(glyph_index))
102					.map(|advance| {
103						glyph::freetype_advance(u32::from(advance), units_per_em, FONT_SIZE_PX)
104					})
105					.unwrap_or(0)
106			})
107			.collect();
108
109		let font = fontdue::Font::from_bytes(
110			bytes,
111			fontdue::FontSettings {
112				scale: FONT_SIZE_PX as f32,
113				collection_index: 0,
114				..fontdue::FontSettings::default()
115			},
116		)
117		.map_err(|message| Error::InvalidFont(message.to_owned()))?;
118
119		Ok(FontFace {
120			font,
121			family_name,
122			style_name,
123			fontstack_name,
124			ascender_round,
125			charmap,
126			advances,
127		})
128	}
129
130	/// Returns name ID 16 (typographic family), falling back to name ID 1.
131	pub fn family_name(&self) -> &str {
132		&self.family_name
133	}
134
135	/// Returns name ID 17 (typographic subfamily), falling back to name ID 2.
136	pub fn style_name(&self) -> &str {
137		&self.style_name
138	}
139
140	/// Returns the MapLibre font stack name in `"{family} {style}"` form.
141	pub fn fontstack_name(&self) -> &str {
142		&self.fontstack_name
143	}
144
145	/// Returns sorted starts of 256-codepoint ranges containing at least one glyph.
146	pub fn covered_ranges(&self) -> Vec<u32> {
147		self.charmap
148			.keys()
149			.map(|codepoint| codepoint & !0xff)
150			.collect::<BTreeSet<_>>()
151			.into_iter()
152			.collect()
153	}
154
155	/// Returns the number of codepoints mapped to nonzero glyphs by the Unicode cmap.
156	pub fn glyph_count(&self) -> usize {
157		self.charmap.len()
158	}
159}
160
161/// Generates one MapLibre glyph PBF for `start..=start + 255`.
162///
163/// A range with no mapped glyphs still produces a valid PBF containing the
164/// font stack name and range string.
165///
166/// # Errors
167///
168/// Returns [`Error::InvalidRangeStart`] when `start` is not a multiple of 256.
169pub fn generate_range(face: &FontFace, start: u32) -> Result<Vec<u8>, Error> {
170	if !start.is_multiple_of(256) {
171		return Err(Error::InvalidRangeStart(start));
172	}
173
174	let end = start + 255;
175	let glyphs = face
176		.charmap
177		.range(start..=end)
178		.map(|(&codepoint, &glyph_index)| glyph::generate(face, codepoint, glyph_index))
179		.collect::<Vec<_>>();
180	let range = format!("{start}-{end}");
181	Ok(pbf::encode_fontstack(
182		face.fontstack_name(),
183		&range,
184		&glyphs,
185	))
186}
187
188fn preferred_name(
189	face: &ttf_parser::Face<'_>,
190	preferred_id: u16,
191	fallback_id: u16,
192	kind: &str,
193) -> Result<String, Error> {
194	for name_id in [preferred_id, fallback_id] {
195		if let Some(value) = face
196			.names()
197			.into_iter()
198			.filter(|name| name.name_id == name_id && name.is_unicode())
199			.filter_map(|name| name.to_string())
200			.find(|value| !value.is_empty())
201		{
202			return Ok(value);
203		}
204	}
205
206	Err(Error::UnsupportedFont(format!(
207		"missing Unicode {kind} name (name IDs {preferred_id} and {fallback_id})"
208	)))
209}
210
211fn extract_charmap(face: &ttf_parser::Face<'_>) -> BTreeMap<u32, u16> {
212	let mut charmap = BTreeMap::new();
213	let Some(cmap) = face.tables().cmap else {
214		return charmap;
215	};
216
217	for subtable in cmap
218		.subtables
219		.into_iter()
220		.filter(|table| table.is_unicode())
221	{
222		subtable.codepoints(|codepoint| {
223			if char::from_u32(codepoint).is_none() {
224				return;
225			}
226			if let Some(glyph_id) = subtable.glyph_index(codepoint)
227				&& glyph_id.0 != 0
228			{
229				charmap.insert(codepoint, glyph_id.0);
230			}
231		});
232	}
233
234	charmap
235}
236
237#[cfg(test)]
238mod tests;