zpl_forge/engine/font.rs
1use std::collections::HashMap;
2
3use crate::{ZplError, ZplResult};
4use ab_glyph::{Font, FontArc, PxScale, ScaleFont};
5
6/// Default fallback font bytes embedded in the binary.
7/// This guarantees the library runs on any OS/Platform without C dependencies.
8const MONO_FONT_BYTES: &[u8] = include_bytes!("../assets/IosevkaTermSlab-Regular.ttf");
9
10/// High-fidelity default scalable sans font embedded in the binary
11/// (TeX Gyre Heros Condensed, a free Helvetica-metric clone derived from
12/// URW Nimbus Sans). Used for font identifiers '0'-'9' and standard
13/// scalable text fields.
14const SANS_FONT_BYTES: &[u8] = include_bytes!("../assets/TeXGyreHerosCn-Bold.otf");
15
16/// Default OCR-A font bytes embedded in the binary.
17/// Used for font identifier 'H'.
18const OCR_A_FONT_BYTES: &[u8] = include_bytes!("../assets/OCRA.ttf");
19
20/// Default OCR-B font bytes embedded in the binary (Schwarz/Wagner free
21/// digitization, distributed without limitation). Used for font identifier 'E'.
22const OCR_B_FONT_BYTES: &[u8] = include_bytes!("../assets/OCRB.otf");
23
24/// List of valid ZPL font identifiers (A-Z and 0-9).
25const FONT_MAP: &[char] = &[
26 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
27 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
28];
29
30/// Zebra scalable fonts (e.g. `^A0`): capital letters span ~73% of the `^A`
31/// height with the cap top sitting exactly on the field origin.
32///
33/// Re-calibrated against Labelary by measuring rendered advance width for
34/// `^A0` at heights 20/30/50/80/120: the previous 0.75 overshot reference
35/// width by a uniform 2.5% once the `PxScale` normalization below was fixed.
36const SCALABLE_CAP_RATIO: f32 = 0.7315;
37
38/// Default `^A` height in dots when none was specified (ZPL font A default).
39const DEFAULT_FONT_HEIGHT: u32 = 9;
40
41/// Fallback cap height when a font has no 'H' outline, in em units.
42const FALLBACK_CAP_RATIO: f32 = 0.7;
43
44/// Geometry of a Zebra built-in bitmap font cell at 203 dpi, in dots.
45///
46/// `base_h`/`base_w` are the glyph matrix, `cell_w` is the horizontal advance
47/// (matrix width + intercharacter gap) and `baseline` is the distance from the
48/// cell top to the baseline, which equals the capital-letter height.
49/// Values from the ZPL II programming guide font matrices.
50struct BitmapCell {
51 base_h: f32,
52 base_w: f32,
53 cell_w: f32,
54 baseline: f32,
55}
56
57/// Zebra bitmap font matrices for identifiers A-H. Other identifiers are
58/// treated as scalable fonts.
59fn bitmap_cell(font_char: char) -> Option<BitmapCell> {
60 let (base_h, base_w, cell_w, baseline) = match font_char {
61 'A' => (9.0, 5.0, 6.0, 7.0),
62 'B' => (11.0, 7.0, 9.0, 11.0),
63 'C' | 'D' => (18.0, 10.0, 12.0, 14.0),
64 'E' => (28.0, 15.0, 20.0, 23.0),
65 'F' => (26.0, 13.0, 16.0, 21.0),
66 'G' => (60.0, 40.0, 48.0, 48.0),
67 'H' => (21.0, 13.0, 19.0, 21.0),
68 _ => return None,
69 };
70 Some(BitmapCell {
71 base_h,
72 base_w,
73 cell_w,
74 baseline,
75 })
76}
77
78/// Normalization metrics extracted once per registered font, in font units.
79#[derive(Debug, Clone, Copy)]
80struct FontMetrics {
81 /// Units per em.
82 units_per_em: f32,
83 /// `ascent - descent` (what an `ab_glyph::PxScale` maps to its `y` value).
84 height_unscaled: f32,
85 /// Capital-letter height, measured from the 'H' outline.
86 cap_height: f32,
87 /// Representative advance width (digit '0'), used to fit monospace cells.
88 advance: f32,
89}
90
91impl FontMetrics {
92 fn from_font(font: &FontArc) -> Self {
93 let units_per_em = font.units_per_em().unwrap_or(1000.0);
94 // Outline bounds come in screen-style order (min.y holds the glyph
95 // top in font units), so take the larger of the two y values.
96 let cap_height = font
97 .outline(font.glyph_id('H'))
98 .map(|o| o.bounds.min.y.max(o.bounds.max.y))
99 .filter(|&v| v > 0.0)
100 .unwrap_or(units_per_em * FALLBACK_CAP_RATIO);
101 let advance = font.h_advance_unscaled(font.glyph_id('0'));
102 let advance = if advance > 0.0 {
103 advance
104 } else {
105 units_per_em * 0.5
106 };
107 Self {
108 units_per_em,
109 height_unscaled: font.height_unscaled(),
110 cap_height,
111 advance,
112 }
113 }
114}
115
116/// Resolved scaling for one ZPL text field, in dots.
117///
118/// Both backends must consume this instead of building an `ab_glyph::PxScale`
119/// from the raw `^A` parameters: `PxScale` maps `ascent - descent` (not the
120/// em) to its `y` value, which shrinks glyphs and misplaces the baseline.
121#[derive(Debug, Clone, Copy)]
122pub(crate) struct TextLayout {
123 /// Rasterization scale for `ab_glyph`/`imageproc`.
124 pub px: PxScale,
125 /// Distance from the top of the ZPL character cell to the baseline.
126 /// Capital letters render from the cell top down to this line.
127 pub baseline: f32,
128 /// Em size in dots, horizontal (vector backends: PDF text matrix).
129 pub em_x: f32,
130 /// Em size in dots, vertical.
131 pub em_y: f32,
132 /// Total character-cell height in dots (rotation anchors, reverse boxes).
133 pub cell_h: f32,
134}
135
136/// Interpretation-line geometry for 1-D barcodes, as
137/// `(font, height, width, gap)` in dots.
138///
139/// Zebra draws the interpretation line in built-in bitmap font A magnified by
140/// the module width, not in the scalable font: at `^BY2` the digits measure 14
141/// dots of cap height (7 x 2) on a 12-dot advance (6 x 2), and at `^BY5` 35-36
142/// dots tall, both matching Labelary. The baseline sits a fixed 6 dots below
143/// the bars regardless of module width.
144///
145/// Returning the font A cell dimensions lets [`FontManager::text_layout`]
146/// recover a magnification of exactly `module_width`.
147pub(crate) fn interpretation_metrics(module_width: u32) -> (char, u32, u32, u32) {
148 let mag = module_width.max(1);
149 let cell = bitmap_cell('A').expect("font A has a bitmap cell");
150 (
151 'A',
152 cell.base_h as u32 * mag,
153 cell.base_w as u32 * mag,
154 INTERPRETATION_GAP,
155 )
156}
157
158/// Vertical gap in dots between the bottom of the bars and the top of the
159/// interpretation line. Measured constant across `^BY` module widths.
160const INTERPRETATION_GAP: u32 = 6;
161
162/// Manages fonts and their mapping to ZPL font identifiers.
163///
164/// This structure tracks registered fonts and maps them to the single-character
165/// identifiers used in ZPL commands (e.g., '^A0', '^AA').
166#[derive(Debug, Clone)]
167pub struct FontManager {
168 /// Maps ZPL font identifiers (as Strings) to internal font names.
169 font_map: HashMap<String, String>,
170 /// Stores the actual font data indexed by internal font names.
171 font_index: HashMap<String, FontArc>,
172 /// Stores the raw TTF/OTF bytes indexed by internal font names.
173 font_bytes: HashMap<String, Vec<u8>>,
174 /// Normalization metrics per internal font name, computed at registration.
175 font_metrics: HashMap<String, FontMetrics>,
176}
177
178impl Default for FontManager {
179 /// Creates a `FontManager` with high-fidelity, lightweight open-source fonts
180 /// registered for their respective identifiers.
181 ///
182 /// - TeX Gyre Heros Cn (GUST Font License) is registered for scalable/sans identifiers ('0' to '9').
183 /// - Iosevka Term Slab (SIL Open Font License) is registered for monospace/slab identifiers ('A' to 'Z').
184 /// - OCR-A is registered for OCR-A identifier ('H').
185 /// - OCR-B is registered for OCR-B identifier ('E').
186 fn default() -> Self {
187 let mut current = Self {
188 font_map: HashMap::new(),
189 font_index: HashMap::new(),
190 font_bytes: HashMap::new(),
191 font_metrics: HashMap::new(),
192 };
193
194 // Register default fonts for their respective alphanumeric ZPL identifiers
195 let _ = current.register_font("Iosevka Term Slab", MONO_FONT_BYTES, 'A', 'Z');
196 let _ = current.register_font("TeX Gyre Heros Cn", SANS_FONT_BYTES, '0', '9');
197 let _ = current.register_font("OCR-A", OCR_A_FONT_BYTES, 'H', 'H');
198 let _ = current.register_font("OCR-B", OCR_B_FONT_BYTES, 'E', 'E');
199
200 current
201 }
202}
203
204impl FontManager {
205 /// Retrieves the raw TTF/OTF bytes for a font by its ZPL identifier.
206 ///
207 /// This is used by backends that need the raw font data (e.g., PDF embedding).
208 pub fn get_font_bytes(&self, name: &str) -> Option<&[u8]> {
209 let font_name = self.font_map.get(name)?;
210 self.font_bytes.get(font_name).map(|v| v.as_slice())
211 }
212
213 /// Returns the internal font name mapped to a ZPL identifier.
214 pub fn get_font_name(&self, name: &str) -> Option<&str> {
215 self.font_map.get(name).map(|s| s.as_str())
216 }
217
218 /// Retrieves a font by its ZPL identifier.
219 ///
220 /// # Arguments
221 /// * `name` - The ZPL font identifier (e.g., "0", "A").
222 pub fn get_font(&self, name: &str) -> Option<&FontArc> {
223 let font_name = self.font_map.get(name);
224 if let Some(font_name) = font_name {
225 self.font_index.get(font_name)
226 } else {
227 None
228 }
229 }
230
231 /// Resolves a ZPL font identifier (falling back to font '0') and computes
232 /// the Zebra-calibrated [`TextLayout`] for the given `^A` height/width.
233 ///
234 /// Bitmap identifiers (A-H) use integer cell magnification like real
235 /// printers; every other identifier uses the scalable-font model.
236 pub(crate) fn text_layout(
237 &self,
238 font_char: char,
239 height: Option<u32>,
240 width: Option<u32>,
241 ) -> Option<(&FontArc, TextLayout)> {
242 let mut buf = [0; 4];
243 let key = font_char.encode_utf8(&mut buf);
244 let name = self.font_map.get(key).or_else(|| self.font_map.get("0"))?;
245 let font = self.font_index.get(name)?;
246 let metrics = self
247 .font_metrics
248 .get(name)
249 .copied()
250 .unwrap_or_else(|| FontMetrics::from_font(font));
251
252 let h = height.unwrap_or(DEFAULT_FONT_HEIGHT).max(1) as f32;
253
254 let (em_x, em_y, baseline, cell_h) = if let Some(cell) = bitmap_cell(font_char) {
255 // Bitmap fonts magnify a fixed dot matrix by integer factors.
256 let mag_h = (h / cell.base_h).round().max(1.0);
257 let mag_w = match width {
258 Some(w) if w > 0 => (w as f32 / cell.base_w).round().max(1.0),
259 _ => mag_h,
260 };
261 let cap_px = cell.baseline * mag_h;
262 let advance_px = cell.cell_w * mag_w;
263 let em_y = cap_px * metrics.units_per_em / metrics.cap_height;
264 let em_x = advance_px * metrics.units_per_em / metrics.advance;
265 (em_x, em_y, cap_px, cell.base_h * mag_h)
266 } else {
267 // Scalable fonts: caps span SCALABLE_CAP_RATIO of the ^A height.
268 let cap_px = SCALABLE_CAP_RATIO * h;
269 let em_y = cap_px * metrics.units_per_em / metrics.cap_height;
270 let em_x = match width {
271 Some(w) if w > 0 => em_y * w as f32 / h,
272 _ => em_y,
273 };
274 (em_x, em_y, cap_px, h)
275 };
276
277 // `ab_glyph` resolves a `PxScale` against `height_unscaled`
278 // (`ascent - descent`), not against the em square: its scale factor is
279 // `scale.y / height_unscaled`. Assigning an em size straight to
280 // `PxScale` therefore renders it `units_per_em / height_unscaled` times
281 // too small - a 1.41x shortfall for TeX Gyre Heros Cn and 1.25x for
282 // Iosevka, which is exactly the text undersizing measured against
283 // Labelary. Pre-multiply by that ratio so `em_x`/`em_y` land as true
284 // em sizes in dots.
285 let px_norm = metrics.height_unscaled / metrics.units_per_em;
286 let px = PxScale {
287 x: em_x * px_norm,
288 y: em_y * px_norm,
289 };
290
291 Some((
292 font,
293 TextLayout {
294 px,
295 baseline,
296 em_x,
297 em_y,
298 cell_h,
299 },
300 ))
301 }
302
303 /// Measures the advance width of `text` in dots for the given `^A` spec.
304 /// Single source of truth for every backend and for `^FB` wrapping.
305 pub(crate) fn measure_text(
306 &self,
307 font_char: char,
308 height: Option<u32>,
309 width: Option<u32>,
310 text: &str,
311 ) -> u32 {
312 let Some((font, layout)) = self.text_layout(font_char, height, width) else {
313 return 0;
314 };
315 let scaled = font.as_scaled(layout.px);
316 let mut w = 0.0_f32;
317 let mut last = None;
318 for c in text.chars() {
319 let gid = font.glyph_id(c);
320 if let Some(prev) = last {
321 w += scaled.kern(prev, gid);
322 }
323 w += scaled.h_advance(gid);
324 last = Some(gid);
325 }
326 w.ceil() as u32
327 }
328
329 /// Registers a new font and maps it to a range of ZPL identifiers.
330 ///
331 /// Custom fonts must be in TrueType (`.ttf`) or OpenType (`.otf`) format.
332 /// Once registered, the font can be used in ZPL commands like `^A` or `^CF`
333 /// by referencing the assigned identifiers.
334 ///
335 /// # Arguments
336 /// * `name` - An internal name for the font.
337 /// * `bytes` - The raw TrueType/OpenType font data.
338 /// * `from` - The starting ZPL identifier in the range (A-Z, 0-9).
339 /// * `to` - The ending ZPL identifier in the range (A-Z, 0-9).
340 ///
341 /// # Errors
342 /// Returns an error if the font data is invalid.
343 ///
344 /// # Example
345 ///
346 /// ```rust
347 /// use zpl_forge::FontManager;
348 ///
349 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
350 /// let mut font_manager = FontManager::default();
351 ///
352 /// // Load your font file bytes
353 /// // let font_bytes = std::fs::read("fonts/IosevkaTermSlab-Regular.ttf")?;
354 ///
355 /// // Register it for a range of ZPL identifiers (e.g., from 'A' to 'Z')
356 /// // font_manager.register_font("Iosevka Term Slab", &font_bytes, 'A', 'Z')?;
357 /// # Ok(())
358 /// # }
359 /// ```
360 pub fn register_font(
361 &mut self,
362 name: &str,
363 bytes: &[u8],
364 from: char,
365 to: char,
366 ) -> ZplResult<()> {
367 let font = FontArc::try_from_vec(bytes.to_vec())
368 .map_err(|_| ZplError::FontError("Invalid font data".into()))?;
369 self.font_metrics
370 .insert(name.to_string(), FontMetrics::from_font(&font));
371 self.font_index.insert(name.to_string(), font);
372 self.font_bytes.insert(name.to_string(), bytes.to_vec());
373 self.assign_font(name, from, to);
374 Ok(())
375 }
376
377 /// Internal helper to assign a registered font to a range of ZPL identifiers.
378 fn assign_font(&mut self, name: &str, from: char, to: char) {
379 let from_idx = FONT_MAP.iter().position(|&x| x == from);
380 let to_idx = FONT_MAP.iter().position(|&x| x == to);
381
382 if from_idx.is_none() || to_idx.is_none() {
383 return;
384 }
385
386 if let (Some(start), Some(end)) = (from_idx, to_idx)
387 && start <= end
388 {
389 for key in &FONT_MAP[start..=end] {
390 self.font_map.insert(key.to_string(), name.to_string());
391 }
392 }
393 }
394}