Skip to content

Commit b235d9f

Browse files
feat(n0_cli): declare fonts with --font, and render text
Text was compilable but unreachable from the product surface: the CLI had no way to declare a font, so every <text> run refused. It now does, under the hermetic rule the text-oracle method ratified. --font FAMILY=PATH@sha256:HEX (repeatable) A family name is not a font identity — the same name resolves to different bytes on different machines — so a declaration carries the digest of the bytes it means, and the host verifies them before a frame exists. A mismatch refuses the render; it never becomes a silently different pixel. An undeclared family refuses by name: no system fallback, no ambient face, no machine-local pixel anywhere on this path. The end-to-end law renders fixtures/web-first/text/svg-text-em-box.svg through the host and asserts it is the committed Chromium oracle byte for byte — the same cell the compiler gate uses, now proven through the product surface it ships behind. The statement of record gains the text slice: the environment rule, the v0 profile, what places a run, the numeric domain and why it is where the gate holds, and every named refusal (including the CSS spelling of text-anchor, a generic family, and the inline-HTML entry, which declares no fonts).
1 parent 3a06dd9 commit b235d9f

5 files changed

Lines changed: 401 additions & 27 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/n0_cli/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ websem = { path = "../websem" }
2020
n0 = { path = "../n0" }
2121
math2 = { path = "../math2" }
2222
rframe = { path = "../rframe" }
23+
# The host declares the fonts text resolves against, and verifies their
24+
# bytes against the declared digest before any pixel exists.
25+
textlayout = { path = "../textlayout" }
26+
sha2 = "0.10"
2327
skia-safe = { version = "=0.99.0", features = [
2428
"gpu",
2529
"gl",

crates/n0_cli/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ cargo run -p n0_cli --bin n0 -- \
2929
fixtures/web-first/animation/svg-scene-cub-animation.svg /tmp/cub-1s.png 96x96 \
3030
--time-ns 1000000000
3131

32+
# text: the font is a declared, verified input of the render — the family
33+
# names bytes, and the bytes are checked before any pixel
34+
cargo run -p n0_cli --bin n0 -- \
35+
fixtures/web-first/text/svg-text-em-box.svg /tmp/text.png 100x100 \
36+
--font Ahem=fixtures/web-first/fonts/ahem.ttf@sha256:b719ecb31c5b21fc573c03f6421c74ac63c271a5a3ff841e34f9705fb94b8448
37+
3238
# dev harness: refuse on the first beyond-slice construct instead of
3339
# rendering best-effort with declared degradations (the default)
3440
cargo run -p n0_cli --bin n0 -- \
@@ -135,6 +141,36 @@ cargo run -p n0_cli --bin n0 -- \
135141
attribute refuses the paint), font-relative units in gradient geometry,
136142
a percentage in a gradient's computed transform (Chromium resolves it
137143
against mismatched spaces), an external reference, and `<pattern>`.
144+
`<text>` is consumed (the text rung), and its font environment is the
145+
host's: text resolves only against fonts declared with
146+
`--font FAMILY=PATH@sha256:HEX` (repeatable), whose bytes are **verified
147+
against the declared digest before any pixel exists** — a family name is
148+
not a font identity, and a mismatch refuses the render rather than
149+
producing a silently different one. A `<text>` run whose family was never
150+
declared refuses by name; there is no system fallback, no ambient face,
151+
and therefore no machine-local pixel anywhere on this path. Inside that
152+
environment one run resolves once through
153+
[the text oracle](../../docs/wg/feat-paragraph/text-layout.md) at its v0
154+
profile — one style run of printable ASCII, horizontal and
155+
left-to-right, no wrapping and no fallback — and its glyph outlines lower
156+
to the contract's ordinary path facts, so no font identity crosses into
157+
the resolved frame. `x`, `y`, and the `text-anchor` attribute
158+
(`start`/`middle`/`end`) place the run; `font-family` and `font-size`
159+
come from the one cascade, where an author rule beats the presentation
160+
attribute exactly as Chromium measured. Geometry is admitted only inside
161+
the ratified [numeric domain](../../docs/wg/consolidation/text-oracle.md)
162+
— integer position, a `font-size` that is an integer multiple of 5, an
163+
integer anchor-resolved start — because that is where every rasterizer's
164+
per-pixel coverage is 0 or 1 and the byte-exact gate holds; Chromium
165+
snaps everything else by a rasterizer-internal rule, and this refuses by
166+
name instead of codifying it. What refuses by name: the CSS spelling of
167+
`text-anchor` (Chromium consumes it from the cascade, the pinned Stylo
168+
build has no such longhand — a silent drop before the rung), a generic
169+
family (which names no declared font), `<tspan>` and any other element
170+
child, `dx`/`dy`/`rotate` lists, `textLength`, decorations, letter and
171+
word spacing, writing mode and direction, stroke on text, a colour or
172+
bitmap face, and any character outside the v0 repertoire. The inline-HTML
173+
entry declares no fonts, so its `<text>` refuses there.
138174
`display: none` and `visibility` are consumed from the one cascade
139175
(attribute and CSS spellings alike): a pruned or hidden element renders
140176
the correct nothing rather than a declared hole, a `visibility: visible`

