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)]
102pub struct FvarTable {
103    axes: Vec<VariationAxis>,
104    instances: Vec<NamedInstance>,
105}
106
107impl FvarTable {
108    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
109        if bytes.len() < 16 {
110            return Err(Error::UnexpectedEof);
111        }
112        let major = read_u16(bytes, 0)?;
113        let minor = read_u16(bytes, 2)?;
114        if major != 1 || minor != 0 {
115            return Err(Error::BadStructure("fvar version not 1.0"));
116        }
117        let axes_array_offset = read_u16(bytes, 4)? as usize;
118        // bytes[6..8] is reserved (== 2 in valid fonts) — we don't
119        // enforce so we accept the rare font that emits 0 here.
120        let axis_count = read_u16(bytes, 8)?;
121        let axis_size = read_u16(bytes, 10)?;
122        let instance_count = read_u16(bytes, 12)?;
123        let instance_size = read_u16(bytes, 14)?;
124
125        if axis_count > MAX_AXES {
126            return Err(Error::BadStructure("fvar axisCount exceeds sanity cap"));
127        }
128        if instance_count > MAX_INSTANCES {
129            return Err(Error::BadStructure("fvar instanceCount exceeds sanity cap"));
130        }
131        if axis_size < MIN_AXIS_SIZE {
132            return Err(Error::BadStructure("fvar axisSize < 20"));
133        }
134        // Per spec the *minimum* instance record size is
135        // `4 + 4 * axisCount`; the optional `postScriptNameID` adds 2.
136        let min_instance_size = 4u16
137            .checked_add(
138                axis_count
139                    .checked_mul(4)
140                    .ok_or(Error::BadStructure("fvar axisCount * 4 overflow"))?,
141            )
142            .ok_or(Error::BadStructure("fvar instanceSize overflow"))?;
143        if instance_size != min_instance_size && instance_size != min_instance_size + 2 {
144            return Err(Error::BadStructure("fvar instanceSize unexpected"));
145        }
146        let has_psname = instance_size == min_instance_size + 2;
147
148        // Parse axes.
149        let mut axes = Vec::with_capacity(axis_count as usize);
150        for i in 0..axis_count as usize {
151            let off = axes_array_offset
152                .checked_add(i.checked_mul(axis_size as usize).ok_or(Error::BadOffset)?)
153                .ok_or(Error::BadOffset)?;
154            if off + axis_size as usize > bytes.len() {
155                return Err(Error::UnexpectedEof);
156            }
157            let rec = &bytes[off..off + axis_size as usize];
158            let mut tag = [0u8; 4];
159            tag.copy_from_slice(&rec[0..4]);
160            let min = fixed_to_f32(read_i32(rec, 4)?);
161            let default = fixed_to_f32(read_i32(rec, 8)?);
162            let max = fixed_to_f32(read_i32(rec, 12)?);
163            let flags = read_u16(rec, 16)?;
164            let name_id = read_u16(rec, 18)?;
165            if !(min <= default && default <= max) {
166                return Err(Error::BadStructure("fvar axis min/default/max disorder"));
167            }
168            axes.push(VariationAxis {
169                tag,
170                min,
171                default,
172                max,
173                flags,
174                name_id,
175            });
176        }
177
178        // Parse instances.
179        let inst_array_offset = axes_array_offset
180            .checked_add(
181                (axis_count as usize)
182                    .checked_mul(axis_size as usize)
183                    .ok_or(Error::BadOffset)?,
184            )
185            .ok_or(Error::BadOffset)?;
186        let mut instances = Vec::with_capacity(instance_count as usize);
187        for i in 0..instance_count as usize {
188            let off = inst_array_offset
189                .checked_add(
190                    i.checked_mul(instance_size as usize)
191                        .ok_or(Error::BadOffset)?,
192                )
193                .ok_or(Error::BadOffset)?;
194            if off + instance_size as usize > bytes.len() {
195                return Err(Error::UnexpectedEof);
196            }
197            let rec = &bytes[off..off + instance_size as usize];
198            let subfamily_name_id = read_u16(rec, 0)?;
199            let flags = read_u16(rec, 2)?;
200            let mut coords = Vec::with_capacity(axis_count as usize);
201            for ai in 0..axis_count as usize {
202                coords.push(fixed_to_f32(read_i32(rec, 4 + ai * 4)?));
203            }
204            let post_script_name_id = if has_psname {
205                Some(read_u16(rec, 4 + axis_count as usize * 4)?)
206            } else {
207                None
208            };
209            instances.push(NamedInstance {
210                subfamily_name_id,
211                flags,
212                coords,
213                post_script_name_id,
214            });
215        }
216
217        Ok(Self { axes, instances })
218    }
219
220    pub fn axes(&self) -> &[VariationAxis] {
221        &self.axes
222    }
223
224    pub fn instances(&self) -> &[NamedInstance] {
225        &self.instances
226    }
227
228    pub fn axis_count(&self) -> usize {
229        self.axes.len()
230    }
231}
232
233#[inline]
234fn fixed_to_f32(raw: i32) -> f32 {
235    raw as f32 / 65536.0
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    /// Build a synthetic fvar with one axis (`wght`, 100..400..900) and
243    /// no instances.
244    fn build_one_axis(min: f32, def: f32, max: f32) -> Vec<u8> {
245        let mut b = vec![0u8; 16 + 20];
246        b[0..2].copy_from_slice(&1u16.to_be_bytes()); // major
247        b[2..4].copy_from_slice(&0u16.to_be_bytes()); // minor
248        b[4..6].copy_from_slice(&16u16.to_be_bytes()); // axesArrayOffset
249        b[6..8].copy_from_slice(&2u16.to_be_bytes()); // reserved
250        b[8..10].copy_from_slice(&1u16.to_be_bytes()); // axisCount
251        b[10..12].copy_from_slice(&20u16.to_be_bytes()); // axisSize
252        b[12..14].copy_from_slice(&0u16.to_be_bytes()); // instanceCount
253        b[14..16].copy_from_slice(&8u16.to_be_bytes()); // instanceSize (4 + 4*1)
254        let rec = &mut b[16..36];
255        rec[0..4].copy_from_slice(b"wght");
256        rec[4..8].copy_from_slice(&((min * 65536.0) as i32).to_be_bytes());
257        rec[8..12].copy_from_slice(&((def * 65536.0) as i32).to_be_bytes());
258        rec[12..16].copy_from_slice(&((max * 65536.0) as i32).to_be_bytes());
259        rec[16..18].copy_from_slice(&0u16.to_be_bytes()); // flags
260        rec[18..20].copy_from_slice(&256u16.to_be_bytes()); // nameID
261        b
262    }
263
264    #[test]
265    fn fvar_parses_wght_axis_min_default_max() {
266        let raw = build_one_axis(100.0, 400.0, 900.0);
267        let f = FvarTable::parse(&raw).expect("parse fvar");
268        assert_eq!(f.axes().len(), 1);
269        let a = &f.axes()[0];
270        assert_eq!(&a.tag, b"wght");
271        assert_eq!(a.min, 100.0);
272        assert_eq!(a.default, 400.0);
273        assert_eq!(a.max, 900.0);
274        assert_eq!(a.name_id, 256);
275        assert!(!a.is_hidden());
276        assert!(f.instances().is_empty());
277    }
278
279    #[test]
280    fn fvar_rejects_disordered_min_default_max() {
281        let raw = build_one_axis(900.0, 400.0, 100.0);
282        assert!(matches!(
283            FvarTable::parse(&raw),
284            Err(Error::BadStructure(_))
285        ));
286    }
287
288    #[test]
289    fn fvar_parses_named_instance() {
290        // One axis (wght 100..400..900), one instance pinning wght=700,
291        // sub-family nameID 257, no postScriptNameID.
292        let mut b = vec![0u8; 16 + 20 + 12];
293        b[0..2].copy_from_slice(&1u16.to_be_bytes());
294        b[4..6].copy_from_slice(&16u16.to_be_bytes());
295        b[6..8].copy_from_slice(&2u16.to_be_bytes());
296        b[8..10].copy_from_slice(&1u16.to_be_bytes());
297        b[10..12].copy_from_slice(&20u16.to_be_bytes());
298        b[12..14].copy_from_slice(&1u16.to_be_bytes());
299        b[14..16].copy_from_slice(&8u16.to_be_bytes()); // 4 + 4*1
300        let rec = &mut b[16..36];
301        rec[0..4].copy_from_slice(b"wght");
302        rec[4..8].copy_from_slice(&(100i32 << 16).to_be_bytes());
303        rec[8..12].copy_from_slice(&(400i32 << 16).to_be_bytes());
304        rec[12..16].copy_from_slice(&(900i32 << 16).to_be_bytes());
305        rec[18..20].copy_from_slice(&256u16.to_be_bytes());
306        let inst = &mut b[36..44];
307        inst[0..2].copy_from_slice(&257u16.to_be_bytes()); // subfamilyNameID
308        inst[2..4].copy_from_slice(&0u16.to_be_bytes());
309        inst[4..8].copy_from_slice(&(700i32 << 16).to_be_bytes());
310
311        let f = FvarTable::parse(&b).expect("parse");
312        assert_eq!(f.instances().len(), 1);
313        let i = &f.instances()[0];
314        assert_eq!(i.subfamily_name_id, 257);
315        assert_eq!(i.coords, vec![700.0]);
316        assert!(i.post_script_name_id.is_none());
317    }
318
319    #[test]
320    fn fvar_rejects_short_header() {
321        let b = vec![0u8; 8];
322        assert!(matches!(FvarTable::parse(&b), Err(Error::UnexpectedEof)));
323    }
324}