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}