crates/n0_cli/src/fonts.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//! The host's font declarations: `--font FAMILY=PATH@sha256:HEX`.
2+
//!
3+
//! A family name is not a font identity — the same name resolves to
4+
//! different bytes on different machines — so a declaration carries the
5+
//! digest of the bytes it means, and the host **verifies before rendering**.
6+
//! A mismatch refuses; it never becomes a silently different pixel.
7+
//!
8+
//! This is the host side of the hermetic environment the ratified
9+
//! [text-oracle method](../../../docs/wg/consolidation/text-oracle.md)
10+
//! requires. The engine never reads a font file and never consults an
11+
//! ambient font database: the environment it resolves against is exactly
12+
//! what was declared here, and an undeclared family refuses by name.
13+
14+
use std::path::Path;
15+
use std::sync::Arc;
16+
17+
use sha2::{Digest, Sha256};
18+
19+
/// One parsed `--font` declaration, before its bytes are read.
20+
#[derive(Clone, Debug, PartialEq, Eq)]
21+
pub(crate) struct FontDeclaration {
22+
pub(crate) family: String,
23+
pub(crate) path: String,
24+
/// Lowercase hex SHA-256 of the bytes this declaration means.
25+
pub(crate) digest: String,
26+
}
27+
28+
/// Parse one `FAMILY=PATH@sha256:HEX`.
29+
///
30+
/// The family is everything before the first `=`, the digest everything
31+
/// after the last `@sha256:` — so a path may contain `=` and `@`, and only
32+
/// the exact digest marker terminates it.
33+
pub(crate) fn parse_declaration(spec: &str) -> Result<FontDeclaration, String> {
34+
const MARKER: &str = "@sha256:";
35+
let Some((family, rest)) = spec.split_once('=') else {
36+
return Err(format!(
37+
"font declaration {spec:?} must look like FAMILY=PATH@sha256:HEX"
38+
));
39+
};
40+
if family.is_empty() {
41+
return Err(format!("font declaration {spec:?} names no family"));
42+
}
43+
let Some(marker_at) = rest.rfind(MARKER) else {
44+
return Err(format!(
45+
"font declaration {spec:?} carries no @sha256: digest — a family name is not a font \
46+
identity, so the bytes it means are declared and verified"
47+
));
48+
};
49+
let path = &rest[..marker_at];
50+
let digest = &rest[marker_at + MARKER.len()..];
51+
if path.is_empty() {
52+
return Err(format!("font declaration {spec:?} names no path"));
53+
}
54+
if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
55+
return Err(format!(
56+
"font declaration {spec:?} carries {digest:?}, which is not a 64-character hex SHA-256"
57+
));
58+
}
59+
Ok(FontDeclaration {
60+
family: family.to_string(),
61+
path: path.to_string(),
62+
digest: digest.to_ascii_lowercase(),
63+
})
64+
}
65+
66+
/// Read and verify every declaration into the engine's font environment.
67+
///
68+
/// Verification is not advisory: bytes whose digest differs from the
69+
/// declaration are refused here, before a frame exists, so no render can
70+
/// proceed against a font the host did not mean.
71+
pub(crate) fn load_environment(
72+
declarations: &[FontDeclaration],
73+
) -> Result<textlayout::Environment, String> {
74+
let mut resources = Vec::with_capacity(declarations.len());
75+
for declaration in declarations {
76+
let bytes = std::fs::read(Path::new(&declaration.path))
77+
.map_err(|error| format!("cannot read font {}: {error}", declaration.path))?;
78+
let actual = format!("{:x}", Sha256::digest(&bytes));
79+
if actual != declaration.digest {
80+
return Err(format!(
81+
"font {} is not the declared identity: expected sha256 {}, read {actual}",
82+
declaration.path, declaration.digest
83+
));
84+
}
85+
let mut digest = [0u8; 32];
86+
for (index, slot) in digest.iter_mut().enumerate() {
87+
*slot = u8::from_str_radix(&actual[index * 2..index * 2 + 2], 16)
88+
.expect("hex from a hex formatter");
89+
}
90+
resources.push(textlayout::FontResource {
91+
key: textlayout::FontKey::new(digest),
92+
family: declaration.family.clone(),
93+
face_index: 0,
94+
bytes: Arc::from(bytes),
95+
});
96+
}
97+
Ok(textlayout::Environment::new(resources))
98+
}
99+
100+
#[cfg(test)]
101+
mod tests {
102+
use super::*;
103+
104+
const HEX: &str = "b719ecb31c5b21fc573c03f6421c74ac63c271a5a3ff841e34f9705fb94b8448";
105+
106+
#[test]
107+
fn a_well_formed_declaration_parses() {
108+
let parsed = parse_declaration(&format!("Ahem=fixtures/ahem.ttf@sha256:{HEX}")).unwrap();
109+
assert_eq!(
110+
parsed,
111+
FontDeclaration {
112+
family: "Ahem".to_string(),
113+
path: "fixtures/ahem.ttf".to_string(),
114+
digest: HEX.to_string(),
115+
}
116+
);
117+
}
118+
119+
#[test]
120+
fn a_path_may_carry_the_delimiters() {
121+
// Only the *last* `@sha256:` terminates the path, and only the first
122+
// `=` ends the family — so awkward real paths still parse.
123+
let parsed =
124+
parse_declaration(&format!("My Font=/tmp/a=b@1/font.ttf@sha256:{HEX}")).unwrap();
125+
assert_eq!(parsed.family, "My Font");
126+
assert_eq!(parsed.path, "/tmp/a=b@1/font.ttf");
127+
}
128+
129+
#[test]
130+
fn an_undeclared_digest_refuses() {
131+
let error = parse_declaration("Ahem=fixtures/ahem.ttf").unwrap_err();
132+
assert!(error.contains("a family name is not a font identity"));
133+
}
134+
135+
#[test]
136+
fn a_malformed_digest_refuses() {
137+
for spec in [
138+
format!("Ahem=f.ttf@sha256:{}", &HEX[..63]),
139+
format!("Ahem=f.ttf@sha256:{}z", &HEX[..63]),
140+
"Ahem=f.ttf@sha256:".to_string(),
141+
] {
142+
let error = parse_declaration(&spec).unwrap_err();
143+
assert!(error.contains("hex SHA-256"), "{spec}: {error}");
144+
}
145+
}
146+
147+
#[test]
148+
fn an_empty_family_or_path_refuses() {
149+
assert!(parse_declaration(&format!("=f.ttf@sha256:{HEX}")).is_err());
150+
assert!(parse_declaration(&format!("Ahem=@sha256:{HEX}")).is_err());
151+
}
152+
153+
#[test]
154+
fn the_pinned_gate_font_verifies_and_a_wrong_digest_does_not() {
155+
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
156+
let path = root
157+
.join("fixtures/web-first/fonts/ahem.ttf")
158+
.display()
159+
.to_string();
160+
161+
let environment = load_environment(&[FontDeclaration {
162+
family: "Ahem".to_string(),
163+
path: path.clone(),
164+
digest: HEX.to_string(),
165+
}])
166+
.expect("the pinned bytes match their recorded digest");
167+
assert_eq!(environment.fonts().len(), 1);
168+
assert_eq!(environment.fonts()[0].family, "Ahem");
169+
170+
let wrong = "0".repeat(64);
171+
let error = load_environment(&[FontDeclaration {
172+
family: "Ahem".to_string(),
173+
path,
174+
digest: wrong,
175+
}])
176+
.expect_err("a font that is not the declared identity must refuse before rendering");
177+
assert!(error.contains("is not the declared identity"));
178+
}
179+
}

0 commit comments

Comments
 (0)