Skip to content

Commit 47fd81f

Browse files
committed
fix(fvar): accept a face with exactly 64 variation axes
The set_variation guard used >= against the coordinate-storage limit while Face::parse and from_raw_tables both clamp the count with .min(MAX_VAR_COORDS), which is inclusive. A 64-axis face was therefore fully supported everywhere except set_variation, which refused every axis on it. Adds tests/tables/fvar.rs; fvar and set_variation had no coverage at all.
1 parent 04a2f9c commit 47fd81f

4 files changed

Lines changed: 180 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
101101
unsupported point-matching component left the stream positioned mid-arguments and every
102102
subsequent read in that glyph decoded from the wrong offset. Point matching itself remains
103103
unimplemented, but it no longer corrupts the components that follow it.
104+
- (`fvar`) `Face::set_variation` works on a face with exactly 64 axes. Its guard rejected an
105+
axis count equal to the coordinate-storage limit, while every other site clamps to that
106+
limit inclusively, so such a face could not be varied on any axis at all.
104107

105108
## [0.25.1] - 2024-11-29
106109
### Changed

src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2371,7 +2371,11 @@ impl<'a> Face<'a> {
23712371
return None;
23722372
}
23732373

2374-
if usize::from(self.variation_axes().len()) >= MAX_VAR_COORDS {
2374+
// Bounds `self.coordinates.data[i]` below, where `i` runs over every axis. A face with
2375+
// more axes than the array holds is rejected outright rather than partially applied.
2376+
// The comparison is inclusive because `MAX_VAR_COORDS` axes still fit: `Face::parse`
2377+
// clamps the coordinate count with `.min(MAX_VAR_COORDS)`, which is inclusive too. ~keep
2378+
if usize::from(self.variation_axes().len()) > MAX_VAR_COORDS {
23752379
return None;
23762380
}
23772381

tests/tables/fvar.rs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
use ttf_parser::fvar::Table;
2+
use ttf_parser::{Face, RawFaceTables, Tag};
3+
use crate::{convert, Unit::*};
4+
5+
// The `fvar` header this parser reads is 10 bytes (version, axesArrayOffset, reserved,
6+
// axisCount); the full spec header is 16, so the axes array starts at 16.
7+
const AXES_ARRAY_OFFSET: u16 = 16;
8+
9+
// Builds an `fvar` table with `count` axes, tagged `a000`, `a001`, ... in order.
10+
// Every axis spans -1.0 ..= 1.0 with a default of 0.0, so `normalized_value` is the identity.
11+
fn fvar_data(count: u16) -> Vec<u8> {
12+
let mut units = vec![
13+
UInt32(0x00010000), // version
14+
UInt16(AXES_ARRAY_OFFSET), // axesArrayOffset
15+
UInt16(0), // reserved
16+
UInt16(count), // axisCount
17+
UInt16(20), // axisSize
18+
UInt16(0), // instanceCount
19+
UInt16(0), // instanceSize
20+
];
21+
22+
for i in 0..count {
23+
units.push(Raw(axis_tag(i)));
24+
units.push(Fixed(-1.0)); // minValue
25+
units.push(Fixed(0.0)); // defaultValue
26+
units.push(Fixed(1.0)); // maxValue
27+
units.push(UInt16(0)); // flags
28+
units.push(UInt16(0)); // axisNameID
29+
}
30+
31+
convert(&units)
32+
}
33+
34+
// `Unit::Raw` needs a `&'static [u8]`, so the tags are a table rather than formatted.
35+
// 64 axes is the storage limit, and the tests reach one past it.
36+
fn axis_tag(index: u16) -> &'static [u8] {
37+
const TAGS: &[u8; 4 * 66] = b"\
38+
a000a001a002a003a004a005a006a007a008a009a010a011a012a013a014a015\
39+
a016a017a018a019a020a021a022a023a024a025a026a027a028a029a030a031\
40+
a032a033a034a035a036a037a038a039a040a041a042a043a044a045a046a047\
41+
a048a049a050a051a052a053a054a055a056a057a058a059a060a061a062a063\
42+
a064a065";
43+
44+
let start = usize::from(index) * 4;
45+
&TAGS[start..start + 4]
46+
}
47+
48+
fn head_data() -> Vec<u8> {
49+
convert(&[
50+
UInt32(0x00010000), // version
51+
Fixed(1.0), // font revision
52+
UInt32(0), // checksum adjustment
53+
UInt32(0x5F0F3CF5), // magic number
54+
UInt16(0), // flags
55+
UInt16(1000), // units per EM
56+
Raw(&[0; 8]), // created
57+
Raw(&[0; 8]), // modified
58+
Int16(0), // x min
59+
Int16(0), // y min
60+
Int16(0), // x max
61+
Int16(0), // y max
62+
UInt16(0), // mac style
63+
UInt16(0), // lowest PPEM
64+
Int16(0), // font direction hint
65+
UInt16(0), // index to loc format
66+
Int16(0), // glyph data format
67+
])
68+
}
69+
70+
fn hhea_data() -> Vec<u8> {
71+
convert(&[
72+
UInt32(0x00010000), // version
73+
Int16(0), // ascender
74+
Int16(0), // descender
75+
Int16(0), // line gap
76+
Raw(&[0; 24]), // metrics unused by `hhea::Table`
77+
UInt16(0), // number of h-metrics
78+
])
79+
}
80+
81+
fn maxp_data() -> Vec<u8> {
82+
convert(&[
83+
Fixed(0.3125), // version 0.5
84+
UInt16(1), // number of glyphs
85+
])
86+
}
87+
88+
// Sets the *first* axis of a `count`-axis face and reports whether it took effect.
89+
// Returns (result of set_variation, coordinate 0 after the call).
90+
fn set_first_axis(count: u16) -> (Option<()>, i16) {
91+
let head = head_data();
92+
let hhea = hhea_data();
93+
let maxp = maxp_data();
94+
let fvar = fvar_data(count);
95+
96+
let mut face = Face::from_raw_tables(RawFaceTables {
97+
head: &head,
98+
hhea: &hhea,
99+
maxp: &maxp,
100+
fvar: Some(&fvar),
101+
..Default::default()
102+
})
103+
.unwrap();
104+
105+
let result = face.set_variation(Tag::from_bytes(b"a000"), 1.0);
106+
(result, face.variation_coordinates()[0].get())
107+
}
108+
109+
#[test]
110+
fn parses_every_axis_record() {
111+
let data = fvar_data(3);
112+
let table = Table::parse(&data).unwrap();
113+
114+
assert_eq!(table.axes.len(), 3);
115+
assert_eq!(table.axes.get(0).unwrap().tag, Tag::from_bytes(b"a000"));
116+
assert_eq!(table.axes.get(2).unwrap().tag, Tag::from_bytes(b"a002"));
117+
assert_eq!(table.axes.get(1).unwrap().min_value, -1.0);
118+
assert_eq!(table.axes.get(1).unwrap().def_value, 0.0);
119+
assert_eq!(table.axes.get(1).unwrap().max_value, 1.0);
120+
}
121+
122+
#[test]
123+
fn zero_axes_is_not_a_variable_font() {
124+
assert!(Table::parse(&fvar_data(0)).is_none());
125+
}
126+
127+
#[test]
128+
fn set_variation_works_below_the_coordinate_limit() {
129+
let (result, coord) = set_first_axis(63);
130+
assert_eq!(result, Some(()));
131+
assert_eq!(coord, 16384);
132+
}
133+
134+
// Regression test for https://github.com/harfbuzz/ttf-parser/issues/237: the guard in
135+
// `set_variation` used `>=` while the coordinate array holds exactly `MAX_VAR_COORDS`
136+
// entries and every other site clamps inclusively, so a 64-axis face could not be varied
137+
// at all even though the rest of the crate supports it.
138+
#[test]
139+
fn set_variation_works_at_exactly_the_coordinate_limit() {
140+
let (result, coord) = set_first_axis(64);
141+
assert_eq!(result, Some(()));
142+
assert_eq!(coord, 16384);
143+
}
144+
145+
#[test]
146+
fn set_variation_rejects_a_face_with_more_axes_than_the_limit() {
147+
let (result, coord) = set_first_axis(65);
148+
assert_eq!(result, None);
149+
assert_eq!(coord, 0);
150+
}
151+
152+
#[test]
153+
fn set_variation_returns_none_for_an_unknown_axis_on_a_full_face() {
154+
let head = head_data();
155+
let hhea = hhea_data();
156+
let maxp = maxp_data();
157+
let fvar = fvar_data(64);
158+
159+
let mut face = Face::from_raw_tables(RawFaceTables {
160+
head: &head,
161+
hhea: &hhea,
162+
maxp: &maxp,
163+
fvar: Some(&fvar),
164+
..Default::default()
165+
})
166+
.unwrap();
167+
168+
assert_eq!(face.set_variation(Tag::from_bytes(b"zzzz"), 1.0), None);
169+
assert_eq!(face.has_non_default_variation_coordinates(), false);
170+
}

tests/tables/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
#[rustfmt::skip] mod cmap;
99
#[rustfmt::skip] mod colr;
1010
#[rustfmt::skip] mod feat;
11+
#[cfg(feature = "variable-fonts")]
12+
#[rustfmt::skip] mod fvar;
1113
#[rustfmt::skip] mod glyf;
1214
#[rustfmt::skip] mod head;
1315
#[rustfmt::skip] mod hmtx;

0 commit comments

Comments
 (0)