nitai/projects
noxstrap / src / crates / nox-text / tests / parser.rs
289 lines · 10.5 KB Raw
1use nox_gfx::geom::{Rect, IDENTITY};
2use nox_gfx::path::{Path, Verb};
3use nox_text::Font;
4
5const FIXTURE: &str = include_str!("fixture.txt");
6
7#[derive(Default)]
8struct Expect {
9 file: String,
10 upem: u16,
11 num_glyphs: u16,
12 vmetrics: (i16, i16, i16),
13 bbox: (i16, i16, i16, i16),
14 has_cmap: bool,
15 gids: Vec<(u32, u16)>,
16 advances: Vec<(u16, u16)>,
17 glyphs: Vec<(u16, usize, usize, bool, (i32, i32, i32, i32))>,
18 kerning: Vec<(u16, u16, i16)>,
19}
20
21fn fixtures() -> Vec<Expect> {
22 let mut out = Vec::new();
23 let mut cur = Expect::default();
24 for line in FIXTURE.lines() {
25 let mut it = line.split_whitespace();
26 let Some(key) = it.next() else { continue };
27 let mut num = |d: &mut dyn Iterator<Item = &str>| -> i64 {
28 d.next().and_then(|v| v.parse::<i64>().ok()).unwrap_or(0)
29 };
30 match key {
31 "FONT" => {
32 cur = Expect::default();
33 cur.file = it.next().unwrap_or("").to_string();
34 }
35 "UPEM" => cur.upem = num(&mut it) as u16,
36 "NGLYPHS" => cur.num_glyphs = num(&mut it) as u16,
37 "VMETRICS" => {
38 cur.vmetrics = (num(&mut it) as i16, num(&mut it) as i16, num(&mut it) as i16)
39 }
40 "BBOX" => {
41 cur.bbox = (
42 num(&mut it) as i16,
43 num(&mut it) as i16,
44 num(&mut it) as i16,
45 num(&mut it) as i16,
46 )
47 }
48 "CMAP" => cur.has_cmap = num(&mut it) == 1,
49 "GID" => {
50 let cp = num(&mut it) as u32;
51 cur.gids.push((cp, num(&mut it) as u16));
52 }
53 "ADV" => {
54 let g = num(&mut it) as u16;
55 cur.advances.push((g, num(&mut it) as u16));
56 }
57 "GLYPH" => {
58 let g = num(&mut it) as u16;
59 let nc = num(&mut it) as usize;
60 let npts = num(&mut it) as usize;
61 let _non = num(&mut it);
62 let simple = num(&mut it) == 1;
63 let bb = (
64 num(&mut it) as i32,
65 num(&mut it) as i32,
66 num(&mut it) as i32,
67 num(&mut it) as i32,
68 );
69 cur.glyphs.push((g, nc, npts, simple, bb));
70 }
71 "KERN" => {
72 let l = num(&mut it) as u16;
73 let r = num(&mut it) as u16;
74 cur.kerning.push((l, r, num(&mut it) as i16));
75 }
76 "END" => out.push(std::mem::take(&mut cur)),
77 _ => {}
78 }
79 }
80 out
81}
82
83fn contours_and_bbox(font: &Font, gid: u16) -> (usize, Option<Rect>) {
84 let mut p = Path::new();
85 nox_text::outline(font, gid, &IDENTITY, &mut p);
86 let closes = p.verbs.iter().filter(|v| **v == Verb::Close).count();
87 let bb = if p.points.is_empty() { None } else { Some(p.bounds()) };
88 (closes, bb)
89}
90
91#[test]
92fn matches_independent_reference_parser() {
93 let fx = fixtures();
94 assert!(fx.len() > 50, "fixture looks empty ({} fonts)", fx.len());
95 let mut checked = 0usize;
96 let mut skipped = 0usize;
97 for e in &fx {
98 let Ok(bytes) = std::fs::read(&e.file) else {
99 skipped += 1;
100 continue;
101 };
102 let font = match Font::parse(&bytes) {
103 Ok(f) => f,
104 Err(err) => panic!("{}: reference parsed it but we failed: {err:?}", e.file),
105 };
106 let f = &e.file;
107 assert_eq!(font.units_per_em, e.upem, "{f}: units_per_em");
108 assert_eq!(font.num_glyphs, e.num_glyphs, "{f}: num_glyphs");
109 let upem = font.units_per_em as f32;
110 let lm = font.line_metrics();
111 assert_eq!((lm.ascent * upem).round() as i16, e.vmetrics.0, "{f}: ascent");
112 assert_eq!((lm.descent * upem).round() as i16, e.vmetrics.1, "{f}: descent");
113 assert_eq!((lm.line_gap * upem).round() as i16, e.vmetrics.2, "{f}: line_gap");
114 assert_eq!(font.bbox(), e.bbox, "{f}: bbox");
115 assert_eq!(font.has_cmap(), e.has_cmap, "{f}: has_cmap");
116
117 for (cp, gid) in &e.gids {
118 let ch = char::from_u32(*cp).unwrap();
119 assert_eq!(font.glyph_index(ch), *gid, "{f}: glyph_index({ch:?})");
120 }
121 for (gid, adv) in &e.advances {
122 assert_eq!(font.advance(*gid), *adv, "{f}: advance({gid})");
123 }
124 for (gid, nc, npts, simple, bb) in &e.glyphs {
125 let (got_nc, got_bb) = contours_and_bbox(&font, *gid);
126 assert_eq!(got_nc, *nc, "{f}: contour count for glyph {gid}");
127 if *simple && *npts > 0 {
128 let bb2 = got_bb.unwrap_or_else(|| panic!("{f}: glyph {gid} produced no points"));
129 assert_eq!(
130 (
131 bb2.x0.round() as i32,
132 bb2.y0.round() as i32,
133 bb2.x1.round() as i32,
134 bb2.y1.round() as i32
135 ),
136 *bb,
137 "{f}: decoded point bbox for glyph {gid}"
138 );
139 }
140 }
141 for (l, r, v) in &e.kerning {
142 assert_eq!(font.kerning(*l, *r), *v, "{f}: kerning({l},{r})");
143 }
144 checked += 1;
145 }
146 assert!(checked > 50, "only {checked} fonts checked, {skipped} skipped");
147 eprintln!("cross-checked {checked} fonts against the reference parser");
148}
149
150#[test]
151fn every_installed_font_parses_and_outlines_without_panicking() {
152 let mut ok = 0usize;
153 let mut rejected = 0usize;
154 for e in fixtures() {
155 let Ok(bytes) = std::fs::read(&e.file) else { continue };
156 match Font::parse(&bytes) {
157 Ok(font) => {
158 let mut p = Path::new();
159 for gid in 0..font.num_glyphs.min(400) {
160 p.clear();
161 nox_text::outline(&font, gid, &IDENTITY, &mut p);
162 for q in &p.points {
163 assert!(q.x.is_finite() && q.y.is_finite(), "{}: non-finite point", e.file);
164 }
165 }
166 ok += 1;
167 }
168 Err(_) => rejected += 1,
169 }
170 }
171 assert!(ok > 50, "only {ok} fonts parsed ({rejected} rejected)");
172 eprintln!("outlined glyphs from {ok} fonts, {rejected} rejected");
173}
174
175fn lcg(s: &mut u64) -> u64 {
176 *s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
177 *s >> 17
178}
179
180#[test]
181fn corrupted_fonts_are_rejected_or_survive_without_panicking() {
182 let sources: Vec<String> = fixtures().into_iter().map(|e| e.file).collect();
183 let mut seed = 0xabcd_1234_5678_9911u64;
184 let mut cases = 0usize;
185 for file in sources.iter().take(24) {
186 let Ok(orig) = std::fs::read(file) else { continue };
187 if orig.len() < 1024 {
188 continue;
189 }
190
191 for k in 1..=24 {
192 let cut = orig.len() * k / 25;
193 let data = &orig[..cut];
194 if let Ok(font) = Font::parse(data) {
195 let mut p = Path::new();
196 for gid in [0u16, 1, 2, 40, 500, font.num_glyphs.saturating_sub(1), 65535] {
197 p.clear();
198 nox_text::outline(&font, gid, &IDENTITY, &mut p);
199 }
200 for ch in ['A', 'z', '\u{5b57}', '\u{10FFFF}'] {
201 let _ = font.glyph_index(ch);
202 }
203 let _ = font.advance(60000);
204 let _ = font.kerning(3, 9);
205 }
206 cases += 1;
207 }
208
209 for _ in 0..64 {
210 let mut data = orig.clone();
211 let hits = 1 + (lcg(&mut seed) % 16) as usize;
212 for _ in 0..hits {
213 let i = (lcg(&mut seed) as usize) % data.len();
214 data[i] = (lcg(&mut seed) & 0xff) as u8;
215 }
216 if let Ok(font) = Font::parse(&data) {
217 let mut p = Path::new();
218 for gid in [0u16, 3, 36, 200, 5000, 65535] {
219 p.clear();
220 nox_text::outline(&font, gid, &IDENTITY, &mut p);
221 assert!(p.points.len() < 4_000_000, "runaway outline");
222 }
223 for ch in ['A', ' ', '\u{5b57}'] {
224 let _ = font.glyph_index(ch);
225 }
226 }
227 cases += 1;
228 }
229 }
230 assert!(cases > 500, "only {cases} corruption cases exercised");
231 eprintln!("survived {cases} truncated/corrupted font cases");
232}
233
234#[test]
235fn junk_input_is_rejected_cleanly() {
236 assert!(Font::parse(&[]).is_err());
237 assert!(Font::parse(&[0; 4]).is_err());
238 assert!(Font::parse(&[0xff; 4096]).is_err());
239 assert!(Font::parse(b"OTTO____________").is_err());
240 let mut fake = vec![0u8; 64];
241 fake[0..4].copy_from_slice(&0x0001_0000u32.to_be_bytes());
242 fake[4..6].copy_from_slice(&9999u16.to_be_bytes());
243 assert!(Font::parse(&fake).is_err());
244}
245
246#[test]
247fn glyph_cache_does_not_confuse_faces() {
248 use nox_text::GlyphCache;
249 let files = [
250 "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
251 "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
252 "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
253 "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
254 ];
255 let blobs: Vec<Vec<u8>> = files.iter().filter_map(|f| std::fs::read(f).ok()).collect();
256 if blobs.len() < 2 {
257 return;
258 }
259 let fonts: Vec<Font> = blobs.iter().map(|b| Font::parse(b).unwrap()).collect();
260
261 for (i, a) in fonts.iter().enumerate() {
262 for (j, b) in fonts.iter().enumerate() {
263 if i < j {
264 assert_ne!(a.id(), b.id(), "{} and {} share a face id", files[i], files[j]);
265 }
266 }
267 }
268
269 let mut shared = GlyphCache::new();
270 for (i, font) in fonts.iter().enumerate() {
271 let mut solo = GlyphCache::new();
272 for ch in "AaBbGgQqWw@#8".chars() {
273 let gid = font.glyph_index(ch);
274 for px in [11.0f32, 17.0, 31.0] {
275 for subx in 0..4u8 {
276 let want = solo.get(font, gid, px, subx).cloned().unwrap();
277 let got = shared.get(font, gid, px, subx).cloned().unwrap();
278 assert_eq!(
279 (got.w, got.h, got.left, got.top),
280 (want.w, want.h, want.left, want.top),
281 "{}: metrics differ for {ch:?} at {px}px via shared cache",
282 files[i]
283 );
284 assert_eq!(got.a, want.a, "{}: pixels differ for {ch:?} at {px}px", files[i]);
285 }
286 }
287 }
288 }
289}