open-standards/wai/wai-vector/src/wsf1.rs
Transaction Science 4c61c5b67b wai-vector: the default stroke set and the test repertoire, as WSF1 sources expanded by Annex A; the text vectors (D7-5)
- WSF1 (feature author): the stroke source format, its checks, the pinned Q15 table of
  64 unit vectors and the integer expansion into a polygon-only WOS1.
- The ASCII stroke set, U+0020-U+007E, drawn here as integer centre lines; bundled as
  builtin::STROKE_ASCII with its SHA-256, which Annex B states.
- The test repertoire: Arabic, Hebrew and Devanagari glyphs and a U+1F600 face, drawn
  here, with the joining table and the small shaper the corpus generator uses.
- graphics-vector.md section 4, Annex A (WSF1, the table as bytes, the expansion) and
  Annex B (the digest).
- vector-conformance: sources/, the two sets, the text group, and stroke-glyph members of
  runs and motion; the README's provenance line names the in-house sets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:41:08 -04:00

543 lines
20 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `WSF1`, the stroke source format, and its integer expansion into a `WOS1`
//! (`graphics-vector.md` Annex A). Authoring only: a sink never reads a `WSF1` and
//! has no stroker (§3.14), so this module is behind the feature `author`.
//!
//! A source holds, for each glyph, stroke paths: polylines of integer centre-line
//! points in outline units, drawn with one round pen of radius `ρ`. The expansion
//! turns each stroke path into one closed polygon contour:
//! - each segment `(dx, dy)` is offset by `n = (R_rat(−dy·ρ, len), R_rat(dx·ρ, len))`
//! with `len = isqrt(dx² + dy²)`;
//! - caps and the outer side of joins take their vertices from [`UNIT`], the pinned
//! table of 64 unit vectors in Q15, each placed at `R_half_up(ρ·u, 15)`, chosen by
//! exact cross-product sign tests;
//! - the inner side of a join takes the two offset points.
//!
//! No trigonometry and no floating point run anywhere: the same source gives the same
//! `WOS1` bytes on every machine.
use alloc::vec::Vec;
use core::cmp::Ordering;
use crate::arith::{r_half_up, r_rat};
use crate::json::hex;
use crate::sha256::sha256;
use crate::wos1::{Point, Wos1};
/// The header's size.
pub const HEADER: usize = 32;
/// A glyph record's size.
pub const GLYPH: usize = 8;
/// The caps of Annex A. They make every expansion fit `WOS1`'s caps (§2.1): a stroke
/// of `m ≥ 2` points expands to at most `36·m − 4` points and a dot to 64, so a glyph
/// expands to at most `36·256 + 64·64 = 13,312 ≤ 16,383` points, and a source to at
/// most `36·2^16 + 64·2^14 ≤ 2^22`.
pub const GLYPHS_MAX: u64 = 65_536;
pub const STROKES_TOTAL_MAX: u64 = 1 << 14;
pub const POINTS_TOTAL_MAX: u64 = 1 << 16;
pub const NOTES_MAX: u64 = 1 << 19;
pub const GLYPH_STROKES_MAX: u16 = 64;
pub const GLYPH_POINTS_MAX: u16 = 256;
pub const RADIUS_MAX: u16 = 4096;
/// The expansion's name and version, written into the notes of every set it makes.
pub const CONVERTER: &str = "wsf1-expansion/1";
/// The pinned table (Annex A): `U[k] = (round(2^15·cos(2πk/64)), round(2^15·sin(2πk/64)))`
/// for `k = 0 … 63`, rounded once to the nearest integer (no value lies within 0.02 of
/// a tie) and frozen. With y down, `k` increases from +x toward +y. The bytes of
/// Annex A, each component an `i32` little-endian in this order, are normative.
pub const UNIT: [(i32, i32); 64] = [
(32768, 0),
(32610, 3212),
(32138, 6393),
(31357, 9512),
(30274, 12540),
(28899, 15447),
(27246, 18205),
(25330, 20788),
(23170, 23170),
(20788, 25330),
(18205, 27246),
(15447, 28899),
(12540, 30274),
(9512, 31357),
(6393, 32138),
(3212, 32610),
(0, 32768),
(-3212, 32610),
(-6393, 32138),
(-9512, 31357),
(-12540, 30274),
(-15447, 28899),
(-18205, 27246),
(-20788, 25330),
(-23170, 23170),
(-25330, 20788),
(-27246, 18205),
(-28899, 15447),
(-30274, 12540),
(-31357, 9512),
(-32138, 6393),
(-32610, 3212),
(-32768, 0),
(-32610, -3212),
(-32138, -6393),
(-31357, -9512),
(-30274, -12540),
(-28899, -15447),
(-27246, -18205),
(-25330, -20788),
(-23170, -23170),
(-20788, -25330),
(-18205, -27246),
(-15447, -28899),
(-12540, -30274),
(-9512, -31357),
(-6393, -32138),
(-3212, -32610),
(0, -32768),
(3212, -32610),
(6393, -32138),
(9512, -31357),
(12540, -30274),
(15447, -28899),
(18205, -27246),
(20788, -25330),
(23170, -23170),
(25330, -20788),
(27246, -18205),
(28899, -15447),
(30274, -12540),
(31357, -9512),
(32138, -6393),
(32610, -3212),
];
/// The table as Annex A states it: 512 bytes, `cos` then `sin` of each entry, each an
/// `i32` little-endian.
pub fn unit_table_bytes() -> Vec<u8> {
let mut o = Vec::with_capacity(512);
for (c, s) in UNIT {
o.extend_from_slice(&c.to_le_bytes());
o.extend_from_slice(&s.to_le_bytes());
}
o
}
/// A glyph of a source: its ID, its advance (read by the authoring tool's layout, never
/// by a sink), and its stroke paths.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Glyph {
pub gid: u16,
pub advance: u16,
pub strokes: Vec<Vec<(i16, i16)>>,
}
/// A parsed `WSF1`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Wsf1 {
pub upem2: u16,
pub radius: u16,
pub glyphs: Vec<Glyph>,
pub notes: Vec<u8>,
}
/// Why a source is refused: the Annex A check that failed, and where.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SourceError {
pub check: u8,
pub rule: &'static str,
pub index: u64,
}
fn err<T>(check: u8, rule: &'static str, index: u64) -> Result<T, SourceError> {
Err(SourceError { check, rule, index })
}
fn rd16(b: &[u8], o: usize) -> u16 {
u16::from_le_bytes([b[o], b[o + 1]])
}
fn rd32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
/// The exact length a header's counts give.
pub fn exact_len(n_glyphs: u64, strokes_total: u64, points_total: u64, notes_len: u64) -> u64 {
HEADER as u64 + GLYPH as u64 * n_glyphs + 2 * strokes_total + 4 * points_total + notes_len
}
/// `cross(a, b) = a.x·b.y − a.y·b.x`.
#[inline]
fn cross(a: (i64, i64), b: (i64, i64)) -> i64 {
a.0 * b.1 - a.1 * b.0
}
/// Parse a `WSF1`, running Annex A's checks in their order and reporting the first
/// failure.
pub fn parse(b: &[u8]) -> Result<Wsf1, SourceError> {
// Check 1.
if b.len() < HEADER {
return err(1, "the header is incomplete", 0);
}
if &b[0..4] != b"WSF1" {
return err(1, "magic", 0);
}
if rd32(b, 4) != 1 {
return err(1, "format", 0);
}
if rd32(b, 28) != 0 {
return err(1, "zero", 0);
}
let upem2 = rd16(b, 8);
let radius = rd16(b, 10);
// Check 2, in field order.
if radius == 0 || radius > RADIUS_MAX {
return err(2, "radius", 0);
}
let n_glyphs = u64::from(rd32(b, 12));
let strokes_total = u64::from(rd32(b, 16));
let points_total = u64::from(rd32(b, 20));
let notes_len = u64::from(rd32(b, 24));
if n_glyphs == 0 || n_glyphs > GLYPHS_MAX {
return err(2, "n_glyphs", 0);
}
if strokes_total > STROKES_TOTAL_MAX {
return err(2, "strokes_total", 0);
}
if points_total > POINTS_TOTAL_MAX {
return err(2, "points_total", 0);
}
if notes_len == 0 || notes_len > NOTES_MAX {
return err(2, "notes_len", 0);
}
// Check 3.
if exact_len(n_glyphs, strokes_total, points_total, notes_len) != b.len() as u64 {
return err(3, "exact length", 0);
}
let (ng, ns, np) = (n_glyphs as usize, strokes_total as usize, points_total as usize);
let e0 = HEADER + GLYPH * ng;
let p0 = e0 + 2 * ns;
let n0 = p0 + 4 * np;
// Check 4: each glyph record.
let mut recs: Vec<(u16, u16, u16, u16)> = Vec::with_capacity(ng);
for i in 0..ng {
let o = HEADER + GLYPH * i;
let (gid, n_strokes, n_points, advance) = (rd16(b, o), rd16(b, o + 2), rd16(b, o + 4), rd16(b, o + 6));
if n_strokes > GLYPH_STROKES_MAX || n_points > GLYPH_POINTS_MAX {
return err(4, "a glyph's strokes or points above its cap", i as u64);
}
if n_strokes > n_points || (n_points >= 1 && n_strokes == 0) {
return err(4, "n_strokes against n_points", i as u64);
}
recs.push((gid, n_strokes, n_points, advance));
}
// Check 5.
for i in 1..ng {
if recs[i].0 <= recs[i - 1].0 {
return err(5, "gids strictly increase", i as u64);
}
}
// Check 6.
let ss: u64 = recs.iter().map(|r| u64::from(r.1)).sum();
let sp: u64 = recs.iter().map(|r| u64::from(r.2)).sum();
if ss != strokes_total || sp != points_total {
return err(6, "sums", 0);
}
// Check 7: stroke ends.
let ends: Vec<u16> = (0..ns).map(|i| rd16(b, e0 + 2 * i)).collect();
let pts: Vec<(i16, i16)> = (0..np).map(|i| (rd16(b, p0 + 4 * i) as i16, rd16(b, p0 + 4 * i + 2) as i16)).collect();
let (mut fe, mut fp) = (0usize, 0usize);
let mut glyphs: Vec<Glyph> = Vec::with_capacity(ng);
for (i, &(gid, n_strokes, n_points, advance)) in recs.iter().enumerate() {
let ge = &ends[fe..fe + n_strokes as usize];
if ge.windows(2).any(|w| w[1] <= w[0]) || ge.last().is_some_and(|&l| u32::from(l) + 1 != u32::from(n_points)) {
return err(7, "stroke ends", i as u64);
}
let gp = &pts[fp..fp + n_points as usize];
let mut strokes = Vec::with_capacity(ge.len());
let mut start = 0usize;
for &e in ge {
strokes.push(gp[start..=e as usize].to_vec());
start = e as usize + 1;
}
glyphs.push(Glyph { gid, advance, strokes });
fe += n_strokes as usize;
fp += n_points as usize;
}
// Check 8: every point leaves room for the pen inside i16.
let r = i32::from(radius);
if let Some(i) = pts.iter().position(|&(x, y)| {
let ok = |v: i16| i32::from(v) - r >= i32::from(i16::MIN) && i32::from(v) + r <= i32::from(i16::MAX);
!(ok(x) && ok(y))
}) {
return err(8, "a point within the radius of the i16 range's edge", i as u64);
}
// Check 9: consecutive points differ, and no interior vertex reverses.
for (i, g) in glyphs.iter().enumerate() {
for s in &g.strokes {
let d: Vec<(i64, i64)> = s.windows(2).map(|w| (i64::from(w[1].0) - i64::from(w[0].0), i64::from(w[1].1) - i64::from(w[0].1))).collect();
if d.contains(&(0, 0)) {
return err(9, "two consecutive points of a stroke are equal", i as u64);
}
if d.windows(2).any(|w| cross(w[0], w[1]) == 0 && w[0].0 * w[1].0 + w[0].1 * w[1].1 < 0) {
return err(9, "a stroke reverses on itself", i as u64);
}
}
}
// Check 10.
if core::str::from_utf8(&b[n0..]).is_err() {
return err(10, "notes UTF-8", 0);
}
Ok(Wsf1 { upem2, radius, glyphs, notes: b[n0..].to_vec() })
}
impl Wsf1 {
/// The canonical bytes.
pub fn write(&self) -> Vec<u8> {
let ns: usize = self.glyphs.iter().map(|g| g.strokes.len()).sum();
let np: usize = self.glyphs.iter().flat_map(|g| g.strokes.iter()).map(Vec::len).sum();
let mut o = Vec::with_capacity(exact_len(self.glyphs.len() as u64, ns as u64, np as u64, self.notes.len() as u64) as usize);
o.extend_from_slice(b"WSF1");
o.extend_from_slice(&1u32.to_le_bytes());
o.extend_from_slice(&self.upem2.to_le_bytes());
o.extend_from_slice(&self.radius.to_le_bytes());
o.extend_from_slice(&(self.glyphs.len() as u32).to_le_bytes());
o.extend_from_slice(&(ns as u32).to_le_bytes());
o.extend_from_slice(&(np as u32).to_le_bytes());
o.extend_from_slice(&(self.notes.len() as u32).to_le_bytes());
o.extend_from_slice(&0u32.to_le_bytes());
for g in &self.glyphs {
let n: usize = g.strokes.iter().map(Vec::len).sum();
for v in [g.gid, g.strokes.len() as u16, n as u16, g.advance] {
o.extend_from_slice(&v.to_le_bytes());
}
}
for g in &self.glyphs {
let mut end = 0usize;
for s in &g.strokes {
end += s.len();
o.extend_from_slice(&((end - 1) as u16).to_le_bytes());
}
}
for g in &self.glyphs {
for &(x, y) in g.strokes.iter().flatten() {
o.extend_from_slice(&x.to_le_bytes());
o.extend_from_slice(&y.to_le_bytes());
}
}
o.extend_from_slice(&self.notes);
o
}
/// The glyph of `gid`.
pub fn glyph(&self, gid: u16) -> Option<&Glyph> {
self.glyphs.binary_search_by_key(&gid, |g| g.gid).ok().map(|i| &self.glyphs[i])
}
}
/// `isqrt(n)`: the largest `r ≥ 0` with `r² ≤ n` ([integer-operators] §2), by Newton's
/// iteration from above, which is exact on integers.
pub fn isqrt(n: u64) -> u64 {
if n < 2 {
return n;
}
let mut x = n;
let mut y = x.div_ceil(2);
while y < x {
x = y;
y = (x + n / x) / 2;
}
x
}
/// The offset of a segment `d` for pen radius `ρ`: `(R_rat(−dy·ρ, len), R_rat(dx·ρ, len))`
/// with `len = isqrt(dx² + dy²)`.
pub fn normal(d: (i64, i64), rho: i64) -> (i64, i64) {
let len = isqrt((d.0 * d.0 + d.1 * d.1) as u64) as i64;
(r_rat(-d.1 * rho, len), r_rat(d.0 * rho, len))
}
fn add(p: (i64, i64), n: (i64, i64)) -> (i64, i64) {
(p.0 + n.0, p.1 + n.1)
}
fn sub(p: (i64, i64), n: (i64, i64)) -> (i64, i64) {
(p.0 - n.0, p.1 - n.1)
}
fn neg(n: (i64, i64)) -> (i64, i64) {
(-n.0, -n.1)
}
fn unit(k: usize) -> (i64, i64) {
(i64::from(UNIT[k].0), i64::from(UNIT[k].1))
}
/// The arc vertices at `p` from the offset `a` toward `b`, in the negative sense (the
/// sense of decreasing `k`): every table direction strictly between them, in order
/// from `a`. With `b = None` the arc is a cap, from `a` to `−a`: the directions `u`
/// with `cross(a, u) < 0`. With `b = Some(b)` it is a join, and is empty unless
/// `cross(a, b) < 0`: the directions with `cross(a, u) < 0` and `cross(u, b) < 0`.
fn arc(out: &mut Vec<(i64, i64)>, p: (i64, i64), a: (i64, i64), b: Option<(i64, i64)>, rho: i64) {
if let Some(b) = b
&& cross(a, b) >= 0
{
return;
}
let mut ks: Vec<usize> = (0..64).filter(|&k| cross(a, unit(k)) < 0 && b.is_none_or(|b| cross(unit(k), b) < 0)).collect();
// All selected directions lie in one open half-plane, so `cross` orders them: `u`
// comes before `v` when `v` lies in the negative sense from `u`.
ks.sort_by(|&i, &j| match cross(unit(i), unit(j)) {
c if c < 0 => Ordering::Less,
c if c > 0 => Ordering::Greater,
_ => Ordering::Equal,
});
for k in ks {
let u = unit(k);
out.push((p.0 + r_half_up(rho * u.0, 15), p.1 + r_half_up(rho * u.1, 15)));
}
}
/// One stroke path's closed contour (Annex A): the left offsets forward, the end cap,
/// the right offsets backward, then the start cap; a single point is a dot, every
/// table direction from `k = 0` in the negative sense. A vertex equal to the one before
/// it is written once, and a last vertex equal to the first is dropped.
pub fn stroke_contour(stroke: &[(i16, i16)], rho: i64) -> Vec<Point> {
let p: Vec<(i64, i64)> = stroke.iter().map(|&(x, y)| (i64::from(x), i64::from(y))).collect();
let m = p.len();
let mut v: Vec<(i64, i64)> = Vec::new();
if m == 1 {
for t in 0..64 {
let u = unit((64 - t) % 64);
v.push((p[0].0 + r_half_up(rho * u.0, 15), p[0].1 + r_half_up(rho * u.1, 15)));
}
} else {
let d: Vec<(i64, i64)> = p.windows(2).map(|w| (w[1].0 - w[0].0, w[1].1 - w[0].1)).collect();
let n: Vec<(i64, i64)> = d.iter().map(|&d| normal(d, rho)).collect();
// The left side, forward. Turning in the negative sense, it is the outer side.
v.push(add(p[0], n[0]));
for i in 1..m - 1 {
v.push(add(p[i], n[i - 1]));
if cross(d[i - 1], d[i]) < 0 {
arc(&mut v, p[i], n[i - 1], Some(n[i]), rho);
}
v.push(add(p[i], n[i]));
}
v.push(add(p[m - 1], n[m - 2]));
// The end cap, from n to −n through the last direction.
arc(&mut v, p[m - 1], n[m - 2], None, rho);
// The right side, backward. Turning in the positive sense, it is the outer side.
v.push(sub(p[m - 1], n[m - 2]));
for i in (1..m - 1).rev() {
v.push(sub(p[i], n[i]));
if cross(d[i - 1], d[i]) > 0 {
arc(&mut v, p[i], neg(n[i]), Some(neg(n[i - 1])), rho);
}
v.push(sub(p[i], n[i - 1]));
}
v.push(sub(p[0], n[0]));
// The start cap, from −n to n through the reverse of the first direction.
arc(&mut v, p[0], neg(n[0]), None, rho);
}
let mut out: Vec<Point> = Vec::with_capacity(v.len());
for (x, y) in v {
let q = Point { x: x as i16, y: y as i16, tag: 0 };
if out.last() != Some(&q) {
out.push(q);
}
}
while out.len() > 1 && out.last() == out.first() {
out.pop();
}
out
}
/// The notes of the set a source expands to: the line `parent_sha256=` with the
/// source's SHA-256 in lowercase hex, the line `converter=wsf1-expansion/1`, then the
/// source's notes, byte for byte.
pub fn expanded_notes(src: &[u8], notes: &[u8]) -> Vec<u8> {
let mut o = Vec::new();
o.extend_from_slice(b"parent_sha256=");
o.extend_from_slice(hex(&sha256(src)).as_bytes());
o.extend_from_slice(b"\nconverter=");
o.extend_from_slice(CONVERTER.as_bytes());
o.push(b'\n');
o.extend_from_slice(notes);
o
}
/// Expand a source into its `WOS1` (Annex A): one glyph record per source glyph, with
/// the same `gid`; one contour per stroke path; `upem2` copied; `flags` 0, since
/// overlaps are not removed; and the notes of [`expanded_notes`].
pub fn expand(src: &[u8]) -> Result<Wos1, SourceError> {
let s = parse(src)?;
let rho = i64::from(s.radius);
let glyphs: Vec<(u16, Vec<Vec<Point>>)> =
s.glyphs.iter().map(|g| (g.gid, g.strokes.iter().map(|st| stroke_contour(st, rho)).collect())).collect();
Ok(Wos1::from_outlines(s.upem2, 0, &glyphs, &expanded_notes(src, &s.notes)))
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
#[test]
fn isqrt_is_the_floor() {
for n in 0u64..20_000 {
let r = isqrt(n);
assert!(r * r <= n && (r + 1) * (r + 1) > n, "{n}");
}
for n in [u64::from(u32::MAX), (1u64 << 33) - 1, 2 * 65_535 * 65_535] {
let r = isqrt(n);
assert!(r * r <= n && (r + 1) * (r + 1) > n, "{n}");
}
}
#[test]
fn the_table_is_symmetric_and_unit() {
for k in 0..64 {
let (c, s) = UNIT[k];
assert_eq!(UNIT[(k + 16) % 64], (-s, c), "a quarter turn of {k}");
assert_eq!(UNIT[(64 - k) % 64], (c, -s), "the reflection of {k}");
let r2 = i64::from(c) * i64::from(c) + i64::from(s) * i64::from(s);
assert!((r2 - (1 << 30)).abs() < 1 << 17, "{k}: |u|² = {r2}");
}
}
#[test]
fn a_horizontal_stroke_is_a_stadium() {
let c = stroke_contour(&[(0, 0), (1000, 0)], 100);
// Left offsets (y + 100: n = (0, 100)), the cap round +x, the right offsets,
// the cap round −x.
assert_eq!(c[0], Point { x: 0, y: 100, tag: 0 });
assert_eq!(c[1], Point { x: 1000, y: 100, tag: 0 });
assert!(c.iter().all(|p| p.x >= -100 && p.x <= 1100 && p.y >= -100 && p.y <= 100));
assert!(c.contains(&Point { x: 1100, y: 0, tag: 0 }) && c.contains(&Point { x: -100, y: 0, tag: 0 }));
// Each cap holds the 31 directions strictly inside its half-plane.
assert_eq!(c.len(), 4 + 31 + 31);
}
#[test]
fn a_dot_is_a_polygon_of_the_table() {
let c = stroke_contour(&[(500, -500)], 80);
assert_eq!(c.len(), 64);
assert_eq!(c[0], Point { x: 580, y: -500, tag: 0 });
assert_eq!(c[16], Point { x: 500, y: -580, tag: 0 }, "the negative sense: up after right");
}
#[test]
fn round_trip() {
let s = Wsf1 {
upem2: 2000,
radius: 80,
glyphs: vec![
Glyph { gid: 32, advance: 600, strokes: vec![] },
Glyph { gid: 65, advance: 1200, strokes: vec![vec![(0, 0), (500, -1400), (1000, 0)], vec![(300, -500)]] },
],
notes: b"licence=Apache-2.0\n\ntext".to_vec(),
};
let b = s.write();
assert_eq!(parse(&b), Ok(s));
let w = expand(&b).unwrap();
assert_eq!(crate::wos1::parse(&w.write()).unwrap(), w);
}
}