221 lines
10 KiB
Rust
221 lines
10 KiB
Rust
//! The in-house sets (`graphics-vector.md` §4, Annexes A and B): the default stroke set's
|
||
//! digest equals Annex B; Annex A states the expansion's table; the expansion is
|
||
//! deterministic and re-derives both committed sets from their committed sources; the
|
||
//! sets' notes carry their licence; and the test repertoire's joining table picks the
|
||
//! forms of the test words.
|
||
//!
|
||
//! Every leg runs these: natively, and under WASI with the `wai` directory preopened.
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use wai_vector::builtin;
|
||
use wai_vector::json::hex;
|
||
use wai_vector::sha256::sha256;
|
||
use wai_vector::vectors::{repertoire, stroke, text};
|
||
use wai_vector::wsf1;
|
||
|
||
fn wai() -> PathBuf {
|
||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||
}
|
||
|
||
fn read(rel: &str) -> Vec<u8> {
|
||
std::fs::read(wai().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
|
||
}
|
||
|
||
/// The text of the extension from `heading` up to the next heading of the same level.
|
||
fn section(heading: &str) -> String {
|
||
let doc = String::from_utf8(read("extensions/graphics-vector.md")).unwrap();
|
||
let start = doc.find(heading).unwrap_or_else(|| panic!("no {heading}"));
|
||
let level = heading.split(' ').next().unwrap();
|
||
let rest = &doc[start + heading.len()..];
|
||
let end = rest.find(&format!("\n{level} ")).unwrap_or(rest.len());
|
||
rest[..end].to_string()
|
||
}
|
||
|
||
/// The first run of 64 lowercase hexadecimal digits on the line holding `label`.
|
||
fn digest_on(text: &str, label: &str) -> String {
|
||
let line = text.lines().find(|l| l.contains(label)).unwrap_or_else(|| panic!("no line with {label}"));
|
||
line.split(|c: char| !c.is_ascii_hexdigit()).find(|w| w.len() == 64).unwrap_or_else(|| panic!("no digest on {line}")).to_string()
|
||
}
|
||
|
||
/// The number before " bytes" on the line holding `label`, commas removed.
|
||
fn length_on(text: &str, label: &str) -> usize {
|
||
let line = text.lines().find(|l| l.contains(label)).unwrap();
|
||
let before = &line[..line.find(" bytes").unwrap()];
|
||
before.rsplit("length ").next().unwrap().replace(',', "").parse().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn the_stroke_sets_digest_equals_annex_b() {
|
||
let b = section("## Annex B.");
|
||
let (set_digest, src_digest) = (digest_on(&b, "the set (`WOS1`)"), digest_on(&b, "its source (`WSF1`)"));
|
||
// The bundled bytes, their constant and the annex agree.
|
||
assert_eq!(hex(&sha256(builtin::STROKE_ASCII)), set_digest, "the bundled set's SHA-256 is Annex B's");
|
||
assert_eq!(hex(&builtin::STROKE_ASCII_SHA256), set_digest, "the digest constant is Annex B's");
|
||
assert_eq!(builtin::STROKE_ASCII.len(), length_on(&b, "the set (`WOS1`)"));
|
||
assert_eq!(builtin::STROKE_ASCII_LEN as usize, builtin::STROKE_ASCII.len());
|
||
// The committed source is Annex B's, and expanding it by Annex A gives the set.
|
||
let src = read("vector-conformance/sources/stroke-ascii.wsf1");
|
||
assert_eq!(hex(&sha256(&src)), src_digest, "the committed source's SHA-256 is Annex B's");
|
||
assert_eq!(src.len(), length_on(&b, "its source (`WSF1`)"));
|
||
let set = wsf1::expand(&src).unwrap().write();
|
||
assert_eq!(hex(&sha256(&set)), set_digest, "Annex A's expansion of the source gives Annex B's set");
|
||
assert!(set == builtin::STROKE_ASCII);
|
||
// Coverage: printable ASCII, each glyph ID its code point.
|
||
let w = wai_vector::wos1::parse(builtin::STROKE_ASCII).unwrap();
|
||
assert_eq!(w.glyphs.iter().map(|g| g.gid).collect::<Vec<u16>>(), (0x20..=0x7E).collect::<Vec<u16>>());
|
||
}
|
||
|
||
#[test]
|
||
fn annex_a_states_the_table() {
|
||
let a = section("### A.2 ");
|
||
let block = &a[a.find("```text").unwrap() + 7..];
|
||
let block = &block[..block.find("```").unwrap()];
|
||
let hexs: String = block.split_whitespace().collect();
|
||
assert_eq!(hexs, hex(&wsf1::unit_table_bytes()), "Annex A's bytes are the table the expansion reads");
|
||
assert_eq!(digest_on(&a, "Their SHA-256"), hex(&sha256(&wsf1::unit_table_bytes())));
|
||
}
|
||
|
||
#[test]
|
||
fn the_expansion_is_deterministic() {
|
||
// Offsets worked by hand. (300, 400) has an exact length; (100, 200) does not:
|
||
// isqrt(50000) = 223, and −18000/223 = −80.72 rounds to −81, 9000/223 = 40.36 to 40.
|
||
assert_eq!(wsf1::normal((300, 400), 80), (-64, 48));
|
||
assert_eq!(wsf1::normal((100, 200), 90), (-81, 40));
|
||
assert_eq!(wsf1::normal((-7, 3), 80), (-34, -80), "isqrt(58) = 7: (R_rat(−240, 7), R_rat(−560, 7))");
|
||
// Both committed sets re-derive from their committed sources, twice, byte for byte.
|
||
for name in ["stroke-ascii", "test-repertoire"] {
|
||
let src = read(&format!("vector-conformance/sources/{name}.wsf1"));
|
||
let set = read(&format!("vector-conformance/sets/{name}.wos1"));
|
||
let a = wsf1::expand(&src).unwrap().write();
|
||
let b = wsf1::expand(&src).unwrap().write();
|
||
assert!(a == b, "{name}: two expansions agree");
|
||
assert!(a == set, "{name}: the expansion reproduces the committed set");
|
||
// The source written by the generator is the committed one.
|
||
let generated = if name == "stroke-ascii" { stroke::ascii_source_bytes() } else { repertoire::repertoire_source_bytes() };
|
||
assert!(generated == src, "{name}: the generator writes the committed source");
|
||
// The set's notes name its source by SHA-256, and the expansion.
|
||
let w = wai_vector::wos1::parse(&set).unwrap();
|
||
let notes = String::from_utf8(w.notes.clone()).unwrap();
|
||
assert!(notes.starts_with(&format!("parent_sha256={}\nconverter=wsf1-expansion/1\n", hex(&sha256(&src)))), "{name}");
|
||
// A polygon-only set: every point on-curve, overlaps not removed.
|
||
assert!(w.points.iter().all(|p| p.tag == 0) && w.flags == 0, "{name}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn each_set_carries_its_licence() {
|
||
for name in ["stroke-ascii", "test-repertoire"] {
|
||
let set = read(&format!("vector-conformance/sets/{name}.wos1"));
|
||
let w = wai_vector::wos1::parse(&set).unwrap();
|
||
let notes = String::from_utf8(w.notes).unwrap();
|
||
let (keys, licence) = notes.split_once("\n\n").expect("key lines, a blank line, the licence text");
|
||
let key = |k: &str| keys.lines().find_map(|l| l.strip_prefix(&format!("{k}="))).unwrap_or_else(|| panic!("{name}: no {k}"));
|
||
assert_eq!(key("licence"), "Apache-2.0", "{name}");
|
||
assert_eq!(key("licence_sha256"), hex(&sha256(licence.as_bytes())), "{name}: the verifier's licence check");
|
||
assert_eq!(key("rfn"), "none", "{name}");
|
||
assert!(key("source").contains("no outline is traced from any font"), "{name}");
|
||
assert!(licence.contains("Apache License") && licence.contains("Version 2.0"), "{name}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_joining_table_picks_the_forms_of_the_test_words() {
|
||
for (word, want) in text::test_words() {
|
||
let got: Vec<u16> = repertoire::shape_arabic(word).0.iter().map(|g| g.gid).collect();
|
||
assert_eq!(got, want, "{word}");
|
||
}
|
||
// Every form the table names is drawn in the test repertoire.
|
||
let src = repertoire::repertoire_source();
|
||
for (_, _, forms) in repertoire::JOINING {
|
||
for f in forms {
|
||
assert!(src.glyph(f).is_some(), "form {f:04X} is drawn");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The winding number of the closed polygon contours of a glyph about `(px, py)`.
|
||
fn winding(contours: &[Vec<(i64, i64)>], px: i64, py: i64) -> i64 {
|
||
let mut w = 0;
|
||
for c in contours {
|
||
for i in 0..c.len() {
|
||
let (a, b) = (c[i], c[(i + 1) % c.len()]);
|
||
let side = (b.0 - a.0) * (py - a.1) - (b.1 - a.1) * (px - a.0);
|
||
if a.1 <= py && py < b.1 && side > 0 {
|
||
w += 1;
|
||
} else if b.1 <= py && py < a.1 && side < 0 {
|
||
w -= 1;
|
||
}
|
||
}
|
||
}
|
||
w
|
||
}
|
||
|
||
/// Whether `(px, py)` lies within `r` of a centre-line polyline, exactly in integers.
|
||
fn within(stroke: &[(i16, i16)], px: i64, py: i64, r: i64) -> bool {
|
||
let p: Vec<(i64, i64)> = stroke.iter().map(|&(x, y)| (i64::from(x), i64::from(y))).collect();
|
||
let d2 = |a: (i64, i64)| (px - a.0).pow(2) + (py - a.1).pow(2) <= r * r;
|
||
if p.len() == 1 {
|
||
return d2(p[0]);
|
||
}
|
||
p.windows(2).any(|s| {
|
||
let (a, b) = (s[0], s[1]);
|
||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||
let l = dx * dx + dy * dy;
|
||
let t = (px - a.0) * dx + (py - a.1) * dy;
|
||
if t <= 0 {
|
||
d2(a)
|
||
} else if t >= l {
|
||
d2(b)
|
||
} else {
|
||
let c = i128::from((px - a.0) * dy - (py - a.1) * dx);
|
||
c * c <= i128::from(r * r) * i128::from(l)
|
||
}
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn the_expansion_covers_the_pens_path() {
|
||
// Sampled every 20 outline units: a point within ρ − 4 of a centre line is inside
|
||
// the glyph's fill (non-zero winding), and a point farther than ρ + 8 from every
|
||
// centre line is outside it. The bands absorb the 64-gon's chords and the offsets'
|
||
// rounding; the inner loops of joins must not open a hole, and no outer join may
|
||
// cut its corner.
|
||
for src in [stroke::ascii_source(), repertoire::repertoire_source()] {
|
||
let rho = i64::from(src.radius);
|
||
let set = wsf1::expand(&src.write()).unwrap();
|
||
for g in &src.glyphs {
|
||
let rec = set.glyph(g.gid).unwrap();
|
||
let pts = set.glyph_points(rec);
|
||
let mut contours = Vec::new();
|
||
let mut start = 0usize;
|
||
for &e in set.glyph_ends(rec) {
|
||
contours.push(pts[start..=e as usize].iter().map(|p| (i64::from(p.x), i64::from(p.y))).collect::<Vec<_>>());
|
||
start = e as usize + 1;
|
||
}
|
||
let (x0, y0, x1, y1) = (i64::from(rec.xmin) - 20, i64::from(rec.ymin) - 20, i64::from(rec.xmax) + 20, i64::from(rec.ymax) + 20);
|
||
let (mut inside, mut outside) = (0, 0);
|
||
let mut y = y0 + 3;
|
||
while y <= y1 {
|
||
let mut x = x0 + 7;
|
||
while x <= x1 {
|
||
let near = g.strokes.iter().any(|s| within(s, x, y, rho - 4));
|
||
let far = !g.strokes.iter().any(|s| within(s, x, y, rho + 8));
|
||
let w = winding(&contours, x, y);
|
||
if near {
|
||
inside += 1;
|
||
assert!(w != 0, "glyph {:04X}: ({x}, {y}) is within the pen's path but not filled", g.gid);
|
||
}
|
||
if far {
|
||
outside += 1;
|
||
assert!(w == 0, "glyph {:04X}: ({x}, {y}) is outside the pen's path but winds {w}", g.gid);
|
||
}
|
||
x += 20;
|
||
}
|
||
y += 20;
|
||
}
|
||
assert!(g.strokes.is_empty() || inside > 0, "glyph {:04X}: the samples reach its strokes", g.gid);
|
||
assert!(outside > 0);
|
||
}
|
||
}
|
||
}
|