Skip to main content

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}