text-1b: render <text> — the text rung - #71
Conversation
…or in CSS The text rung's cascade pair, both measured against Chromium 149 first. font-family joins the admitted presentation-hint set: the attribute alone selects the face, an author rule beats it, font-family="" drops like an invalid declaration, and the property inherits — each pinned by the precedence law. text-anchor joins the not-represented refusal list. Chromium consumes it from the cascade (a rule anchors an attribute-free <text>, and text-anchor: end beats text-anchor="middle"), but the property is engine = "gecko" at the Stylo pin, so the servo build has no such longhand and a sheet declaring one was a silent drop. Same shape as the gradient rung's stop-color row, one rung later. The attribute spelling stays admitted and is read by the text compiler.
The Web family gains a real shaped-text producer, and <text> leaves the refusal corpus for six Chromium-baked cells. The document says, the oracle resolves: - websem's <text> arm owns source semantics only — character data with XML whitespace collapsing (measured: a tab, a newline, and a run of spaces each collapse to one advance), x/y, text-anchor, and the cascaded family and size. It shapes nothing. - textlayout resolves the run once against a declared font environment and returns the immutable artifact; glyph outlines lower to rframe's existing path facts, so no font identity crosses the contract and the frame stays honestly glyphless. - the admitted numeric domain is enforced, not assumed: integer position, font-size a multiple of 5, integer anchor-resolved start. Chromium snaps everything else by a rasterizer-internal rule; this refuses by name rather than codifying it. Hermetic by default: a run whose family the host never declared refuses by name — no tofu, no ambient face, no machine-local pixel. A generic family (including the initial value) names no declared font and says so. fixtures/web-first/text/ is the suite, on the animation/ precedent and under the corpus-growth law: the fixture is the document, the font is the environment, and the baker declares the pinned identity to Chromium exactly as the host declares it to the engine — verified against its digest before any capture. All six cells are byte-exact with no tolerance admissible. svg-text graduates; what remains refused holds its own rows (svg-text-undeclared-font, svg-text-tspan). Two legacy expectations move with the behavior: basic-shapes.svg's stylesheet declares text-anchor and its labels ask for a generic family — both were silent drops before this rung and are named holes after it.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughThis PR adds SVG ChangesSVG font-family and text rendering
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant SvgFrameSource
participant compile_svg_element
participant compile_text
participant svg_text_module as svg_text
participant Environment
Caller->>SvgFrameSource: from_standalone_svg_with_fonts(source, viewport, fonts)
SvgFrameSource->>compile_svg_element: compile(source, fonts)
compile_svg_element->>compile_text: compile_text(node, fonts)
compile_text->>svg_text_module: resolve_text_path(text, family, size, x, y, anchor, fonts)
svg_text_module->>Environment: resolve styled text
Environment-->>svg_text_module: glyph outlines
svg_text_module-->>compile_text: PathData or TextError
compile_text-->>compile_svg_element: path geometry or refusal
compile_svg_element-->>SvgFrameSource: compiled cascade
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/websem/src/svg_text.rs`:
- Around line 147-170: Extend the numeric-domain enforcement beyond
admit_numeric_domain to validate every resolved glyph outline before
ResolvedTextLayout::outline streams it through FlipSink. Reject glyph contours
containing quad_to or curve_to, or establish and require a validated-face
contract that guarantees line-only geometry; preserve existing rejection
behavior for unsupported faces. Add a non-Ahem regression covering a face with
non-line glyph outlines.
In `@crates/websem/src/svg.rs`:
- Around line 2419-2420: Update the text-to-path flow around collapse_whitespace
and resolve_text_path so collapsed-empty content returns Ok(None) after
supported coordinate and anchor validation, before reading or validating
font-family. Preserve the existing font-selection and path-resolution behavior
for non-empty content.
- Around line 529-585: Add public strict and best-effort
from_html_inline_svg_with_fonts constructors that accept textlayout::Environment
and delegate to from_source_with_fonts with SourceEntry::InlineHtml, the
appropriate CompileMode, and the existing viewport handling. Keep the existing
no-font constructors unchanged, and add a regression covering inline Ahem text
resolving through the supplied font environment.
In `@fixtures/web-first/text/bake_chromium.ts`:
- Around line 68-83: Update capture in fixtures/web-first/text/bake_chromium.ts
to wait for the declared font family with document.fonts.load and
document.fonts.ready before svg.screenshot(), then require document.fonts.check
to pass before capturing. Preserve the existing SVG validation; in
fixtures/web-first/text/README.md at line 41, retain the “awaited ready” wording
because the baker now enforces that guarantee.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 930b4676-30e9-414b-b77d-bd265e9174db
⛔ Files ignored due to path filters (15)
Cargo.lockis excluded by!**/*.lockfixtures/web-first/text/chromium/svg-text-anchor-end.pngis excluded by!**/*.pngfixtures/web-first/text/chromium/svg-text-anchor-middle.pngis excluded by!**/*.pngfixtures/web-first/text/chromium/svg-text-em-box.pngis excluded by!**/*.pngfixtures/web-first/text/chromium/svg-text-fill-color.pngis excluded by!**/*.pngfixtures/web-first/text/chromium/svg-text-space-advance.pngis excluded by!**/*.pngfixtures/web-first/text/chromium/svg-text-whitespace-collapse.pngis excluded by!**/*.pngfixtures/web-first/text/svg-text-anchor-end.svgis excluded by!**/*.svgfixtures/web-first/text/svg-text-anchor-middle.svgis excluded by!**/*.svgfixtures/web-first/text/svg-text-em-box.svgis excluded by!**/*.svgfixtures/web-first/text/svg-text-fill-color.svgis excluded by!**/*.svgfixtures/web-first/text/svg-text-space-advance.svgis excluded by!**/*.svgfixtures/web-first/text/svg-text-whitespace-collapse.svgis excluded by!**/*.svgfixtures/web-first/unsupported/svg-text-tspan.svgis excluded by!**/*.svgfixtures/web-first/unsupported/svg-text-undeclared-font.svgis excluded by!**/*.svg
📒 Files selected for processing (17)
crates/csscascade/src/dom.rscrates/csscascade/tests/svg_presentation_hints.rscrates/n0_cli/src/main.rscrates/websem/Cargo.tomlcrates/websem/src/lib.rscrates/websem/src/svg.rscrates/websem/src/svg_text.rscrates/websem/tests/best_effort.rscrates/websem/tests/groups_contract.rscrates/websem/tests/svg_text.rscrates/websem/tests/unsupported_corpus.rsdocs/wg/consolidation/svg-checklist.mdfixtures/web-first/STATUS.mdfixtures/web-first/text/README.mdfixtures/web-first/text/bake_chromium.tsfixtures/web-first/text/cases.jsonfixtures/web-first/text/oracle-bake.json
| /// The admitted numeric domain (the ratified text-oracle method): a glyph box | ||
| /// edge must land on an integer coordinate, so every rasterizer's per-pixel | ||
| /// coverage is 0 or 1 and bilevel and antialiased raster agree. | ||
| /// | ||
| /// `font_size` divisible by 5 keeps a 0.8/0.2 em split integral; integer | ||
| /// authored and anchor-resolved positions keep the run's origin integral. | ||
| fn admit_numeric_domain(x: f32, y: f32, font_size: f32, start_x: f32) -> Result<(), TextError> { | ||
| let integral = |value: f32| value.fract() == 0.0; | ||
| if !integral(x) || !integral(y) { | ||
| return Err(TextError::OutsideNumericDomain(format!( | ||
| "position ({x}, {y}) is not integral" | ||
| ))); | ||
| } | ||
| if !integral(font_size) || font_size % 5.0 != 0.0 { | ||
| return Err(TextError::OutsideNumericDomain(format!( | ||
| "font-size {font_size} is not an integer multiple of 5" | ||
| ))); | ||
| } | ||
| if !integral(start_x) { | ||
| return Err(TextError::OutsideNumericDomain(format!( | ||
| "the anchor-resolved start x {start_x} is not integral" | ||
| ))); | ||
| } | ||
| Ok(()) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
ast-grep outline crates/textlayout/src/environment.rs --items all --view expanded
ast-grep outline crates/textlayout/src/artifact.rs --items all --view expanded
rg -n -C 5 'FontResource|pub struct Environment|impl Environment|trait OutlineSink|fn outline|pub fn resolve' crates/textlayout/srcRepository: gridaco/nothing
Length of output: 9287
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,290p' crates/textlayout/src/artifact.rs
printf '\n--- environment.rs ---\n'
sed -n '1,120p' crates/textlayout/src/environment.rs
printf '\n--- resolve.rs top and resolve outline checks ---\n'
sed -n '1,220p' crates/textlayout/src/resolve.rs
printf '\n--- websem svg_text relevant ranges ---\n'
sed -n '120,285p' crates/websem/src/svg_text.rsRepository: gridaco/nothing
Length of output: 27249
Enforce the numeric domain on every resolved glyph outline.
admit_numeric_domain(...) only checks page position, font size, and total advance. Resolution only rejects color/bitmap tables and unsupported positioning, while ResolvedTextLayout::outline(...) streams every parsed contour through FlipSink. quad_to and curve_to do not preserve binary raster coverage, so this gate can pass a declared face while producing outside-domain geometry. Add face-level geometry validation, reject non-line-to glyphs, or require a separate validated-face contract, and add a non-Ahem regression.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/websem/src/svg_text.rs` around lines 147 - 170, Extend the
numeric-domain enforcement beyond admit_numeric_domain to validate every
resolved glyph outline before ResolvedTextLayout::outline streams it through
FlipSink. Reject glyph contours containing quad_to or curve_to, or establish and
require a validated-face contract that guarantees line-only geometry; preserve
existing rejection behavior for unsupported faces. Add a non-Ahem regression
covering a face with non-line glyph outlines.
| /// Retain a standalone SVG document together with the fonts a `<text>` | ||
| /// run may resolve against. The environment is a manifest of exact bytes | ||
| /// the host has already verified against their declared digests — this | ||
| /// crate reads no font file and consults no ambient font database. | ||
| pub fn from_standalone_svg_with_fonts( | ||
| source: impl Into<Arc<str>>, | ||
| initial_viewport: InitialViewport, | ||
| fonts: textlayout::Environment, | ||
| ) -> Result<Self, CompileError> { | ||
| Self::from_source_with_fonts( | ||
| source.into(), | ||
| SourceEntry::StandaloneSvg, | ||
| CompileMode::Strict, | ||
| Some(initial_viewport), | ||
| fonts, | ||
| ) | ||
| } | ||
|
|
||
| /// The best-effort variant of [`Self::from_standalone_svg_with_fonts`]. | ||
| pub fn from_standalone_svg_best_effort_with_fonts( | ||
| source: impl Into<Arc<str>>, | ||
| initial_viewport: InitialViewport, | ||
| fonts: textlayout::Environment, | ||
| ) -> Result<Self, CompileError> { | ||
| Self::from_source_with_fonts( | ||
| source.into(), | ||
| SourceEntry::StandaloneSvg, | ||
| CompileMode::BestEffort, | ||
| Some(initial_viewport), | ||
| fonts, | ||
| ) | ||
| } | ||
|
|
||
| fn from_source( | ||
| source: Arc<str>, | ||
| entry: SourceEntry, | ||
| mode: CompileMode, | ||
| initial_viewport: Option<InitialViewport>, | ||
| ) -> Result<Self, CompileError> { | ||
| // No declared fonts: a `<text>` run refuses by name rather than | ||
| // reaching for an ambient face. That is the hermetic default the | ||
| // text-oracle method ratified, not an omission. | ||
| Self::from_source_with_fonts( | ||
| source, | ||
| entry, | ||
| mode, | ||
| initial_viewport, | ||
| textlayout::Environment::default(), | ||
| ) | ||
| } | ||
|
|
||
| fn from_source_with_fonts( | ||
| source: Arc<str>, | ||
| entry: SourceEntry, | ||
| mode: CompileMode, | ||
| initial_viewport: Option<InitialViewport>, | ||
| fonts: textlayout::Environment, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files | rg '(^|/)svg\.rs$|textlayout|Cargo\.toml$|grida|n0' | head -200
echo "== outline svg =="
ast-grep outline crates/websem/src/svg.rs --view compact || true
echo "== relevant svg sections =="
sed -n '1,120p' crates/websem/src/svg.rs
sed -n '480,640p' crates/websem/src/svg.rs
sed -n '1150,1550p' crates/websem/src/svg.rs
echo "== symbol search =="
rg -n "from_html_inline|InlineHtml|StandaloneSvg|from_source_with_fonts|compile_text|textlayout::Environment|public_api|regression|Ahem" crates/websem crates -g '*.rs' | head -300Repository: gridaco/nothing
Length of output: 9473
🏁 Script executed:
#!/bin/bash
set -u
echo "== locate svg.rs =="
fd -a 'svg\.rs$' . | sed 's#^\./##' | head -200
echo "== locate websem =="
git ls-files | rg '(^|/)websem|crates/websem|crates/.*svg\.rs$' | head -300
echo "== textlayout env mentions in tracked rust files =="
rg -n "textlayout::Environment|SourceEntry|from_html_inline|from_standalone|from_source_with_fonts|compile_text|InlineHtml|StandaloneSvg" --glob '*.rs' CRATES crates . 2>/dev/null | head -400 || trueRepository: gridaco/nothing
Length of output: 46391
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Relevant websem/svg.rs sections =="
sed -n '130,160p' crates/websem/src/svg.rs
sed -n '430,585p' crates/websem/src/svg.rs
sed -n '2340,2410p' crates/websem/src/svg.rs
echo "== websem tests around Ahem/font environment =="
sed -n '1,220p' crates/websem/tests/svg_text.rs
echo "== Check public API constructors available via docs/source grep =="
rg -n "pub fn from_html|pub fn from_standalone|with_fonts|textlayout::Environment" crates/websem/src/svg.rs crates/websem/tests/svg_text.rs
echo "== behavioral route probe from source text =="
python3 - <<'PY'
from pathlib import Path
p = Path("crates/websem/src/svg.rs").read_text()
def body(start_marker, end_marker):
i = p.index(start_marker)
j = p.index(end_marker, i+1)
return p[i:j]
print("from_html_inline_svg calls from_source:", "Self::from_source(source;" in body(" pub fn from_html_inline_svg", " /// The best-effort variant"))
print("from_source defaults fonts:", "textlayout::Environment::default()" in body(" fn from_source(", " fn from_source_with_fonts("))
print("compile_text font arg:", "fonts: &textlayout::Environment" in body("fn compile_text", "} // compile_text"))
PYRepository: gridaco/nothing
Length of output: 20260
Add a font-aware inline SVG constructor.
from_html_inline_svg* calls from_source, and from_source always passes textlayout::Environment::default(). No public inline SVG API can supply the declared font, so inline <text font-family="Ahem"> can only refuse by name. Add strict and best-effort from_html_inline_svg*_with_fonts constructors using SourceEntry::InlineHtml; add a covered regression for inline Ahem text.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/websem/src/svg.rs` around lines 529 - 585, Add public strict and
best-effort from_html_inline_svg_with_fonts constructors that accept
textlayout::Environment and delegate to from_source_with_fonts with
SourceEntry::InlineHtml, the appropriate CompileMode, and the existing viewport
handling. Keep the existing no-font constructors unchanged, and add a regression
covering inline Ahem text resolving through the supplied font environment.
| let content = crate::svg_text::collapse_whitespace(&raw); | ||
|
|
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Return before font selection for collapsed-empty content.
Line 2419 can produce an empty string. Lines 2444-2455 reject the generic initial family before resolve_text_path can return Ok(None). Therefore <text/> and whitespace-only text fail unless an unnecessary declared font exists.
Return Ok(None) after supported coordinate and anchor validation, but before reading font-family.
Proposed fix
let anchor = match get_attr(el, "text-anchor") {
// ...
};
+ if content.is_empty() {
+ return Ok(None);
+ }
+
let data = el.borrow_data().expect("styled element");Also applies to: 2432-2464
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/websem/src/svg.rs` around lines 2419 - 2420, Update the text-to-path
flow around collapse_whitespace and resolve_text_path so collapsed-empty content
returns Ok(None) after supported coordinate and anchor validation, before
reading or validating font-family. Preserve the existing font-selection and
path-resolution behavior for non-empty content.
| async function capture(page: Page, fixture: Case, source: string): Promise<Buffer> { | ||
| await page.setViewportSize({ width: fixture.width, height: fixture.height }); | ||
| const dataUrl = `data:image/svg+xml;base64,${Buffer.from(source).toString("base64")}`; | ||
| await page.goto(dataUrl, { waitUntil: "load" }); | ||
|
|
||
| const svg = page.locator("svg").first(); | ||
| if ((await svg.count()) !== 1) { | ||
| throw new Error(`${fixture.id}: expected a first <svg> element`); | ||
| } | ||
| const box = await svg.boundingBox(); | ||
| if (!box || box.width !== fixture.width || box.height !== fixture.height) { | ||
| throw new Error( | ||
| `${fixture.id}: unexpected SVG box ${JSON.stringify(box)}; expected ${fixture.width}x${fixture.height}`, | ||
| ); | ||
| } | ||
| return svg.screenshot({ omitBackground: true, type: "png" }); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## files"
git ls-files | rg '(^fixtures/web-first/text/(bake_chromium\.ts|README\.md|.*\.ts)|^fixtures/web-first/text)' || true
echo "## bake_chromium outline"
ast-grep outline fixtures/web-first/text/bake_chromium.ts --view expanded || true
echo "## relevant bake_chromium lines"
cat -n fixtures/web-first/text/bake_chromium.ts | sed -n '1,180p'
echo "## relevant README lines"
cat -n fixtures/web-first/text/README.md | sed -n '1,120p'
echo "## search font declarations/await/ready in fixtures/text"
rg -n "font|`@font-face`|document\\.fonts|ready|readyState|load|expect\\(--ready\\|readiness|capture\\(" fixtures/web-first/text -SRepository: gridaco/nothing
Length of output: 1916
Wait for the injected font before each screenshot.
capture waits for the page load event and locator actionability only. It does not wait for the injected @font-face used by the text case. Add a document.fonts.load(...)/document.fonts.ready wait for the declared family before svg.screenshot(), then require document.fonts.check(...), and keep the README “awaited ready” language only while the baker enforces it.
📍 Affects 2 files
fixtures/web-first/text/bake_chromium.ts#L68-L83(this comment)fixtures/web-first/text/README.md#L41-L41
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@fixtures/web-first/text/bake_chromium.ts` around lines 68 - 83, Update
capture in fixtures/web-first/text/bake_chromium.ts to wait for the declared
font family with document.fonts.load and document.fonts.ready before
svg.screenshot(), then require document.fonts.check to pass before capturing.
Preserve the existing SVG validation; in fixtures/web-first/text/README.md at
line 41, retain the “awaited ready” wording because the baker now enforces that
guarantee.
<text>leaves the refusal corpus. Six Chromium-baked cells, byte-exact, no tolerance — and the Web family now has a real shaped-text producer, which is the precondition D-M's shaped-text stage has been gated on (gridaco/nothing#69).Stacked on #70 (the
textlayoutresolver), executing against the method ratified in #68.The seam
The document says; the oracle resolves. websem's
<text>arm owns source semantics only — character data with XML whitespace collapsing,x/y,text-anchor, and the cascaded family and size. It shapes nothing.textlayoutresolves the run once and returns the immutable artifact; glyph outlines lower torframe's existing path facts, so no font identity crosses the contract and the frame stays honestly glyphless. The D-M join is untouched.Measured before written
Every claim in the cascade pair was probed against Chromium 149 first:
font-familyattribute alonefont-family=""text-anchorin CSSengine = "gecko"at the Stylo pin — the servo build has no such longhand, so a sheet declaring it was a silent drop. It joins the not-represented refusal list, same shape as the gradient rung'sstop-colorrow.Note the correction that measurement forced: the plan was to admit both properties as hints.
text-anchorcannot be represented at all, so the attribute is read directly and the CSS spelling refuses by name.The numeric domain is enforced, not assumed
Integer position,
font-sizea multiple of 5, integer anchor-resolved start. Chromium snaps everything else by a rasterizer-internal rule; this refuses by name rather than codifying a rasterizer's rounding.Hermetic by default
A run whose family the host never declared refuses by name — no tofu, no ambient face, no machine-local pixel. A generic family (including the initial value a document with no
font-familycomputes to) names no declared font and says so.The suite
fixtures/web-first/text/on theanimation/precedent, under the corpus-growth law: its own manifest, baker, and test binary; the primitive root stays closed to text. The fixture is the document, the font is the environment — the committed.svgcarries no font bytes (six copies of a pinned font is a corpus), and the baker declares the pinned identity to Chromium exactly as the host declares it to the engine, verified against its digest before any capture.Graduation and the two legacy moves
svg-textgraduates; what remains refused holds its own rows (svg-text-undeclared-font,svg-text-tspan). Two legacy expectations moved with the behavior, both in the right direction:basic-shapes.svg's stylesheet declarestext-anchorand its labels ask for a generic family — silent drops before this rung, named holes after it.Full workspace green, fmt and clippy clean.