Skip to main content

adobe_font_metrics/
lib.rs

1//! Pure-Rust, zero-dependency parser for Adobe Font Metrics (AFM) files,
2//! per Adobe Tech Note 5004 ([`5004.AFM_Spec`]).
3//!
4//! [`5004.AFM_Spec`]: https://adobe-type-tools.github.io/font-tech-notes/pdfs/5004.AFM_Spec.pdf
5//!
6//! # Scope
7//!
8//! Supports AFM **v4.x** (the format Adobe shipped with the Core 14
9//! PostScript fonts). The single entry point is [`parse`], which
10//! consumes a `&str` and returns a borrowed [`FontMetrics`] whose
11//! `Cow<'_, str>` fields point into the source slice: zero allocations
12//! for glyph names and kerning operands. Call [`FontMetrics::into_owned`]
13//! to obtain an [`OwnedFontMetrics`] (`FontMetrics<'static>`) suitable
14//! for caching, baking into static tables, or sending across threads.
15//!
16//! AFM v3.x files (e.g. older Adobe samples) are deliberately rejected
17//! with [`ParseError::UnsupportedVersion`]. The reader subset here
18//! would handle most v3 files, but the v4-only scope claim is honest;
19//! relax it once a real v3 fixture is on hand to validate against.
20//!
21//! # Coverage
22//!
23//! - Header: `StartFontMetrics` (rejects non-4.x versions).
24//! - Global keys: `FontName`, `FullName`, `FamilyName`, `Weight`,
25//!   `ItalicAngle`, `IsFixedPitch`, `FontBBox`, `UnderlinePosition`,
26//!   `UnderlineThickness`, `CapHeight`, `XHeight`, `Ascender`,
27//!   `Descender`, `EncodingScheme`.
28//! - Per-character records: `C`, `CH`, `WX`, `W0X`, `W`/`W0` (X taken),
29//!   `N`, `B`. `WY`, `L`, and other tokens are ignored within a record.
30//! - Kerning: `KPX`, `KPY`, `KP` (KPY rows store `adjust = 0.0`; only
31//!   the X axis is exposed in the public type today). `StartKernPairs1`
32//!   blocks (direction-1 kerning) are accepted and dropped.
33//! - `StartComposites`/`CC` blocks are accepted and discarded per the
34//!   user-facing scope of the v0.1 surface.
35//! - `StartTrackKern`/`TrackKern`/`EndTrackKern` (track kerning) are
36//!   not modelled and pass through silently.
37//! - `StartDirection 1` blocks are skipped; direction-0 and
38//!   direction-2 blocks are accepted (their inner keys read as if at
39//!   the top level, matching the layout of real Core 14 AFMs).
40//! - Unknown keywords at the top level are silently ignored.
41//!
42//! # Errors
43//!
44//! [`ParseError`] carries a 1-based `line` number on every variant
45//! that originates inside the source. The parser never panics on
46//! ill-formed input; every malformed record is converted into a
47//! [`ParseError::InvalidNumber`] or [`ParseError::MalformedRecord`].
48
49#![doc(
50    html_logo_url = "https://mosaic.kjanat.dev/assets/A4.svg",
51    html_favicon_url = "https://mosaic.kjanat.dev/assets/A4.svg"
52)]
53#![deny(missing_docs)]
54
55use std::borrow::Cow;
56use std::error::Error;
57use std::fmt;
58
59/// A glyph or font bounding box, in 1/1000 em.
60///
61/// `f32` rather than `i16` so AFMs that emit fractional values for
62/// `FontBBox` or character `B` records (rare but legal) round-trip
63/// without precision loss.
64///
65/// # Examples
66///
67/// ```
68/// use adobe_font_metrics::BBox;
69///
70/// let bbox = BBox {
71///     llx: -20.0,
72///     lly: -200.0,
73///     urx: 1000.0,
74///     ury: 900.0,
75/// };
76///
77/// assert_eq!(bbox.urx, 1000.0);
78/// ```
79#[derive(Debug, Clone, Copy, Default, PartialEq)]
80pub struct BBox {
81    /// Lower-left x coordinate.
82    pub llx: f32,
83    /// Lower-left y coordinate.
84    pub lly: f32,
85    /// Upper-right x coordinate.
86    pub urx: f32,
87    /// Upper-right y coordinate.
88    pub ury: f32,
89}
90
91/// One entry from a `StartCharMetrics` block.
92///
93/// # Examples
94///
95/// ```
96/// use std::borrow::Cow;
97///
98/// use adobe_font_metrics::{BBox, CharacterMetric};
99///
100/// let metric = CharacterMetric {
101///     code: 65,
102///     name: Cow::Borrowed("A"),
103///     width_x: 667.0,
104///     bbox: Some(BBox {
105///         llx: 8.0,
106///         lly: 0.0,
107///         urx: 660.0,
108///         ury: 718.0,
109///     }),
110/// };
111///
112/// assert_eq!(metric.name, "A");
113/// ```
114#[derive(Debug, Clone, PartialEq)]
115pub struct CharacterMetric<'a> {
116    /// Encoding-table code, or `-1` if the glyph is unencoded.
117    /// `i32` to accommodate multi-byte `CH <hex>` codes (e.g. CJK
118    /// fonts where values exceed `i16::MAX`).
119    pub code: i32,
120    /// PostScript glyph name (e.g. `"A"`, `"section"`).
121    pub name: Cow<'a, str>,
122    /// Horizontal advance in 1/1000 em.
123    pub width_x: f32,
124    /// Glyph bounding box if the AFM provided one.
125    pub bbox: Option<BBox>,
126}
127
128/// One entry from a `StartKernPairs` block.
129///
130/// # Examples
131///
132/// ```
133/// use std::borrow::Cow;
134///
135/// use adobe_font_metrics::KerningPair;
136///
137/// let pair = KerningPair {
138///     left: Cow::Borrowed("A"),
139///     right: Cow::Borrowed("V"),
140///     adjust: -80.0,
141/// };
142///
143/// assert_eq!(pair.adjust, -80.0);
144/// ```
145#[derive(Debug, Clone, PartialEq)]
146pub struct KerningPair<'a> {
147    /// PostScript name of the left-hand glyph.
148    pub left: Cow<'a, str>,
149    /// PostScript name of the right-hand glyph.
150    pub right: Cow<'a, str>,
151    /// Horizontal kerning adjustment in 1/1000 em. `KPY` records
152    /// always store `0.0` here at v0.1; the public type does not
153    /// expose vertical kerning yet.
154    pub adjust: f32,
155}
156
157/// All public AFM data extracted from a single `.adobe-font-metrics` file.
158///
159/// `Cow` everywhere so a single type serves both runtime parsing
160/// (`Cow::Borrowed` slices of the source) and compile-time baked
161/// statics (`Cow::Borrowed` of `&'static`).
162///
163/// # Examples
164///
165/// ```
166/// # fn main() -> Result<(), adobe_font_metrics::ParseError> {
167/// use adobe_font_metrics::{FontMetrics, parse};
168///
169/// let src = "StartFontMetrics 4.1\nFontName Demo\nFontBBox 0 0 1000 1000\nEndFontMetrics\n";
170/// let metrics: FontMetrics<'_> = parse(src)?;
171///
172/// assert_eq!(metrics.font_name, "Demo");
173/// # Ok(())
174/// # }
175/// ```
176#[derive(Debug, Clone, PartialEq)]
177pub struct FontMetrics<'a> {
178    /// PostScript `FontName` (e.g. `"Helvetica"`).
179    pub font_name: Cow<'a, str>,
180    /// Human-readable `FullName` (e.g. `"Helvetica Bold Oblique"`).
181    pub full_name: Cow<'a, str>,
182    /// PostScript `FamilyName` (e.g. `"Helvetica"`).
183    pub family_name: Cow<'a, str>,
184    /// `Weight` token, free-form per the spec (`"Medium"`, `"Bold"`, etc.).
185    pub weight: Cow<'a, str>,
186    /// Italic angle in degrees, counter-clockwise from vertical.
187    pub italic_angle: f32,
188    /// `true` if every glyph has the same advance width.
189    pub is_fixed_pitch: bool,
190    /// Bounding box that contains every glyph in the font.
191    pub font_bbox: BBox,
192    /// Recommended y position of the underline, in 1/1000 em.
193    pub underline_position: f32,
194    /// Recommended thickness of the underline, in 1/1000 em.
195    pub underline_thickness: f32,
196    /// Height of an unaccented capital, in 1/1000 em.
197    pub cap_height: f32,
198    /// Height of a lowercase `x`, in 1/1000 em.
199    pub x_height: f32,
200    /// Ascender height, in 1/1000 em.
201    pub ascender: f32,
202    /// Descender depth (negative for descents below the baseline).
203    pub descender: f32,
204    /// Encoding scheme name (e.g. `"AdobeStandardEncoding"`).
205    pub encoding_scheme: Cow<'a, str>,
206    /// Per-glyph metrics. [`parse`] always returns this as
207    /// `Cow::Owned`; `Cow::Borrowed(&'static [...])` is reserved for
208    /// compile-time-baked statics in downstream crates (e.g.
209    /// `pdf-base14-metrics`).
210    pub character_metrics: Cow<'a, [CharacterMetric<'a>]>,
211    /// Kerning pairs. [`parse`] always returns this as `Cow::Owned`;
212    /// `Cow::Borrowed(&'static [...])` is reserved for
213    /// compile-time-baked statics in downstream crates (e.g.
214    /// `pdf-base14-metrics`).
215    pub kerning_pairs: Cow<'a, [KerningPair<'a>]>,
216}
217
218/// Convenience alias for fully-owned metrics (`'static`).
219///
220/// # Examples
221///
222/// ```
223/// # fn main() -> Result<(), adobe_font_metrics::ParseError> {
224/// use adobe_font_metrics::{OwnedFontMetrics, parse};
225///
226/// let src = "StartFontMetrics 4.1\nFontName Demo\nFontBBox 0 0 1000 1000\nEndFontMetrics\n";
227/// let metrics: OwnedFontMetrics = parse(src)?.into_owned();
228///
229/// assert_eq!(metrics.font_name, "Demo");
230/// # Ok(())
231/// # }
232/// ```
233pub type OwnedFontMetrics = FontMetrics<'static>;
234
235/// Errors returned by [`parse`]. Line numbers are 1-based.
236///
237/// # Examples
238///
239/// ```
240/// use adobe_font_metrics::{ParseError, parse};
241///
242/// let err = parse("").err();
243///
244/// assert!(matches!(err, Some(ParseError::MissingHeader { line: 1 })));
245/// ```
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum ParseError {
248    /// First non-blank, non-comment line was not `StartFontMetrics`.
249    MissingHeader {
250        /// 1-based source line number where the missing header was expected.
251        line: usize,
252    },
253    /// `StartFontMetrics` declared a version outside the 4.x family.
254    UnsupportedVersion {
255        /// 1-based source line number of the offending `StartFontMetrics`.
256        line: usize,
257        /// Version literal that was rejected (e.g. `"5.0"`).
258        version: String,
259    },
260    /// A field that the parser requires (currently `FontName` and
261    /// `FontBBox`) never appeared.
262    MissingRequiredField {
263        /// Name of the missing field.
264        field: &'static str,
265    },
266    /// A token that should have parsed as a number didn't.
267    InvalidNumber {
268        /// 1-based source line number where parsing failed.
269        line: usize,
270        /// Logical field whose value couldn't be parsed (e.g. `"FontBBox"`).
271        field: &'static str,
272        /// The raw token that failed to parse.
273        value: String,
274    },
275    /// A record was structurally malformed (wrong arity, unrecognised
276    /// boolean, etc.).
277    MalformedRecord {
278        /// 1-based source line number where the record appeared.
279        line: usize,
280        /// AFM keyword that introduced the record.
281        keyword: &'static str,
282        /// Human-readable description of how the record was malformed.
283        reason: &'static str,
284    },
285}
286
287impl fmt::Display for ParseError {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        match self {
290            Self::MissingHeader { line } => {
291                write!(f, "line {line}: expected StartFontMetrics header")
292            }
293            Self::UnsupportedVersion { line, version } => {
294                write!(
295                    f,
296                    "line {line}: unsupported AFM version {version:?} (need 4.x)"
297                )
298            }
299            Self::MissingRequiredField { field } => {
300                write!(f, "missing required field {field}")
301            }
302            Self::InvalidNumber { line, field, value } => {
303                write!(f, "line {line}: invalid number {value:?} for {field}")
304            }
305            Self::MalformedRecord {
306                line,
307                keyword,
308                reason,
309            } => {
310                write!(f, "line {line}: malformed {keyword} record: {reason}")
311            }
312        }
313    }
314}
315
316impl Error for ParseError {}
317
318// ---------------------------------------------------------------- impls
319
320impl<'a> CharacterMetric<'a> {
321    /// Lift to `'static` by cloning any borrowed strings.
322    ///
323    /// # Examples
324    ///
325    /// ```
326    /// use std::borrow::Cow;
327    ///
328    /// use adobe_font_metrics::CharacterMetric;
329    ///
330    /// let metric = CharacterMetric {
331    ///     code: 65,
332    ///     name: Cow::Borrowed("A"),
333    ///     width_x: 667.0,
334    ///     bbox: None,
335    /// };
336    /// let owned = metric.into_owned();
337    ///
338    /// assert_eq!(owned.name, "A");
339    /// ```
340    #[must_use]
341    pub fn into_owned(self) -> CharacterMetric<'static> {
342        CharacterMetric {
343            code: self.code,
344            name: Cow::Owned(self.name.into_owned()),
345            width_x: self.width_x,
346            bbox: self.bbox,
347        }
348    }
349}
350
351impl<'a> KerningPair<'a> {
352    /// Lift to `'static` by cloning any borrowed strings.
353    ///
354    /// # Examples
355    ///
356    /// ```
357    /// use std::borrow::Cow;
358    ///
359    /// use adobe_font_metrics::KerningPair;
360    ///
361    /// let pair = KerningPair {
362    ///     left: Cow::Borrowed("A"),
363    ///     right: Cow::Borrowed("V"),
364    ///     adjust: -80.0,
365    /// };
366    /// let owned = pair.into_owned();
367    ///
368    /// assert_eq!(owned.right, "V");
369    /// ```
370    #[must_use]
371    pub fn into_owned(self) -> KerningPair<'static> {
372        KerningPair {
373            left: Cow::Owned(self.left.into_owned()),
374            right: Cow::Owned(self.right.into_owned()),
375            adjust: self.adjust,
376        }
377    }
378}
379
380impl<'a> FontMetrics<'a> {
381    /// Lift to `'static`, cloning every borrowed slice. Intended for
382    /// callers who need to outlive the source `&str` (caches, baked
383    /// statics, cross-thread sends).
384    ///
385    /// # Examples
386    ///
387    /// ```
388    /// # fn main() -> Result<(), adobe_font_metrics::ParseError> {
389    /// use adobe_font_metrics::{OwnedFontMetrics, parse};
390    ///
391    /// let src = "StartFontMetrics 4.1\nFontName Demo\nFontBBox 0 0 1000 1000\nEndFontMetrics\n";
392    /// let owned: OwnedFontMetrics = parse(src)?.into_owned();
393    ///
394    /// assert_eq!(owned.font_bbox.urx, 1000.0);
395    /// # Ok(())
396    /// # }
397    /// ```
398    #[must_use]
399    pub fn into_owned(self) -> OwnedFontMetrics {
400        let chars: Vec<CharacterMetric<'static>> = self
401            .character_metrics
402            .into_owned()
403            .into_iter()
404            .map(CharacterMetric::into_owned)
405            .collect();
406        let kerns: Vec<KerningPair<'static>> = self
407            .kerning_pairs
408            .into_owned()
409            .into_iter()
410            .map(KerningPair::into_owned)
411            .collect();
412        FontMetrics {
413            font_name: Cow::Owned(self.font_name.into_owned()),
414            full_name: Cow::Owned(self.full_name.into_owned()),
415            family_name: Cow::Owned(self.family_name.into_owned()),
416            weight: Cow::Owned(self.weight.into_owned()),
417            italic_angle: self.italic_angle,
418            is_fixed_pitch: self.is_fixed_pitch,
419            font_bbox: self.font_bbox,
420            underline_position: self.underline_position,
421            underline_thickness: self.underline_thickness,
422            cap_height: self.cap_height,
423            x_height: self.x_height,
424            ascender: self.ascender,
425            descender: self.descender,
426            encoding_scheme: Cow::Owned(self.encoding_scheme.into_owned()),
427            character_metrics: Cow::Owned(chars),
428            kerning_pairs: Cow::Owned(kerns),
429        }
430    }
431}
432
433// ---------------------------------------------------------------- parser
434
435#[derive(Copy, Clone, Eq, PartialEq, Debug)]
436enum State {
437    Top,
438    CharMetrics,
439    KernPairs,
440    /// Inside a `StartKernPairs1` block: direction-1 kerning is not
441    /// modelled by the public type, so records are dropped instead
442    /// of being conflated into the direction-0 vector.
443    SkipKernPairs,
444}
445
446/// Parse an AFM file into a borrowed [`FontMetrics`].
447///
448/// The returned struct borrows from `src`. Use
449/// [`FontMetrics::into_owned`] to detach.
450///
451/// # Errors
452///
453/// Returns [`ParseError`] if the header is missing, the version is
454/// outside the 4.x range, a required field never appears, or any
455/// record is structurally malformed.
456///
457/// # Examples
458///
459/// ```
460/// # fn main() -> Result<(), adobe_font_metrics::ParseError> {
461/// use adobe_font_metrics::parse;
462///
463/// let src = "StartFontMetrics 4.1\nFontName Demo\nFontBBox 0 0 1000 1000\nEndFontMetrics\n";
464/// let metrics = parse(src)?;
465///
466/// assert_eq!(metrics.font_name, "Demo");
467/// # Ok(())
468/// # }
469/// ```
470#[must_use = "discarding the parsed FontMetrics also discards any parse error"]
471pub fn parse(src: &str) -> Result<FontMetrics<'_>, ParseError> {
472    let mut header_seen = false;
473    let mut state = State::Top;
474    let mut composites_depth: u32 = 0;
475    // Set inside `StartDirection 1` blocks (direction-1 metrics);
476    // cleared on `EndDirection`. While set, every key in the body
477    // is silently dropped so direction-1 values don't clobber the
478    // direction-0 globals we already read at the top level.
479    let mut skip_direction = false;
480
481    let mut font_name: Cow<'_, str> = Cow::Borrowed("");
482    let mut full_name: Cow<'_, str> = Cow::Borrowed("");
483    let mut family_name: Cow<'_, str> = Cow::Borrowed("");
484    let mut weight: Cow<'_, str> = Cow::Borrowed("");
485    let mut encoding_scheme: Cow<'_, str> = Cow::Borrowed("");
486    let mut italic_angle: f32 = 0.0;
487    let mut is_fixed_pitch = false;
488    let mut font_bbox = BBox::default();
489    let mut font_bbox_seen = false;
490    let mut underline_position: f32 = 0.0;
491    let mut underline_thickness: f32 = 0.0;
492    let mut cap_height: f32 = 0.0;
493    let mut x_height: f32 = 0.0;
494    let mut ascender: f32 = 0.0;
495    let mut descender: f32 = 0.0;
496    let mut chars: Vec<CharacterMetric<'_>> = Vec::new();
497    let mut kerns: Vec<KerningPair<'_>> = Vec::new();
498
499    for (idx, raw) in src.lines().enumerate() {
500        let lineno = idx + 1;
501        let line = raw.trim();
502        if line.is_empty() {
503            continue;
504        }
505        let (kw, rest) = split_keyword(line);
506
507        // Skip composite blocks wholesale.
508        if composites_depth > 0 {
509            if kw == "EndComposites" {
510                composites_depth -= 1;
511            }
512            continue;
513        }
514
515        // Skip direction-1 blocks wholesale (direction-0 / direction-2
516        // are accepted at top level; see the StartDirection arm below).
517        if skip_direction {
518            if kw == "EndDirection" {
519                skip_direction = false;
520            }
521            continue;
522        }
523
524        if !header_seen {
525            if kw == "Comment" {
526                continue;
527            }
528            if kw != "StartFontMetrics" {
529                return Err(ParseError::MissingHeader { line: lineno });
530            }
531            let version = rest.trim();
532            // Strict `4.<digits>`: reject `4.`, `4.x`, `4.bad`, etc.
533            let is_v4 = version.split_once('.').is_some_and(|(major, minor)| {
534                major == "4" && !minor.is_empty() && minor.bytes().all(|b| b.is_ascii_digit())
535            });
536            if !is_v4 {
537                return Err(ParseError::UnsupportedVersion {
538                    line: lineno,
539                    version: version.to_owned(),
540                });
541            }
542            header_seen = true;
543            continue;
544        }
545
546        match kw {
547            "EndFontMetrics" => break,
548            "StartComposites" => composites_depth = 1,
549
550            "FontName" => font_name = Cow::Borrowed(rest.trim()),
551            "FullName" => full_name = Cow::Borrowed(rest.trim()),
552            "FamilyName" => family_name = Cow::Borrowed(rest.trim()),
553            "Weight" => weight = Cow::Borrowed(rest.trim()),
554            "EncodingScheme" => encoding_scheme = Cow::Borrowed(rest.trim()),
555
556            "ItalicAngle" => italic_angle = parse_f32(rest, "ItalicAngle", lineno)?,
557            "IsFixedPitch" => is_fixed_pitch = parse_bool(rest, lineno)?,
558            "UnderlinePosition" => {
559                underline_position = parse_f32(rest, "UnderlinePosition", lineno)?;
560            }
561            "UnderlineThickness" => {
562                underline_thickness = parse_f32(rest, "UnderlineThickness", lineno)?;
563            }
564            "CapHeight" => cap_height = parse_f32(rest, "CapHeight", lineno)?,
565            "XHeight" => x_height = parse_f32(rest, "XHeight", lineno)?,
566            "Ascender" => ascender = parse_f32(rest, "Ascender", lineno)?,
567            "Descender" => descender = parse_f32(rest, "Descender", lineno)?,
568            "FontBBox" => {
569                font_bbox = parse_bbox(rest, "FontBBox", lineno)?;
570                font_bbox_seen = true;
571            }
572
573            "StartCharMetrics" => {
574                if let Ok(n) = rest.trim().parse::<usize>() {
575                    chars.reserve(n);
576                }
577                state = State::CharMetrics;
578            }
579            "EndCharMetrics" | "EndKernPairs" | "EndKernData" => state = State::Top,
580
581            "StartKernData" => state = State::KernPairs,
582            "StartKernPairs" | "StartKernPairs0" => {
583                if let Ok(n) = rest.trim().parse::<usize>() {
584                    kerns.reserve(n);
585                }
586                state = State::KernPairs;
587            }
588            "StartKernPairs1" => {
589                // Direction-1 kerning is not exposed in v0.1; route the
590                // block to `SkipKernPairs` so its KP* records are dropped
591                // rather than silently appended to the direction-0 vector.
592                state = State::SkipKernPairs;
593            }
594
595            // Per spec ยง7.2: `StartDirection 0` โ†’ direction-0 metrics
596            // (apply); `StartDirection 1` โ†’ direction-1 (skip);
597            // `StartDirection 2` โ†’ metrics for both (apply). Treat a
598            // missing/unparseable N as 0.
599            "StartDirection" => {
600                let n = rest.trim().parse::<u8>().unwrap_or(0);
601                if n == 1 {
602                    skip_direction = true;
603                }
604            }
605            // `EndDirection` for accepted directions is a no-op: falls
606            // through the wildcard. The skip-direction guard above
607            // handles `EndDirection` for the dropped direction-1 case.
608            "C" | "CH" if state == State::CharMetrics => {
609                chars.push(parse_char_metric_line(line, lineno)?);
610            }
611
612            "KPX" | "KPY" | "KP" if state == State::KernPairs => {
613                if let Some(pair) = parse_kern_record(kw, rest, lineno)? {
614                    kerns.push(pair);
615                }
616            }
617            "KPH" if state == State::KernPairs => {
618                // Hex-encoded kern pairs: accepted and discarded; the
619                // public type doesn't model decoded byte-coded names.
620            }
621
622            _ => {} // unknown / out-of-context: silently skip
623        }
624    }
625
626    if !header_seen {
627        return Err(ParseError::MissingHeader { line: 1 });
628    }
629    if font_name.is_empty() {
630        return Err(ParseError::MissingRequiredField { field: "FontName" });
631    }
632    if !font_bbox_seen {
633        return Err(ParseError::MissingRequiredField { field: "FontBBox" });
634    }
635
636    Ok(FontMetrics {
637        font_name,
638        full_name,
639        family_name,
640        weight,
641        italic_angle,
642        is_fixed_pitch,
643        font_bbox,
644        underline_position,
645        underline_thickness,
646        cap_height,
647        x_height,
648        ascender,
649        descender,
650        encoding_scheme,
651        character_metrics: Cow::Owned(chars),
652        kerning_pairs: Cow::Owned(kerns),
653    })
654}
655
656// ---------------------------------------------------------------- helpers
657
658fn split_keyword(line: &str) -> (&str, &str) {
659    match line.find(|c: char| c.is_ascii_whitespace()) {
660        Some(i) => (&line[..i], &line[i..]),
661        None => (line, ""),
662    }
663}
664
665fn parse_f32(s: &str, field: &'static str, lineno: usize) -> Result<f32, ParseError> {
666    let trimmed = s.trim();
667    trimmed
668        .parse::<f32>()
669        .map_err(|_e| ParseError::InvalidNumber {
670            line: lineno,
671            field,
672            value: trimmed.to_owned(),
673        })
674}
675
676fn parse_i32(s: &str, field: &'static str, lineno: usize) -> Result<i32, ParseError> {
677    let trimmed = s.trim();
678    trimmed
679        .parse::<i32>()
680        .map_err(|_e| ParseError::InvalidNumber {
681            line: lineno,
682            field,
683            value: trimmed.to_owned(),
684        })
685}
686
687fn parse_bool(s: &str, lineno: usize) -> Result<bool, ParseError> {
688    match s.trim() {
689        "true" => Ok(true),
690        "false" => Ok(false),
691        _ => Err(ParseError::MalformedRecord {
692            line: lineno,
693            keyword: "IsFixedPitch",
694            reason: "expected `true` or `false`",
695        }),
696    }
697}
698
699fn parse_bbox(s: &str, field: &'static str, lineno: usize) -> Result<BBox, ParseError> {
700    let mut toks = s.split_ascii_whitespace();
701    let llx = next_f32(&mut toks, field, lineno)?;
702    let lly = next_f32(&mut toks, field, lineno)?;
703    let urx = next_f32(&mut toks, field, lineno)?;
704    let ury = next_f32(&mut toks, field, lineno)?;
705    if toks.next().is_some() {
706        return Err(ParseError::MalformedRecord {
707            line: lineno,
708            keyword: field,
709            reason: "too many numbers",
710        });
711    }
712    Ok(BBox { llx, lly, urx, ury })
713}
714
715fn next_f32(
716    toks: &mut std::str::SplitAsciiWhitespace<'_>,
717    field: &'static str,
718    lineno: usize,
719) -> Result<f32, ParseError> {
720    let t = toks.next().ok_or(ParseError::MalformedRecord {
721        line: lineno,
722        keyword: field,
723        reason: "expected number",
724    })?;
725    parse_f32(t, field, lineno)
726}
727
728fn parse_char_metric_line(line: &str, lineno: usize) -> Result<CharacterMetric<'_>, ParseError> {
729    let mut code: i32 = -1;
730    let mut name: &str = "";
731    let mut width_x: f32 = 0.0;
732    let mut bbox: Option<BBox> = None;
733
734    for seg in line.split(';') {
735        let seg = seg.trim();
736        if seg.is_empty() {
737            continue;
738        }
739        let (tok, rest) = split_keyword(seg);
740        let rest = rest.trim();
741        match tok {
742            "C" => code = parse_i32(rest, "C", lineno)?,
743            "CH" => {
744                let hex = rest.trim_start_matches('<').trim_end_matches('>').trim();
745                code = i32::from_str_radix(hex, 16).map_err(|_e| ParseError::InvalidNumber {
746                    line: lineno,
747                    field: "CH",
748                    value: rest.to_owned(),
749                })?;
750            }
751            "WX" | "W0X" => width_x = parse_f32(rest, "WX", lineno)?,
752            "W" | "W0" => {
753                let x =
754                    rest.split_ascii_whitespace()
755                        .next()
756                        .ok_or(ParseError::MalformedRecord {
757                            line: lineno,
758                            keyword: "W",
759                            reason: "missing x advance",
760                        })?;
761                width_x = parse_f32(x, "W", lineno)?;
762            }
763            "N" => name = rest,
764            "B" => bbox = Some(parse_bbox(rest, "B", lineno)?),
765            _ => {} // WY, L, VV, etc.: silently ignored
766        }
767    }
768
769    Ok(CharacterMetric {
770        code,
771        name: Cow::Borrowed(name),
772        width_x,
773        bbox,
774    })
775}
776
777fn parse_kern_record<'a>(
778    kw: &str,
779    rest: &'a str,
780    lineno: usize,
781) -> Result<Option<KerningPair<'a>>, ParseError> {
782    // Resolve the canonical keyword up front so error messages and the
783    // arity check below carry the actual record name, not `"KP*"`.
784    let keyword = match kw {
785        "KPX" => "KPX",
786        "KPY" => "KPY",
787        "KP" => "KP",
788        _ => return Ok(None),
789    };
790    let mut toks = rest.split_ascii_whitespace();
791    let left = toks.next().ok_or(ParseError::MalformedRecord {
792        line: lineno,
793        keyword,
794        reason: "missing left glyph name",
795    })?;
796    let right = toks.next().ok_or(ParseError::MalformedRecord {
797        line: lineno,
798        keyword,
799        reason: "missing right glyph name",
800    })?;
801    let first_num = toks.next().ok_or(ParseError::MalformedRecord {
802        line: lineno,
803        keyword,
804        reason: "missing kern adjustment",
805    })?;
806    let adjust = match keyword {
807        "KPX" => parse_f32(first_num, "KPX", lineno)?,
808        "KPY" => {
809            // Validate the operand even though we discard it: a y-only
810            // kern still has to be a well-formed number.
811            let _ = parse_f32(first_num, "KPY", lineno)?;
812            0.0
813        }
814        "KP" => {
815            // `KP left right xadj yadj`: both operands required.
816            let x = parse_f32(first_num, "KP", lineno)?;
817            let y = toks.next().ok_or(ParseError::MalformedRecord {
818                line: lineno,
819                keyword,
820                reason: "missing y kern adjustment",
821            })?;
822            let _ = parse_f32(y, "KP", lineno)?;
823            x
824        }
825        // Unreachable: `keyword` was set from the same set of literals.
826        _ => return Ok(None),
827    };
828    if toks.next().is_some() {
829        return Err(ParseError::MalformedRecord {
830            line: lineno,
831            keyword,
832            reason: "too many operands",
833        });
834    }
835    Ok(Some(KerningPair {
836        left: Cow::Borrowed(left),
837        right: Cow::Borrowed(right),
838        adjust,
839    }))
840}