Skip to main content

oxideav_ttf/tables/
fvar.rs

1//! `fvar` — Font Variations Header.
2//!
3//! Spec: Microsoft OpenType §"fvar — Font Variations Table" / OpenType
4//! 1.9. Apple TrueType Reference §"fvar".
5//!
6//! The table publishes the font's *design space*: a list of variation
7//! axes (e.g. `wght`, `wdth`, `slnt`, `opsz`, plus any custom 4-byte
8//! tag) each carrying a `(min, default, max)` triple in user-space
9//! units (Fixed 16.16). It also publishes a list of **named instances**
10//! (e.g. "Light", "Regular", "Bold") that pin the design space to a
11//! single coordinate vector and reference a `name` table id for the
12//! human-readable label.
13//!
14//! Header layout (all fields big-endian):
15//!
16//! ```text
17//!   0  / 2  / majorVersion             (1)
18//!   2  / 2  / minorVersion             (0)
19//!   4  / 2  / axesArrayOffset          (relative to fvar start)
20//!   6  / 2  / (reserved, must be 2)
21//!   8  / 2  / axisCount
22//!  10  / 2  / axisSize                 (== 20 for v1.0)
23//!  12  / 2  / instanceCount
24//!  14  / 2  / instanceSize             (== 4 + 4*axisCount, optionally
25//!                                       + 2 for postScriptNameID)
26//! ```
27//!
28//! Each axis record (`axisSize` bytes):
29//!
30//! ```text
31//!   0 / 4 / axisTag                   (4 ASCII bytes)
32//!   4 / 4 / minValue                  (Fixed 16.16)
33//!   8 / 4 / defaultValue              (Fixed 16.16)
34//!  12 / 4 / maxValue                  (Fixed 16.16)
35//!  16 / 2 / flags                     (bit 0 = HIDDEN_AXIS)
36//!  18 / 2 / axisNameID                (`name` table id)
37//! ```
38//!
39//! Each instance record (`instanceSize` bytes):
40//!
41//! ```text
42//!   0 / 2 / subfamilyNameID           (`name` table id)
43//!   2 / 2 / flags
44//!   4 / 4*axisCount / coordinates      (Fixed 16.16 each)
45//!   ? / 2 / postScriptNameID          (optional — only when
46//!                                      instanceSize == 6 + 4*axisCount)
47//! ```
48
49use crate::parser::{read_i32, read_u16};
50use crate::Error;
51
52/// Minimum legal `axisSize` per the spec (one axis record).
53const MIN_AXIS_SIZE: u16 = 20;
54/// Sanity cap. Real fonts publish at most a handful of axes; the cap
55/// keeps a malformed header from making us allocate wildly.
56const MAX_AXES: u16 = 64;
57/// Sanity cap on named-instance count.
58const MAX_INSTANCES: u16 = 4096;
59/// "HIDDEN_AXIS" bit on `axis.flags` — the axis exists in the design
60/// space but UI pickers should not surface it. We keep parsing it
61/// (callers may need it for shaping) but expose the bit so consumers
62/// can filter.
63pub const AXIS_FLAG_HIDDEN: u16 = 0x0001;
64
65/// One variation axis as published in the font's `fvar` table. All
66/// values are in user-space units (Fixed 16.16 scaled to f32 here).
67#[derive(Debug, Clone, PartialEq)]
68pub struct VariationAxis {
69    pub tag: [u8; 4],
70    pub min: f32,
71    pub default: f32,
72    pub max: f32,
73    pub flags: u16,
74    /// `name` table id for the human-readable axis label.
75    pub name_id: u16,
76}
77
78impl VariationAxis {
79    /// `true` if the axis carries the `HIDDEN_AXIS` flag — UI pickers
80    /// should skip it but shapers should still honour any coordinate
81    /// pinned by the caller.
82    pub fn is_hidden(&self) -> bool {
83        self.flags & AXIS_FLAG_HIDDEN != 0
84    }
85}
86
87/// One named instance (a pre-defined coordinate vector).
88#[derive(Debug, Clone, PartialEq)]
89pub struct NamedInstance {
90    /// `name` table id for the subfamily label ("Light", "Bold" …).
91    pub subfamily_name_id: u16,
92    pub flags: u16,
93    /// One coordinate per axis, in axis-declaration order.
94    pub coords: Vec<f32>,
95    /// Optional `name` table id for the PostScript name; `None` when
96    /// the instance record is the short variant (no trailing
97    /// `postScriptNameID`).
98    pub post_script_name_id: Option<u16>,
99}
100
101#[derive(Debug, Clone)]
102// internal — exposed for tests/fuzz; not part of the stable API
103#[doc(hidden)]
104pub struct FvarTable {
105    axes: Vec<VariationAxis>,
106    instances: Vec<NamedInstance>,
107}
108
109impl FvarTable {
110    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
111        if bytes.len() < 16 {
112            return Err(Error::UnexpectedEof);
113        }
114        let major = read_u16(bytes, 0)?;
115        let minor = read_u16(bytes, 2)?;
116        if major != 1 || minor != 0 {
117            return Err(Error::BadStructure("fvar version not 1.0"));
118        }
119        let axes_array_offset = read_u16(bytes, 4)? as usize;
120        // bytes[6..8] is reserved (== 2 in valid fonts) — we don't
121        // enforce so we accept the rare font that emits 0 here.
122        let axis_count = read_u16(bytes, 8)?;
123        let axis_size = read_u16(bytes, 10)?;
124        let instance_count = read_u16(bytes, 12)?;
125        let instance_size = read_u16(bytes, 14)?;
126
127        if axis_count > MAX_AXES {
128            return Err(Error::BadStructure("fvar axisCount exceeds sanity cap"));
129        }
130        if instance_count > MAX_INSTANCES {
131            return Err(Error::BadStructure("fvar instanceCount exceeds sanity cap"));
132        }
133        if axis_size < MIN_AXIS_SIZE {
134            return Err(Error::BadStructure("fvar axisSize < 20"));
135        }
136        // Per spec the *minimum* instance record size is
137        // `4 + 4 * axisCount`; the optional `postScriptNameID` adds 2.
138        let min_instance_size = 4u16
139            .checked_add(
140                axis_count
141                    .checked_mul(4)
142                    .ok_or(Error::BadStructure("fvar axisCount * 4 overflow"))?,
143            )
144            .ok_or(Error::BadStructure("fvar instanceSize overflow"))?;
145        if instance_size != min_instance_size && instance_size != min_instance_size + 2 {
146            return Err(Error::BadStructure("fvar instanceSize unexpected"));
147        }
148        let has_psname = instance_size == min_instance_size + 2;
149
150        // Parse axes.
151        let mut axes = Vec::with_capacity(axis_count as usize);
152        for i in 0..axis_count as usize {
153            let off = axes_array_offset
154                .checked_add(i.checked_mul(axis_size as usize).ok_or(Error::BadOffset)?)
155                .ok_or(Error::BadOffset)?;
156            if off + axis_size as usize > bytes.len() {
157                return Err(Error::UnexpectedEof);
158            }
159            let rec = &bytes[off..off + axis_size as usize];
160            let mut tag = [0u8; 4];
161            tag.copy_from_slice(&rec[0..4]);
162            let min = fixed_to_f32(read_i32(rec, 4)?);
163            let default = fixed_to_f32(read_i32(rec, 8)?);
164            let max = fixed_to_f32(read_i32(rec, 12)?);
165            let flags = read_u16(rec, 16)?;
166            let name_id = read_u16(rec, 18)?;
167            if !(min <= default && default <= max) {
168                return Err(Error::BadStructure("fvar axis min/default/max disorder"));
169            }
170            axes.push(VariationAxis {
171                tag,
172                min,
173                default,
174                max,
175                flags,
176                name_id,
177            });
178        }
179
180        // Parse instances.
181        let inst_array_offset = axes_array_offset
182            .checked_add(
183                (axis_count as usize)
184                    .checked_mul(axis_size as usize)
185                    .ok_or(Error::BadOffset)?,
186            )
187            .ok_or(Error::BadOffset)?;
188        let mut instances = Vec::with_capacity(instance_count as usize);
189        for i in 0..instance_count as usize {
190            let off = inst_array_offset
191                .checked_add(
192                    i.checked_mul(instance_size as usize)
193                        .ok_or(Error::BadOffset)?,
194                )
195                .ok_or(Error::BadOffset)?;
196            if off + instance_size as usize > bytes.len() {
197                return Err(Error::UnexpectedEof);
198            }
199            let rec = &bytes[off..off + instance_size as usize];
200            let subfamily_name_id = read_u16(rec, 0)?;
201            let flags = read_u16(rec, 2)?;
202            let mut coords = Vec::with_capacity(axis_count as usize);
203            for ai in 0..axis_count as usize {
204                coords.push(fixed_to_f32(read_i32(rec, 4 + ai * 4)?));
205            }
206            let post_script_name_id = if has_psname {
207                Some(read_u16(rec, 4 + axis_count as usize * 4)?)
208            } else {
209                None
210            };
211            instances.push(NamedInstance {
212                subfamily_name_id,
213                flags,
214                coords,
215                post_script_name_id,
216            });
217        }
218
219        Ok(Self { axes, instances })
220    }
221
222    pub fn axes(&self) -> &[VariationAxis] {
223        &self.axes
224    }
225
226    pub fn instances(&self) -> &[NamedInstance] {
227        &self.instances
228    }
229
230    pub fn axis_count(&self) -> usize {
231        self.axes.len()
232    }
233}
234
235#[inline]
236fn fixed_to_f32(raw: i32) -> f32 {
237    raw as f32 / 65536.0
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    /// Build a synthetic fvar with one axis (`wght`, 100..400..900) and
245    /// no instances.
246    fn build_one_axis(min: f32, def: f32, max: f32) -> Vec<u8> {
247        let mut b = vec![0u8; 16 + 20];
248        b[0..2].copy_from_slice(&1u16.to_be_bytes()); // major
249        b[2..4].copy_from_slice(&0u16.to_be_bytes()); // minor
250        b[4..6].copy_from_slice(&16u16.to_be_bytes()); // axesArrayOffset
251        b[6..8].copy_from_slice(&2u16.to_be_bytes()); // reserved
252        b[8..10].copy_from_slice(&1u16.to_be_bytes()); // axisCount
253        b[10..12].copy_from_slice(&20u16.to_be_bytes()); // axisSize
254        b[12..14].copy_from_slice(&0u16.to_be_bytes()); // instanceCount
255        b[14..16].copy_from_slice(&8u16.to_be_bytes()); // instanceSize (4 + 4*1)
256        let rec = &mut b[16..36];
257        rec[0..4].copy_from_slice(b"wght");
258        rec[4..8].copy_from_slice(&((min * 65536.0) as i32).to_be_bytes());
259        rec[8..12].copy_from_slice(&((def * 65536.0) as i32).to_be_bytes());
260        rec[12..16].copy_from_slice(&((max * 65536.0) as i32).to_be_bytes());
261        rec[16..18].copy_from_slice(&0u16.to_be_bytes()); // flags
262        rec[18..20].copy_from_slice(&256u16.to_be_bytes()); // nameID
263        b
264    }
265
266    #[test]
267    fn fvar_parses_wght_axis_min_default_max() {
268        let raw = build_one_axis(100.0, 400.0, 900.0);
269        let f = FvarTable::parse(&raw).expect("parse fvar");
270        assert_eq!(f.axes().len(), 1);
271        let a = &f.axes()[0];
272        assert_eq!(&a.tag, b"wght");
273        assert_eq!(a.min, 100.0);
274        assert_eq!(a.default, 400.0);
275        assert_eq!(a.max, 900.0);
276        assert_eq!(a.name_id, 256);
277        assert!(!a.is_hidden());
278        assert!(f.instances().is_empty());
279    }
280
281    #[test]
282    fn fvar_rejects_disordered_min_default_max() {
283        let raw = build_one_axis(900.0, 400.0, 100.0);
284        assert!(matches!(
285            FvarTable::parse(&raw),
286            Err(Error::BadStructure(_))
287        ));
288    }
289
290    #[test]
291    fn fvar_parses_named_instance() {
292        // One axis (wght 100..400..900), one instance pinning wght=700,
293        // sub-family nameID 257, no postScriptNameID.
294        let mut b = vec![0u8; 16 + 20 + 12];
295        b[0..2].copy_from_slice(&1u16.to_be_bytes());
296        b[4..6].copy_from_slice(&16u16.to_be_bytes());
297        b[6..8].copy_from_slice(&2u16.to_be_bytes());
298        b[8..10].copy_from_slice(&1u16.to_be_bytes());
299        b[10..12].copy_from_slice(&20u16.to_be_bytes());
300        b[12..14].copy_from_slice(&1u16.to_be_bytes());
301        b[14..16].copy_from_slice(&8u16.to_be_bytes()); // 4 + 4*1
302        let rec = &mut b[16..36];
303        rec[0..4].copy_from_slice(b"wght");
304        rec[4..8].copy_from_slice(&(100i32 << 16).to_be_bytes());
305        rec[8..12].copy_from_slice(&(400i32 << 16).to_be_bytes());
306        rec[12..16].copy_from_slice(&(900i32 << 16).to_be_bytes());
307        rec[18..20].copy_from_slice(&256u16.to_be_bytes());
308        let inst = &mut b[36..44];
309        inst[0..2].copy_from_slice(&257u16.to_be_bytes()); // subfamilyNameID
310        inst[2..4].copy_from_slice(&0u16.to_be_bytes());
311        inst[4..8].copy_from_slice(&(700i32 << 16).to_be_bytes());
312
313        let f = FvarTable::parse(&b).expect("parse");
314        assert_eq!(f.instances().len(), 1);
315        let i = &f.instances()[0];
316        assert_eq!(i.subfamily_name_id, 257);
317        assert_eq!(i.coords, vec![700.0]);
318        assert!(i.post_script_name_id.is_none());
319    }
320
321    #[test]
322    fn fvar_rejects_short_header() {
323        let b = vec![0u8; 8];
324        assert!(matches!(FvarTable::parse(&b), Err(Error::UnexpectedEof)));
325    }
326}