//! Font choice for sketch text. A small TrueType font is built here, so the outlines it must give are //! known by hand; the repository's second font (BIZ UDPGothic, `assets/fonts/biz-udp-gothic`) stands for //! a real one. use std::path::PathBuf; use solidcraft_geom::Vec2; use crate::fonts::find_family_in; use crate::tests::v; use crate::*; /// One glyph of the test font: its character, advance width and contours (font units, y up; an outer /// contour runs clockwise, a counter counter-clockwise, as in TrueType). struct Glyph { ch: char, advance: u16, contours: Vec>, } fn block(x0: i16, y0: i16, x1: i16, y1: i16) -> Vec<(i16, i16)> { vec![(x0, y0), (x0, y1), (x1, y1), (x1, y0)] } fn hole(x0: i16, y0: i16, x1: i16, y1: i16) -> Vec<(i16, i16)> { vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)] } /// A TrueType font of 1000 units per em with these glyphs (and an empty `.notdef`), named `family`. It has /// no OS/2 table, so it has no capital height of its own. fn build_font(family: &str, glyphs: &[Glyph]) -> Vec { fn u16s(out: &mut Vec, values: &[u16]) { values.iter().for_each(|v| out.extend(v.to_be_bytes())); } fn u32s(out: &mut Vec, values: &[u32]) { values.iter().for_each(|v| out.extend(v.to_be_bytes())); } let count = glyphs.len() as u16 + 1; // glyf and loca (long offsets). let (mut glyf, mut loca) = (Vec::new(), vec![0u32]); for g in std::iter::once(None).chain(glyphs.iter().map(Some)) { if let Some(g) = g { let pts: Vec<(i16, i16)> = g.contours.iter().flatten().copied().collect(); let (xs, ys): (Vec, Vec) = pts.iter().copied().unzip(); let (x0, x1, y0, y1) = (xs.iter().min(), xs.iter().max(), ys.iter().min(), ys.iter().max()); u16s(&mut glyf, &[g.contours.len() as u16]); glyf.extend([x0, y0, x1, y1].map(|v| v.copied().unwrap_or(0)).iter().flat_map(|v| v.to_be_bytes())); let mut end = 0; for c in &g.contours { end += c.len() as u16; u16s(&mut glyf, &[end - 1]); } u16s(&mut glyf, &[0]); // no instructions glyf.extend(vec![1u8; pts.len()]); // on-curve points, 16-bit deltas for coords in [xs, ys] { let mut prev = 0; for v in coords { glyf.extend((v - prev).to_be_bytes()); prev = v; } } while glyf.len() % 4 != 0 { glyf.push(0); } } loca.push(glyf.len() as u32); } let mut head = Vec::new(); u32s(&mut head, &[0x0001_0000, 0x0001_0000, 0, 0x5F0F_3CF5]); u16s(&mut head, &[0, 1000]); head.extend([0u8; 16]); // created, modified u16s(&mut head, &[0, 0, 1000, 1000, 0, 8, 2, 1, 0]); // box, style, smallest size, direction, long loca, format let mut hhea = Vec::new(); u32s(&mut hhea, &[0x0001_0000]); u16s(&mut hhea, &[800, (-200i16) as u16, 0, 1000, 0, 0, 1000, 1, 0, 0, 0, 0, 0, 0, 0, count]); let mut maxp = Vec::new(); u32s(&mut maxp, &[0x0001_0000]); u16s(&mut maxp, &[count]); let mut hmtx = Vec::new(); u16s(&mut hmtx, &[0, 0]); for g in glyphs { u16s(&mut hmtx, &[g.advance, 0]); } // cmap: one format 12 subtable, a group per character. let mut chars: Vec<(u32, u32)> = glyphs.iter().enumerate().map(|(i, g)| (g.ch as u32, i as u32 + 1)).collect(); chars.sort(); let mut cmap = Vec::new(); u16s(&mut cmap, &[0, 1, 3, 10]); u32s(&mut cmap, &[12, 0x000C_0000, 16 + 12 * chars.len() as u32, 0, chars.len() as u32]); for (c, g) in chars { u32s(&mut cmap, &[c, c, g]); } // name: family (1), style (2), full name (4), all Windows Unicode. let strings = [(1u16, family.to_string()), (2, "Regular".to_string()), (4, format!("{family} Regular"))]; let (mut name, mut storage) = (Vec::new(), Vec::new()); u16s(&mut name, &[0, strings.len() as u16, 6 + 12 * strings.len() as u16]); for (id, s) in &strings { let utf16: Vec = s.encode_utf16().collect(); u16s(&mut name, &[3, 1, 0x409, *id, 2 * utf16.len() as u16, storage.len() as u16]); utf16.iter().for_each(|u| storage.extend(u.to_be_bytes())); } name.extend(storage); // The table directory, tables sorted by tag. let tables: [(&[u8; 4], Vec); 8] = [ (b"cmap", cmap), (b"glyf", glyf), (b"head", head), (b"hhea", hhea), (b"hmtx", hmtx), (b"loca", loca.iter().flat_map(|o| o.to_be_bytes()).collect()), (b"maxp", maxp), (b"name", name), ]; let (mut dir, mut body) = (Vec::new(), Vec::new()); u32s(&mut dir, &[0x0001_0000]); u16s(&mut dir, &[tables.len() as u16, 128, 3, 0]); for (tag, data) in &tables { dir.extend(*tag); u32s(&mut dir, &[0, 12 + 16 * tables.len() as u32 + body.len() as u32, data.len() as u32]); body.extend(data); while body.len() % 4 != 0 { body.push(0); } } dir.extend(body); dir } /// The test font "Tiny Block": H is a 600 x 700 block, I a 100 x 700 bar and O an 800 x 700 block with a /// 400 x 300 counter at (200, 200), all in font units. fn tiny_font() -> Vec { build_font( "Tiny Block", &[ Glyph { ch: 'H', advance: 700, contours: vec![block(0, 0, 600, 700)] }, Glyph { ch: 'I', advance: 200, contours: vec![block(0, 0, 100, 700)] }, Glyph { ch: 'O', advance: 900, contours: vec![block(0, 0, 800, 700), hole(200, 200, 600, 500)] }, ], ) } /// A folder of the test font and a few things that are not fonts, removed at the end of the test. struct Folder(PathBuf); impl Folder { fn new(name: &str) -> Folder { let dir = std::env::temp_dir().join(format!("solidcraft-font-tests-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("nested")).unwrap(); std::fs::write(dir.join("nested").join("tiny.ttf"), tiny_font()).unwrap(); std::fs::write(dir.join("notes.txt"), "not a font").unwrap(); std::fs::write(dir.join("broken.ttf"), "not a font either").unwrap(); Folder(dir) } fn font(&self) -> String { self.0.join("nested").join("tiny.ttf").to_string_lossy().into_owned() } } impl Drop for Folder { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn web_fonts() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/biz-udp-gothic") } /// The corner points of the lines of `geometry`, as (min, max) over x and y. fn extent(geometry: &[LinkGeom]) -> (Vec2, Vec2) { let pts: Vec = geometry .iter() .flat_map(|g| match g { LinkGeom::Line(a, b) => vec![*a, *b], other => panic!("only lines here: {other:?}"), }) .collect(); let lo = pts.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y))); let hi = pts.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y))); (lo, hi) } #[test] fn a_font_file_sets_the_outlines_and_the_scale() { let folder = Folder::new("outlines"); // The capital height is that of the font's H (700 units, no OS/2 table), so 7 mm tall text is 0.01 mm per unit: // the O is an 8 x 7 mm block (x 10 to 18, y 20 to 27) with a counter from (12, 22) to (16, 25). let o = text_geometry_in(Some(&folder.font()), "O", v(10.0, 20.0), 7.0, 0.0).unwrap(); assert_eq!(o.len(), 8); let (lo, hi) = extent(&o); assert!((lo.x - 10.0).abs() < 1e-9 && (lo.y - 20.0).abs() < 1e-9 && (hi.x - 18.0).abs() < 1e-9 && (hi.y - 27.0).abs() < 1e-9, "{lo:?} {hi:?}"); let inner = o.iter().filter(|g| matches!(g, LinkGeom::Line(a, _) if a.x > 10.5 && a.x < 17.5 && a.y > 20.5 && a.y < 26.5)).count(); assert_eq!(inner, 4, "{o:?}"); // The next glyph starts one advance later: an I after the 700 units of an H is 7 mm to the right. let hi_text = text_geometry_in(Some(&folder.font()), "HI", Vec2::ZERO, 7.0, 0.0).unwrap(); let (lo, hi) = extent(&hi_text[4..]); assert!((lo.x - 7.0).abs() < 1e-9 && (hi.x - 8.0).abs() < 1e-9 && (hi.y - 7.0).abs() < 1e-9, "{lo:?} {hi:?}"); // Without a font name the built-in one is used: its O is not a block. let builtin = text_geometry_in(None, "O", v(10.0, 20.0), 7.0, 0.0).unwrap(); assert!(builtin.iter().all(|g| !matches!(g, LinkGeom::Line(..)))); assert_eq!(builtin, text_geometry("O", v(10.0, 20.0), 7.0, 0.0).unwrap()); // Turned by 90 degrees about its start, the 8 x 7 mm block stands 7 mm wide and 8 mm tall, left of the start. let turned = text_geometry_in(Some(&folder.font()), "O", Vec2::ZERO, 7.0, std::f64::consts::FRAC_PI_2).unwrap(); let (lo, hi) = extent(&turned); assert!((lo.x + 7.0).abs() < 1e-9 && hi.x.abs() < 1e-9 && lo.y.abs() < 1e-9 && (hi.y - 8.0).abs() < 1e-9, "{lo:?} {hi:?}"); } #[test] fn a_font_without_a_capital_height_or_an_h_uses_seven_tenths_of_the_em() { let font = build_font("Bar", &[Glyph { ch: 'I', advance: 200, contours: vec![block(0, 0, 100, 700)] }]); let folder = Folder::new("no-h"); let path = folder.0.join("bar.ttf"); std::fs::write(&path, font).unwrap(); // 0.7 em = 700 units: the 100 x 700 bar at a capital height of 14 mm is 2 x 14 mm. let bar = text_geometry_in(Some(&path.to_string_lossy()), "I", Vec2::ZERO, 14.0, 0.0).unwrap(); let (lo, hi) = extent(&bar); assert!(lo.x.abs() < 1e-9 && lo.y.abs() < 1e-9 && (hi.x - 2.0).abs() < 1e-9 && (hi.y - 14.0).abs() < 1e-9, "{lo:?} {hi:?}"); } #[test] fn text_in_a_font_closes_into_profiles_with_counters() { let folder = Folder::new("profiles"); let (at, height) = (v(10.0, 20.0), 7.0); let font = folder.font(); let source = LinkSource::Text { text: "O".into(), at, height, angle: 0.0, font: Some(font.clone()), style: Default::default() }; let mut sk = Sketch::new(); sk.add_link(LinkKind::Text, source, &text_geometry_in(Some(&font), "O", at, height, 0.0).unwrap()).unwrap(); // The block with the counter in it is 8 x 7 mm minus 4 x 3 mm; the counter is a region of its own. let mut areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); areas.sort_by(f64::total_cmp); assert_eq!(areas.len(), 2, "{areas:?}"); assert!((areas[0] - 12.0).abs() < 1e-9 && (areas[1] - 44.0).abs() < 1e-9, "{areas:?}"); } #[test] fn fonts_resolve_by_file_by_installed_name_or_to_the_built_in_one() { let folder = Folder::new("names"); // No name, an empty one or the built-in family in any spelling: the built-in font. for name in [None, Some(""), Some(" "), Some("Liberation Sans"), Some("liberation-sans")] { assert_eq!(font_data(name).unwrap().bytes(), font_data(None).unwrap().bytes(), "{name:?}"); } // A file by its path; its bytes are those of the file. let by_file = font_data(Some(&folder.font())).unwrap(); assert_eq!(by_file.bytes(), tiny_font().as_slice()); // A family by name in a font folder (searched below it, other files skipped), in any spelling; also by its full name. for name in ["Tiny Block", "tiny_block", "TINYBLOCK", "tiny-block", "Tiny Block Regular"] { let found = find_family_in(std::slice::from_ref(&folder.0), name).unwrap().unwrap_or_else(|| panic!("{name}")); assert_eq!(found.bytes(), tiny_font().as_slice(), "{name}"); } assert!(find_family_in(std::slice::from_ref(&folder.0), "No Such Family").unwrap().is_none()); assert!(find_family_in(&[folder.0.join("missing")], "Tiny Block").unwrap().is_none()); // The second font of the repository, by its family name. let bundled = find_family_in(&[web_fonts()], "BIZ UDPGothic").unwrap().unwrap(); assert_eq!(bundled.bytes(), std::fs::read(web_fonts().join("BIZUDPGothic-Regular.ttf")).unwrap().as_slice()); } #[test] fn unknown_fonts_and_unreadable_files_are_errors() { let folder = Folder::new("errors"); let message = |name: &str| font_data(Some(name)).err().unwrap().to_string(); assert!(message("No Such Family 12345").contains("unknown font `No Such Family 12345`")); assert!(message("C:/no/such/dir/font.ttf").contains("font file `C:/no/such/dir/font.ttf`")); assert!(message(&folder.0.join("notes.txt").to_string_lossy()).contains("font file")); assert!(message(&folder.0.join("broken.ttf").to_string_lossy()).contains("is not a TrueType/OpenType font")); assert!(message(&"x".repeat(2000)).contains("too long")); // The error reaches text_geometry_in, which makes no geometry from it. assert!(text_geometry_in(Some("No Such Family 12345"), "A", Vec2::ZERO, 8.0, 0.0).is_err()); } #[test] fn a_font_name_is_a_path_when_it_has_a_separator_or_a_font_extension() { let message = |name: &str| font_data(Some(name)).err().unwrap().to_string(); // A name with a separator or a font extension is a file, whatever else it looks like; `%VAR%` is expanded in it and a // variable that is not set (or no variable at all) stays as written. assert!(message("%NO_SUCH_VAR_SOLIDCRAFT%/a.ttf").contains("font file `%NO_SUCH_VAR_SOLIDCRAFT%/a.ttf`")); assert!(message("100% sure.ttf").contains("font file `100% sure.ttf`")); assert!(message("Arial.TTF").contains("font file `Arial.TTF`")); assert!(message("fonts/a.otf").contains("font file `fonts/a.otf`")); // Anything else is a family name. assert!(message("No Such Family 12345").contains("unknown font")); } #[test] fn a_text_that_the_font_makes_too_large_is_refused() { // A glyph of 300 corner points, 20 000 lines at most in a link: 66 of them are 19 800 lines, 67 are 20 100. let ring: Vec<(i16, i16)> = (0..300).map(|i| ((500.0 * (f64::from(i) * 0.0209).cos()) as i16, (500.0 * (f64::from(i) * 0.0209).sin()) as i16)).collect(); let font = build_font("Many", &[Glyph { ch: 'H', advance: 100, contours: vec![ring] }]); let folder = Folder::new("large"); let path = folder.0.join("many.ttf").to_string_lossy().into_owned(); std::fs::write(&path, font).unwrap(); assert_eq!(text_geometry_in(Some(&path), &"H".repeat(66), Vec2::ZERO, 5.0, 0.0).unwrap().len(), 66 * 300); assert!(matches!(text_geometry_in(Some(&path), &"H".repeat(67), Vec2::ZERO, 5.0, 0.0), Err(SketchError::TooLarge))); } #[test] fn a_font_is_kept_in_the_design_and_old_designs_read_without_one() { let source = |font: Option<&str>| LinkSource::Text { text: "I".into(), at: Vec2::ZERO, height: 5.0, angle: 0.0, font: font.map(str::to_string), style: Default::default(), }; // No font: nothing is written, so the text of a design reads and writes as it did before fonts, and a design saved // before them reads as text in the built-in font. let plain = serde_json::to_string(&source(None)).unwrap(); assert!(!plain.contains("font"), "{plain}"); assert_eq!(serde_json::from_str::(&plain).unwrap(), source(None)); // A font name or path comes back as it was written. let json = serde_json::to_string(&source(Some("C:/fonts/Some Font.ttf"))).unwrap(); assert!(json.contains(r#""font":"C:/fonts/Some Font.ttf""#), "{json}"); assert_eq!(serde_json::from_str::(&json).unwrap(), source(Some("C:/fonts/Some Font.ttf"))); } #[test] fn installed_families_are_listed_by_name_once_without_the_built_in_one() { let folder = Folder::new("families"); // A second copy of the tiny font, the built-in font and the repository's second font: each family once, // sorted; the built-in family is not among the installed ones, broken files and other files are skipped. std::fs::write(folder.0.join("copy.ttf"), tiny_font()).unwrap(); std::fs::copy( PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"), folder.0.join("lib.ttf"), ) .unwrap(); std::fs::copy(web_fonts().join("BIZUDPGothic-Regular.ttf"), folder.0.join("biz.ttf")).unwrap(); let families = crate::families_in(std::slice::from_ref(&folder.0)); assert_eq!(families, ["BIZ UDPGothic", "Tiny Block"]); // Each name finds its font again. for family in &families { assert!(find_family_in(std::slice::from_ref(&folder.0), family).unwrap().is_some(), "{family}"); } assert!(crate::families_in(&[folder.0.join("missing")]).is_empty()); }