| 1 | use nox_gfx::geom::{Rect, IDENTITY}; |
| 2 | use nox_gfx::path::{Path, Verb}; |
| 3 | use nox_text::Font; |
| 4 | |
| 5 | const FIXTURE: &str = include_str!("fixture.txt"); |
| 6 | |
| 7 | #[derive(Default)] |
| 8 | struct 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 | |
| 21 | fn 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 | |
| 83 | fn 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] |
| 92 | fn 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] |
| 151 | fn 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 | |
| 175 | fn lcg(s: &mut u64) -> u64 { |
| 176 | *s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); |
| 177 | *s >> 17 |
| 178 | } |
| 179 | |
| 180 | #[test] |
| 181 | fn 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] |
| 235 | fn 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] |
| 247 | fn 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 | } |