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://mosaiclang.dev/assets/A4.svg",
51    html_favicon_url = "https://mosaiclang.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 CharacterMetric<'_> {
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 KerningPair<'_> {
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 FontMetrics<'_> {
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#[derive(Copy, Clone, Eq, PartialEq, Debug)]
447enum HeaderState {
448    Pending,
449    Seen,
450}
451
452#[derive(Copy, Clone, Eq, PartialEq, Debug)]
453enum DirectionState {
454    Reading,
455    Skipping,
456}
457
458#[derive(Copy, Clone, Eq, PartialEq, Debug)]
459enum FinishState {
460    Reading,
461    Done,
462}
463
464#[derive(Copy, Clone, Eq, PartialEq, Debug)]
465enum Presence {
466    Missing,
467    Present,
468}
469
470struct ParseAccumulator<'a> {
471    header: HeaderState,
472    state: State,
473    composites_depth: u32,
474    direction: DirectionState,
475    finish: FinishState,
476    font_name: Cow<'a, str>,
477    full_name: Cow<'a, str>,
478    family_name: Cow<'a, str>,
479    weight: Cow<'a, str>,
480    encoding_scheme: Cow<'a, str>,
481    italic_angle: f32,
482    is_fixed_pitch: bool,
483    font_bbox: BBox,
484    font_bbox_presence: Presence,
485    underline_position: f32,
486    underline_thickness: f32,
487    cap_height: f32,
488    x_height: f32,
489    ascender: f32,
490    descender: f32,
491    chars: Vec<CharacterMetric<'a>>,
492    kerns: Vec<KerningPair<'a>>,
493}
494
495impl<'a> ParseAccumulator<'a> {
496    fn new() -> Self {
497        Self {
498            header: HeaderState::Pending,
499            state: State::Top,
500            composites_depth: 0,
501            direction: DirectionState::Reading,
502            finish: FinishState::Reading,
503            font_name: Cow::Borrowed(""),
504            full_name: Cow::Borrowed(""),
505            family_name: Cow::Borrowed(""),
506            weight: Cow::Borrowed(""),
507            encoding_scheme: Cow::Borrowed(""),
508            italic_angle: 0.0,
509            is_fixed_pitch: false,
510            font_bbox: BBox::default(),
511            font_bbox_presence: Presence::Missing,
512            underline_position: 0.0,
513            underline_thickness: 0.0,
514            cap_height: 0.0,
515            x_height: 0.0,
516            ascender: 0.0,
517            descender: 0.0,
518            chars: Vec::new(),
519            kerns: Vec::new(),
520        }
521    }
522
523    fn parse_line(&mut self, raw: &'a str, lineno: usize) -> Result<(), ParseError> {
524        let line = raw.trim();
525        if line.is_empty() || self.is_done() {
526            return Ok(());
527        }
528        let (kw, rest) = split_keyword(line);
529
530        if self.skip_block_line(kw) || self.parse_header_line(kw, rest, lineno)? {
531            return Ok(());
532        }
533
534        self.parse_body_line(line, kw, rest, lineno)
535    }
536
537    fn finish(self) -> Result<FontMetrics<'a>, ParseError> {
538        if self.header == HeaderState::Pending {
539            return Err(ParseError::MissingHeader { line: 1 });
540        }
541        if self.font_name.is_empty() {
542            return Err(ParseError::MissingRequiredField { field: "FontName" });
543        }
544        if self.font_bbox_presence == Presence::Missing {
545            return Err(ParseError::MissingRequiredField { field: "FontBBox" });
546        }
547
548        Ok(FontMetrics {
549            font_name: self.font_name,
550            full_name: self.full_name,
551            family_name: self.family_name,
552            weight: self.weight,
553            italic_angle: self.italic_angle,
554            is_fixed_pitch: self.is_fixed_pitch,
555            font_bbox: self.font_bbox,
556            underline_position: self.underline_position,
557            underline_thickness: self.underline_thickness,
558            cap_height: self.cap_height,
559            x_height: self.x_height,
560            ascender: self.ascender,
561            descender: self.descender,
562            encoding_scheme: self.encoding_scheme,
563            character_metrics: Cow::Owned(self.chars),
564            kerning_pairs: Cow::Owned(self.kerns),
565        })
566    }
567
568    fn skip_block_line(&mut self, kw: &str) -> bool {
569        if self.composites_depth > 0 {
570            if kw == "EndComposites" {
571                self.composites_depth -= 1;
572            }
573            return true;
574        }
575        if self.direction == DirectionState::Skipping {
576            if kw == "EndDirection" {
577                self.direction = DirectionState::Reading;
578            }
579            return true;
580        }
581        false
582    }
583
584    fn parse_header_line(
585        &mut self,
586        kw: &str,
587        rest: &str,
588        lineno: usize,
589    ) -> Result<bool, ParseError> {
590        if self.header == HeaderState::Seen {
591            return Ok(false);
592        }
593        if kw == "Comment" {
594            return Ok(true);
595        }
596        if kw != "StartFontMetrics" {
597            return Err(ParseError::MissingHeader { line: lineno });
598        }
599        let version = rest.trim();
600        let is_v4 = version.split_once('.').is_some_and(|(major, minor)| {
601            major == "4" && !minor.is_empty() && minor.bytes().all(|b| b.is_ascii_digit())
602        });
603        if !is_v4 {
604            return Err(ParseError::UnsupportedVersion {
605                line: lineno,
606                version: version.to_owned(),
607            });
608        }
609        self.header = HeaderState::Seen;
610        Ok(true)
611    }
612
613    fn is_done(&self) -> bool {
614        self.finish == FinishState::Done
615    }
616
617    fn parse_body_line(
618        &mut self,
619        line: &'a str,
620        kw: &str,
621        rest: &'a str,
622        lineno: usize,
623    ) -> Result<(), ParseError> {
624        match kw {
625            "EndFontMetrics" => self.finish = FinishState::Done,
626            "StartComposites" => self.composites_depth = 1,
627            "FontName" => self.font_name = Cow::Borrowed(rest.trim()),
628            "FullName" => self.full_name = Cow::Borrowed(rest.trim()),
629            "FamilyName" => self.family_name = Cow::Borrowed(rest.trim()),
630            "Weight" => self.weight = Cow::Borrowed(rest.trim()),
631            "EncodingScheme" => self.encoding_scheme = Cow::Borrowed(rest.trim()),
632            "ItalicAngle" => self.italic_angle = parse_f32(rest, "ItalicAngle", lineno)?,
633            "IsFixedPitch" => self.is_fixed_pitch = parse_bool(rest, lineno)?,
634            "UnderlinePosition" => {
635                self.underline_position = parse_f32(rest, "UnderlinePosition", lineno)?;
636            }
637            "UnderlineThickness" => {
638                self.underline_thickness = parse_f32(rest, "UnderlineThickness", lineno)?;
639            }
640            "CapHeight" => self.cap_height = parse_f32(rest, "CapHeight", lineno)?,
641            "XHeight" => self.x_height = parse_f32(rest, "XHeight", lineno)?,
642            "Ascender" => self.ascender = parse_f32(rest, "Ascender", lineno)?,
643            "Descender" => self.descender = parse_f32(rest, "Descender", lineno)?,
644            "FontBBox" => {
645                self.font_bbox = parse_bbox(rest, "FontBBox", lineno)?;
646                self.font_bbox_presence = Presence::Present;
647            }
648            "StartCharMetrics" => self.start_char_metrics(rest, lineno)?,
649            "EndCharMetrics" | "EndKernPairs" | "EndKernData" => self.state = State::Top,
650            "StartKernData" => self.state = State::KernPairs,
651            "StartKernPairs" | "StartKernPairs0" => self.start_kern_pairs(rest, lineno)?,
652            "StartKernPairs1" => self.state = State::SkipKernPairs,
653            "StartDirection" => self.start_direction(rest, lineno)?,
654            "C" | "CH" if self.state == State::CharMetrics => {
655                self.chars.push(parse_char_metric_line(line, lineno)?);
656            }
657            "KPX" | "KPY" | "KP" if self.state == State::KernPairs => {
658                if let Some(pair) = parse_kern_record(kw, rest, lineno)? {
659                    self.kerns.push(pair);
660                }
661            }
662            "KPH" if self.state == State::KernPairs => {}
663            _ => {}
664        }
665        Ok(())
666    }
667
668    fn start_char_metrics(&mut self, rest: &str, lineno: usize) -> Result<(), ParseError> {
669        let n = parse_declared_count(rest, "StartCharMetrics", lineno)?;
670        self.chars
671            .try_reserve(n)
672            .map_err(|_err| ParseError::MalformedRecord {
673                line: lineno,
674                keyword: "StartCharMetrics",
675                reason: "declared count exceeds allocatable capacity",
676            })?;
677        self.state = State::CharMetrics;
678        Ok(())
679    }
680
681    fn start_kern_pairs(&mut self, rest: &str, lineno: usize) -> Result<(), ParseError> {
682        let n = parse_declared_count(rest, "StartKernPairs", lineno)?;
683        self.kerns
684            .try_reserve(n)
685            .map_err(|_err| ParseError::MalformedRecord {
686                line: lineno,
687                keyword: "StartKernPairs",
688                reason: "declared count exceeds allocatable capacity",
689            })?;
690        self.state = State::KernPairs;
691        Ok(())
692    }
693
694    fn start_direction(&mut self, rest: &str, lineno: usize) -> Result<(), ParseError> {
695        let value = rest.trim();
696        let direction = value
697            .parse::<u8>()
698            .map_err(|_err| ParseError::InvalidNumber {
699                line: lineno,
700                field: "StartDirection",
701                value: value.to_owned(),
702            })?;
703        match direction {
704            0 | 2 => {}
705            1 => self.direction = DirectionState::Skipping,
706            _ => {
707                return Err(ParseError::MalformedRecord {
708                    line: lineno,
709                    keyword: "StartDirection",
710                    reason: "expected direction selector 0, 1, or 2",
711                });
712            }
713        }
714        Ok(())
715    }
716}
717
718fn parse_declared_count(
719    rest: &str,
720    field: &'static str,
721    lineno: usize,
722) -> Result<usize, ParseError> {
723    let value = rest.trim();
724    value
725        .parse::<usize>()
726        .map_err(|_err| ParseError::InvalidNumber {
727            line: lineno,
728            field,
729            value: value.to_owned(),
730        })
731}
732
733/// Parse an AFM file into a borrowed [`FontMetrics`].
734///
735/// The returned struct borrows from `src`. Use
736/// [`FontMetrics::into_owned`] to detach.
737///
738/// # Errors
739///
740/// Returns [`ParseError`] if the header is missing, the version is
741/// outside the 4.x range, a required field never appears, or any
742/// record is structurally malformed.
743///
744/// # Examples
745///
746/// ```
747/// # fn main() -> Result<(), adobe_font_metrics::ParseError> {
748/// use adobe_font_metrics::parse;
749///
750/// let src = "StartFontMetrics 4.1\nFontName Demo\nFontBBox 0 0 1000 1000\nEndFontMetrics\n";
751/// let metrics = parse(src)?;
752///
753/// assert_eq!(metrics.font_name, "Demo");
754/// # Ok(())
755/// # }
756/// ```
757#[must_use = "discarding the parsed FontMetrics also discards any parse error"]
758pub fn parse(src: &str) -> Result<FontMetrics<'_>, ParseError> {
759    let mut accumulator = ParseAccumulator::new();
760
761    for (idx, raw) in src.lines().enumerate() {
762        accumulator.parse_line(raw, idx + 1)?;
763        if accumulator.is_done() {
764            break;
765        }
766    }
767
768    accumulator.finish()
769}
770
771// ---------------------------------------------------------------- helpers
772
773fn split_keyword(line: &str) -> (&str, &str) {
774    line.find(|c: char| c.is_ascii_whitespace())
775        .map_or((line, ""), |i| (&line[..i], &line[i..]))
776}
777
778fn parse_f32(s: &str, field: &'static str, lineno: usize) -> Result<f32, ParseError> {
779    let trimmed = s.trim();
780    trimmed
781        .parse::<f32>()
782        .map_err(|_e| ParseError::InvalidNumber {
783            line: lineno,
784            field,
785            value: trimmed.to_owned(),
786        })
787}
788
789fn parse_i32(s: &str, field: &'static str, lineno: usize) -> Result<i32, ParseError> {
790    let trimmed = s.trim();
791    trimmed
792        .parse::<i32>()
793        .map_err(|_e| ParseError::InvalidNumber {
794            line: lineno,
795            field,
796            value: trimmed.to_owned(),
797        })
798}
799
800fn parse_bool(s: &str, lineno: usize) -> Result<bool, ParseError> {
801    match s.trim() {
802        "true" => Ok(true),
803        "false" => Ok(false),
804        _ => Err(ParseError::MalformedRecord {
805            line: lineno,
806            keyword: "IsFixedPitch",
807            reason: "expected `true` or `false`",
808        }),
809    }
810}
811
812fn parse_bbox(s: &str, field: &'static str, lineno: usize) -> Result<BBox, ParseError> {
813    let mut toks = s.split_ascii_whitespace();
814    let llx = next_f32(&mut toks, field, lineno)?;
815    let lly = next_f32(&mut toks, field, lineno)?;
816    let urx = next_f32(&mut toks, field, lineno)?;
817    let ury = next_f32(&mut toks, field, lineno)?;
818    if toks.next().is_some() {
819        return Err(ParseError::MalformedRecord {
820            line: lineno,
821            keyword: field,
822            reason: "too many numbers",
823        });
824    }
825    Ok(BBox { llx, lly, urx, ury })
826}
827
828fn next_f32(
829    toks: &mut std::str::SplitAsciiWhitespace<'_>,
830    field: &'static str,
831    lineno: usize,
832) -> Result<f32, ParseError> {
833    let t = toks.next().ok_or(ParseError::MalformedRecord {
834        line: lineno,
835        keyword: field,
836        reason: "expected number",
837    })?;
838    parse_f32(t, field, lineno)
839}
840
841fn parse_char_metric_line(line: &str, lineno: usize) -> Result<CharacterMetric<'_>, ParseError> {
842    let mut code: i32 = -1;
843    let mut name: &str = "";
844    let mut width_x: f32 = 0.0;
845    let mut bbox: Option<BBox> = None;
846
847    for seg in line.split(';') {
848        let seg = seg.trim();
849        if seg.is_empty() {
850            continue;
851        }
852        let (tok, rest) = split_keyword(seg);
853        let rest = rest.trim();
854        match tok {
855            "C" => code = parse_i32(rest, "C", lineno)?,
856            "CH" => {
857                let hex = rest.trim_start_matches('<').trim_end_matches('>').trim();
858                code = i32::from_str_radix(hex, 16).map_err(|_e| ParseError::InvalidNumber {
859                    line: lineno,
860                    field: "CH",
861                    value: rest.to_owned(),
862                })?;
863            }
864            "WX" | "W0X" => width_x = parse_f32(rest, "WX", lineno)?,
865            "W" | "W0" => {
866                let x =
867                    rest.split_ascii_whitespace()
868                        .next()
869                        .ok_or(ParseError::MalformedRecord {
870                            line: lineno,
871                            keyword: "W",
872                            reason: "missing x advance",
873                        })?;
874                width_x = parse_f32(x, "W", lineno)?;
875            }
876            "N" => name = rest,
877            "B" => bbox = Some(parse_bbox(rest, "B", lineno)?),
878            _ => {} // WY, L, VV, etc.: silently ignored
879        }
880    }
881
882    Ok(CharacterMetric {
883        code,
884        name: Cow::Borrowed(name),
885        width_x,
886        bbox,
887    })
888}
889
890fn parse_kern_record<'a>(
891    kw: &str,
892    rest: &'a str,
893    lineno: usize,
894) -> Result<Option<KerningPair<'a>>, ParseError> {
895    // Resolve the canonical keyword up front so error messages and the
896    // arity check below carry the actual record name, not `"KP*"`.
897    let keyword = match kw {
898        "KPX" => "KPX",
899        "KPY" => "KPY",
900        "KP" => "KP",
901        _ => return Ok(None),
902    };
903    let mut toks = rest.split_ascii_whitespace();
904    let left = toks.next().ok_or(ParseError::MalformedRecord {
905        line: lineno,
906        keyword,
907        reason: "missing left glyph name",
908    })?;
909    let right = toks.next().ok_or(ParseError::MalformedRecord {
910        line: lineno,
911        keyword,
912        reason: "missing right glyph name",
913    })?;
914    let first_num = toks.next().ok_or(ParseError::MalformedRecord {
915        line: lineno,
916        keyword,
917        reason: "missing kern adjustment",
918    })?;
919    let adjust = match keyword {
920        "KPX" => parse_f32(first_num, "KPX", lineno)?,
921        "KPY" => {
922            // Validate the operand even though we discard it: a y-only
923            // kern still has to be a well-formed number.
924            let _ = parse_f32(first_num, "KPY", lineno)?;
925            0.0
926        }
927        "KP" => {
928            // `KP left right xadj yadj`: both operands required.
929            let x = parse_f32(first_num, "KP", lineno)?;
930            let y = toks.next().ok_or(ParseError::MalformedRecord {
931                line: lineno,
932                keyword,
933                reason: "missing y kern adjustment",
934            })?;
935            let _ = parse_f32(y, "KP", lineno)?;
936            x
937        }
938        // Unreachable: `keyword` was set from the same set of literals.
939        _ => return Ok(None),
940    };
941    if toks.next().is_some() {
942        return Err(ParseError::MalformedRecord {
943            line: lineno,
944            keyword,
945            reason: "too many operands",
946        });
947    }
948    Ok(Some(KerningPair {
949        left: Cow::Borrowed(left),
950        right: Cow::Borrowed(right),
951        adjust,
952    }))
953}