diff --git a/.github/scripts/check-engine-pins.sh b/.github/scripts/check-engine-pins.sh
new file mode 100755
index 0000000..6092132
--- /dev/null
+++ b/.github/scripts/check-engine-pins.sh
@@ -0,0 +1,185 @@
+#!/usr/bin/env bash
+#
+# Checks that the `[patch.crates-io]` overrides the README publishes still
+# resolve — the contract its "Using a patched Servo" section promises
+# consumers.
+#
+# The patched engine lives as `tauri-runtime-patches` branches on this
+# organisation's forks, so there is nothing here to apply and nothing to
+# compile. What can rot instead is a pin, in two ways, and cargo reports both
+# at build time rather than in the manifest:
+#
+# * the rev is gone — the branch was rebased or force-pushed away.
+# * the rev no longer — cargo accepts a [patch] entry only if the
+# satisfies the lock replacement's own version satisfies the
+# requirement it replaces. Publish a new release,
+# let Cargo.lock move, and a fork left behind stops
+# resolving.
+#
+# Both are checked against the versions in Cargo.lock, so nothing is pinned by
+# hand here: when the `servo` requirement moves, this check moves with it, and
+# a Dependabot bump that outruns the forks arrives as a red pull request. That
+# is the intended signal — rebase the branches, move the revs, and it goes
+# green.
+#
+# Read over the GitHub API rather than by cloning. A shallow clone of servo is
+# well over a gigabyte, which is not a per-pull-request cost; eleven API reads
+# take seconds. The cost is that this only understands github.com URLs, which
+# it says plainly rather than skipping.
+
+set -euo pipefail
+
+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
+
+fail() { printf '\n%s\n' "$*" >&2; exit 1; }
+
+# Where each overridden crate's manifest lives inside its fork. Deliberately
+# explicit: these were the `path = "../stylo/
"` values the README used to
+# publish, and if a crate ever moves inside its repository a human should
+# notice here rather than have CI quietly check the wrong manifest.
+crate_dir() {
+ case $1 in
+ servo) echo "components/servo" ;;
+ content-security-policy) echo "." ;;
+ selectors) echo "selectors" ;;
+ servo_arc) echo "servo_arc" ;;
+ stylo) echo "style" ;;
+ stylo_atoms) echo "stylo_atoms" ;;
+ stylo_dom) echo "stylo_dom" ;;
+ stylo_malloc_size_of) echo "malloc_size_of" ;;
+ stylo_static_prefs) echo "stylo_static_prefs" ;;
+ stylo_traits) echo "style_traits" ;;
+ *) return 1 ;;
+ esac
+}
+
+# Every version Cargo.lock resolves for a crate, newline-separated.
+#
+# Deliberately not "the one version": the graph legitimately carries two
+# `selectors`, 0.36.1 for one unrelated dependent and 0.40.0 for the stylo
+# crates, and cargo replaces whichever entries the patched version satisfies
+# while leaving the rest alone. So a pin is correct when it matches *a* locked
+# version, and broken when it matches none — at which point nothing in the
+# graph would use it and the override is silently inert.
+locked_versions() {
+ local crate=$1 found
+ found=$(awk -v want="name = \"$crate\"" '
+ $0 == want { getline; if ($1 == "version") { gsub(/"/, "", $3); print $3 } }
+ ' "$repo_root/Cargo.lock")
+ [ -n "$found" ] || fail "Cargo.lock: no '$crate' entry — is it still in the graph?"
+ printf '%s' "$found"
+}
+
+# One file out of a repository at one revision, over the API.
+fetch_file() {
+ local slug=$1 path=$2 rev=$3
+ gh api "repos/$slug/contents/${path#./}?ref=$rev" \
+ --header 'Accept: application/vnd.github.raw' 2> /dev/null
+}
+
+# A crate's own version at a revision. `version.workspace = true` sends us to
+# the workspace root, which is how both servo and stylo declare most of theirs.
+crate_version_at() {
+ local slug=$1 dir=$2 rev=$3 manifest version
+ manifest=$(fetch_file "$slug" "${dir%/}/Cargo.toml" "$rev") || return 1
+ [ -n "$manifest" ] || return 1
+ version=$(awk '/^\[/ { p = ($0 == "[package]") } p && /^version/ { print; exit }' <<< "$manifest")
+ if [[ $version == *workspace* ]]; then
+ manifest=$(fetch_file "$slug" "Cargo.toml" "$rev") || return 1
+ version=$(awk '/^\[/ { p = ($0 == "[workspace.package]") } p && /^version/ { print; exit }' <<< "$manifest")
+ fi
+ sed -E 's/.*"([^"]+)".*/\1/' <<< "$version"
+}
+
+# --- the override block -----------------------------------------------------
+#
+# Lifted out of the README rather than restated, so what is checked is the
+# block a consumer is handed. Same extraction prepare-patched-servo.sh uses.
+
+overrides=$(awk '
+ /^### 1\./ { section = 1; next }
+ section && /^```toml$/ { block = 1; next }
+ block && /^```/ { exit }
+ block { print }
+' "$repo_root/README.md")
+
+grep -q '^\[patch\.crates-io\]' <<< "$overrides" \
+ || fail "README.md: found no [patch.crates-io] block under a '### 1.' heading.
+The override block that section publishes is what this checks; if it moved,
+point this script at wherever it went."
+grep -q '^servo = ' <<< "$overrides" \
+ || fail "README.md: the block under '### 1.' overrides no servo:
+
+$overrides"
+
+status=0
+checked=0
+
+while IFS= read -r line; do
+ case $line in ''|'#'*|'[patch'*) continue ;; esac
+ crate=${line%%=*}; crate=${crate// /}
+
+ # A path override cannot be checked from here and has no business in the
+ # published recipe: it only resolves on the machine that has the checkout.
+ if grep -q 'path[[:space:]]*=' <<< "$line"; then
+ echo "$crate: FAILED — the published override is a path, which resolves only locally" >&2
+ status=1; continue
+ fi
+
+ url=$(sed -n 's/.*git[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' <<< "$line")
+ rev=$(sed -n 's/.*rev[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' <<< "$line")
+ [ -n "$url" ] || fail "README.md: cannot read a git source out of:
+ $line"
+
+ # A git override with no rev pins a branch, and these branches are rebased —
+ # so what the build resolves would change under `cargo update`.
+ [ -n "$rev" ] || fail "README.md: the $crate override names a git source with no rev:
+ $line
+A branch pin changes meaning whenever the fork is rebased. Pin the rev."
+
+ case $url in
+ https://github.com/*) slug=${url#https://github.com/}; slug=${slug%.git} ;;
+ *) fail "$crate: $url is not a github.com URL, and reading it over the API
+is the only thing this script knows how to do. Teach it, or clone instead." ;;
+ esac
+
+ dir=$(crate_dir "$crate") || fail "no directory known for '$crate' inside its fork.
+Add it to crate_dir() — this script has to know which manifest carries its version."
+
+ locked=$(locked_versions "$crate")
+ if ! pinned=$(crate_version_at "$slug" "$dir" "$rev"); then
+ echo "$crate: FAILED — cannot read ${dir%/}/Cargo.toml at ${rev:0:8} in $slug" >&2
+ echo " was the branch rebased or force-pushed away?" >&2
+ status=1; continue
+ fi
+
+ checked=$((checked + 1))
+ locked_list=$(tr '\n' ' ' <<< "$locked"); locked_list=${locked_list% }
+ printf ' %-24s pinned %-8s at %-9s lock has %s\n' \
+ "$crate" "$pinned" "${rev:0:8}" "$locked_list"
+ if ! grep -qxF "$pinned" <<< "$locked"; then
+ echo " Cargo.lock resolves $locked_list, so nothing would use this pin" >&2
+ status=1
+ fi
+done <<< "$overrides"
+
+[ "$checked" -gt 0 ] || fail "checked nothing — the override block parsed to no entries"
+
+echo
+if [ "$status" -eq 0 ]; then
+ echo "All $checked engine pins resolve to the versions Cargo.lock expects."
+else
+ cat >&2 <<'EOF'
+
+An engine pin no longer resolves. If a dependency bump brought you here, the
+fork branch has to move onto the new revision before that bump can land:
+
+ 1. clone the fork and check out tauri-runtime-patches
+ 2. rebase it onto the revision behind the new release — the revision behind
+ any published version is in the crate itself:
+ curl -sL https://static.crates.io/crates/servo/servo-X.Y.Z.crate \
+ | tar xzO servo-X.Y.Z/.cargo_vcs_info.json
+ 3. push, and update the rev in README.md's "Using a patched Servo"
+EOF
+fi
+exit "$status"
diff --git a/.github/scripts/check-patch-series.sh b/.github/scripts/check-patch-series.sh
deleted file mode 100755
index ffe0c14..0000000
--- a/.github/scripts/check-patch-series.sh
+++ /dev/null
@@ -1,236 +0,0 @@
-#!/usr/bin/env bash
-#
-# Checks that servo-patches/ still applies to the revisions behind the crate
-# versions this repository resolves — the contract the README's "Using a
-# patched Servo" section promises consumers.
-#
-# No revision is pinned here. Every published crate records the commit it was
-# cut from in .cargo_vcs_info.json, so the versions in Cargo.lock decide what
-# gets checked: when the `servo` requirement moves, this check moves with it,
-# and stylo follows because it is resolved transitively through servo.
-#
-# The commands mirror the README exactly — plain `git am` for the servo and
-# csp series, `git apply --3way` for stylo, whose files are plain diffs behind
-# a prose preamble rather than format-patch output. Testing anything else
-# would leave the documented recipe unverified.
-#
-# Applying is checked by outcome, not just exit status: `git apply --reverse
-# --check` afterwards proves each stylo patch's content is actually in the
-# tree, which a silently sloppy three-way merge would not satisfy.
-
-set -euo pipefail
-
-repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
-patches="$repo_root/servo-patches"
-
-work=$(mktemp -d)
-# Best-effort: a trap that fails becomes the script's exit status, and a
-# temp directory that outlives the job is not worth reporting a failure over.
-cleanup() { rm -rf "$work" 2> /dev/null || true; }
-trap cleanup EXIT
-
-# The upstream each group targets. Deliberately explicit rather than read out
-# of the crate metadata: if one of these ever moves, a human should notice
-# instead of CI quietly following it somewhere else.
-servo_upstream=https://github.com/servo/servo
-stylo_upstream=https://github.com/servo/stylo
-csp_upstream=https://github.com/rust-ammonia/rust-content-security-policy
-
-fail() { printf '\n%s\n' "$*" >&2; exit 1; }
-
-# The single version Cargo.lock resolves for a crate. More than one entry
-# means the graph carries duplicate copies, which the override recipe cannot
-# express — worth failing on rather than guessing which to check.
-locked_version() {
- local crate=$1 found count
- found=$(awk -v want="name = \"$crate\"" '
- $0 == want { getline; if ($1 == "version") { gsub(/"/, "", $3); print $3 } }
- ' "$repo_root/Cargo.lock")
- count=$(printf '%s' "$found" | grep -c . || true)
- [ "$count" -eq 1 ] || fail "Cargo.lock: expected exactly one '$crate' entry, found $count"
- printf '%s' "$found"
-}
-
-crate_revision() {
- local crate=$1 version=$2 rev
- rev=$(curl -fsSL "https://static.crates.io/crates/$crate/$crate-$version.crate" \
- | tar xzO "$crate-$version/.cargo_vcs_info.json" \
- | jq -re '.git.sha1') \
- || fail "$crate $version: no .cargo_vcs_info.json — cannot tell which revision it was cut from"
- printf '%s' "$rev"
-}
-
-# Shallow: the series only ever touches the one tree, so history is dead
-# weight. Fetching a bare sha needs it to be reachable from a ref, which is
-# true of anything crates.io was published from.
-checkout() {
- local name=$1 url=$2 rev=$3
- git init -q "$work/$name"
- # Keep git from detaching background maintenance into a tree we are about
- # to delete — that races the cleanup and leaves the directory non-empty.
- git -C "$work/$name" config gc.auto 0
- git -C "$work/$name" config maintenance.auto false
- git -C "$work/$name" remote add origin "$url"
- # Returns non-zero rather than exiting: one caller wants to report the
- # failure in its own terms.
- git -C "$work/$name" fetch -q --depth 1 origin "$rev" || return 1
- git -C "$work/$name" -c advice.detachedHead=false checkout -q FETCH_HEAD
- git -C "$work/$name" config user.name "patch series check"
- git -C "$work/$name" config user.email "ci@invalid"
-}
-
-count_files() {
- local n=0 f
- for f in "$patches"/$1; do [ -e "$f" ] && n=$((n + 1)); done
- printf '%s' "$n"
-}
-
-echo "Resolving the revisions behind the versions in Cargo.lock"
-echo
-
-servo_version=$(locked_version servo)
-stylo_version=$(locked_version stylo)
-csp_version=$(locked_version content-security-policy)
-
-servo_rev=$(crate_revision servo "$servo_version")
-stylo_rev=$(crate_revision stylo "$stylo_version")
-
-printf ' %-26s %-10s %s\n' servo "$servo_version" "$servo_rev"
-printf ' %-26s %-10s %s\n' stylo "$stylo_version" "$stylo_rev"
-echo
-
-checkout servo "$servo_upstream" "$servo_rev" \
- || fail "servo: cannot fetch $servo_rev from $servo_upstream — was it force-pushed away?"
-checkout stylo "$stylo_upstream" "$stylo_rev" \
- || fail "stylo: cannot fetch $stylo_rev from $stylo_upstream — was it force-pushed away?"
-
-status=0
-
-# --- servo: format-patch output, applied with git am ------------------------
-
-apply_am_group() {
- local name=$1 glob=$2 expected before applied
- expected=$(count_files "$glob")
- [ "$expected" -gt 0 ] || fail "no patches matched $glob"
-
- before=$(git -C "$work/$name" rev-parse HEAD)
- # Separate streams: git block-buffers stdout when redirected but not stderr,
- # so merging them puts the error above the "Applying:" line that caused it.
- if git -C "$work/$name" -c advice.mergeConflict=false am "$patches"/$glob \
- > "$work/$name-am.out" 2> "$work/$name-am.err"; then
- applied=$(git -C "$work/$name" rev-list --count "$before"..HEAD)
- echo "$name: $applied/$expected applied"
- [ "$applied" -eq "$expected" ] \
- || { echo " expected $expected commits, got $applied" >&2; status=1; }
- else
- echo "$name: FAILED — the series no longer applies" >&2
- sed 's/^/ /' "$work/$name-am.out" "$work/$name-am.err" >&2
- git -C "$work/$name" am --abort > /dev/null 2>&1 || true
- status=1
- fi
-}
-
-apply_am_group servo "0*.patch"
-
-# --- content-security-policy: a fork pin, or patch files here ---------------
-#
-# These two are alternatives, and the README decides which. When the override
-# names a git rev, the crate's fixes live as commits on a fork and there is
-# nothing to apply — what can rot instead is the pin itself, so check that.
-# When it does not, they are .patch files here like the rest of the series.
-# Checking whichever the README documents keeps this honest across the move
-# rather than pinning the check to one arrangement.
-
-csp_override=$(grep -E '^content-security-policy[[:space:]]*=' \
- "$repo_root/README.md" || true)
-csp_override_count=$(printf '%s' "$csp_override" | grep -c . || true)
-[ "$csp_override_count" -le 1 ] \
- || fail "README.md: $csp_override_count content-security-policy override lines, expected at most one"
-
-csp_url=$(printf '%s' "$csp_override" | sed -n 's/.*git[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p')
-csp_pin=$(printf '%s' "$csp_override" | sed -n 's/.*rev[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p')
-
-# A git override with no rev pins a branch, and that branch is rebased — so
-# what a source-expression matcher does would change under `cargo update`.
-[ -z "$csp_url" ] || [ -n "$csp_pin" ] \
- || fail "README.md: the content-security-policy override names a git source with no rev:
- $csp_override
-A branch pin changes meaning whenever the fork is rebased. Pin the rev."
-
-if [ -n "$csp_url" ] && [ -n "$csp_pin" ]; then
- if checkout csp "$csp_url" "$csp_pin" 2> "$work/csp-fetch.err"; then
- # Cargo accepts a [patch] entry only if the replacement's own version
- # satisfies the requirement it replaces. Publish a new crate version, let
- # the lockfile move, and a fork left behind stops resolving — silently in
- # the manifest, loudly at build time. That is the failure worth catching.
- pinned=$(awk '/^\[package\]/ { p = 1; next }
- p && /^\[/ { exit }
- p && /^version/ { gsub(/"/, "", $3); print $3; exit }' \
- "$work/csp/Cargo.toml")
- echo "content-security-policy: pinned to ${csp_pin:0:8} on ${csp_url##*/} (crate $pinned)"
- if [ "$pinned" != "$csp_version" ]; then
- echo " Cargo.lock resolves $csp_version, so this pin no longer satisfies it" >&2
- status=1
- fi
- else
- echo "content-security-policy: FAILED — cannot fetch $csp_pin from $csp_url" >&2
- sed 's/^/ /' "$work/csp-fetch.err" >&2
- status=1
- fi
- # The fork branch is rebased whenever the crate's master moves — which is
- # why the override pins a rev rather than a branch — so it is deliberately
- # not checked against upstream's current tip.
-else
- csp_rev=$(crate_revision content-security-policy "$csp_version")
- echo "content-security-policy: $csp_version at ${csp_rev:0:8}"
- checkout csp "$csp_upstream" "$csp_rev" \
- || fail "csp: cannot fetch $csp_rev from $csp_upstream — was it force-pushed away?"
- apply_am_group csp "csp-*.patch"
-fi
-
-# --- stylo: plain diffs, applied with git apply -----------------------------
-
-expected=$(count_files "stylo-*.patch")
-[ "$expected" -gt 0 ] || fail "no patches matched stylo-*.patch"
-applied=0
-for p in "$patches"/stylo-*.patch; do
- if git -C "$work/stylo" apply --3way "$p" > "$work/stylo-apply.log" 2>&1; then
- applied=$((applied + 1))
- else
- echo "stylo: FAILED on $(basename "$p")" >&2
- sed 's/^/ /' "$work/stylo-apply.log" >&2
- status=1
- break
- fi
-done
-echo "stylo: $applied/$expected applied"
-
-# A three-way merge can succeed while landing something other than the patch.
-# Reversing each one proves its content really is in the tree.
-if [ "$applied" -eq "$expected" ]; then
- for p in "$patches"/stylo-*.patch; do
- git -C "$work/stylo" apply --reverse --check "$p" > /dev/null 2>&1 \
- || { echo "stylo: $(basename "$p") applied but is not present in the tree" >&2; status=1; }
- done
-fi
-
-echo
-if [ "$status" -eq 0 ]; then
- echo "The series applies to the revisions behind the pinned releases."
-else
- cat >&2 <<'EOF'
-
-The series no longer applies, or an override no longer resolves. If a
-dependency bump brought you here, the patched sources have to move onto the
-new revision before that bump can land:
-
- .patch files rebase onto the revision printed above and regenerate with
- git format-patch --zero-commit --no-signature --full-index --numbered
-
- a fork pin rebase the fork onto the new upstream, then update the rev
- in the override
-
-Either way, update the revisions quoted in README.md's "Using a patched Servo".
-EOF
-fi
-exit "$status"
diff --git a/.github/scripts/prepare-patched-servo.sh b/.github/scripts/prepare-patched-servo.sh
index 53ee4a3..89a7fad 100755
--- a/.github/scripts/prepare-patched-servo.sh
+++ b/.github/scripts/prepare-patched-servo.sh
@@ -1,213 +1,65 @@
#!/usr/bin/env bash
#
# Prepares a patched-servo build: adds the README's `[patch.crates-io]` block
-# to this workspace's manifest, checks out whatever that block overrides by
-# path, and applies servo-patches/ to each. It runs no cargo itself — the
-# build stays a visible step of the job that calls this.
+# to this workspace's manifest. It runs no cargo itself — the build stays a
+# visible step of the job that calls this.
#
-# check-patch-series.sh already proves the series applies. What it cannot
-# prove is that the tree it produces still compiles, or that this crate's
-# feature-gated code still matches what the series provides: `patched-servo`
-# sets `layout_svg_native_enabled`, a pref that exists only once patch 0009
-# has landed, so a series that applies but has stopped carrying that pref
-# fails in the build this prepares and nowhere else.
+# There is nothing to check out and nothing to apply. The patched engine lives
+# as `tauri-runtime-patches` branches on this organisation's forks, and every
+# override names one by `rev`, so cargo fetches them as ordinary git
+# dependencies. This script used to clone servo and stylo and `git am` a
+# series onto each; all of that went away with the patch files.
#
-# Nothing is pinned here, for the same reason nothing is pinned there. The
-# versions in Cargo.lock decide the revisions — every published crate records
-# the commit it was cut from — and the override block is lifted out of
-# README.md rather than restated, so what CI builds is the block the "Using a
-# patched Servo" section hands to a consumer. Break that recipe and this is
-# where it shows.
+# What remains is worth keeping honest: the block is lifted out of README.md
+# rather than restated, so what CI builds is the block the "Using a patched
+# Servo" section hands to a consumer. Break that recipe and this is where it
+# shows.
#
-# Which repositories get checked out is read out of that block too, rather
-# than listed here. A crate can be overridden by path (needs a patched
-# checkout) or by git rev (a fork already carries the fixes, and cargo fetches
-# it), and content-security-policy has been both. Following the block means
-# this keeps working across that move instead of silently building the wrong
-# thing; an override by path this script has no recipe for is a hard error.
-#
-# The checkouts are siblings of the repository because that is what the
-# README's paths mean: `../servo/components/servo` resolves against the
-# workspace root the overrides are added to. Cloning them somewhere else
-# would mean rewriting the paths, and the documented ones would go untested.
+# check-engine-pins.sh already proves each pin resolves to a version the
+# lockfile accepts. What it cannot prove is that the tree those pins produce
+# still compiles, or that this crate's feature-gated code still matches what
+# the branches provide: `patched-servo` sets `layout_svg_native_enabled`, a
+# pref that exists only on the patched tree, so a branch that resolves but has
+# stopped carrying that pref fails in the build this prepares and nowhere else.
set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
-patches="$repo_root/servo-patches"
-checkouts=$(cd "$repo_root/.." && pwd)
fail() { printf '\n%s\n' "$*" >&2; exit 1; }
-# How to build each checkout the override block can ask for: where it comes
-# from, which locked crate version fixes its revision, which patches belong to
-# it, and how they apply. The upstreams are deliberately explicit rather than
-# read out of crate metadata — if one ever moves, a human should notice
-# instead of CI quietly following it somewhere else.
-#
-# `am` is format-patch output. `apply` is for plain diffs behind a prose
-# preamble, which `git am` rejects — the stylo files are written that way.
-recipe() {
- case $1 in
- servo)
- echo "https://github.com/servo/servo servo 0*.patch am" ;;
- stylo)
- echo "https://github.com/servo/stylo stylo stylo-*.patch apply" ;;
- rust-content-security-policy)
- echo "https://github.com/rust-ammonia/rust-content-security-policy content-security-policy csp-*.patch am" ;;
- *)
- return 1 ;;
- esac
-}
-
-# Kept in step with check-patch-series.sh rather than shared with it: the two
-# scripts have to agree on which revision a locked version implies, and a
-# divergence would mean the series is checked against one tree and built
-# against another.
-locked_version() {
- local crate=$1 found count
- found=$(awk -v want="name = \"$crate\"" '
- $0 == want { getline; if ($1 == "version") { gsub(/"/, "", $3); print $3 } }
- ' "$repo_root/Cargo.lock")
- count=$(printf '%s' "$found" | grep -c . || true)
- [ "$count" -eq 1 ] || fail "Cargo.lock: expected exactly one '$crate' entry, found $count"
- printf '%s' "$found"
-}
-
-crate_revision() {
- local crate=$1 version=$2 rev
- rev=$(curl -fsSL "https://static.crates.io/crates/$crate/$crate-$version.crate" \
- | tar xzO "$crate-$version/.cargo_vcs_info.json" \
- | jq -re '.git.sha1') \
- || fail "$crate $version: no .cargo_vcs_info.json — cannot tell which revision it was cut from"
- printf '%s' "$rev"
-}
-
-# Shallow: the patches only ever touch the one tree, and nothing in the build
-# reads history, so it is dead weight. Fetching a bare sha needs it to be
-# reachable from a ref, which is true of anything crates.io was published from.
-checkout() {
- local name=$1 url=$2 rev=$3 dest="$checkouts/$1"
- git init -q "$dest"
- # Keep git from detaching background maintenance into a checkout the job is
- # about to hammer with a build.
- git -C "$dest" config gc.auto 0
- git -C "$dest" config maintenance.auto false
- git -C "$dest" remote add origin "$url"
- git -C "$dest" fetch -q --depth 1 origin "$rev" \
- || fail "$name: cannot fetch $rev from $url — was it force-pushed away?"
- git -C "$dest" -c advice.detachedHead=false checkout -q FETCH_HEAD
- git -C "$dest" config user.name "patched servo build"
- git -C "$dest" config user.email "ci@invalid"
-}
-
-count_files() {
- local n=0 f
- for f in "$patches"/$1; do [ -e "$f" ] && n=$((n + 1)); done
- printf '%s' "$n"
-}
-
-# --- the override block -----------------------------------------------------
-#
-# Lifted out of the README instead of restated here. The paths in it are
-# relative to the workspace root, which is exactly where it is appended, so
-# the block goes in verbatim.
-
overrides=$(awk '
- /^### 3\./ { section = 1; next }
+ /^### 1\./ { section = 1; next }
section && /^```toml$/ { block = 1; next }
block && /^```/ { exit }
block { print }
' "$repo_root/README.md")
[ -n "$overrides" ] \
- || fail "README.md: found no toml block under a '### 3.' heading.
+ || fail "README.md: found no toml block under a '### 1.' heading.
The override block that section publishes is what this builds; if it moved,
point this script at wherever it went."
grep -q '^\[patch\.crates-io\]' <<< "$overrides" \
- || fail "README.md: the block under '### 3.' does not open with [patch.crates-io]:
+ || fail "README.md: the block under '### 1.' does not open with [patch.crates-io]:
$overrides"
grep -q '^servo = ' <<< "$overrides" \
- || fail "README.md: the block under '### 3.' overrides no servo:
+ || fail "README.md: the block under '### 1.' overrides no servo:
$overrides"
-# Every `path = "..//..."` in the block is a checkout this has to build.
-# Anything overridden by git rev is cargo's problem, not ours.
-needed=$(grep -o 'path[[:space:]]*=[[:space:]]*"\.\./[^/"]*' <<< "$overrides" \
- | sed 's|.*\.\./||' | sort -u)
-[ -n "$needed" ] \
- || fail "README.md: the block under '### 3.' overrides nothing by path, so
-there is no patched tree to build:
-
-$overrides"
-
-# Everything that can be known without touching the network, before the
-# first multi-gigabyte clone.
-for name in $needed; do
- recipe "$name" > /dev/null 2>&1 \
- || fail "README.md's override block wants ../$name, which this script has
-no recipe for. Add it to recipe() — upstream, the locked crate that fixes its
-revision, its patch glob, and whether the patches are format-patch output."
- if [ -e "$checkouts/$name" ]; then
- fail "$checkouts/$name already exists — refusing to clobber it.
-Remove it, or run this where the repository has no such sibling."
- fi
- read -r _ _ glob _ <<< "$(recipe "$name")"
- # A path override exists to put patched code in the graph. Overriding a
- # pristine checkout builds something indistinguishable from stock while
- # reporting success, which is the one outcome this job must not have.
- [ "$(count_files "$glob")" -gt 0 ] \
- || fail "README.md overrides $name by path, but no patches match $glob"
-done
-
-# --- check out and patch ----------------------------------------------------
-#
-# The same commands the README's step 2 gives, for the same reason: testing
-# anything else would leave the documented recipe unverified. Failures are
-# reported tersely — check-patch-series.sh is the job that exists to explain
-# them, and it runs on every pull request rather than only on this one's
-# triggers.
-
-echo "Resolving the revisions behind the versions in Cargo.lock"
-echo
-
-for name in $needed; do
- read -r url crate glob mode <<< "$(recipe "$name")"
- version=$(locked_version "$crate")
- rev=$(crate_revision "$crate" "$version")
- printf ' %-28s %-10s %s\n' "$name" "$version" "$rev"
-
- checkout "$name" "$url" "$rev"
-
- expected=$(count_files "$glob") # non-zero, checked above
+# A path override would need a checkout this job does not make, and would
+# resolve to whatever happens to sit at that path on the runner — which is
+# nothing. Fail loudly rather than build something indistinguishable from
+# stock while reporting success.
+if grep -q 'path[[:space:]]*=' <<< "$overrides"; then
+ fail "README.md: the block under '### 1.' overrides something by path:
- case $mode in
- am)
- before=$(git -C "$checkouts/$name" rev-parse HEAD)
- git -C "$checkouts/$name" -c advice.mergeConflict=false am "$patches"/$glob || {
- git -C "$checkouts/$name" am --abort > /dev/null 2>&1 || true
- fail "$name: the series no longer applies to $rev"
- }
- applied=$(git -C "$checkouts/$name" rev-list --count "$before"..HEAD)
- [ "$applied" -eq "$expected" ] \
- || fail "$name: expected $expected commits, got $applied"
- ;;
- apply)
- applied=0
- for p in "$patches"/$glob; do
- git -C "$checkouts/$name" apply --3way "$p" \
- || fail "$name: $(basename "$p") no longer applies to $rev"
- applied=$((applied + 1))
- done
- ;;
- esac
- echo " $name: $applied/$expected applied"
- echo
-done
+$(grep 'path[[:space:]]*=' <<< "$overrides")
-# --- wire up the overrides --------------------------------------------------
+Every published override has to name a fork by rev — a path resolves only on
+the machine that has the checkout."
+fi
{
printf '\n# Appended by .github/scripts/prepare-patched-servo.sh, copied from\n'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c5c9d22..f7a461c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,17 +26,21 @@ jobs:
- run: cargo fmt --all --check
# The build jobs below compile against *stock* libservo, so nothing else
- # here exercises servo-patches/. This checks the promise the README's
- # "Using a patched Servo" section makes: that the series still applies to
- # the revisions behind the crate versions this repository resolves. It
- # applies patches only — no compilation — so it costs a couple of minutes.
- patches:
- name: patch series applies
+ # here exercises the patched engine. This checks the promise the README's
+ # "Using a patched Servo" section makes: that every override it publishes
+ # still resolves to a version this repository's Cargo.lock accepts. It reads
+ # ten manifests over the API and compiles nothing, so it costs seconds.
+ pins:
+ name: engine pins resolve
runs-on: ubuntu-latest
- timeout-minutes: 20
+ timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- - run: .github/scripts/check-patch-series.sh
+ - run: .github/scripts/check-engine-pins.sh
+ env:
+ # `gh api` reads the forks' manifests; the default token is enough,
+ # they are public.
+ GH_TOKEN: ${{ github.token }}
build:
strategy:
diff --git a/.github/workflows/patched-servo.yml b/.github/workflows/patched-servo.yml
index c88fe66..cdce9e4 100644
--- a/.github/workflows/patched-servo.yml
+++ b/.github/workflows/patched-servo.yml
@@ -1,22 +1,24 @@
# Builds this crate against a *patched* servo — the one configuration nothing
# else in CI exercises.
#
-# ci.yml builds against stock libservo from crates.io, and its "patch series
-# applies" job proves servo-patches/ still applies. Neither compiles the
-# result. This does: it applies the series, adds the `[patch.crates-io]`
-# overrides README.md publishes, and builds with the `patched-servo` feature,
-# whose one job is to set a pref that exists only on a patched tree. A series
-# that applies but no longer carries `layout_svg_native_enabled` fails here
-# and passes everywhere else.
+# ci.yml builds against stock libservo from crates.io, and its "engine pins
+# resolve" job proves every override README.md publishes still names a version
+# the lockfile accepts. Neither compiles the result. This does: it adds the
+# `[patch.crates-io]` overrides README.md publishes and builds with the
+# `patched-servo` feature, whose one job is to set a pref that exists only on
+# the patched tree. A fork branch that resolves but has lost
+# `layout_svg_native_enabled` fails here and passes everywhere else.
#
-# Linux only, and only on the paths that can invalidate the recipe: the
-# series, what it is applied to (Cargo.lock), and the README block the build
-# is assembled from. A cold run is about a quarter of an hour — comparable to
-# ci.yml's own Linux build, so widening this is cheap if the filter ever looks
-# too narrow. The weekly run catches what the filter cannot — a fork branch
-# that moved, an upstream that force-pushed, a src/ change that outgrew the
-# feature gate. The two path lists are duplicated because the Actions parser
-# has no YAML anchors; keep them in step.
+# Linux only, and only on the paths that can invalidate the recipe: the README
+# block the build is assembled from, and the manifests it is applied to.
+# servo-patches/ is no longer among them — it holds documentation now, not the
+# patches, which live on the fork branches the overrides name. A cold run is
+# about a quarter of an hour — comparable to ci.yml's own Linux build, so
+# widening this is cheap if the filter ever looks too narrow. The weekly run
+# catches what the filter cannot: a fork branch that moved under a rev, an
+# upstream that force-pushed, a src/ change that outgrew the feature gate. The
+# two path lists are duplicated because the Actions parser has no YAML
+# anchors; keep them in step.
name: patched servo
@@ -24,7 +26,6 @@ on:
push:
branches: [main]
paths:
- - servo-patches/**
- README.md
- Cargo.toml
- Cargo.lock
@@ -33,7 +34,6 @@ on:
- .github/scripts/prepare-patched-servo.sh
pull_request:
paths:
- - servo-patches/**
- README.md
- Cargo.toml
- Cargo.lock
@@ -85,15 +85,16 @@ jobs:
# pull request depends on to warm a job that runs a few times a month is
# a bad trade. Every run here builds cold.
- - name: check out the engine crates, apply servo-patches/
+ - name: add the README's engine overrides
run: .github/scripts/prepare-patched-servo.sh
- # The checkouts land beside a target/ directory that will hold the whole
- # engine; when this job dies on a full disk, this is the evidence.
+ # Cargo's git cache holds the engine forks and target/ will hold the
+ # whole compiled engine; when this job dies on a full disk, this is the
+ # evidence.
- name: disk and checkout sizes
run: |
df -h /
- du -sh ../*/
+ du -sh ~/.cargo/git 2>/dev/null || true
- name: build the crate against the patched engine
run: cargo build --lib --features patched-servo
diff --git a/README.md b/README.md
index da66bf1..8fd6ab2 100644
--- a/README.md
+++ b/README.md
@@ -105,123 +105,98 @@ This crate depends on **stock libservo from crates.io**, which is what makes
it publishable there: crates.io accepts registry dependencies only, so an
engine fork pinned by git revision cannot travel inside a release.
-The [`servo-patches/`](servo-patches) series — native SVG layout,
-`contenteditable`, the CSS `:has()` selector — is therefore opt-in. To build
-against it, override the engine crates **in your own workspace root**.
-`[patch]` is honoured only there, never from a dependency's manifest.
+The patched engine — native SVG layout, `contenteditable`, the CSS `:has()`
+selector — is therefore opt-in. It lives as `tauri-runtime-patches` branches
+on this organisation's forks, not as patch files to apply by hand: cargo
+fetches the branches itself, so there is no checkout to make and nothing to
+`git am`. Two steps.
-### 1. Check out the revisions behind the published crates
+### 1. Add the overrides to your workspace root
-A `[patch]` entry is accepted only if the checkout's own version satisfies
-the requirement it replaces, so start from the exact trees the published
-crates were cut from. This crate requires `servo = "0.5"`, which is servo
-`77fccacc` (2026-08-04); that tree in turn wants stylo `0.20`, which is
-stylo `67faaab3`:
-
-```bash
-git clone https://github.com/servo/servo
-git -C servo checkout -b tauri-runtime-patches 77fccacc1f1fdce10498d50173aafaa09d02879e
-
-git clone https://github.com/servo/stylo
-git -C stylo checkout -b tauri-runtime-patches 67faaab3ff7aa66780ec1d0f51ca47e177b812d3
-```
-
-The CSP crate needs no checkout: its override in step 3 points straight at a
-fork branch that already carries its two patches.
-
-### 2. Apply the series
-
-The servo files are `git format-patch` output. The stylo files are plain
-diffs with a prose preamble, so `git am` rejects them — apply those with
-`git apply`:
-
-```bash
-git -C servo am ../tauri-runtime-servo/servo-patches/0*.patch
-
-for p in ../tauri-runtime-servo/servo-patches/stylo-*.patch; do
- git -C stylo apply --3way "$p"
-done
-```
-
-The series is authored against servo `f4dde27` and stylo `2d289c1` (the 0.19
-line), but applies cleanly to the revisions above — 24/24 and 5/5 with no
-conflicts, verified against servo `77fccacc` and stylo `67faaab3`. Expect
-that to need rebasing once the pin moves further. The CSP crate's two
-patches are not files here at all — they are commits on the fork branch the
-override below names, described in
-[servo-patches/README.md](servo-patches/README.md).
-
-### 3. Add the overrides to your workspace root
-
-Every entry goes under `[patch.crates-io]`: as of 0.5.0 servo takes its
-stylo crates from the registry too, so there is no git source left to
-override.
+`[patch]` is honoured only in a workspace root, never from a dependency's
+manifest — so this block goes in *your* manifest, not this crate's. Every
+entry is keyed on `crates-io`: as of 0.5.0 servo takes its stylo crates from
+the registry too, so no git source survives to override.
```toml
[patch.crates-io]
-servo = { path = "../servo/components/servo" }
+servo = { git = "https://github.com/copse-dev/servo", rev = "3cb6867644a222d54dd5ed7fbd991cd5811fd2f4" }
content-security-policy = { git = "https://github.com/copse-dev/rust-content-security-policy", rev = "fb5fd0f1af7f0c0dc315bf938507290b2e48cdbe" }
# All eight stylo entries are required. Overriding `stylo` alone leaves the
# others resolving from the registry, which puts a second copy of
# `stylo_traits` and friends in the graph and fails to compile.
-selectors = { path = "../stylo/selectors" }
-servo_arc = { path = "../stylo/servo_arc" }
-stylo = { path = "../stylo/style" }
-stylo_atoms = { path = "../stylo/stylo_atoms" }
-stylo_dom = { path = "../stylo/stylo_dom" }
-stylo_malloc_size_of = { path = "../stylo/malloc_size_of" }
-stylo_static_prefs = { path = "../stylo/stylo_static_prefs" }
-stylo_traits = { path = "../stylo/style_traits" }
+selectors = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" }
+servo_arc = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" }
+stylo = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" }
+stylo_atoms = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" }
+stylo_dom = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" }
+stylo_malloc_size_of = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" }
+stylo_static_prefs = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" }
+stylo_traits = { git = "https://github.com/copse-dev/stylo", rev = "4973e76a24701bdf68ef15cc6f493b0737f07f64" }
```
`stylo_derive`, `to_shmem`, and `to_shmem_derive` need no entries — the
patched crates reach them by path.
-The CSP entry is pinned by `rev` rather than by `branch = "tauri-runtime-patches"`
-on purpose: that branch is rebased whenever the crate's upstream master moves,
-and a branch pin would change what a CSP matcher does under you on the next
-`cargo update`. The branch tip and the rev are the same commit today.
+Every entry is pinned by `rev` rather than by `branch = "tauri-runtime-patches"`
+on purpose: those branches are rebased whenever the crate they fork publishes
+a release the pin has to move to, and a branch pin would change what your
+build does under you on the next `cargo update`. Each branch tip and the rev
+quoted for it are the same commit today.
-### 4. Enable the feature
+### 2. Enable the feature
```toml
[dependencies]
tauri-runtime-servo = { version = "0.1", features = ["patched-servo"] }
```
-`patched-servo` sets preferences that exist only once the series is applied
-(`layout_svg_native_enabled`, added by patch 0009). Without all four steps
-the crate builds and runs against stock Servo.
+`patched-servo` sets preferences that exist only on the patched tree
+(`layout_svg_native_enabled`). Enabling it *without* the overrides above is a
+compile error rather than a silent no-op — the pref is a struct field that
+stock libservo does not have — so the two steps cannot drift apart unnoticed.
+Without either, the crate builds and runs against stock Servo.
+
+### What each fork carries
+
+| fork | branch | based on | commits |
+| ---- | ------ | -------- | ------- |
+| [copse-dev/servo](https://github.com/copse-dev/servo) | `tauri-runtime-patches` | servo `77fccacc` (the revision `servo 0.5.0` was cut from) | 24 |
+| [copse-dev/stylo](https://github.com/copse-dev/stylo) | `tauri-runtime-patches` | stylo `67faaab3` (`stylo 0.20.0`) | 5 |
+| [copse-dev/rust-content-security-policy](https://github.com/copse-dev/rust-content-security-policy) | `tauri-runtime-patches` | upstream `05528760` (`0.8.2`) | 2 |
+
+[servo-patches/README.md](servo-patches/README.md) describes every commit —
+what it fixes, what validated it, and where it stands upstream.
### What CI checks
-The claims above are checked rather than remembered. The *patch series
-applies* job in [`ci.yml`](.github/workflows/ci.yml) re-applies the series on
-every pull request, to whatever revisions the current `Cargo.lock` implies —
-so a dependency bump that outruns the patches fails there.
+The claims above are checked rather than remembered. The *engine pins
+resolve* job in [`ci.yml`](.github/workflows/ci.yml) fetches each pinned rev
+on every pull request and compares the crate version it carries against the
+one `Cargo.lock` resolves — because a `[patch]` entry cargo cannot accept
+fails at build time, not in the manifest, and a dependency bump that outruns
+the forks should fail here instead.
[`patched-servo.yml`](.github/workflows/patched-servo.yml) goes the rest of
-the way on Linux: it follows all four steps, lifting the override block
-straight out of step 3 rather than restating it, and builds the result with
-`patched-servo` enabled. That build is the only thing proving the patched
-tree still compiles and still carries `layout_svg_native_enabled` — a series
-that applies cleanly but has lost the pref passes every other job. It runs
-when the series, the manifests, or this section change, plus weekly and on
-demand; a cold build of the patched engine runs in about a quarter of an
-hour.
-
-### When the pin moves
-
-Whenever this crate's `servo` requirement changes, the checkout revisions
-above have to move with it, or the overrides stop resolving. The revision
+the way on Linux: it lifts the override block straight out of step 1 rather
+than restating it, and builds the result with `patched-servo` enabled. That
+build is the only thing proving the patched tree still compiles and still
+carries `layout_svg_native_enabled`. It runs when the manifests or this
+section change, plus weekly and on demand; a cold build of the patched engine
+runs in about a quarter of an hour.
+
+### When the pins move
+
+Whenever this crate's `servo` requirement changes, the forks have to move
+with it or the overrides stop resolving. Rebase `tauri-runtime-patches` onto
+the revision behind the new release and update the `rev` above. The revision
behind any published version is recorded in the crate itself:
```bash
curl -sL https://static.crates.io/crates/servo/servo-0.5.0.crate \
| tar xzO servo-0.5.0/.cargo_vcs_info.json
```
-
## Publishing
Releases go to crates.io from CI: push a `v*` tag and the
diff --git a/servo-patches/0001-honor-embedder-secure-schemes-in-secure-context-checks.patch b/servo-patches/0001-honor-embedder-secure-schemes-in-secure-context-checks.patch
deleted file mode 100644
index 399ad59..0000000
--- a/servo-patches/0001-honor-embedder-secure-schemes-in-secure-context-checks.patch
+++ /dev/null
@@ -1,129 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Fri, 21 Aug 2026 12:09:51 +0000
-Subject: [PATCH 01/24] Honor embedder-registered secure schemes in
- secure-context checks
-
-Custom protocols registered through ProtocolHandler::is_secure() were only
-honored by the fetch layer (is_url_potentially_trustworthy); script's
-GlobalScope::is_secure_context() reaches ServoUrl::is_potentially_trustworthy,
-where a non-special scheme's opaque origin can never be trustworthy. As a
-result, [SecureContext] APIs (crypto.randomUUID, crypto.subtle, ...) were
-absent on embedder-served pages such as tauri://localhost even when the
-embedder declared the protocol secure.
-
-Register secure schemes from the merged ProtocolRegistry into a process-global
-set in servo_url at startup, and consult it in
-ServoUrl::is_potentially_trustworthy.
-
-Co-Authored-By: Claude Fable 5
-Claude-Session: https://claude.ai/code/session_018Jf9dnzfAnoLzptgcNbxDy
----
- components/net/protocols/mod.rs | 11 ++++++++++
- components/servo/servo.rs | 8 +++++++
- components/url/lib.rs | 37 +++++++++++++++++++++++++++++++++
- 3 files changed, 56 insertions(+)
-
-diff --git a/components/net/protocols/mod.rs b/components/net/protocols/mod.rs
-index fbc2d68fa545c27ce30ca41bbf542be551b52acb..ac14bbd1fbd3ea433fa028f6ed2d9571cdb1d319 100644
---- a/components/net/protocols/mod.rs
-+++ b/components/net/protocols/mod.rs
-@@ -158,6 +158,17 @@ impl ProtocolRegistry {
- .is_some_and(|handler| handler.is_secure())
- }
-
-+ /// Schemes whose handlers declared themselves secure-context capable
-+ /// ([`ProtocolHandler::is_secure`]). The embedding layer feeds these to
-+ /// `servo_url::register_secure_scheme` so script's secure-context
-+ /// computation agrees with the fetch layer.
-+ pub fn secure_schemes(&self) -> impl Iterator- {
-+ self.handlers
-+ .iter()
-+ .filter(|(_, handler)| handler.is_secure())
-+ .map(|(scheme, _)| scheme.as_str())
-+ }
-+
- pub fn privileged_urls(&self) -> Vec
{
- self.handlers
- .iter()
-diff --git a/components/servo/servo.rs b/components/servo/servo.rs
-index b08d06e8502505823e6feaaed49370391f5581e5..a1989fe91ddd4298227bcaefb0bf15fe50b8f171 100644
---- a/components/servo/servo.rs
-+++ b/components/servo/servo.rs
-@@ -946,6 +946,14 @@ impl Servo {
- // layout, as well as the navigation context.
- let mut protocols = ProtocolRegistry::with_internal_protocols();
- protocols.merge(builder.protocol_registry);
-+ // Let script's secure-context computation (GlobalScope::is_secure_context,
-+ // which reaches ServoUrl::is_potentially_trustworthy) see the same
-+ // embedder security declarations the fetch layer honors - without this,
-+ // [SecureContext] APIs are absent on custom-protocol pages even when the
-+ // handler is registered with is_secure().
-+ for scheme in protocols.secure_schemes() {
-+ servo_url::register_secure_scheme(scheme);
-+ }
-
- // The `Paint` coordinates with the client window to create the final
- // rendered page and display it somewhere.
-diff --git a/components/url/lib.rs b/components/url/lib.rs
-index 5d1620d02820da407de8cc2fca6635164ba1a726..617745807824ceb8d70553cc27ad8127e128b208 100644
---- a/components/url/lib.rs
-+++ b/components/url/lib.rs
-@@ -13,6 +13,7 @@ use std::collections::hash_map::DefaultHasher;
- use std::fmt;
- use std::hash::Hasher;
- use std::net::IpAddr;
-+use std::sync::RwLock;
- use std::ops::{Index, Range, RangeFrom, RangeFull, RangeTo};
- use std::path::Path;
- use std::str::FromStr;
-@@ -25,6 +26,33 @@ use url::{Position, Url};
-
- pub use crate::origin::{ImmutableOrigin, MutableOrigin, OpaqueOrigin, OriginSnapshot};
-
-+/// Custom schemes the embedder registered as secure-context capable
-+/// (`ProtocolHandler::is_secure()`). Non-special schemes get opaque origins,
-+/// which the webappsec "is origin potentially trustworthy?" algorithm can
-+/// never bless - so the embedder's declaration has to be consulted at the URL
-+/// level. Populated once by the embedding layer at startup.
-+static EMBEDDER_SECURE_SCHEMES: RwLock> = RwLock::new(Vec::new());
-+
-+/// Registers a scheme whose URLs should be treated as potentially trustworthy
-+/// (secure-context capable). Called by the embedding layer for each custom
-+/// protocol handler registered with `is_secure()`.
-+pub fn register_secure_scheme(scheme: &str) {
-+ let mut schemes = EMBEDDER_SECURE_SCHEMES
-+ .write()
-+ .expect("secure scheme registry poisoned");
-+ if !schemes.iter().any(|registered| registered == scheme) {
-+ schemes.push(scheme.to_owned());
-+ }
-+}
-+
-+fn is_embedder_secure_scheme(scheme: &str) -> bool {
-+ EMBEDDER_SECURE_SCHEMES
-+ .read()
-+ .expect("secure scheme registry poisoned")
-+ .iter()
-+ .any(|registered| registered == scheme)
-+}
-+
- const DATA_URL_DISPLAY_LENGTH: usize = 40;
-
- #[derive(Debug)]
-@@ -245,6 +273,15 @@ impl ServoUrl {
- return true;
- }
-
-+ // Custom protocols the embedder registered as secure
-+ // (`ProtocolHandler::is_secure()`): their non-special schemes yield
-+ // opaque origins, which step 3 can never bless, so honor the
-+ // embedder's declaration here. This is what makes [SecureContext]
-+ // APIs reachable on embedder-served pages (e.g. tauri://).
-+ if is_embedder_secure_scheme(self.scheme()) {
-+ return true;
-+ }
-+
- // Step 3. Return the result of executing § 3.1 Is origin potentially trustworthy? on url’s origin.
- self.origin().is_potentially_trustworthy()
- }
diff --git a/servo-patches/0002-script-support-user-input-in-contenteditable-element.patch b/servo-patches/0002-script-support-user-input-in-contenteditable-element.patch
deleted file mode 100644
index 4fd19e5..0000000
--- a/servo-patches/0002-script-support-user-input-in-contenteditable-element.patch
+++ /dev/null
@@ -1,115 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Jonathan Kingston
-Date: Sun, 2 Aug 2026 01:45:10 +0100
-Subject: [PATCH 02/24] script: support user input in contenteditable elements
-
-(cherry picked from commit eaa6c602683ceb0a4a3c61de50a4380b866d4fd8)
----
- .../dom/document/document_event_handler.rs | 62 ++++++++++++++++++-
- 1 file changed, 59 insertions(+), 3 deletions(-)
-
-diff --git a/components/script/dom/document/document_event_handler.rs b/components/script/dom/document/document_event_handler.rs
-index eb1e0cc4ca5f4c9e160a359713cacda981b2e8f2..82de8e5d508154093bb6eb00d2877985d1db16d5 100644
---- a/components/script/dom/document/document_event_handler.rs
-+++ b/components/script/dom/document/document_event_handler.rs
-@@ -58,6 +58,7 @@ use crate::dom::clipboardevent::ClipboardEventType;
- use crate::dom::document::FireMouseEventType;
- use crate::dom::document::focus::FocusableArea;
- use crate::dom::event::{EventBubbles, EventCancelable, EventComposed, EventFlags};
-+use crate::dom::execcommand::execcommands::DocumentExecCommandSupport;
- #[cfg(feature = "gamepad")]
- use crate::dom::gamepad::gamepad::{Gamepad, contains_user_gesture};
- #[cfg(feature = "gamepad")]
-@@ -1588,20 +1589,36 @@ impl DocumentEventHandler {
- return Default::default();
- };
-
-- let cancelable = composition_event.state == keyboard_types::CompositionState::Start;
-+ let state = composition_event.state;
-+ let data = DOMString::from(composition_event.data);
-+ let cancelable = state == keyboard_types::CompositionState::Start;
- let event = CompositionEvent::new(
- cx,
- &self.window,
-- composition_event.state.event_type().into(),
-+ state.event_type().into(),
- true,
- cancelable,
- Some(&self.window),
- 0,
-- DOMString::from(composition_event.data),
-+ data.clone(),
- );
-
- let event = event.upcast::();
- event.fire(cx, focused_element.upcast());
-+
-+ // Native form controls handle committed composition text in their own
-+ // default event handlers. Editing hosts do not have a specialized
-+ // element handler, so perform their default insertion through the
-+ // existing editing command implementation.
-+ if state == keyboard_types::CompositionState::End &&
-+ focused_element
-+ .upcast::()
-+ .editing_host_of()
-+ .is_some()
-+ {
-+ document.exec_command_for_command_id(cx, "insertText".into(), data);
-+ }
-+
- event.flags().into()
- }
-
-@@ -2180,6 +2197,10 @@ impl DocumentEventHandler {
- return;
- }
-
-+ if self.maybe_handle_contenteditable_key(cx, node, event) {
-+ return;
-+ }
-+
- let mut is_space = false;
- let scroll = match event.key() {
- Key::Named(NamedKey::ArrowDown) => KeyboardScroll::Down,
-@@ -2220,6 +2241,41 @@ impl DocumentEventHandler {
- self.do_keyboard_scroll(cx, scroll);
- }
-
-+ fn maybe_handle_contenteditable_key(
-+ &self,
-+ cx: &mut JSContext,
-+ node: &Node,
-+ event: &KeyboardEvent,
-+ ) -> bool {
-+ if node.editing_host_of().is_none() {
-+ return false;
-+ }
-+
-+ let mut modifiers = event.modifiers();
-+ modifiers.remove(Modifiers::SHIFT);
-+ let (command, value) = match event.key() {
-+ Key::Character(value) if modifiers.is_empty() => {
-+ ("insertText", DOMString::from(value))
-+ },
-+ Key::Named(NamedKey::Enter) if modifiers.is_empty() => {
-+ ("insertParagraph", DOMString::new())
-+ },
-+ Key::Named(NamedKey::Backspace) if modifiers.is_empty() => {
-+ ("delete", DOMString::new())
-+ },
-+ Key::Named(NamedKey::Delete) if modifiers.is_empty() => {
-+ ("forwardDelete", DOMString::new())
-+ },
-+ _ => return false,
-+ };
-+
-+ self.window.Document().exec_command_for_command_id(
-+ cx,
-+ command.into(),
-+ value,
-+ )
-+ }
-+
- pub(crate) fn do_keyboard_scroll(&self, cx: &mut JSContext, scroll: KeyboardScroll) {
- let scroll_axis = match scroll {
- KeyboardScroll::Left | KeyboardScroll::Right => ScrollingBoxAxis::X,
diff --git a/servo-patches/0003-layout-resolve-currentColor-in-rasterized-inline-SVG.patch b/servo-patches/0003-layout-resolve-currentColor-in-rasterized-inline-SVG.patch
deleted file mode 100644
index 64eaa23..0000000
--- a/servo-patches/0003-layout-resolve-currentColor-in-rasterized-inline-SVG.patch
+++ /dev/null
@@ -1,121 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Fri, 21 Aug 2026 14:58:47 +0000
-Subject: [PATCH 03/24] layout: resolve currentColor in rasterized inline SVG
-
-Inline subtrees are XML-serialized to a data: URL and rasterized
-with no CSS context, so currentColor resolved to the rasterizer's default
-(black) instead of the element's computed color - icon sets drawn as
-currentColor strokes rendered as solid dark shapes.
-
-Inject the element's computed CSS color as a color attribute on the
-serialized root (which the rasterizer honors per SVG's color property)
-unless the markup already declares one. The rewritten data URL doubles as
-the cache key, so identical markup rasterizes once per resolved color and
-theme changes stay correct.
-
-Co-Authored-By: Claude Fable 5
-Claude-Session: https://claude.ai/code/session_018Jf9dnzfAnoLzptgcNbxDy
----
- components/layout/Cargo.toml | 1 +
- components/layout/replaced.rs | 64 +++++++++++++++++++++++++++++++++++
- 2 files changed, 65 insertions(+)
-
-diff --git a/components/layout/Cargo.toml b/components/layout/Cargo.toml
-index 52e73ef68dd21760bdfa34e8a335e655d1527498..2f31a1163eb6216f07881b8a2e5c675e12433cc7 100644
---- a/components/layout/Cargo.toml
-+++ b/components/layout/Cargo.toml
-@@ -21,6 +21,7 @@ tracing = ["dep:tracing"]
- [dependencies]
- accesskit = { workspace = true }
- app_units = { workspace = true }
-+base64 = { workspace = true }
- arrayvec = { workspace = true }
- atomic_refcell = { workspace = true }
- bitflags = { workspace = true }
-diff --git a/components/layout/replaced.rs b/components/layout/replaced.rs
-index 97f215706dc18ece3cae638a3ae71e5525c98018..09dda617c133ecf23cec86cda46e694f2d0c3805 100644
---- a/components/layout/replaced.rs
-+++ b/components/layout/replaced.rs
-@@ -17,6 +17,7 @@ use servo_arc::Arc as ServoArc;
- use servo_base::id::{BrowsingContextId, PipelineId};
- use servo_url::ServoUrl;
- use style::Zero;
-+use style_traits::ToCss;
- use style::attr::AttrValue;
- use style::computed_values::object_fit::T as ObjectFit;
- use style::context::TreeCountingCaches;
-@@ -298,6 +299,18 @@ impl ReplacedContents {
- };
-
- let cached_image = svg_source.and_then(|svg_source| {
-+ // The serialized copy is rasterized with no CSS context, so
-+ // `currentColor` inside it would resolve to black instead of the
-+ // element's computed `color`. Carry that color into the document
-+ // as a root attribute (which the rasterizer honors); the rewritten
-+ // URL also becomes the cache key, so the same markup rasterizes
-+ // once per resolved color.
-+ let color = node
-+ .style(&context.style_context)
-+ .clone_color()
-+ .to_css_string();
-+ let svg_source =
-+ inject_computed_color_into_svg_source(&svg_source, &color).unwrap_or(svg_source);
- context
- .image_resolver
- .get_cached_image_for_url(
-@@ -794,3 +807,54 @@ fn try_to_parse_image_data_url(string: &str) -> Option {
-
- Url::parse(string).ok()
- }
-+
-+/// Rewrites a serialized inline-SVG data URL so its root carries the
-+/// element's computed CSS `color` as a presentation attribute, unless the
-+/// markup already declares one. Returns `None` (leaving the source untouched)
-+/// for anything unexpected.
-+fn inject_computed_color_into_svg_source(source: &ServoUrl, color_css: &str) -> Option {
-+ use base64::Engine as _;
-+
-+ let base64_payload = source
-+ .as_str()
-+ .strip_prefix("data:image/svg+xml;base64,")?;
-+ let decoded = base64::engine::general_purpose::STANDARD
-+ .decode(base64_payload)
-+ .ok()?;
-+ let mut xml = String::from_utf8(decoded).ok()?;
-+
-+ let tag_start = xml.find("' || next == '/')
-+ {
-+ return None;
-+ }
-+ let tag_end = tag_start + xml[tag_start..].find('>')?;
-+ let open_tag = &xml[tag_start..tag_end];
-+
-+ // A `color` attribute already present on the root wins. The whitespace
-+ // check keeps `stop-color=` and friends from matching.
-+ let has_color_attribute = open_tag.match_indices("color=").any(|(index, _)| {
-+ open_tag[..index]
-+ .chars()
-+ .next_back()
-+ .is_some_and(|preceding| preceding.is_ascii_whitespace())
-+ });
-+ if has_color_attribute {
-+ return None;
-+ }
-+
-+ // Attribute values are CSS color serializations like `rgb(1, 2, 3)`;
-+ // refuse anything that could break out of the quoted attribute.
-+ if color_css.contains(['"', '<', '>', '&']) {
-+ return None;
-+ }
-+
-+ xml.insert_str(after_name, &format!(" color=\"{color_css}\""));
-+ let reencoded = base64::engine::general_purpose::STANDARD.encode(xml);
-+ ServoUrl::parse(&format!("data:image/svg+xml;base64,{reencoded}")).ok()
-+}
diff --git a/servo-patches/0004-script-report-module-evaluation-errors-asynchronously.patch b/servo-patches/0004-script-report-module-evaluation-errors-asynchronously.patch
deleted file mode 100644
index df1a8c8..0000000
--- a/servo-patches/0004-script-report-module-evaluation-errors-asynchronously.patch
+++ /dev/null
@@ -1,226 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Jonathan Kingston
-Date: Fri, 21 Aug 2026 15:50:12 +0100
-Subject: [PATCH 04/24] script: Report module evaluation errors asynchronously
-
-`execute_module` passed `ModuleErrorBehaviour::ThrowModuleErrorsSync` to
-`ThrowOnModuleEvaluationFailure`, which forces the evaluation promise to
-settle synchronously. That is incorrect for modules using top-level
-await, whose evaluation promise is still pending when `ModuleEvaluate`
-returns.
-
-Switch to `ReportModuleErrorsAsync` so SpiderMonkey attaches a rejection
-handler to the evaluation promise instead. This matches step 8 of
-"run a module script", which reports an exception *upon rejection of*
-evaluationPromise rather than at evaluation time.
-
-Testing: Adds tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html
-covering top-level await after a static import, of a dynamic import,
-while a message is pending, and a rejected top-level await surfacing on
-Worker.onerror. Not yet run locally; needs a WPT run before review.
-
-Co-Authored-By: Claude Opus 5
-(cherry picked from commit f7c87376be8f6d98ca428879764c96d568f8b82e)
----
- components/script/script_module.rs | 10 ++--
- tests/wpt/meta/MANIFEST.json | 31 +++++++++++
- .../dedicated-worker-top-level-await.html | 53 +++++++++++++++++++
- .../top-level-await-dynamic-import-worker.js | 2 +
- .../top-level-await-dynamic-import.js | 1 +
- .../top-level-await-message-worker.js | 4 ++
- .../top-level-await-rejection-worker.js | 1 +
- .../top-level-await-static-import-worker.js | 4 ++
- .../top-level-await-static-import.js | 1 +
- 9 files changed, 102 insertions(+), 5 deletions(-)
- create mode 100644 tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html
- create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import-worker.js
- create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import.js
- create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-message-worker.js
- create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-rejection-worker.js
- create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-static-import-worker.js
- create mode 100644 tests/wpt/tests/workers/modules/resources/top-level-await-static-import.js
-
-diff --git a/components/script/script_module.rs b/components/script/script_module.rs
-index fd27aac8f4efa2ee49ff1298bc269a7ae01785fe..e53637a8ff91bdf093096c3a3b64e09ac39f35ee 100644
---- a/components/script/script_module.rs
-+++ b/components/script/script_module.rs
-@@ -459,17 +459,17 @@ impl ModuleTree {
- evaluation_promise.set(eval_result.to_object());
- }
-
-- let throw_result = ThrowOnModuleEvaluationFailure(
-+ let report_result = ThrowOnModuleEvaluationFailure(
- cx,
- evaluation_promise.handle(),
-- ModuleErrorBehaviour::ThrowModuleErrorsSync,
-+ ModuleErrorBehaviour::ReportModuleErrorsAsync,
- );
-- if !throw_result {
-- warn!("fail to evaluate module");
-+ if !report_result {
-+ warn!("failed to install module evaluation error reporting");
-
- Err(RethrowError::from_pending_exception(cx))
- } else {
-- debug!("module evaluated successfully");
-+ debug!("module evaluation started successfully");
- Ok(())
- }
- }
-diff --git a/tests/wpt/meta/MANIFEST.json b/tests/wpt/meta/MANIFEST.json
-index f506b1c8373cb1da65d9a556f190bd9a314a3ac4..3c92cbea46b968c32d82a9ad98e193a02759eb97 100644
---- a/tests/wpt/meta/MANIFEST.json
-+++ b/tests/wpt/meta/MANIFEST.json
-@@ -598717,6 +598717,30 @@
- "throw.js": [
- "3d876d43d930d281c1d6aa595e527497b622e80b",
- []
-+ ],
-+ "top-level-await-dynamic-import-worker.js": [
-+ "539e11513a866bca67b61477d396bbf7a24a749c",
-+ []
-+ ],
-+ "top-level-await-dynamic-import.js": [
-+ "18c9b55806aaef71faa8511d212ccb3600d1ff11",
-+ []
-+ ],
-+ "top-level-await-message-worker.js": [
-+ "cf66a51636a175b0d35d606b8b4244b5969366ec",
-+ []
-+ ],
-+ "top-level-await-rejection-worker.js": [
-+ "4c39c8830a2cee4cc2c01f923c1464ae124aa98b",
-+ []
-+ ],
-+ "top-level-await-static-import-worker.js": [
-+ "f1c4e9bb7ff2e67dbdd05fc33ba2e27c20c17a40",
-+ []
-+ ],
-+ "top-level-await-static-import.js": [
-+ "7e2f2b676c71e31eb0e368c3e93fecec99a017f1",
-+ []
- ]
- },
- "shared-worker-options-credentials.html.headers": [
-@@ -1462648,6 +1462672,13 @@
- {}
- ]
- ],
-+ "dedicated-worker-top-level-await.html": [
-+ "96d617b7f32cbc84813e055d0d3763516d968de7",
-+ [
-+ null,
-+ {}
-+ ]
-+ ],
- "shared-worker-import-blob-url.window.js": [
- "a79d5725ad879106ec78bd4a66977352db756818",
- [
-diff --git a/tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html b/tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html
-new file mode 100644
-index 0000000000000000000000000000000000000000..96d617b7f32cbc84813e055d0d3763516d968de7
---- /dev/null
-+++ b/tests/wpt/tests/workers/modules/dedicated-worker-top-level-await.html
-@@ -0,0 +1,53 @@
-+
-+
-+Top-level await in dedicated module workers
-+
-+
-+
-diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import-worker.js b/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import-worker.js
-new file mode 100644
-index 0000000000000000000000000000000000000000..539e11513a866bca67b61477d396bbf7a24a749c
---- /dev/null
-+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import-worker.js
-@@ -0,0 +1,2 @@
-+const {value} = await import('./top-level-await-dynamic-import.js');
-+postMessage(value);
-diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import.js b/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import.js
-new file mode 100644
-index 0000000000000000000000000000000000000000..18c9b55806aaef71faa8511d212ccb3600d1ff11
---- /dev/null
-+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-dynamic-import.js
-@@ -0,0 +1 @@
-+export const value = 'dynamic import';
-diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-message-worker.js b/tests/wpt/tests/workers/modules/resources/top-level-await-message-worker.js
-new file mode 100644
-index 0000000000000000000000000000000000000000..cf66a51636a175b0d35d606b8b4244b5969366ec
---- /dev/null
-+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-message-worker.js
-@@ -0,0 +1,4 @@
-+const event = await new Promise(resolve => {
-+ addEventListener('message', resolve, {once: true});
-+});
-+postMessage(event.data);
-diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-rejection-worker.js b/tests/wpt/tests/workers/modules/resources/top-level-await-rejection-worker.js
-new file mode 100644
-index 0000000000000000000000000000000000000000..4c39c8830a2cee4cc2c01f923c1464ae124aa98b
---- /dev/null
-+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-rejection-worker.js
-@@ -0,0 +1 @@
-+await Promise.reject(new Error('top-level await rejection'));
-diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-static-import-worker.js b/tests/wpt/tests/workers/modules/resources/top-level-await-static-import-worker.js
-new file mode 100644
-index 0000000000000000000000000000000000000000..f1c4e9bb7ff2e67dbdd05fc33ba2e27c20c17a40
---- /dev/null
-+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-static-import-worker.js
-@@ -0,0 +1,4 @@
-+import {value} from './top-level-await-static-import.js';
-+
-+await Promise.resolve();
-+postMessage(value);
-diff --git a/tests/wpt/tests/workers/modules/resources/top-level-await-static-import.js b/tests/wpt/tests/workers/modules/resources/top-level-await-static-import.js
-new file mode 100644
-index 0000000000000000000000000000000000000000..7e2f2b676c71e31eb0e368c3e93fecec99a017f1
---- /dev/null
-+++ b/tests/wpt/tests/workers/modules/resources/top-level-await-static-import.js
-@@ -0,0 +1 @@
-+export const value = 'static import';
diff --git a/servo-patches/0005-script-honor-the-svg-color-presentation-attribute.patch b/servo-patches/0005-script-honor-the-svg-color-presentation-attribute.patch
deleted file mode 100644
index 0dade3a..0000000
--- a/servo-patches/0005-script-honor-the-svg-color-presentation-attribute.patch
+++ /dev/null
@@ -1,51 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Fri, 21 Aug 2026 17:04:16 +0000
-Subject: [PATCH 05/24] script: honor the SVG color presentation attribute
-MIME-Version: 1.0
-Content-Type: text/plain; charset=UTF-8
-Content-Transfer-Encoding: 8bit
-
-SVG 2 defines presentation attributes for CSS properties including
-color, but SVGElement's presentational-hint synthesis only covered the
-paint and geometry properties, so markup like
-never influenced the computed color. That broke currentColor resolution
-for such documents — including in the serialized-subtree rasterization
-path, where the computed color is injected into the rasterized document.
-
-Parses the color attribute alongside fill/stroke/etc. and includes it in
-attribute_affects_presentational_hints.
----
- components/script/dom/svg/svgelement.rs | 12 +++++++++++-
- 1 file changed, 11 insertions(+), 1 deletion(-)
-
-diff --git a/components/script/dom/svg/svgelement.rs b/components/script/dom/svg/svgelement.rs
-index a87640a1c7ab13ddd7ace4dab6b69c4ec772de8d..d5a02526b3f727fec83b4003085192b2fe522ea9 100644
---- a/components/script/dom/svg/svgelement.rs
-+++ b/components/script/dom/svg/svgelement.rs
-@@ -117,7 +117,8 @@ impl VirtualMethods for SVGElement {
- fn attribute_affects_presentational_hints(&self, attr: AttrRef<'_>) -> bool {
- matches!(
- attr.local_name(),
-- &local_name!("fill") |
-+ &local_name!("color") |
-+ &local_name!("fill") |
- &local_name!("fill-opacity") |
- &local_name!("fill-rule") |
- &local_name!("stroke") |
-@@ -290,6 +291,15 @@ impl<'dom> LayoutDom<'dom, SVGElement> {
- Default::default(),
- );
-
-+ // `color` is a presentation attribute in SVG like the paint
-+ // properties below; it feeds `currentColor` resolution, including in
-+ // the serialized-subtree rasterization path.
-+ self.parse_svg_attribute(
-+ &parser_context,
-+ "color",
-+ longhands::color::parse_declared,
-+ push,
-+ );
- self.parse_svg_attribute(
- &parser_context,
- "fill",
diff --git a/servo-patches/0006-layout-flatten-computed-styles-into-rasterized-inline-svg.patch b/servo-patches/0006-layout-flatten-computed-styles-into-rasterized-inline-svg.patch
deleted file mode 100644
index 7d9ecd0..0000000
--- a/servo-patches/0006-layout-flatten-computed-styles-into-rasterized-inline-svg.patch
+++ /dev/null
@@ -1,388 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Fri, 21 Aug 2026 17:04:36 +0000
-Subject: [PATCH 06/24] layout: flatten computed styles into rasterized inline
- SVG
-MIME-Version: 1.0
-Content-Type: text/plain; charset=UTF-8
-Content-Transfer-Encoding: 8bit
-
-Inline is XML-serialized and rasterized by resvg with no CSS
-context, so selector-driven styling on the subtree — class rules for
-fill, stroke, stroke-width and friends — was lost entirely: icon sets
-styled via stylesheet classes rendered with initial paints.
-
-Serialization (SVGSVGElement::serialize_and_cache_subtree) now stamps
-each element in the cloned subtree with a data-servo-style-id attribute
-recording its preorder position; the ids depend only on structure, so
-the cached serialization stays valid across restyles. At layout time,
-the replaced-content path walks the original subtree's computed styles
-in the same preorder and injects a ", escape_xml_text(flattened_rules)),
-+ );
-+ }
-+ if inject_color {
-+ xml.insert_str(after_name, &format!(" color=\"{color_css}\""));
- }
-
-- xml.insert_str(after_name, &format!(" color=\"{color_css}\""));
- let reencoded = base64::engine::general_purpose::STANDARD.encode(xml);
- ServoUrl::parse(&format!("data:image/svg+xml;base64,{reencoded}")).ok()
- }
-diff --git a/components/layout/traversal.rs b/components/layout/traversal.rs
-index d3c328f97555dde8f0602dd9008c30a15c2221d0..a1674fdcbf1ef4e533d7d1ae3093f5800e001a2d 100644
---- a/components/layout/traversal.rs
-+++ b/components/layout/traversal.rs
-@@ -226,6 +226,22 @@ pub(crate) fn compute_damage_and_rebuild_box_tree_below_dirty_root<'dom>(
- layout_roots,
- );
-
-+ // An `` element renders as a rasterization of its serialized subtree
-+ // resolved against the subtree's computed styles (see
-+ // `ReplacedContents::svg_kind_size`). Its descendants have no boxes, so a
-+ // restyle inside the subtree — or of the ``'s own inherited inputs
-+ // like `color` — surfaces only as repaint-level damage that would leave a
-+ // stale rasterization on screen. Escalate any damage at the ``
-+ // boundary to a box rebuild so the rasterization inputs are recomputed.
-+ if !(damage_set.on_element | damage_set.from_children).is_empty() &&
-+ node.type_id() ==
-+ Some(layout_api::LayoutNodeType::Element(
-+ layout_api::LayoutElementType::SVGSVGElement,
-+ ))
-+ {
-+ damage_set.on_element.insert(LayoutDamage::BoxDamage);
-+ }
-+
- // Apply the calculated damage to this element (perhaps triggering box tree layout),
- // and propagate resulting damage to ancestors.
- damage_set.apply_damage(layout_context, layout_roots)
-diff --git a/components/script/dom/svg/svgsvgelement.rs b/components/script/dom/svg/svgsvgelement.rs
-index d3ce15857b3360fe76a521e88eddf979633d6742..5f38f8351ea58206181935f3a6a63a162f4b2c6a 100644
---- a/components/script/dom/svg/svgsvgelement.rs
-+++ b/components/script/dom/svg/svgsvgelement.rs
-@@ -94,6 +94,7 @@ impl SVGSVGElement {
- return;
- }
-
-+ self.stamp_style_flattening_ids(cx, &cloned_node);
- self.process_use_elements(cx, &cloned_node);
-
- let Ok(xml_source) = cloned_node.xml_serialize(TraversalScope::IncludeNode) else {
-@@ -110,6 +111,27 @@ impl SVGSVGElement {
- };
- }
-
-+ /// Stamps every element in the cloned subtree with a
-+ /// `data-servo-style-id` attribute recording its preorder position.
-+ /// Layout walks the original subtree in the same order and generates a
-+ /// stylesheet of computed styles keyed by these ids, so the rasterizer
-+ /// (which parses the serialization with no CSS context) can honor
-+ /// selector-driven styling. The ids depend only on subtree structure,
-+ /// never on styles, so the cached serialization stays valid across
-+ /// restyles. This must run before `` expansion to keep the
-+ /// preorder aligned with the original subtree; expanded `` content
-+ /// is intentionally left unstamped.
-+ fn stamp_style_flattening_ids(&self, cx: &mut JSContext, root_node: &Node) {
-+ let style_id_name = LocalName::from("data-servo-style-id");
-+ let mut index: u32 = 0;
-+ for node in root_node.traverse_preorder(ShadowIncluding::No) {
-+ if let Some(element) = node.downcast::() {
-+ element.set_attribute(cx, &style_id_name, AttrValue::String(index.to_string()));
-+ index += 1;
-+ }
-+ }
-+ }
-+
- fn process_use_elements(&self, cx: &mut JSContext, root_node: &Node) {
- for node in root_node.traverse_preorder(ShadowIncluding::No) {
- if let Some(element) = node.downcast::() &&
diff --git a/servo-patches/0007-script-fall-back-to-generic-sans-serif-for-svg-text-fonts.patch b/servo-patches/0007-script-fall-back-to-generic-sans-serif-for-svg-text-fonts.patch
deleted file mode 100644
index a95e9d9..0000000
--- a/servo-patches/0007-script-fall-back-to-generic-sans-serif-for-svg-text-fonts.patch
+++ /dev/null
@@ -1,96 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Fri, 21 Aug 2026 18:26:33 +0000
-Subject: [PATCH 07/24] script: fall back to generic sans-serif for SVG text
- font resolution
-
-The rasterizer substitutes fonts per-glyph only after a base font
-resolves for a text span; when the embedder's FontResolver returns None
-the whole span is dropped. Markup with no font-family reaches the
-resolver as usvg's default family ("Times New Roman"), so on systems
-without that font every such element silently disappeared.
-
-When no requested family resolves, retry with the embedder's generic
-sans-serif before giving up.
----
- components/script/svg_font.rs | 59 ++++++++++++++++++++++-------------
- 1 file changed, 38 insertions(+), 21 deletions(-)
-
-diff --git a/components/script/svg_font.rs b/components/script/svg_font.rs
-index 88f9ee0052d4ec445cf3085f1f5222586be4a4bb..ced43cc8cf59694ebb9fea691012c0581b657676 100644
---- a/components/script/svg_font.rs
-+++ b/components/script/svg_font.rs
-@@ -76,35 +76,52 @@ fn convert_font_family(family: &FontFamily) -> SingleFontFamily {
- }
- }
-
-+impl SvgFontResolver {
-+ fn resolve_single_family(
-+ &self,
-+ font_descriptor: &FontDescriptor,
-+ family: SingleFontFamily,
-+ database: &mut Arc,
-+ ) -> Option {
-+ let family_descriptor = FontFamilyDescriptor::new(family, FontSearchScope::Any);
-+ let font_template = self
-+ .context
-+ .matching_templates(font_descriptor, &family_descriptor)
-+ .into_iter()
-+ .next()?;
-+ let font = self.context.font(font_template, font_descriptor)?;
-+ let data_and_index = font.font_data_and_index().ok()?;
-+ let ids = Arc::make_mut(database).load_font_source(fontdb::Source::Binary(Arc::new(
-+ data_and_index.data.clone(),
-+ )));
-+ ids.get(data_and_index.index as usize).copied()
-+ }
-+}
-+
- impl FontResolver for SvgFontResolver {
- fn resolve(&self, font: &Font, database: &mut Arc) -> Option {
- let font_descriptor = convert_font_descriptor(font);
-
- for family in font.families() {
-- let family_descriptor =
-- FontFamilyDescriptor::new(convert_font_family(family), FontSearchScope::Any);
-- let Some(font_template) = self
-- .context
-- .matching_templates(&font_descriptor, &family_descriptor)
-- .into_iter()
-- .next()
-- else {
-- continue;
-- };
-- let Some(font) = self.context.font(font_template, &font_descriptor) else {
-- continue;
-- };
-- let Ok(data_and_index) = font.font_data_and_index() else {
-- continue;
-- };
-- let ids = Arc::make_mut(database).load_font_source(fontdb::Source::Binary(Arc::new(
-- data_and_index.data.clone(),
-- )));
-- if let Some(id) = ids.get(data_and_index.index as usize).copied() {
-+ if let Some(id) = self.resolve_single_family(
-+ &font_descriptor,
-+ convert_font_family(family),
-+ database,
-+ ) {
- return Some(id);
- }
- }
-
-- None
-+ // No requested family resolved. Fall back to the generic sans-serif
-+ // rather than returning `None`: the rasterizer only substitutes
-+ // fonts per-glyph once a base font resolves, so `None` here drops
-+ // the whole text span. This matters in particular for markup with
-+ // no font-family at all, which reaches us as the rasterizer's
-+ // default family ("Times New Roman") — absent on many systems.
-+ self.resolve_single_family(
-+ &font_descriptor,
-+ SingleFontFamily::Generic(GenericFontFamily::SansSerif),
-+ database,
-+ )
- }
- }
diff --git a/servo-patches/0008-give-embedder-registered-custom-schemes-tuple-origins.patch b/servo-patches/0008-give-embedder-registered-custom-schemes-tuple-origins.patch
deleted file mode 100644
index d86bdfa..0000000
--- a/servo-patches/0008-give-embedder-registered-custom-schemes-tuple-origins.patch
+++ /dev/null
@@ -1,133 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Sat, 22 Aug 2026 00:30:40 +0000
-Subject: [PATCH 08/24] Give embedder-registered custom schemes tuple origins
-
-The URL standard gives every non-special-scheme URL an opaque origin, so
-a document served over a registered custom protocol (tauri://localhost)
-gets a unique origin per load: CSP 'self' can never match it, same-origin
-checks against its own subresources fail, and origin-keyed storage
-(localStorage) throws or resets on every navigation. Embedders coming
-from Chromium/WebKit expect a scheme they register to behave like a
-"standard" scheme there: a stable (scheme, host, port) tuple origin.
-
-Model URLs on embedder-registered secure schemes (the registry introduced
-for secure-context checks) with a tuple origin when the URL carries a
-host. Such schemes have no default port; the tuple stores the sentinel
-port 0, and ascii_serialization emits these origins without a port
-(tauri://localhost, matching Chromium/WebKit) instead of rust-url's
-literal ':0'.
-
-Note CSP 'self' additionally needs the content-security-policy crate to
-component-compare tuple origins against non-special-scheme URLs, since
-rust-url reports those URLs' origins as opaque; see the companion patch
-to rust-content-security-policy.
-
-Co-Authored-By: Claude Fable 5
-Claude-Session: https://claude.ai/code/session_018Jf9dnzfAnoLzptgcNbxDy
----
- Cargo.lock | 1 +
- components/script/dom/url/urlhelper.rs | 8 ++++++-
- components/url/lib.rs | 2 +-
- components/url/origin.rs | 33 +++++++++++++++++++++++++-
- 4 files changed, 41 insertions(+), 3 deletions(-)
-
-diff --git a/Cargo.lock b/Cargo.lock
-index 02b3cb7e8502c69f964ddbbd8e09cc176631f830..b74bbc55951a23b006f526797a06ed479b722bfe 100644
---- a/Cargo.lock
-+++ b/Cargo.lock
-@@ -8308,6 +8308,7 @@ dependencies = [
- "app_units",
- "arrayvec",
- "atomic_refcell",
-+ "base64",
- "bitflags 2.13.1",
- "data-url",
- "euclid",
-diff --git a/components/script/dom/url/urlhelper.rs b/components/script/dom/url/urlhelper.rs
-index 12dc8ee60145759c78d08637f752245505559e55..861fd360e48f6aba9591d8bd3bdd5dd4ba22f10a 100644
---- a/components/script/dom/url/urlhelper.rs
-+++ b/components/script/dom/url/urlhelper.rs
-@@ -15,7 +15,13 @@ pub(crate) struct UrlHelper;
- #[expect(non_snake_case)]
- impl UrlHelper {
- pub(crate) fn Origin(url: &ServoUrl) -> USVString {
-- USVString(quirks::origin(url.as_url()))
-+ // Not `quirks::origin`: rust-url can only serialize its own notion of
-+ // the origin, which is opaque ("null") for every non-special scheme.
-+ // ServoUrl's origin knows about embedder-registered custom schemes
-+ // (tuple origins, e.g. "tauri://localhost") and matches rust-url for
-+ // everything else, keeping location.origin / URL.origin consistent
-+ // with window.origin.
-+ USVString(url.origin().ascii_serialization())
- }
- pub(crate) fn Href(url: &ServoUrl) -> USVString {
- USVString(quirks::href(url.as_url()).to_owned())
-diff --git a/components/url/lib.rs b/components/url/lib.rs
-index 617745807824ceb8d70553cc27ad8127e128b208..37a2ed74fa4a4392e0fcdf45463904bdf837167f 100644
---- a/components/url/lib.rs
-+++ b/components/url/lib.rs
-@@ -45,7 +45,7 @@ pub fn register_secure_scheme(scheme: &str) {
- }
- }
-
--fn is_embedder_secure_scheme(scheme: &str) -> bool {
-+pub(crate) fn is_embedder_secure_scheme(scheme: &str) -> bool {
- EMBEDDER_SECURE_SCHEMES
- .read()
- .expect("secure scheme registry poisoned")
-diff --git a/components/url/origin.rs b/components/url/origin.rs
-index 651a0182c83427ca769ed1f8af32f1c8dd7f42e8..f4af2ae18f15f63266f70994cb69f390af724d48 100644
---- a/components/url/origin.rs
-+++ b/components/url/origin.rs
-@@ -52,11 +52,33 @@ impl ImmutableOrigin {
- }
-
- match url.origin() {
-- Origin::Opaque(_) => ImmutableOrigin::new_opaque(),
-+ Origin::Opaque(_) => ImmutableOrigin::new_for_non_special_scheme(url),
- Origin::Tuple(scheme, host, port) => ImmutableOrigin::Tuple(scheme, host, port),
- }
- }
-
-+ /// The URL standard gives every non-special-scheme URL an opaque origin,
-+ /// but embedders that serve an application over a registered custom
-+ /// protocol (e.g. `tauri://localhost`) expect the scheme to behave like a
-+ /// standard one: a stable (scheme, host, port) origin, so that same-origin
-+ /// checks, CSP `'self'`, and origin-keyed storage work across loads — the
-+ /// treatment Chromium and WebKit give schemes registered as "standard".
-+ /// Model URLs on embedder-registered secure schemes with a tuple origin
-+ /// when they carry a host; everything else stays opaque. Such schemes
-+ /// have no default port, represented here by the sentinel port 0.
-+ fn new_for_non_special_scheme(url: &Url) -> ImmutableOrigin {
-+ if crate::is_embedder_secure_scheme(url.scheme()) &&
-+ let Some(host) = url.host()
-+ {
-+ return ImmutableOrigin::Tuple(
-+ url.scheme().to_owned(),
-+ host.to_owned(),
-+ url.port().unwrap_or(0),
-+ );
-+ }
-+ ImmutableOrigin::new_opaque()
-+ }
-+
- pub fn same_origin(&self, other: &impl DomainComparable) -> bool {
- self == other.immutable()
- }
-@@ -194,6 +216,15 @@ impl ImmutableOrigin {
-
- ///
- pub fn ascii_serialization(&self) -> String {
-+ // Tuple origins for embedder-registered custom schemes have no
-+ // default port (sentinel 0); rust-url would serialize the sentinel
-+ // as a literal ":0", so serialize those without a port instead
-+ // (`tauri://localhost`, matching Chromium/WebKit).
-+ if let ImmutableOrigin::Tuple(scheme, host, 0) = self &&
-+ crate::is_embedder_secure_scheme(scheme)
-+ {
-+ return format!("{}://{}", scheme, host);
-+ }
- self.clone().into_url_origin().ascii_serialization()
- }
- }
diff --git a/servo-patches/0009-layout-add-an-svg-viewport-behind-a-native-svg-pref.patch b/servo-patches/0009-layout-add-an-svg-viewport-behind-a-native-svg-pref.patch
deleted file mode 100644
index e1a1e17..0000000
--- a/servo-patches/0009-layout-add-an-svg-viewport-behind-a-native-svg-pref.patch
+++ /dev/null
@@ -1,685 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Sat, 22 Aug 2026 15:16:38 +0100
-Subject: [PATCH 09/24] layout: add an SVG viewport behind
- layout.svg.native.enabled
-
-First step of native SVG layout. Inline is currently XML-serialized
-to a data: URL and rasterized by the image cache, so nothing inside it is
-a layout participant -- which is why CSS animations on SVG descendants
-never run, and why getting even static CSS styling into the raster took
-three separate patches. Replacing that means giving a real viewport
-first: the mapping from the subtree's user coordinates onto the CSS box
-the element was given.
-
-Adds components/layout/svg/ with viewBox and preserveAspectRatio parsing
-and the viewport transform from
-,
-covered by unit tests over the meet/slice and alignment matrix.
-
-The one behaviour this changes today is the intrinsic aspect ratio.
-SVGElementData::ratio_from_view_box parses viewBox with parse_integer and
-parse_unsigned_integer, so viewBox="0 0 24.5 12.25" contributes no ratio
-at all and such an element sizes as if it had no viewBox. The new parser
-implements SVG's grammar, and the sizing path in replaced.rs
-consults it when the pref is on. Rasterization is untouched, so turning
-the pref on is not yet a rendering change.
-
-layout.svg.native.enabled defaults to false and is listed in
-EXPERIMENTAL_PREFS. Keeping the rasterization path alive behind it is
-what makes the rest of this work landable while incomplete.
-
-Also carries preserveAspectRatio through SVGElementData; layout had no
-access to it because the rasterizer read it out of the serialized copy.
-
-Seam: 21 added lines across pre-existing files, no deletions.
----
- components/config/prefs.rs | 5 +
- components/layout/lib.rs | 1 +
- components/layout/replaced.rs | 10 +
- components/layout/svg/mod.rs | 169 ++++++++++
- components/layout/svg/viewbox.rs | 344 +++++++++++++++++++++
- components/script/dom/svg/svgsvgelement.rs | 3 +
- components/shared/layout/lib.rs | 1 +
- ports/servoshell/prefs.rs | 1 +
- 8 files changed, 534 insertions(+)
- create mode 100644 components/layout/svg/mod.rs
- create mode 100644 components/layout/svg/viewbox.rs
-
-diff --git a/components/config/prefs.rs b/components/config/prefs.rs
-index 9c1ff9469c17eb0e501458e0303c0179ee58136a..489178a8a3bf468a23cc03fb03787543b2e1c2d5 100644
---- a/components/config/prefs.rs
-+++ b/components/config/prefs.rs
-@@ -320,6 +320,10 @@ pub struct Preferences {
- pub layout_css_ellipse_corners_enabled: bool,
- pub layout_css_progress_function_enabled: bool,
- pub layout_style_sharing_cache_enabled: bool,
-+ /// Lay out and paint inline `` natively, instead of serializing the
-+ /// subtree to a `data:` URL and rasterizing it as a replaced image.
-+ // feature: Native SVG layout | #12973 | Web/SVG
-+ pub layout_svg_native_enabled: bool,
- pub layout_threads: i64,
- /// The minimum number of parallelizable jobs required before turning on parallelism
- /// for a set of jobs.
-@@ -559,6 +563,7 @@ impl Preferences {
- layout_css_progress_function_enabled: false,
- layout_grid_enabled: false,
- layout_style_sharing_cache_enabled: true,
-+ layout_svg_native_enabled: false,
- // TODO(mrobinson): This should likely be based on the number of processors.
- layout_threads: 3,
- layout_parallelism_job_count_minimum: 4,
-diff --git a/components/layout/lib.rs b/components/layout/lib.rs
-index 1a066e4cd5825d4b19a7c7c8ad394a81cbdc5aa1..9c02d33382bfa0d73bf34ff8cb4aa2505e0513d1 100644
---- a/components/layout/lib.rs
-+++ b/components/layout/lib.rs
-@@ -31,6 +31,7 @@ mod quotes;
- mod replaced;
- mod sizing;
- mod style_ext;
-+mod svg;
- pub mod table;
- mod traversal;
-
-diff --git a/components/layout/replaced.rs b/components/layout/replaced.rs
-index 66859f743f20f6e68efbca7eaa4a666061f6e2cc..ffa6ce767b0566ce505c19bc9e063fb28166cc73 100644
---- a/components/layout/replaced.rs
-+++ b/components/layout/replaced.rs
-@@ -46,6 +46,7 @@ use crate::layout_box_base::{IndependentFormattingContextLayoutResult, LayoutBox
- use crate::sizing::{
- ComputeInlineContentSizes, InlineContentSizesResult, LazySize, SizeConstraint,
- };
-+use crate::svg::{SVGViewport, native_svg_enabled};
- use crate::style_ext::{AspectRatio, Clamp, ComputedValuesExt, LayoutStyle};
- use crate::{ConstraintSpace, ContainingBlock};
-
-@@ -271,10 +272,19 @@ impl ReplacedContents {
- let width = svg_data.width.and_then(attr_to_computed);
- let height = svg_data.height.and_then(attr_to_computed);
-
-+ // `SVGElementData::ratio_from_view_box` parses `viewBox` with the
-+ // integer parsers, so `viewBox="0 0 24.5 12.25"` contributes no
-+ // intrinsic ratio at all. The native path parses SVG's number grammar
-+ // instead, via the viewport it will also use to place the subtree.
-+ let viewport = SVGViewport::new(
-+ svg_data.view_box.map(|value| &**value),
-+ svg_data.preserve_aspect_ratio.map(|value| &**value),
-+ );
- let ratio = match (width, height) {
- (Some(width), Some(height)) if !width.is_zero() && !height.is_zero() => {
- Some(width.px() / height.px())
- },
-+ _ if native_svg_enabled() => viewport.aspect_ratio(),
- _ => svg_data.ratio_from_view_box(),
- };
-
-diff --git a/components/layout/svg/mod.rs b/components/layout/svg/mod.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..87f5a6317b172a38e7521c5a8979ee92a075433c
---- /dev/null
-+++ b/components/layout/svg/mod.rs
-@@ -0,0 +1,169 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! Native layout for inline ``.
-+//!
-+//! Today an inline `` is XML-serialized to a `data:` URL and rasterized by
-+//! the image cache (see [`crate::replaced`]), so nothing inside it is a layout
-+//! participant. This module is the beginning of the replacement: the subtree
-+//! keeps its computed styles and is laid out and painted in-process.
-+//!
-+//! The whole path is gated on the `layout.svg.native.enabled` preference,
-+//! which is off by default, so the rasterization path stays live and each
-+//! piece is landable while the rest is incomplete.
-+//!
-+//! Currently implemented: the SVG viewport, i.e. the `viewBox` /
-+//! `preserveAspectRatio` transform from user space to the CSS box the element
-+//! occupies. Geometry traversal and painting are not yet here.
-+
-+// Phase 1 establishes the viewport; the transform it computes is consumed by
-+// the geometry traversal in the next phase, so parts of this module have no
-+// caller yet. Remove this once that lands.
-+#![expect(dead_code)]
-+
-+use euclid::default::{Size2D, Transform2D};
-+use servo_config::pref;
-+
-+pub(crate) mod viewbox;
-+
-+use viewbox::{MeetOrSlice, PreserveAspectRatio, ViewBox};
-+
-+/// Whether inline `` should be laid out natively rather than rasterized.
-+pub(crate) fn native_svg_enabled() -> bool {
-+ pref!(layout_svg_native_enabled)
-+}
-+
-+/// The SVG viewport established by an `` element: everything needed to map
-+/// the subtree's user coordinates onto the CSS box the element was given.
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) struct SVGViewport {
-+ /// `None` when the attribute is absent or invalid, in which case user space
-+ /// is the viewport's own coordinate system.
-+ pub view_box: Option,
-+ pub preserve_aspect_ratio: PreserveAspectRatio,
-+}
-+
-+impl SVGViewport {
-+ pub(crate) fn new(view_box: Option<&str>, preserve_aspect_ratio: Option<&str>) -> Self {
-+ Self {
-+ view_box: view_box.and_then(ViewBox::parse),
-+ preserve_aspect_ratio: preserve_aspect_ratio
-+ .map(PreserveAspectRatio::parse_or_default)
-+ .unwrap_or_default(),
-+ }
-+ }
-+
-+ /// The intrinsic aspect ratio contributed by `viewBox`, used when the
-+ /// element has no `width`/`height` of its own.
-+ pub(crate) fn aspect_ratio(&self) -> Option {
-+ self.view_box.map(|view_box| view_box.aspect_ratio())
-+ }
-+
-+ /// The transform from SVG user space to the viewport's coordinate system,
-+ /// whose origin is the top-left of the element's content box.
-+ ///
-+ ///
-+ pub(crate) fn transform(&self, viewport_size: Size2D) -> Transform2D {
-+ let Some(view_box) = self.view_box else {
-+ return Transform2D::identity();
-+ };
-+
-+ let mut scale_x = viewport_size.width / view_box.width;
-+ let mut scale_y = viewport_size.height / view_box.height;
-+
-+ // `none` is the only value that scales non-uniformly.
-+ let align = self.preserve_aspect_ratio.align;
-+ if align != viewbox::Align::None {
-+ let uniform_scale = match self.preserve_aspect_ratio.meet_or_slice {
-+ MeetOrSlice::Meet => scale_x.min(scale_y),
-+ MeetOrSlice::Slice => scale_x.max(scale_y),
-+ };
-+ scale_x = uniform_scale;
-+ scale_y = uniform_scale;
-+ }
-+
-+ // Whatever the uniform scale left over (negative under `slice`) is
-+ // distributed according to the alignment.
-+ let leftover_x = viewport_size.width - view_box.width * scale_x;
-+ let leftover_y = viewport_size.height - view_box.height * scale_y;
-+ let translate_x = leftover_x * align.x_fraction() - view_box.min_x * scale_x;
-+ let translate_y = leftover_y * align.y_fraction() - view_box.min_y * scale_y;
-+
-+ Transform2D::scale(scale_x, scale_y).then_translate(euclid::vec2(translate_x, translate_y))
-+ }
-+}
-+
-+#[cfg(test)]
-+mod test {
-+ use euclid::default::{Point2D, Size2D};
-+
-+ use super::SVGViewport;
-+
-+ /// The viewport used by the worked examples in the SVG 2 spec's
-+ /// `preserveAspectRatio` section: a 300x200 viewBox shown in boxes that do
-+ /// not match its ratio.
-+ fn map(view_box: &str, preserve_aspect_ratio: &str, size: (f32, f32), point: (f32, f32)) -> (f32, f32) {
-+ let viewport = SVGViewport::new(Some(view_box), Some(preserve_aspect_ratio));
-+ let mapped = viewport
-+ .transform(Size2D::new(size.0, size.1))
-+ .transform_point(Point2D::new(point.0, point.1));
-+ ((mapped.x * 1e4).round() / 1e4, (mapped.y * 1e4).round() / 1e4)
-+ }
-+
-+ #[test]
-+ fn no_view_box_is_the_identity() {
-+ let viewport = SVGViewport::new(None, None);
-+ assert_eq!(
-+ viewport.transform(Size2D::new(100., 50.)),
-+ euclid::default::Transform2D::identity()
-+ );
-+ assert_eq!(viewport.aspect_ratio(), None);
-+ }
-+
-+ #[test]
-+ fn align_none_scales_non_uniformly() {
-+ // 300x200 viewBox into a 150x200 viewport: 0.5 across, 1.0 down.
-+ assert_eq!(map("0 0 300 200", "none", (150., 200.), (300., 200.)), (150., 200.));
-+ assert_eq!(map("0 0 300 200", "none", (150., 200.), (150., 100.)), (75., 100.));
-+ }
-+
-+ #[test]
-+ fn meet_fits_and_centres() {
-+ // Uniform scale is min(150/300, 200/200) = 0.5, so the content is
-+ // 150x100 in a 150x200 box and xMidYMid centres it vertically.
-+ assert_eq!(map("0 0 300 200", "xMidYMid meet", (150., 200.), (0., 0.)), (0., 50.));
-+ assert_eq!(
-+ map("0 0 300 200", "xMidYMid meet", (150., 200.), (300., 200.)),
-+ (150., 150.)
-+ );
-+ // xMinYMin pins to the top-left instead.
-+ assert_eq!(map("0 0 300 200", "xMinYMin meet", (150., 200.), (0., 0.)), (0., 0.));
-+ // xMaxYMax pushes the leftover space before the content.
-+ assert_eq!(map("0 0 300 200", "xMaxYMax meet", (150., 200.), (0., 0.)), (0., 100.));
-+ }
-+
-+ #[test]
-+ fn slice_covers_and_overflows() {
-+ // Uniform scale is max(150/300, 200/200) = 1.0, so the content is
-+ // 300x200 in a 150x200 box; centring pushes it 75 to the left.
-+ assert_eq!(map("0 0 300 200", "xMidYMid slice", (150., 200.), (0., 0.)), (-75., 0.));
-+ assert_eq!(map("0 0 300 200", "xMinYMin slice", (150., 200.), (0., 0.)), (0., 0.));
-+ assert_eq!(map("0 0 300 200", "xMaxYMax slice", (150., 200.), (0., 0.)), (-150., 0.));
-+ }
-+
-+ #[test]
-+ fn min_x_and_min_y_offset_the_origin() {
-+ // A viewBox origin of (10, 20) at scale 2 puts user-space (10, 20) at
-+ // the top-left of the viewport.
-+ assert_eq!(map("10 20 50 50", "xMidYMid meet", (100., 100.), (10., 20.)), (0., 0.));
-+ assert_eq!(map("10 20 50 50", "xMidYMid meet", (100., 100.), (60., 70.)), (100., 100.));
-+ }
-+
-+ #[test]
-+ fn view_box_supplies_the_aspect_ratio() {
-+ assert_eq!(SVGViewport::new(Some("0 0 24.5 49"), None).aspect_ratio(), Some(0.5));
-+ // Invalid viewBoxes contribute nothing.
-+ assert_eq!(SVGViewport::new(Some("0 0 -1 1"), None).aspect_ratio(), None);
-+ }
-+}
-diff --git a/components/layout/svg/viewbox.rs b/components/layout/svg/viewbox.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..d940a11267d03302319fc174fba461ca5a9176ac
---- /dev/null
-+++ b/components/layout/svg/viewbox.rs
-@@ -0,0 +1,344 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! Parsing for the `viewBox` and `preserveAspectRatio` attributes.
-+//!
-+//! These use SVG's own attribute grammar, not CSS's, so they cannot go through
-+//! stylo: `viewBox` is a `` separated by comma-wsp, and
-+//! `preserveAspectRatio` is a keyword pair. See
-+//! and
-+//! .
-+//!
-+//! Note that `layout_api::SVGElementData::ratio_from_view_box` parses `viewBox`
-+//! with the *integer* parsers, so `viewBox="0 0 24.5 24"` yields no intrinsic
-+//! ratio at all. That is a pre-existing bug on the rasterization path; the
-+//! native path uses the number grammar below instead.
-+
-+/// A parsed `viewBox`, guaranteed to have non-negative width and height.
-+///
-+/// A negative width or height is an error per spec (the element is not
-+/// rendered); a zero width or height disables rendering of the element. Both
-+/// cases parse to `None` here, so a `ViewBox` value is always usable.
-+#[derive(Clone, Copy, Debug, PartialEq)]
-+pub(crate) struct ViewBox {
-+ pub min_x: f32,
-+ pub min_y: f32,
-+ pub width: f32,
-+ pub height: f32,
-+}
-+
-+impl ViewBox {
-+ ///
-+ pub(crate) fn parse(input: &str) -> Option {
-+ let mut parser = NumberListParser::new(input);
-+ let min_x = parser.next_number()?;
-+ let min_y = parser.next_number()?;
-+ let width = parser.next_number()?;
-+ let height = parser.next_number()?;
-+ if !parser.at_end() {
-+ return None;
-+ }
-+ // Negative is an error, zero disables rendering; neither yields a
-+ // usable viewport transform.
-+ if !(width > 0. && height > 0.) {
-+ return None;
-+ }
-+ Some(Self {
-+ min_x,
-+ min_y,
-+ width,
-+ height,
-+ })
-+ }
-+
-+ pub(crate) fn aspect_ratio(&self) -> f32 {
-+ self.width / self.height
-+ }
-+}
-+
-+/// The alignment half of `preserveAspectRatio`.
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) enum Align {
-+ /// `none` — scale non-uniformly so the viewBox exactly fills the viewport.
-+ None,
-+ XMinYMin,
-+ XMidYMin,
-+ XMaxYMin,
-+ XMinYMid,
-+ #[default]
-+ XMidYMid,
-+ XMaxYMid,
-+ XMinYMax,
-+ XMidYMax,
-+ XMaxYMax,
-+}
-+
-+impl Align {
-+ /// The fraction of the leftover horizontal space placed before the
-+ /// content: 0 for `xMin`, 0.5 for `xMid`, 1 for `xMax`.
-+ pub(crate) fn x_fraction(&self) -> f32 {
-+ match self {
-+ Align::None => 0.,
-+ Align::XMinYMin | Align::XMinYMid | Align::XMinYMax => 0.,
-+ Align::XMidYMin | Align::XMidYMid | Align::XMidYMax => 0.5,
-+ Align::XMaxYMin | Align::XMaxYMid | Align::XMaxYMax => 1.,
-+ }
-+ }
-+
-+ /// The fraction of the leftover vertical space placed before the content.
-+ pub(crate) fn y_fraction(&self) -> f32 {
-+ match self {
-+ Align::None => 0.,
-+ Align::XMinYMin | Align::XMidYMin | Align::XMaxYMin => 0.,
-+ Align::XMinYMid | Align::XMidYMid | Align::XMaxYMid => 0.5,
-+ Align::XMinYMax | Align::XMidYMax | Align::XMaxYMax => 1.,
-+ }
-+ }
-+}
-+
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) enum MeetOrSlice {
-+ /// Scale uniformly to fit the whole viewBox inside the viewport.
-+ #[default]
-+ Meet,
-+ /// Scale uniformly to cover the whole viewport.
-+ Slice,
-+}
-+
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) struct PreserveAspectRatio {
-+ pub align: Align,
-+ pub meet_or_slice: MeetOrSlice,
-+}
-+
-+impl PreserveAspectRatio {
-+ ///
-+ ///
-+ /// An unparseable value falls back to the initial value, matching the
-+ /// error-handling other UAs apply to this attribute.
-+ pub(crate) fn parse_or_default(input: &str) -> Self {
-+ Self::parse(input).unwrap_or_default()
-+ }
-+
-+ fn parse(input: &str) -> Option {
-+ let mut tokens = input.split_ascii_whitespace();
-+ let mut token = tokens.next()?;
-+ // `defer` is only meaningful on the `` that references content,
-+ // never on the referenced `` itself, so it is accepted and
-+ // ignored here.
-+ if token == "defer" {
-+ token = tokens.next()?;
-+ }
-+ let align = match token {
-+ "none" => Align::None,
-+ "xMinYMin" => Align::XMinYMin,
-+ "xMidYMin" => Align::XMidYMin,
-+ "xMaxYMin" => Align::XMaxYMin,
-+ "xMinYMid" => Align::XMinYMid,
-+ "xMidYMid" => Align::XMidYMid,
-+ "xMaxYMid" => Align::XMaxYMid,
-+ "xMinYMax" => Align::XMinYMax,
-+ "xMidYMax" => Align::XMidYMax,
-+ "xMaxYMax" => Align::XMaxYMax,
-+ _ => return None,
-+ };
-+ let meet_or_slice = match tokens.next() {
-+ None => MeetOrSlice::Meet,
-+ Some("meet") => MeetOrSlice::Meet,
-+ Some("slice") => MeetOrSlice::Slice,
-+ Some(_) => return None,
-+ };
-+ if tokens.next().is_some() {
-+ return None;
-+ }
-+ Some(Self {
-+ align,
-+ meet_or_slice,
-+ })
-+ }
-+}
-+
-+/// A comma-wsp separated list of SVG ``s.
-+///
-+/// SVG's number grammar is not CSS's: it accepts a leading `+`, a bare `.5`,
-+/// and an exponent, but not units. `str::parse::` additionally accepts
-+/// `inf`, `nan` and hex floats, so the token is scanned by hand first.
-+struct NumberListParser<'a> {
-+ input: &'a str,
-+ position: usize,
-+ /// Set once a number has been read, after which a separator is required.
-+ expect_separator: bool,
-+}
-+
-+impl<'a> NumberListParser<'a> {
-+ fn new(input: &'a str) -> Self {
-+ Self {
-+ input,
-+ position: 0,
-+ expect_separator: false,
-+ }
-+ }
-+
-+ fn rest(&self) -> &'a str {
-+ &self.input[self.position..]
-+ }
-+
-+ /// comma-wsp: at most one comma, surrounded by any amount of whitespace.
-+ fn skip_comma_wsp(&mut self) -> bool {
-+ let mut saw_separator = false;
-+ let mut seen_comma = false;
-+ for character in self.rest().chars() {
-+ match character {
-+ ' ' | '\t' | '\r' | '\n' => {},
-+ ',' if !seen_comma => seen_comma = true,
-+ _ => break,
-+ }
-+ saw_separator = true;
-+ self.position += character.len_utf8();
-+ }
-+ saw_separator
-+ }
-+
-+ fn at_end(&mut self) -> bool {
-+ self.skip_comma_wsp();
-+ self.rest().is_empty()
-+ }
-+
-+ fn next_number(&mut self) -> Option {
-+ let had_separator = self.skip_comma_wsp();
-+ if self.expect_separator && !had_separator {
-+ return None;
-+ }
-+
-+ let rest = self.rest();
-+ let mut end = 0;
-+ let bytes = rest.as_bytes();
-+
-+ if matches!(bytes.first(), Some(b'+' | b'-')) {
-+ end += 1;
-+ }
-+ let integer_digits = bytes[end..]
-+ .iter()
-+ .take_while(|byte| byte.is_ascii_digit())
-+ .count();
-+ end += integer_digits;
-+
-+ let mut fraction_digits = 0;
-+ if bytes.get(end) == Some(&b'.') {
-+ end += 1;
-+ fraction_digits = bytes[end..]
-+ .iter()
-+ .take_while(|byte| byte.is_ascii_digit())
-+ .count();
-+ end += fraction_digits;
-+ }
-+ if integer_digits == 0 && fraction_digits == 0 {
-+ return None;
-+ }
-+
-+ if matches!(bytes.get(end), Some(b'e' | b'E')) {
-+ let mut exponent_end = end + 1;
-+ if matches!(bytes.get(exponent_end), Some(b'+' | b'-')) {
-+ exponent_end += 1;
-+ }
-+ let exponent_digits = bytes[exponent_end..]
-+ .iter()
-+ .take_while(|byte| byte.is_ascii_digit())
-+ .count();
-+ // A trailing `e` with no digits is not part of the number; leave it
-+ // for the caller to reject as trailing garbage.
-+ if exponent_digits > 0 {
-+ end = exponent_end + exponent_digits;
-+ }
-+ }
-+
-+ let number: f32 = rest[..end].parse().ok()?;
-+ if !number.is_finite() {
-+ return None;
-+ }
-+ self.position += end;
-+ self.expect_separator = true;
-+ Some(number)
-+ }
-+}
-+
-+#[cfg(test)]
-+mod test {
-+ use super::{Align, MeetOrSlice, PreserveAspectRatio, ViewBox};
-+
-+ fn view_box(min_x: f32, min_y: f32, width: f32, height: f32) -> Option {
-+ Some(ViewBox {
-+ min_x,
-+ min_y,
-+ width,
-+ height,
-+ })
-+ }
-+
-+ #[test]
-+ fn parses_the_number_grammar() {
-+ assert_eq!(ViewBox::parse("0 0 24 24"), view_box(0., 0., 24., 24.));
-+ assert_eq!(ViewBox::parse("0,0,24,24"), view_box(0., 0., 24., 24.));
-+ assert_eq!(
-+ ViewBox::parse(" 0 , 0 , 24 , 24 "),
-+ view_box(0., 0., 24., 24.)
-+ );
-+ // Fractional values, which `ratio_from_view_box` cannot parse.
-+ assert_eq!(
-+ ViewBox::parse("0 0 24.5 12.25"),
-+ view_box(0., 0., 24.5, 12.25)
-+ );
-+ assert_eq!(ViewBox::parse("-.5 +.5 1e1 2E+1"), view_box(-0.5, 0.5, 10., 20.));
-+ assert_eq!(ViewBox::parse("0 0 1e-1 1"), view_box(0., 0., 0.1, 1.));
-+ }
-+
-+ #[test]
-+ fn rejects_malformed_view_boxes() {
-+ assert_eq!(ViewBox::parse(""), None);
-+ assert_eq!(ViewBox::parse("0 0 24"), None);
-+ assert_eq!(ViewBox::parse("0 0 24 24 24"), None);
-+ // Two commas is not a valid comma-wsp separator.
-+ assert_eq!(ViewBox::parse("0,,0,24,24"), None);
-+ // Adjacent numbers need a separator.
-+ assert_eq!(ViewBox::parse("0 0 24 24px"), None);
-+ assert_eq!(ViewBox::parse("0 0 24 2e"), None);
-+ assert_eq!(ViewBox::parse("0 0 inf 24"), None);
-+ assert_eq!(ViewBox::parse("0 0 nan 24"), None);
-+ // Negative is an error, zero disables rendering.
-+ assert_eq!(ViewBox::parse("0 0 -24 24"), None);
-+ assert_eq!(ViewBox::parse("0 0 24 0"), None);
-+ }
-+
-+ #[test]
-+ fn parses_preserve_aspect_ratio() {
-+ let parse = PreserveAspectRatio::parse_or_default;
-+ assert_eq!(parse("xMidYMid meet"), PreserveAspectRatio::default());
-+ assert_eq!(
-+ parse("xMinYMax slice"),
-+ PreserveAspectRatio {
-+ align: Align::XMinYMax,
-+ meet_or_slice: MeetOrSlice::Slice,
-+ }
-+ );
-+ // `meet` is the default when omitted; `defer` is ignored.
-+ assert_eq!(
-+ parse("defer xMaxYMin"),
-+ PreserveAspectRatio {
-+ align: Align::XMaxYMin,
-+ meet_or_slice: MeetOrSlice::Meet,
-+ }
-+ );
-+ assert_eq!(
-+ parse("none"),
-+ PreserveAspectRatio {
-+ align: Align::None,
-+ meet_or_slice: MeetOrSlice::Meet,
-+ }
-+ );
-+ // Invalid values fall back to the initial value rather than to `none`.
-+ assert_eq!(parse("xmidymid"), PreserveAspectRatio::default());
-+ assert_eq!(parse(""), PreserveAspectRatio::default());
-+ assert_eq!(parse("xMidYMid fit"), PreserveAspectRatio::default());
-+ }
-+}
-diff --git a/components/script/dom/svg/svgsvgelement.rs b/components/script/dom/svg/svgsvgelement.rs
-index 5f38f8351ea58206181935f3a6a63a162f4b2c6a..dfea562b40b6dc5935f799427121d0be2a32ef0c 100644
---- a/components/script/dom/svg/svgsvgelement.rs
-+++ b/components/script/dom/svg/svgsvgelement.rs
-@@ -211,6 +211,8 @@ impl<'dom> LayoutDom<'dom, SVGSVGElement> {
- let width = element.get_attr_for_layout(&ns!(), &local_name!("width"));
- let height = element.get_attr_for_layout(&ns!(), &local_name!("height"));
- let view_box = element.get_attr_for_layout(&ns!(), &local_name!("viewBox"));
-+ let preserve_aspect_ratio =
-+ element.get_attr_for_layout(&ns!(), &local_name!("preserveAspectRatio"));
- SVGElementData {
- source: self
- .unsafe_get()
-@@ -220,6 +222,7 @@ impl<'dom> LayoutDom<'dom, SVGSVGElement> {
- width,
- height,
- view_box,
-+ preserve_aspect_ratio,
- svg_id,
- }
- }
-diff --git a/components/shared/layout/lib.rs b/components/shared/layout/lib.rs
-index e82d369e5394bd45a40b5ed9629ae3a461906c03..d4960578b612e52c2f07426bd5d2ff4935c5f214 100644
---- a/components/shared/layout/lib.rs
-+++ b/components/shared/layout/lib.rs
-@@ -166,6 +166,7 @@ pub struct SVGElementData<'dom> {
- pub height: Option<&'dom AttrValue>,
- pub svg_id: Uuid,
- pub view_box: Option<&'dom AttrValue>,
-+ pub preserve_aspect_ratio: Option<&'dom AttrValue>,
- }
-
- impl SVGElementData<'_> {
-diff --git a/ports/servoshell/prefs.rs b/ports/servoshell/prefs.rs
-index d82e3c6229b44c5f224e2fb6610c3b5924761e7f..3441f2603f69c4005e23b23c8bd2c8b81060ba1c 100644
---- a/ports/servoshell/prefs.rs
-+++ b/ports/servoshell/prefs.rs
-@@ -52,6 +52,7 @@ pub(crate) static EXPERIMENTAL_PREFS: &[&str] = &[
- "layout_columns_enabled",
- "layout_container_queries_enabled",
- "layout_grid_enabled",
-+ "layout_svg_native_enabled",
- "layout_variable_fonts_enabled",
- ];
-
diff --git a/servo-patches/0010-layout-SVG-geometry-traversal-and-vello-painting.patch b/servo-patches/0010-layout-SVG-geometry-traversal-and-vello-painting.patch
deleted file mode 100644
index 08504d9..0000000
--- a/servo-patches/0010-layout-SVG-geometry-traversal-and-vello-painting.patch
+++ /dev/null
@@ -1,2114 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Sat, 22 Aug 2026 16:12:53 +0100
-Subject: [PATCH 10/24] layout: SVG geometry traversal and vello painting
-
-Builds on the viewport from the previous patch: walks the subtree,
-resolves each element's geometry and paint from its computed style, and
-paints the result with vello_cpu -- the same backend components/canvas
-already uses, so there is no rasterizer here and no tessellation.
-
-Most of the geometry arrives through the cascade rather than off the
-element. SVGElement::synthesize_presentational_hints already maps x, y,
-cx, cy, r, rx, ry, width, height and d onto real CSS longhands, and
-stylo's SVGPathData::normalize(true) reduces path data to absolute
-M/L/C/A/Z, so only five commands need converting. The exceptions are
- and /, whose x1/y1/x2/y2 and points are not
-CSS properties in SVG 2 and so are read from attributes, and the
-transform attribute, which Servo does not map as a presentation
-attribute at all -- and could not usefully, since SVG's transform-list
-grammar is unitless and its rotate() takes an origin CSS has no
-equivalent for. That grammar is parsed here.
-
-The module is split so that the parts with real edge cases stay pure
-functions of numbers -- geometry, paint, transform, number, scene and
-render depend only on kurbo, euclid and vello_cpu. Only resolve.rs and
-tree.rs touch stylo and the DOM, and they are deliberately thin. That
-split is what makes this testable at all: servo's own cargo test cannot
-run in this stack, so scripts/servo-svg-unit-tests.sh copies the pure
-modules into a throwaway crate. 37 tests now, 9 of them rendering real
-pixels and asserting on them.
-
-Two behaviours are worth calling out because getting them wrong looks
-right in a screenshot. Group opacity pushes a layer rather than folding
-into each child's alpha, so overlapping siblings inside a translucent
-group do not show through one another; there is a pixel test for exactly
-that. And an unresolved paint server resolves to no paint rather than to
-black, so a gradient-filled shape goes missing rather than turning into
-a solid dark blob that looks deliberate.
-
-Not yet done, and each skipped rather than approximated:
- - : Servo's SVGUseElement is a bare DOM stub with no shadow
- instancing, so there is nothing under it to walk. Rendering it needs
- an href-to-element lookup layout does not have.
- - Paint servers (gradients, patterns), clip, mask, marker.
- - Nested viewports; treated as plain groups for now.
- - Delivery to the compositor -- see the next patch.
-
-Seam: 1 added line in a pre-existing file (the vello_cpu dependency).
----
- components/layout/Cargo.toml | 1 +
- components/layout/svg/geometry.rs | 439 +++++++++++++++++++++++++++++
- components/layout/svg/mod.rs | 8 +
- components/layout/svg/number.rs | 114 ++++++++
- components/layout/svg/paint.rs | 231 +++++++++++++++
- components/layout/svg/render.rs | 342 ++++++++++++++++++++++
- components/layout/svg/resolve.rs | 334 ++++++++++++++++++++++
- components/layout/svg/scene.rs | 59 ++++
- components/layout/svg/transform.rs | 140 +++++++++
- components/layout/svg/tree.rs | 182 ++++++++++++
- components/layout/svg/viewbox.rs | 106 +------
- 11 files changed, 1852 insertions(+), 104 deletions(-)
- create mode 100644 components/layout/svg/geometry.rs
- create mode 100644 components/layout/svg/number.rs
- create mode 100644 components/layout/svg/paint.rs
- create mode 100644 components/layout/svg/render.rs
- create mode 100644 components/layout/svg/resolve.rs
- create mode 100644 components/layout/svg/scene.rs
- create mode 100644 components/layout/svg/transform.rs
- create mode 100644 components/layout/svg/tree.rs
-
-diff --git a/components/layout/Cargo.toml b/components/layout/Cargo.toml
-index 2f31a1163eb6216f07881b8a2e5c675e12433cc7..ee7cb9eed5bc59bb2ed3b276fb991ffb7c46f504 100644
---- a/components/layout/Cargo.toml
-+++ b/components/layout/Cargo.toml
-@@ -71,6 +71,7 @@ unicode-script = { workspace = true }
- unicode_categories = { workspace = true }
- url = { workspace = true }
- uuid = { workspace = true }
-+vello_cpu = { workspace = true }
- web_atoms = { workspace = true }
- webrender_api = { workspace = true }
-
-diff --git a/components/layout/svg/geometry.rs b/components/layout/svg/geometry.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..6cc243f5049e494352cb94b2cb22c82177cab8f8
---- /dev/null
-+++ b/components/layout/svg/geometry.rs
-@@ -0,0 +1,439 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! Shape geometry: SVG's basic shapes and path data as `kurbo` outlines.
-+//!
-+//! Everything here is a pure function of numbers already resolved against the
-+//! viewport — no stylo types, no DOM. That is deliberate: it keeps the part of
-+//! SVG layout with the most edge cases (auto radii, degenerate shapes, arc
-+//! flags) testable without building Servo. `resolve.rs` is the thin layer that
-+//! turns computed styles into these inputs.
-+//!
-+//!
-+
-+use kurbo::{Arc, BezPath, Point, Shape as _, SvgArc, Vec2};
-+
-+use super::number::NumberListParser;
-+
-+/// One segment of path data, already normalized to absolute coordinates and
-+/// reduced to the five commands stylo's `SVGPathData::normalize(true)` emits.
-+#[derive(Clone, Copy, Debug, PartialEq)]
-+pub(crate) enum PathSegment {
-+ MoveTo {
-+ point: (f64, f64),
-+ },
-+ LineTo {
-+ point: (f64, f64),
-+ },
-+ CurveTo {
-+ control1: (f64, f64),
-+ control2: (f64, f64),
-+ point: (f64, f64),
-+ },
-+ ArcTo {
-+ radii: (f64, f64),
-+ /// Degrees, per the `d` grammar.
-+ x_rotation: f64,
-+ large_arc: bool,
-+ sweep: bool,
-+ point: (f64, f64),
-+ },
-+ ClosePath,
-+}
-+
-+/// ``:
-+///
-+/// `rx`/`ry` are `Option` to model `auto`, which takes the other radius. A
-+/// zero or negative width or height disables rendering, and each radius is
-+/// clamped to half the corresponding side.
-+pub(crate) fn rect(
-+ x: f64,
-+ y: f64,
-+ width: f64,
-+ height: f64,
-+ rx: Option,
-+ ry: Option,
-+) -> Option {
-+ if !(width > 0. && height > 0.) {
-+ return None;
-+ }
-+ // "auto" resolves to the other radius; both auto means square corners.
-+ let (rx, ry) = match (rx, ry) {
-+ (None, None) => (0., 0.),
-+ (Some(rx), None) => (rx, rx),
-+ (None, Some(ry)) => (ry, ry),
-+ (Some(rx), Some(ry)) => (rx, ry),
-+ };
-+ let rx = rx.max(0.).min(width / 2.);
-+ let ry = ry.max(0.).min(height / 2.);
-+
-+ let rect = kurbo::Rect::new(x, y, x + width, y + height);
-+ if rx == 0. || ry == 0. {
-+ return Some(rect.to_path(TOLERANCE));
-+ }
-+ // kurbo's RoundedRect takes a single radius per corner, so an elliptical
-+ // corner has to be built from arcs directly.
-+ let mut path = BezPath::new();
-+ let (right, bottom) = (x + width, y + height);
-+ path.move_to((x + rx, y));
-+ path.line_to((right - rx, y));
-+ append_corner(&mut path, (right, y + ry), rx, ry);
-+ path.line_to((right, bottom - ry));
-+ append_corner(&mut path, (right - rx, bottom), rx, ry);
-+ path.line_to((x + rx, bottom));
-+ append_corner(&mut path, (x, bottom - ry), rx, ry);
-+ path.line_to((x, y + ry));
-+ append_corner(&mut path, (x + rx, y), rx, ry);
-+ path.close_path();
-+ Some(path)
-+}
-+
-+/// A 90° elliptical corner from the current point to `to`, always sweeping
-+/// clockwise, which is the direction SVG's rounded-rect definition uses.
-+fn append_corner(path: &mut BezPath, to: (f64, f64), rx: f64, ry: f64) {
-+ let from = path
-+ .elements()
-+ .last()
-+ .and_then(|element| element.end_point())
-+ .unwrap_or(Point::ZERO);
-+ append_svg_arc(
-+ path,
-+ from,
-+ SvgArc {
-+ from,
-+ to: Point::new(to.0, to.1),
-+ radii: Vec2::new(rx, ry),
-+ x_rotation: 0.,
-+ large_arc: false,
-+ sweep: true,
-+ },
-+ );
-+}
-+
-+/// ``:
-+///
-+/// A zero or negative `r` disables rendering.
-+pub(crate) fn circle(cx: f64, cy: f64, r: f64) -> Option {
-+ (r > 0.).then(|| kurbo::Circle::new((cx, cy), r).to_path(TOLERANCE))
-+}
-+
-+/// ``:
-+///
-+/// `auto` on one radius takes the other; a zero or negative radius disables
-+/// rendering.
-+pub(crate) fn ellipse(cx: f64, cy: f64, rx: Option, ry: Option) -> Option {
-+ let (rx, ry) = match (rx, ry) {
-+ // Both auto is "auto auto", which per spec means the used value is 0
-+ // for both, so nothing renders.
-+ (None, None) => return None,
-+ (Some(rx), None) => (rx, rx),
-+ (None, Some(ry)) => (ry, ry),
-+ (Some(rx), Some(ry)) => (rx, ry),
-+ };
-+ (rx > 0. && ry > 0.)
-+ .then(|| kurbo::Ellipse::new((cx, cy), (rx, ry), 0.).to_path(TOLERANCE))
-+}
-+
-+/// ``:
-+///
-+/// A line has no fill region, only a stroke — including the degenerate
-+/// zero-length case, which still paints caps.
-+pub(crate) fn line(x1: f64, y1: f64, x2: f64, y2: f64) -> BezPath {
-+ let mut path = BezPath::new();
-+ path.move_to((x1, y1));
-+ path.line_to((x2, y2));
-+ path
-+}
-+
-+/// `` and ``:
-+///
-+///
-+/// Per spec an odd trailing coordinate is dropped and the points before the
-+/// error are still rendered, so this never fails — it renders what it could
-+/// read.
-+pub(crate) fn polygon(points: &[(f64, f64)], close: bool) -> Option {
-+ let (first, rest) = points.split_first()?;
-+ let mut path = BezPath::new();
-+ path.move_to(*first);
-+ for point in rest {
-+ path.line_to(*point);
-+ }
-+ if close {
-+ path.close_path();
-+ }
-+ Some(path)
-+}
-+
-+/// The `points` attribute: a comma-wsp separated list of coordinate pairs.
-+///
-+/// An unpaired trailing number is dropped, and parsing stops at the first
-+/// thing that is not a number — both are "render what came before the error",
-+/// which is what SVG's error handling requires for this attribute.
-+pub(crate) fn parse_points(input: &str) -> Vec<(f64, f64)> {
-+ let mut parser = NumberListParser::new(input);
-+ let mut points = Vec::new();
-+ while let Some(x) = parser.next_number() {
-+ let Some(y) = parser.next_number() else { break };
-+ points.push((x as f64, y as f64));
-+ }
-+ points
-+}
-+
-+/// ``:
-+pub(crate) fn path(segments: &[PathSegment]) -> Option {
-+ let mut path = BezPath::new();
-+ // SVG requires path data to begin with a moveto; anything before one is
-+ // an error and the whole path is not rendered.
-+ if !matches!(segments.first(), Some(PathSegment::MoveTo { .. })) {
-+ return None;
-+ }
-+ let mut current = Point::ZERO;
-+ let mut subpath_start = Point::ZERO;
-+ for segment in segments {
-+ match *segment {
-+ PathSegment::MoveTo { point } => {
-+ current = point.into();
-+ subpath_start = current;
-+ path.move_to(current);
-+ },
-+ PathSegment::LineTo { point } => {
-+ current = point.into();
-+ path.line_to(current);
-+ },
-+ PathSegment::CurveTo {
-+ control1,
-+ control2,
-+ point,
-+ } => {
-+ current = point.into();
-+ path.curve_to(Point::from(control1), Point::from(control2), current);
-+ },
-+ PathSegment::ArcTo {
-+ radii,
-+ x_rotation,
-+ large_arc,
-+ sweep,
-+ point,
-+ } => {
-+ let to = Point::from(point);
-+ append_svg_arc(
-+ &mut path,
-+ current,
-+ SvgArc {
-+ from: current,
-+ to,
-+ radii: Vec2::new(radii.0, radii.1),
-+ x_rotation: x_rotation.to_radians(),
-+ large_arc,
-+ sweep,
-+ },
-+ );
-+ current = to;
-+ },
-+ PathSegment::ClosePath => {
-+ path.close_path();
-+ current = subpath_start;
-+ },
-+ }
-+ }
-+ Some(path)
-+}
-+
-+/// Appends an SVG arc as cubics, falling back to a line for the degenerate
-+/// cases the spec defines that way: either radius zero, or coincident
-+/// endpoints.
-+fn append_svg_arc(path: &mut BezPath, from: Point, arc: SvgArc) {
-+ match Arc::from_svg_arc(&arc) {
-+ Some(arc) => {
-+ for element in arc.append_iter(TOLERANCE) {
-+ path.push(element);
-+ }
-+ },
-+ None if from != arc.to => path.line_to(arc.to),
-+ None => {},
-+ }
-+}
-+
-+/// Curve-flattening tolerance in user units. Arcs and ellipses are converted
-+/// to cubics at construction, so this bounds the geometric error of the
-+/// outline itself, not of rasterization.
-+const TOLERANCE: f64 = 0.1;
-+
-+#[cfg(test)]
-+mod test {
-+ use kurbo::{PathEl, Shape as _};
-+
-+ use super::{PathSegment, circle, ellipse, line, parse_points, path, polygon, rect};
-+
-+ /// The outline's bounding box, rounded, which is the cheapest assertion
-+ /// that says something real about a curve's shape.
-+ fn bounds(path: &kurbo::BezPath) -> (i64, i64, i64, i64) {
-+ let bounds = path.bounding_box();
-+ (
-+ bounds.x0.round() as i64,
-+ bounds.y0.round() as i64,
-+ bounds.x1.round() as i64,
-+ bounds.y1.round() as i64,
-+ )
-+ }
-+
-+ #[test]
-+ fn square_cornered_rect_is_four_lines() {
-+ let path = rect(10., 20., 30., 40., None, None).expect("renders");
-+ assert_eq!(bounds(&path), (10, 20, 40, 60));
-+ assert_eq!(
-+ path.elements()
-+ .iter()
-+ .filter(|element| matches!(element, PathEl::CurveTo(..)))
-+ .count(),
-+ 0
-+ );
-+ }
-+
-+ #[test]
-+ fn rect_radii_resolve_auto_and_clamp() {
-+ // One `auto` radius takes the other.
-+ let one = rect(0., 0., 100., 100., Some(10.), None).expect("renders");
-+ let both = rect(0., 0., 100., 100., Some(10.), Some(10.)).expect("renders");
-+ assert_eq!(one.to_svg(), both.to_svg());
-+
-+ // Radii are clamped to half the side, so an over-large radius gives
-+ // the same outline as exactly half.
-+ let huge = rect(0., 0., 40., 20., Some(500.), Some(500.)).expect("renders");
-+ let half = rect(0., 0., 40., 20., Some(20.), Some(10.)).expect("renders");
-+ assert_eq!(huge.to_svg(), half.to_svg());
-+
-+ // Rounded corners stay inside the rect.
-+ assert_eq!(bounds(&one), (0, 0, 100, 100));
-+ }
-+
-+ #[test]
-+ fn degenerate_shapes_do_not_render() {
-+ assert!(rect(0., 0., 0., 10., None, None).is_none());
-+ assert!(rect(0., 0., 10., -1., None, None).is_none());
-+ assert!(circle(0., 0., 0.).is_none());
-+ assert!(circle(0., 0., -5.).is_none());
-+ assert!(ellipse(0., 0., Some(0.), Some(10.)).is_none());
-+ // `auto` on both radii means no ellipse at all.
-+ assert!(ellipse(0., 0., None, None).is_none());
-+ assert!(polygon(&[], false).is_none());
-+ }
-+
-+ #[test]
-+ fn circle_and_ellipse_bounds() {
-+ assert_eq!(bounds(&circle(50., 50., 25.).expect("renders")), (25, 25, 75, 75));
-+ assert_eq!(
-+ bounds(&ellipse(50., 50., Some(40.), Some(10.)).expect("renders")),
-+ (10, 40, 90, 60)
-+ );
-+ // A single `auto` radius takes the other, giving a circle.
-+ assert_eq!(
-+ ellipse(0., 0., Some(7.), None).expect("renders").to_svg(),
-+ ellipse(0., 0., Some(7.), Some(7.)).expect("renders").to_svg()
-+ );
-+ }
-+
-+ #[test]
-+ fn line_is_unclosed_and_survives_zero_length() {
-+ let path = line(1., 2., 3., 4.);
-+ assert_eq!(bounds(&path), (1, 2, 3, 4));
-+ assert!(!path.elements().iter().any(|el| matches!(el, PathEl::ClosePath)));
-+ // A zero-length line still exists, so caps can paint.
-+ assert_eq!(line(5., 5., 5., 5.).elements().len(), 2);
-+ }
-+
-+ #[test]
-+ fn polygon_closes_and_polyline_does_not() {
-+ let points = [(0., 0.), (10., 0.), (10., 10.)];
-+ let closed = polygon(&points, true).expect("renders");
-+ let open = polygon(&points, false).expect("renders");
-+ assert!(closed.elements().iter().any(|el| matches!(el, PathEl::ClosePath)));
-+ assert!(!open.elements().iter().any(|el| matches!(el, PathEl::ClosePath)));
-+ assert_eq!(bounds(&closed), (0, 0, 10, 10));
-+ }
-+
-+ #[test]
-+ fn points_parsing_follows_svg_error_handling() {
-+ assert_eq!(parse_points("0,0 10,0 10,10"), vec![(0., 0.), (10., 0.), (10., 10.)]);
-+ assert_eq!(parse_points("0 0 10 0"), vec![(0., 0.), (10., 0.)]);
-+ // An unpaired trailing number is dropped, earlier points survive.
-+ assert_eq!(parse_points("1,2 3"), vec![(1., 2.)]);
-+ // Parsing stops at the first error, keeping what came before.
-+ assert_eq!(parse_points("1,2 3,4 bogus"), vec![(1., 2.), (3., 4.)]);
-+ assert_eq!(parse_points(""), vec![]);
-+ }
-+
-+ #[test]
-+ fn path_must_begin_with_a_moveto() {
-+ assert!(path(&[PathSegment::LineTo { point: (1., 1.) }]).is_none());
-+ assert!(path(&[]).is_none());
-+ assert!(path(&[PathSegment::MoveTo { point: (1., 1.) }]).is_some());
-+ }
-+
-+ #[test]
-+ fn path_segments_build_the_expected_outline() {
-+ let built = path(&[
-+ PathSegment::MoveTo { point: (0., 0.) },
-+ PathSegment::LineTo { point: (10., 0.) },
-+ PathSegment::CurveTo {
-+ control1: (10., 5.),
-+ control2: (5., 10.),
-+ point: (0., 10.),
-+ },
-+ PathSegment::ClosePath,
-+ ])
-+ .expect("renders");
-+ assert_eq!(bounds(&built), (0, 0, 10, 10));
-+ assert!(matches!(built.elements()[0], PathEl::MoveTo(..)));
-+ assert!(matches!(built.elements().last(), Some(PathEl::ClosePath)));
-+ }
-+
-+ #[test]
-+ fn arcs_become_curves_and_degenerate_to_lines() {
-+ // A half-circle arc of radius 5 from (0,0) to (10,0). `sweep: true` is
-+ // SVG's "positive-angle direction", and because SVG's y axis points
-+ // down that sweeps the arc through negative y — above the chord on
-+ // screen, not below it.
-+ let arc = path(&[
-+ PathSegment::MoveTo { point: (0., 0.) },
-+ PathSegment::ArcTo {
-+ radii: (5., 5.),
-+ x_rotation: 0.,
-+ large_arc: false,
-+ sweep: true,
-+ point: (10., 0.),
-+ },
-+ ])
-+ .expect("renders");
-+ assert_eq!(bounds(&arc), (0, -5, 10, 0));
-+ // `sweep: false` bulges the other way.
-+ let other = path(&[
-+ PathSegment::MoveTo { point: (0., 0.) },
-+ PathSegment::ArcTo {
-+ radii: (5., 5.),
-+ x_rotation: 0.,
-+ large_arc: false,
-+ sweep: false,
-+ point: (10., 0.),
-+ },
-+ ])
-+ .expect("renders");
-+ assert_eq!(bounds(&other), (0, 0, 10, 5));
-+
-+ // A zero radius is a straight line, per spec, not a dropped segment.
-+ let zero = path(&[
-+ PathSegment::MoveTo { point: (0., 0.) },
-+ PathSegment::ArcTo {
-+ radii: (0., 0.),
-+ x_rotation: 0.,
-+ large_arc: false,
-+ sweep: true,
-+ point: (10., 4.),
-+ },
-+ ])
-+ .expect("renders");
-+ assert_eq!(bounds(&zero), (0, 0, 10, 4));
-+ assert!(!zero.elements().iter().any(|el| matches!(el, PathEl::CurveTo(..))));
-+ }
-+}
-diff --git a/components/layout/svg/mod.rs b/components/layout/svg/mod.rs
-index 87f5a6317b172a38e7521c5a8979ee92a075433c..ddd25b9d00d50e60cd7d5dc058eee9b697189d32 100644
---- a/components/layout/svg/mod.rs
-+++ b/components/layout/svg/mod.rs
-@@ -25,6 +25,14 @@
- use euclid::default::{Size2D, Transform2D};
- use servo_config::pref;
-
-+pub(crate) mod geometry;
-+pub(crate) mod number;
-+pub(crate) mod paint;
-+pub(crate) mod render;
-+pub(crate) mod resolve;
-+pub(crate) mod scene;
-+pub(crate) mod transform;
-+pub(crate) mod tree;
- pub(crate) mod viewbox;
-
- use viewbox::{MeetOrSlice, PreserveAspectRatio, ViewBox};
-diff --git a/components/layout/svg/number.rs b/components/layout/svg/number.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..c6c0170c9992a2e1df32be1e0bb146fd66ea4532
---- /dev/null
-+++ b/components/layout/svg/number.rs
-@@ -0,0 +1,114 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! SVG's `` list grammar, used by `viewBox` and by the `points`
-+//! attribute of `` and ``.
-+//!
-+//! This is not CSS's number grammar, so it cannot go through stylo: it accepts
-+//! a leading `+`, a bare `.5` and an exponent, but no units, and separates
-+//! items with "comma-wsp" — any amount of whitespace around at most one comma.
-+//! `str::parse::` is not a substitute either; it accepts `inf`, `nan` and
-+//! hex floats, so each token is scanned by hand before being handed to it.
-+
-+/// A comma-wsp separated list of SVG ``s.
-+pub(crate) struct NumberListParser<'a> {
-+ input: &'a str,
-+ position: usize,
-+ /// Set once a number has been read, after which a separator is required
-+ /// before the next one. Without this, `24 24px` and `1 2e` would parse as
-+ /// two numbers with trailing garbage silently dropped.
-+ expect_separator: bool,
-+}
-+
-+impl<'a> NumberListParser<'a> {
-+ pub(crate) fn new(input: &'a str) -> Self {
-+ Self {
-+ input,
-+ position: 0,
-+ expect_separator: false,
-+ }
-+ }
-+
-+ fn rest(&self) -> &'a str {
-+ &self.input[self.position..]
-+ }
-+
-+ /// comma-wsp: at most one comma, surrounded by any amount of whitespace.
-+ fn skip_comma_wsp(&mut self) -> bool {
-+ let mut saw_separator = false;
-+ let mut seen_comma = false;
-+ for character in self.rest().chars() {
-+ match character {
-+ ' ' | '\t' | '\r' | '\n' => {},
-+ ',' if !seen_comma => seen_comma = true,
-+ _ => break,
-+ }
-+ saw_separator = true;
-+ self.position += character.len_utf8();
-+ }
-+ saw_separator
-+ }
-+
-+ pub(crate) fn at_end(&mut self) -> bool {
-+ self.skip_comma_wsp();
-+ self.rest().is_empty()
-+ }
-+
-+ pub(crate) fn next_number(&mut self) -> Option {
-+ let had_separator = self.skip_comma_wsp();
-+ if self.expect_separator && !had_separator {
-+ return None;
-+ }
-+
-+ let rest = self.rest();
-+ let bytes = rest.as_bytes();
-+ let mut end = 0;
-+
-+ if matches!(bytes.first(), Some(b'+' | b'-')) {
-+ end += 1;
-+ }
-+ let integer_digits = bytes[end..]
-+ .iter()
-+ .take_while(|byte| byte.is_ascii_digit())
-+ .count();
-+ end += integer_digits;
-+
-+ let mut fraction_digits = 0;
-+ if bytes.get(end) == Some(&b'.') {
-+ end += 1;
-+ fraction_digits = bytes[end..]
-+ .iter()
-+ .take_while(|byte| byte.is_ascii_digit())
-+ .count();
-+ end += fraction_digits;
-+ }
-+ if integer_digits == 0 && fraction_digits == 0 {
-+ return None;
-+ }
-+
-+ if matches!(bytes.get(end), Some(b'e' | b'E')) {
-+ let mut exponent_end = end + 1;
-+ if matches!(bytes.get(exponent_end), Some(b'+' | b'-')) {
-+ exponent_end += 1;
-+ }
-+ let exponent_digits = bytes[exponent_end..]
-+ .iter()
-+ .take_while(|byte| byte.is_ascii_digit())
-+ .count();
-+ // A trailing `e` with no digits is not part of the number; leave it
-+ // for the caller to reject as trailing garbage.
-+ if exponent_digits > 0 {
-+ end = exponent_end + exponent_digits;
-+ }
-+ }
-+
-+ let number: f32 = rest[..end].parse().ok()?;
-+ if !number.is_finite() {
-+ return None;
-+ }
-+ self.position += end;
-+ self.expect_separator = true;
-+ Some(number)
-+ }
-+}
-diff --git a/components/layout/svg/paint.rs b/components/layout/svg/paint.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..e387654133c314325e6561ba2ff85fa824a90b6b
---- /dev/null
-+++ b/components/layout/svg/paint.rs
-@@ -0,0 +1,231 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! Resolved paint and stroke, and the percentage bases SVG resolves lengths
-+//! against.
-+//!
-+//! Pure like [`super::geometry`]: `resolve.rs` maps computed styles onto
-+//! these types, and nothing here knows about stylo.
-+
-+/// Straight-alpha RGBA in the sRGB space, which is what `vello_cpu` takes.
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) struct Rgba {
-+ pub red: u8,
-+ pub green: u8,
-+ pub blue: u8,
-+ pub alpha: u8,
-+}
-+
-+impl Rgba {
-+ pub(crate) const TRANSPARENT: Self = Self {
-+ red: 0,
-+ green: 0,
-+ blue: 0,
-+ alpha: 0,
-+ };
-+
-+ /// Applies a `fill-opacity`/`stroke-opacity` factor to the alpha channel.
-+ pub(crate) fn multiply_alpha(self, opacity: f32) -> Self {
-+ let alpha = (self.alpha as f32 * opacity.clamp(0., 1.)).round();
-+ Self {
-+ alpha: alpha.clamp(0., 255.) as u8,
-+ ..self
-+ }
-+ }
-+
-+ pub(crate) fn is_invisible(&self) -> bool {
-+ self.alpha == 0
-+ }
-+}
-+
-+/// A resolved paint source. Paint servers (gradients, patterns) are not
-+/// resolved yet; they arrive as `None` so that a referencing shape does not
-+/// silently paint solid black.
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) enum Paint {
-+ #[default]
-+ None,
-+ Color(Rgba),
-+}
-+
-+impl Paint {
-+ pub(crate) fn color(&self) -> Option {
-+ match self {
-+ Paint::None => None,
-+ Paint::Color(color) if color.is_invisible() => None,
-+ Paint::Color(color) => Some(*color),
-+ }
-+ }
-+}
-+
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) enum FillRule {
-+ #[default]
-+ NonZero,
-+ EvenOdd,
-+}
-+
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) enum LineCap {
-+ #[default]
-+ Butt,
-+ Round,
-+ Square,
-+}
-+
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) enum LineJoin {
-+ #[default]
-+ Miter,
-+ Round,
-+ Bevel,
-+}
-+
-+/// A resolved stroke, in user units.
-+#[derive(Clone, Debug, PartialEq)]
-+pub(crate) struct Stroke {
-+ pub paint: Paint,
-+ pub width: f64,
-+ pub line_cap: LineCap,
-+ pub line_join: LineJoin,
-+ pub miter_limit: f64,
-+ /// Already validated and normalized to an even length; empty means solid.
-+ pub dash_array: Vec,
-+ pub dash_offset: f64,
-+}
-+
-+impl Default for Stroke {
-+ fn default() -> Self {
-+ Self {
-+ paint: Paint::None,
-+ width: 1.,
-+ line_cap: LineCap::default(),
-+ line_join: LineJoin::default(),
-+ miter_limit: 4.,
-+ dash_array: Vec::new(),
-+ dash_offset: 0.,
-+ }
-+ }
-+}
-+
-+impl Stroke {
-+ /// Whether this stroke would put any pixels on the page.
-+ pub(crate) fn is_visible(&self) -> bool {
-+ self.width > 0. && self.paint.color().is_some()
-+ }
-+}
-+
-+/// Validates and normalizes `stroke-dasharray` per
-+/// .
-+///
-+/// A negative value makes the whole list invalid, and a list summing to zero
-+/// means no dashing. An odd-length list is repeated once so the on/off pairs
-+/// come out even, which is what the spec means by "the list is repeated".
-+pub(crate) fn normalize_dash_array(values: &[f64]) -> Vec {
-+ if values.is_empty() || values.iter().any(|value| *value < 0. || !value.is_finite()) {
-+ return Vec::new();
-+ }
-+ if values.iter().sum::() <= 0. {
-+ return Vec::new();
-+ }
-+ if values.len() % 2 == 1 {
-+ return values.iter().chain(values.iter()).copied().collect();
-+ }
-+ values.to_vec()
-+}
-+
-+/// The three bases SVG resolves percentage lengths against, per
-+/// .
-+#[derive(Clone, Copy, Debug, PartialEq)]
-+pub(crate) struct PercentageBasis {
-+ pub width: f64,
-+ pub height: f64,
-+ /// For lengths in neither direction (`r`, `stroke-width`, dashes):
-+ /// the viewport diagonal normalized by √2.
-+ pub diagonal: f64,
-+}
-+
-+impl PercentageBasis {
-+ pub(crate) fn for_viewport(width: f64, height: f64) -> Self {
-+ Self {
-+ width,
-+ height,
-+ diagonal: (width * width + height * height).sqrt() / std::f64::consts::SQRT_2,
-+ }
-+ }
-+}
-+
-+#[cfg(test)]
-+mod test {
-+ use super::{PercentageBasis, Paint, Rgba, Stroke, normalize_dash_array};
-+
-+ const RED: Rgba = Rgba {
-+ red: 255,
-+ green: 0,
-+ blue: 0,
-+ alpha: 255,
-+ };
-+
-+ #[test]
-+ fn opacity_multiplies_into_alpha() {
-+ assert_eq!(RED.multiply_alpha(1.).alpha, 255);
-+ assert_eq!(RED.multiply_alpha(0.5).alpha, 128);
-+ assert_eq!(RED.multiply_alpha(0.).alpha, 0);
-+ // Out-of-range opacities clamp rather than wrap.
-+ assert_eq!(RED.multiply_alpha(4.).alpha, 255);
-+ assert_eq!(RED.multiply_alpha(-1.).alpha, 0);
-+ }
-+
-+ #[test]
-+ fn fully_transparent_paint_is_no_paint() {
-+ assert_eq!(Paint::Color(RED).color(), Some(RED));
-+ assert_eq!(Paint::Color(RED.multiply_alpha(0.)).color(), None);
-+ assert_eq!(Paint::None.color(), None);
-+ // An unresolved paint server must not fall back to black.
-+ assert_eq!(Paint::default(), Paint::None);
-+ }
-+
-+ #[test]
-+ fn stroke_visibility_needs_width_and_paint() {
-+ let visible = Stroke {
-+ paint: Paint::Color(RED),
-+ ..Default::default()
-+ };
-+ assert!(visible.is_visible());
-+ assert!(!Stroke::default().is_visible());
-+ assert!(
-+ !Stroke {
-+ width: 0.,
-+ ..visible
-+ }
-+ .is_visible()
-+ );
-+ }
-+
-+ #[test]
-+ fn dash_array_validation() {
-+ assert_eq!(normalize_dash_array(&[4., 2.]), vec![4., 2.]);
-+ // Odd length repeats so on/off pairs are even.
-+ assert_eq!(normalize_dash_array(&[4.]), vec![4., 4.]);
-+ assert_eq!(normalize_dash_array(&[1., 2., 3.]), vec![1., 2., 3., 1., 2., 3.]);
-+ // A negative anywhere invalidates the whole list.
-+ assert_eq!(normalize_dash_array(&[4., -1.]), Vec::::new());
-+ // All-zero means no dashing rather than an invisible stroke.
-+ assert_eq!(normalize_dash_array(&[0., 0.]), Vec::::new());
-+ assert_eq!(normalize_dash_array(&[]), Vec::::new());
-+ assert_eq!(normalize_dash_array(&[f64::NAN, 1.]), Vec::::new());
-+ }
-+
-+ #[test]
-+ fn percentage_bases_follow_the_viewport() {
-+ let basis = PercentageBasis::for_viewport(300., 400.);
-+ assert_eq!(basis.width, 300.);
-+ assert_eq!(basis.height, 400.);
-+ // sqrt(300^2 + 400^2) / sqrt(2) = 500 / sqrt(2)
-+ assert!((basis.diagonal - 500. / std::f64::consts::SQRT_2).abs() < 1e-9);
-+ // A square viewport's diagonal basis is its side.
-+ let square = PercentageBasis::for_viewport(100., 100.);
-+ assert!((square.diagonal - 100.).abs() < 1e-9);
-+ }
-+}
-diff --git a/components/layout/svg/render.rs b/components/layout/svg/render.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..17574562c15874380870eddd856d9ea82faa79f8
---- /dev/null
-+++ b/components/layout/svg/render.rs
-@@ -0,0 +1,342 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! Painting an [`SVGTree`] with `vello_cpu`.
-+//!
-+//! This follows `components/canvas/vello_cpu_backend.rs`: build `kurbo` paths,
-+//! set a paint, fill or stroke, then render into a `Pixmap`. Servo already
-+//! made the path-rendering decision when canvas landed, so there is no
-+//! rasterizer to write and no tessellation to do.
-+//!
-+//! Like [`super::scene`], this takes no stylo or DOM types, which is what lets
-+//! it be tested against actual pixels rather than against a trace.
-+
-+use kurbo::{Affine, Cap, Join, Stroke as KurboStroke};
-+use vello_cpu::peniko::color::{AlphaColor, Srgb};
-+use vello_cpu::peniko::{BlendMode, Fill};
-+use vello_cpu::{Pixmap, RenderContext, RenderMode, Resources};
-+
-+use super::paint::{FillRule, LineCap, LineJoin, Rgba, Stroke};
-+use super::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
-+
-+/// Paints a tree into a new pixmap of `width` x `height` device pixels.
-+///
-+/// `device_scale` maps CSS pixels to device pixels; the tree's own coordinates
-+/// are in CSS pixels within the viewport, so the whole scene is scaled by it.
-+/// The pixmap starts fully transparent — an SVG has no background of its own.
-+pub(crate) fn render(tree: &SVGTree, width: u16, height: u16, device_scale: f64) -> Pixmap {
-+ let mut context = RenderContext::new(width, height);
-+ let mut resources = Resources::new();
-+ let mut pixmap = Pixmap::new(width, height);
-+
-+ paint_group(&mut context, &tree.root, Affine::scale(device_scale));
-+
-+ context.flush();
-+ context.render_to_pixmap(&mut resources, &mut pixmap);
-+ pixmap
-+}
-+
-+fn paint_group(context: &mut RenderContext, group: &SVGGroup, parent_transform: Affine) {
-+ let transform = parent_transform * group.transform;
-+
-+ // Group opacity composites the group as a whole, so it needs a layer:
-+ // applying it per child would let overlapping siblings show through each
-+ // other. A fully opaque group does not pay for one.
-+ let needs_layer = group.opacity < 1.;
-+ if needs_layer {
-+ context.set_transform(transform);
-+ context.push_layer(None, Some(BlendMode::default()), Some(group.opacity), None, None);
-+ }
-+
-+ for child in &group.children {
-+ match child {
-+ SVGNode::Group(child) => paint_group(context, child, transform),
-+ SVGNode::Shape(shape) => paint_shape(context, shape, transform),
-+ }
-+ }
-+
-+ if needs_layer {
-+ context.pop_layer();
-+ }
-+}
-+
-+fn paint_shape(context: &mut RenderContext, shape: &SVGShape, parent_transform: Affine) {
-+ let transform = parent_transform * shape.transform;
-+ context.set_transform(transform);
-+
-+ // Element opacity is a group-like effect too: a shape with both a fill and
-+ // a stroke must composite them together before applying it, or the stroke
-+ // shows through its own translucent fill where they overlap.
-+ let needs_layer =
-+ shape.opacity < 1. && shape.fill.is_some() && shape.stroke.is_some();
-+ if needs_layer {
-+ context.push_layer(None, Some(BlendMode::default()), Some(shape.opacity), None, None);
-+ }
-+ // Without a layer the opacity folds into each paint's alpha.
-+ let alpha = if needs_layer { 1. } else { shape.opacity };
-+
-+ if let Some((color, rule)) = shape.fill {
-+ context.set_fill_rule(match rule {
-+ FillRule::NonZero => Fill::NonZero,
-+ FillRule::EvenOdd => Fill::EvenOdd,
-+ });
-+ context.set_paint(to_alpha_color(color.multiply_alpha(alpha)));
-+ context.fill_path(&shape.path);
-+ }
-+
-+ if let Some(stroke) = &shape.stroke {
-+ context.set_stroke(to_kurbo_stroke(stroke));
-+ if let Some(color) = stroke.paint.color() {
-+ context.set_paint(to_alpha_color(color.multiply_alpha(alpha)));
-+ context.stroke_path(&shape.path);
-+ }
-+ }
-+
-+ if needs_layer {
-+ context.pop_layer();
-+ }
-+}
-+
-+fn to_alpha_color(color: Rgba) -> AlphaColor {
-+ AlphaColor::from_rgba8(color.red, color.green, color.blue, color.alpha)
-+}
-+
-+fn to_kurbo_stroke(stroke: &Stroke) -> KurboStroke {
-+ let cap = match stroke.line_cap {
-+ LineCap::Butt => Cap::Butt,
-+ LineCap::Round => Cap::Round,
-+ LineCap::Square => Cap::Square,
-+ };
-+ KurboStroke {
-+ width: stroke.width,
-+ join: match stroke.line_join {
-+ LineJoin::Miter => Join::Miter,
-+ LineJoin::Round => Join::Round,
-+ LineJoin::Bevel => Join::Bevel,
-+ },
-+ miter_limit: stroke.miter_limit,
-+ start_cap: cap,
-+ end_cap: cap,
-+ dash_pattern: stroke.dash_array.iter().copied().collect(),
-+ dash_offset: stroke.dash_offset,
-+ }
-+}
-+
-+/// Silences an unused-import warning until the delivery path picks a render
-+/// mode; `RenderMode` is part of the API this module will need.
-+#[expect(dead_code)]
-+fn render_mode_placeholder(_: RenderMode) {}
-+
-+#[cfg(test)]
-+mod test {
-+ use kurbo::Affine;
-+
-+ use super::render;
-+ use crate::svg::geometry;
-+ use crate::svg::paint::{FillRule, Rgba, Stroke, Paint};
-+ use crate::svg::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
-+
-+ const RED: Rgba = Rgba {
-+ red: 255,
-+ green: 0,
-+ blue: 0,
-+ alpha: 255,
-+ };
-+ const BLUE: Rgba = Rgba {
-+ red: 0,
-+ green: 0,
-+ blue: 255,
-+ alpha: 255,
-+ };
-+
-+ fn tree(children: Vec) -> SVGTree {
-+ SVGTree {
-+ root: SVGGroup {
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ children,
-+ },
-+ }
-+ }
-+
-+ fn filled_rect(x: f64, y: f64, w: f64, h: f64, color: Rgba) -> SVGNode {
-+ SVGNode::Shape(SVGShape {
-+ path: geometry::rect(x, y, w, h, None, None).expect("renders"),
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ fill: Some((color, FillRule::NonZero)),
-+ stroke: None,
-+ })
-+ }
-+
-+ /// Un-premultiplied RGBA at a pixel.
-+ fn pixel(pixmap: &vello_cpu::Pixmap, x: u16, y: u16) -> (u8, u8, u8, u8) {
-+ let premultiplied = pixmap.data()[(y as usize) * (pixmap.width() as usize) + x as usize];
-+ let alpha = premultiplied.a;
-+ let unpremultiply = |channel: u8| match alpha {
-+ 0 => 0,
-+ _ => ((channel as u32 * 255 + alpha as u32 / 2) / alpha as u32).min(255) as u8,
-+ };
-+ (
-+ unpremultiply(premultiplied.r),
-+ unpremultiply(premultiplied.g),
-+ unpremultiply(premultiplied.b),
-+ alpha,
-+ )
-+ }
-+
-+ #[test]
-+ fn an_empty_tree_paints_nothing() {
-+ let pixmap = render(&tree(vec![]), 8, 8, 1.);
-+ assert!(pixmap.data().iter().all(|pixel| pixel.a == 0));
-+ }
-+
-+ #[test]
-+ fn a_filled_rect_lands_where_it_should() {
-+ let pixmap = render(&tree(vec![filled_rect(2., 2., 4., 4., RED)]), 8, 8, 1.);
-+ // Inside the rect.
-+ assert_eq!(pixel(&pixmap, 3, 3), (255, 0, 0, 255));
-+ assert_eq!(pixel(&pixmap, 5, 5), (255, 0, 0, 255));
-+ // Outside it, on every side.
-+ assert_eq!(pixel(&pixmap, 1, 3).3, 0);
-+ assert_eq!(pixel(&pixmap, 6, 3).3, 0);
-+ assert_eq!(pixel(&pixmap, 3, 1).3, 0);
-+ assert_eq!(pixel(&pixmap, 3, 6).3, 0);
-+ }
-+
-+ #[test]
-+ fn the_device_scale_scales_the_scene() {
-+ // The same 2x2 rect at 2x covers 4x4 device pixels.
-+ let scene = tree(vec![filled_rect(0., 0., 2., 2., RED)]);
-+ let pixmap = render(&scene, 8, 8, 2.);
-+ assert_eq!(pixel(&pixmap, 3, 3), (255, 0, 0, 255));
-+ assert_eq!(pixel(&pixmap, 4, 4).3, 0);
-+ }
-+
-+ #[test]
-+ fn later_siblings_paint_over_earlier_ones() {
-+ let pixmap = render(
-+ &tree(vec![
-+ filled_rect(0., 0., 8., 8., RED),
-+ filled_rect(0., 0., 4., 4., BLUE),
-+ ]),
-+ 8,
-+ 8,
-+ 1.,
-+ );
-+ assert_eq!(pixel(&pixmap, 1, 1), (0, 0, 255, 255));
-+ assert_eq!(pixel(&pixmap, 6, 6), (255, 0, 0, 255));
-+ }
-+
-+ #[test]
-+ fn shape_transforms_compose_with_group_transforms() {
-+ let mut shape = match filled_rect(0., 0., 2., 2., RED) {
-+ SVGNode::Shape(shape) => shape,
-+ _ => unreachable!(),
-+ };
-+ shape.transform = Affine::translate((2., 0.));
-+ let scene = SVGTree {
-+ root: SVGGroup {
-+ transform: Affine::translate((0., 4.)),
-+ opacity: 1.,
-+ children: vec![SVGNode::Shape(shape)],
-+ },
-+ };
-+ let pixmap = render(&scene, 8, 8, 1.);
-+ // Translated 2 right by the shape and 4 down by the group.
-+ assert_eq!(pixel(&pixmap, 3, 5), (255, 0, 0, 255));
-+ assert_eq!(pixel(&pixmap, 1, 1).3, 0);
-+ }
-+
-+ #[test]
-+ fn group_opacity_composites_the_group_as_a_unit() {
-+ // Two overlapping opaque rects in a 50%-opacity group. In the overlap
-+ // the result must be 50% of the *top* rect, not 50% twice over: if
-+ // opacity were applied per shape the lower one would show through.
-+ let scene = SVGTree {
-+ root: SVGGroup {
-+ transform: Affine::IDENTITY,
-+ opacity: 0.5,
-+ children: vec![
-+ filled_rect(0., 0., 8., 8., RED),
-+ filled_rect(0., 0., 8., 8., BLUE),
-+ ],
-+ },
-+ };
-+ let pixmap = render(&scene, 8, 8, 1.);
-+ let (red, green, blue, alpha) = pixel(&pixmap, 4, 4);
-+ assert_eq!(alpha, 128);
-+ assert_eq!((red, green), (0, 0));
-+ assert!(blue > 250, "overlap should be pure blue, got {blue}");
-+ }
-+
-+ #[test]
-+ fn strokes_paint_outside_and_inside_the_outline() {
-+ let scene = tree(vec![SVGNode::Shape(SVGShape {
-+ path: geometry::rect(2., 2., 4., 4., None, None).expect("renders"),
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ fill: None,
-+ stroke: Some(Stroke {
-+ paint: Paint::Color(BLUE),
-+ width: 2.,
-+ ..Default::default()
-+ }),
-+ })]);
-+ let pixmap = render(&scene, 8, 8, 1.);
-+ // A width-2 stroke straddles the edge, so it covers one pixel either
-+ // side of x=2: the interior centre stays empty.
-+ assert_eq!(pixel(&pixmap, 1, 4), (0, 0, 255, 255));
-+ assert_eq!(pixel(&pixmap, 2, 4), (0, 0, 255, 255));
-+ assert_eq!(pixel(&pixmap, 4, 4).3, 0);
-+ }
-+
-+ #[test]
-+ fn even_odd_and_nonzero_differ_on_a_self_overlapping_path() {
-+ // A square with a smaller square inside it, both wound the same way.
-+ // Non-zero fills the hole; even-odd leaves it empty.
-+ let mut path = geometry::rect(0., 0., 8., 8., None, None).expect("renders");
-+ path.extend(geometry::rect(2., 2., 4., 4., None, None).expect("renders"));
-+
-+ let with_rule = |rule| {
-+ render(
-+ &tree(vec![SVGNode::Shape(SVGShape {
-+ path: path.clone(),
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ fill: Some((RED, rule)),
-+ stroke: None,
-+ })]),
-+ 8,
-+ 8,
-+ 1.,
-+ )
-+ };
-+ assert_eq!(pixel(&with_rule(FillRule::NonZero), 4, 4).3, 255);
-+ assert_eq!(pixel(&with_rule(FillRule::EvenOdd), 4, 4).3, 0);
-+ // The outer ring is filled under both rules.
-+ assert_eq!(pixel(&with_rule(FillRule::EvenOdd), 1, 1).3, 255);
-+ }
-+
-+ #[test]
-+ fn dashes_leave_gaps() {
-+ let scene = tree(vec![SVGNode::Shape(SVGShape {
-+ path: geometry::line(0., 4., 16., 4.),
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ fill: None,
-+ stroke: Some(Stroke {
-+ paint: Paint::Color(RED),
-+ width: 2.,
-+ dash_array: vec![4., 4.],
-+ ..Default::default()
-+ }),
-+ })]);
-+ let pixmap = render(&scene, 16, 8, 1.);
-+ // On for [0,4), off for [4,8), on again for [8,12).
-+ assert_eq!(pixel(&pixmap, 1, 4).3, 255);
-+ assert_eq!(pixel(&pixmap, 5, 4).3, 0);
-+ assert_eq!(pixel(&pixmap, 9, 4).3, 255);
-+ }
-+}
-diff --git a/components/layout/svg/resolve.rs b/components/layout/svg/resolve.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..194f2a5a671319b4121167830c7428e67dd04e53
---- /dev/null
-+++ b/components/layout/svg/resolve.rs
-@@ -0,0 +1,334 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! Computed styles and attributes to the plain numbers [`super::geometry`] and
-+//! [`super::paint`] work in.
-+//!
-+//! Most of the geometry arrives through the cascade rather than off the
-+//! element: `SVGElement::synthesize_presentational_hints` maps `x`, `y`, `cx`,
-+//! `cy`, `r`, `rx`, `ry`, `width`, `height` and `d` onto real CSS longhands, so
-+//! selector-driven overrides and inheritance are already applied by the time
-+//! layout looks. The exceptions are ``, `` and ``,
-+//! whose `x1`/`y1`/`x2`/`y2`/`points` are not CSS properties in SVG 2 and so
-+//! have to be read from the attributes.
-+
-+use web_atoms::{LocalName, local_name, ns};
-+use kurbo::{Affine, BezPath};
-+use layout_api::LayoutElement as _;
-+use style::color::{AbsoluteColor, ColorSpace};
-+use style::computed_values::stroke_linecap::T as StrokeLinecap;
-+use style::computed_values::stroke_linejoin::T as StrokeLinejoin;
-+use style::computed_values::visibility::T as Visibility;
-+use style::properties::ComputedValues;
-+use style::values::computed::{DProperty, Length, LengthPercentage};
-+use style::values::generics::NonNegative;
-+use style::values::generics::basic_shape::{
-+ ArcSweep, CommandEndPoint, ControlPoint, FillRule as StyloFillRule, GenericShapeCommand,
-+};
-+use style::values::generics::length::{GenericLengthPercentageOrAuto, GenericSize};
-+use style::values::generics::svg::{SVGLength, SVGPaintKind, SVGStrokeDashArray};
-+
-+use super::geometry::{self, PathSegment};
-+use super::paint::{FillRule, LineCap, LineJoin, Paint, PercentageBasis, Rgba, Stroke};
-+use super::transform::parse_transform_list;
-+use script::layout_dom::ServoLayoutElement;
-+
-+/// Whether this element paints a shape, and if so its outline in the local
-+/// user space (before this element's own `transform`).
-+///
-+/// `None` covers both "not a shape" (``, ``, unknown elements) and
-+/// "a shape that is degenerate and so renders nothing".
-+pub(crate) fn shape(
-+ element: ServoLayoutElement<'_>,
-+ style: &ComputedValues,
-+ basis: PercentageBasis,
-+) -> Option {
-+ let svg = style.get_svg();
-+ let x = || resolve(&svg.x, basis.width);
-+ let y = || resolve(&svg.y, basis.height);
-+ let cx = || resolve(&svg.cx, basis.width);
-+ let cy = || resolve(&svg.cy, basis.height);
-+
-+ match element.local_name() {
-+ name if *name == local_name!("rect") => geometry::rect(
-+ x(),
-+ y(),
-+ resolve_size(&style.get_position().width, basis.width),
-+ resolve_size(&style.get_position().height, basis.height),
-+ resolve_auto(&svg.rx, basis.width),
-+ resolve_auto(&svg.ry, basis.height),
-+ ),
-+ name if *name == local_name!("circle") => {
-+ geometry::circle(cx(), cy(), resolve(&svg.r.0, basis.diagonal))
-+ },
-+ name if *name == local_name!("ellipse") => geometry::ellipse(
-+ cx(),
-+ cy(),
-+ resolve_auto(&svg.rx, basis.width),
-+ resolve_auto(&svg.ry, basis.height),
-+ ),
-+ name if *name == local_name!("line") => Some(geometry::line(
-+ attribute_number(element, &local_name!("x1")),
-+ attribute_number(element, &local_name!("y1")),
-+ attribute_number(element, &local_name!("x2")),
-+ attribute_number(element, &local_name!("y2")),
-+ )),
-+ name if *name == local_name!("polyline") || *name == local_name!("polygon") => {
-+ let points = element
-+ .attribute_as_str(&ns!(), &local_name!("points"))
-+ .map(geometry::parse_points)
-+ .unwrap_or_default();
-+ geometry::polygon(&points, *name == local_name!("polygon"))
-+ },
-+ name if *name == local_name!("path") => match &svg.d {
-+ DProperty::None => None,
-+ DProperty::Path(data) => geometry::path(&path_segments(data)),
-+ },
-+ _ => None,
-+ }
-+}
-+
-+/// The element's own `transform` attribute, or `None` if it is malformed —
-+/// which per spec means the element is rendered untransformed rather than not
-+/// at all, so callers substitute the identity.
-+pub(crate) fn transform(element: ServoLayoutElement<'_>) -> Affine {
-+ element
-+ .attribute_as_str(&ns!(), &local_name!("transform"))
-+ .and_then(parse_transform_list)
-+ .unwrap_or(Affine::IDENTITY)
-+}
-+
-+pub(crate) fn is_visible(style: &ComputedValues) -> bool {
-+ // `display` is deliberately not consulted: Servo computes `display: none`
-+ // for every element inside an `` subtree, because on the
-+ // rasterization path they generate no boxes. That says nothing about
-+ // author intent, and honoring it here would hide every shape.
-+ style.get_inherited_box().visibility == Visibility::Visible
-+}
-+
-+pub(crate) fn opacity(style: &ComputedValues) -> f32 {
-+ style.get_effects().opacity.clamp(0., 1.)
-+}
-+
-+/// The resolved `fill` paint and winding rule.
-+pub(crate) fn fill(style: &ComputedValues, current_color: &AbsoluteColor) -> (Paint, FillRule) {
-+ let svg = style.get_inherited_svg();
-+ let paint = paint_kind(&svg.fill.kind, current_color)
-+ .map(|color| color.multiply_alpha(opacity_value(&svg.fill_opacity)))
-+ .map_or(Paint::None, Paint::Color);
-+ let rule = match svg.fill_rule {
-+ StyloFillRule::Nonzero => FillRule::NonZero,
-+ StyloFillRule::Evenodd => FillRule::EvenOdd,
-+ };
-+ (paint, rule)
-+}
-+
-+/// The resolved `stroke`, with every length already in user units.
-+pub(crate) fn stroke(
-+ style: &ComputedValues,
-+ current_color: &AbsoluteColor,
-+ basis: PercentageBasis,
-+) -> Stroke {
-+ let svg = style.get_inherited_svg();
-+ let paint = paint_kind(&svg.stroke.kind, current_color)
-+ .map(|color| color.multiply_alpha(opacity_value(&svg.stroke_opacity)))
-+ .map_or(Paint::None, Paint::Color);
-+
-+ let dash_array = match &svg.stroke_dasharray {
-+ // `context-value` is a Gecko-only extension for marker content.
-+ SVGStrokeDashArray::ContextValue => Vec::new(),
-+ SVGStrokeDashArray::Values(values) => super::paint::normalize_dash_array(
-+ &values
-+ .iter()
-+ .map(|value| resolve(&value.0, basis.diagonal))
-+ .collect::>(),
-+ ),
-+ };
-+
-+ Stroke {
-+ paint,
-+ width: svg_length(&svg.stroke_width, basis.diagonal, 1.),
-+ line_cap: match svg.stroke_linecap {
-+ StrokeLinecap::Butt => LineCap::Butt,
-+ StrokeLinecap::Round => LineCap::Round,
-+ StrokeLinecap::Square => LineCap::Square,
-+ },
-+ line_join: match svg.stroke_linejoin {
-+ StrokeLinejoin::Miter => LineJoin::Miter,
-+ StrokeLinejoin::Round => LineJoin::Round,
-+ StrokeLinejoin::Bevel => LineJoin::Bevel,
-+ },
-+ miter_limit: svg.stroke_miterlimit.0 as f64,
-+ dash_array,
-+ dash_offset: svg_length(&svg.stroke_dashoffset, basis.diagonal, 0.),
-+ }
-+}
-+
-+/// A paint source, or `None` when nothing should be painted.
-+///
-+/// A paint server reference resolves to `None` rather than to the fallback or
-+/// to black: gradients and patterns are not implemented yet, and painting a
-+/// gradient-filled shape solid black would look far more broken than leaving
-+/// it unpainted.
-+fn paint_kind(
-+ kind: &SVGPaintKind,
-+ current_color: &AbsoluteColor,
-+) -> Option {
-+ match kind {
-+ SVGPaintKind::None => None,
-+ SVGPaintKind::Color(color) => Some(to_rgba(&color.resolve_to_absolute(current_color))),
-+ SVGPaintKind::PaintServer(_) => None,
-+ // Gecko-only, and only meaningful inside marker content.
-+ SVGPaintKind::ContextFill | SVGPaintKind::ContextStroke => None,
-+ }
-+}
-+
-+fn opacity_value(opacity: &style::values::computed::SVGOpacity) -> f32 {
-+ match opacity {
-+ style::values::generics::svg::SVGOpacity::Opacity(value) => *value,
-+ _ => 1.,
-+ }
-+}
-+
-+fn to_rgba(color: &AbsoluteColor) -> Rgba {
-+ let srgb = color.to_color_space(ColorSpace::Srgb);
-+ let channel = |value: f32| (value.clamp(0., 1.) * 255.).round() as u8;
-+ Rgba {
-+ red: channel(srgb.components.0),
-+ green: channel(srgb.components.1),
-+ blue: channel(srgb.components.2),
-+ alpha: channel(srgb.alpha),
-+ }
-+}
-+
-+fn resolve(length: &LengthPercentage, basis: f64) -> f64 {
-+ length.resolve(Length::new(basis as f32)).px() as f64
-+}
-+
-+/// `width`/`height` on `` and ``, where SVG 2 defines `auto` as 0.
-+fn resolve_size(size: &GenericSize>, basis: f64) -> f64 {
-+ match size {
-+ GenericSize::LengthPercentage(length) => resolve(&length.0, basis),
-+ _ => 0.,
-+ }
-+}
-+
-+/// `rx`/`ry`, where `auto` is meaningful and so survives as `None`.
-+fn resolve_auto(
-+ value: &GenericLengthPercentageOrAuto>,
-+ basis: f64,
-+) -> Option {
-+ match value {
-+ GenericLengthPercentageOrAuto::Auto => None,
-+ GenericLengthPercentageOrAuto::LengthPercentage(length) => Some(resolve(&length.0, basis)),
-+ }
-+}
-+
-+fn svg_length(length: &SVGLength, basis: f64, context_value: f64) -> f64
-+where
-+ L: AsLengthPercentage,
-+{
-+ match length {
-+ SVGLength::LengthPercentage(value) => resolve(value.as_length_percentage(), basis),
-+ SVGLength::ContextValue => context_value,
-+ }
-+}
-+
-+/// Lets `svg_length` accept both `SVGLength` (plain) and `SVGWidth`
-+/// (non-negative) without duplicating the match.
-+trait AsLengthPercentage {
-+ fn as_length_percentage(&self) -> &LengthPercentage;
-+}
-+
-+impl AsLengthPercentage for LengthPercentage {
-+ fn as_length_percentage(&self) -> &LengthPercentage {
-+ self
-+ }
-+}
-+
-+impl AsLengthPercentage for NonNegative {
-+ fn as_length_percentage(&self) -> &LengthPercentage {
-+ &self.0
-+ }
-+}
-+
-+/// A bare number attribute, defaulting to 0 as SVG's geometry attributes do.
-+fn attribute_number(element: ServoLayoutElement<'_>, name: &LocalName) -> f64 {
-+ element
-+ .attribute_as_str(&ns!(), name)
-+ .and_then(|value| {
-+ let mut parser = super::number::NumberListParser::new(value);
-+ let number = parser.next_number()?;
-+ parser.at_end().then_some(number as f64)
-+ })
-+ .unwrap_or(0.)
-+}
-+
-+/// Converts stylo's path data into [`PathSegment`]s.
-+///
-+/// `normalize(true)` makes every command absolute and reduces the set to
-+/// M/L/C/A/Z, so only five variants have to be handled here; the relative
-+/// forms, `H`/`V` and the smooth curves are all folded away by stylo.
-+fn path_segments(data: &style::values::specified::svg_path::SVGPathData) -> Vec {
-+ let normalized = data.normalize(true);
-+ let point = |end: &CommandEndPoint<
-+ style::values::specified::svg_path::SVGPathPosition,
-+ f32,
-+ >| match end {
-+ CommandEndPoint::ToPosition(position) => {
-+ (position.horizontal as f64, position.vertical as f64)
-+ },
-+ // Unreachable after normalization, but a wrong coordinate is worse
-+ // than a dropped one, so fall back to the origin rather than guess.
-+ CommandEndPoint::ByCoordinate(_) => (0., 0.),
-+ };
-+ let control = |value: &ControlPoint<
-+ style::values::specified::svg_path::SVGPathPosition,
-+ f32,
-+ >| match value {
-+ ControlPoint::Absolute(position) => (position.horizontal as f64, position.vertical as f64),
-+ ControlPoint::Relative(_) => (0., 0.),
-+ };
-+
-+ normalized
-+ .commands()
-+ .iter()
-+ .filter_map(|command| match command {
-+ GenericShapeCommand::Move { point: end } => Some(PathSegment::MoveTo {
-+ point: point(end),
-+ }),
-+ GenericShapeCommand::Line { point: end } => Some(PathSegment::LineTo {
-+ point: point(end),
-+ }),
-+ GenericShapeCommand::CubicCurve {
-+ point: end,
-+ control1,
-+ control2,
-+ } => Some(PathSegment::CurveTo {
-+ control1: control(control1),
-+ control2: control(control2),
-+ point: point(end),
-+ }),
-+ GenericShapeCommand::Arc {
-+ point: end,
-+ radii,
-+ arc_sweep,
-+ arc_size,
-+ rotate,
-+ } => Some(PathSegment::ArcTo {
-+ radii: (
-+ radii.rx as f64,
-+ radii.ry.clone().into_rust().unwrap_or(radii.rx) as f64,
-+ ),
-+ x_rotation: *rotate as f64,
-+ large_arc: *arc_size == style::values::generics::basic_shape::ArcSize::Large,
-+ sweep: *arc_sweep == ArcSweep::Cw,
-+ point: point(end),
-+ }),
-+ GenericShapeCommand::Close => Some(PathSegment::ClosePath),
-+ // Removed by `normalize(true)`.
-+ _ => None,
-+ })
-+ .collect()
-+}
-diff --git a/components/layout/svg/scene.rs b/components/layout/svg/scene.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..bd7ef4a6934cbaed3c3e88044bb66bebcc775c9e
---- /dev/null
-+++ b/components/layout/svg/scene.rs
-@@ -0,0 +1,59 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! The paintable form of an SVG subtree.
-+//!
-+//! Kept free of stylo and DOM types on purpose, so that [`super::render`] --
-+//! the part with the most ways to be subtly wrong -- can be tested against
-+//! real pixels without building Servo. [`super::tree`] is the DOM walk that
-+//! produces these.
-+
-+use kurbo::{Affine, BezPath};
-+
-+use super::paint::{FillRule, Rgba, Stroke};
-+
-+/// A whole `` subtree, ready to paint.
-+#[derive(Clone, Debug)]
-+pub(crate) struct SVGTree {
-+ pub root: SVGGroup,
-+}
-+
-+#[derive(Clone, Debug, Default)]
-+pub(crate) struct SVGGroup {
-+ pub transform: Affine,
-+ pub opacity: f32,
-+ pub children: Vec,
-+}
-+
-+#[derive(Clone, Debug)]
-+pub(crate) enum SVGNode {
-+ Group(SVGGroup),
-+ Shape(SVGShape),
-+}
-+
-+#[derive(Clone, Debug)]
-+pub(crate) struct SVGShape {
-+ pub path: BezPath,
-+ pub transform: Affine,
-+ pub opacity: f32,
-+ /// `None` when nothing should be filled — `fill: none`, a fully
-+ /// transparent paint, or an unresolved paint server.
-+ pub fill: Option<(Rgba, FillRule)>,
-+ /// `None` when the stroke would put no pixels on the page.
-+ pub stroke: Option,
-+}
-+
-+impl SVGTree {
-+ /// Whether anything in the tree would paint. Used to decide whether the
-+ /// native path can take over from rasterization at all.
-+ pub(crate) fn is_empty(&self) -> bool {
-+ fn group_is_empty(group: &SVGGroup) -> bool {
-+ group.children.iter().all(|child| match child {
-+ SVGNode::Group(group) => group_is_empty(group),
-+ SVGNode::Shape(shape) => shape.fill.is_none() && shape.stroke.is_none(),
-+ })
-+ }
-+ group_is_empty(&self.root)
-+ }
-+}
-diff --git a/components/layout/svg/transform.rs b/components/layout/svg/transform.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..18d2c56a5573278e7fd4437f07c8263617779730
---- /dev/null
-+++ b/components/layout/svg/transform.rs
-@@ -0,0 +1,140 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! The `transform` attribute's transform-list grammar.
-+//!
-+//! This has to be parsed here rather than read off the computed style: Servo
-+//! does not map `transform` as an SVG presentation attribute (see
-+//! `SVGElement::synthesize_presentational_hints`, which covers the paint and
-+//! geometry properties but not this one), so ``
-+//! contributes nothing to `style.get_box().transform`.
-+//!
-+//! Mapping it would not be enough on its own anyway. SVG's grammar is not
-+//! CSS's: values are unitless numbers rather than ``s, angles are bare
-+//! degrees, and `rotate(a cx cy)` takes an origin argument that CSS's `rotate()`
-+//! has no equivalent for.
-+//!
-+//!
-+
-+use kurbo::{Affine, Point};
-+
-+use super::number::NumberListParser;
-+
-+/// Parses a transform-list into a single affine matrix.
-+///
-+/// Returns `None` for a malformed list. SVG's error handling for the
-+/// `transform` attribute is to render the element with no transform at all
-+/// rather than with a partially-applied one, which is what `None` means to
-+/// callers.
-+pub(crate) fn parse_transform_list(input: &str) -> Option {
-+ let mut transform = Affine::IDENTITY;
-+ let mut rest = input.trim();
-+ if rest.is_empty() {
-+ return Some(transform);
-+ }
-+
-+ while !rest.is_empty() {
-+ let open = rest.find('(')?;
-+ let name = rest[..open].trim();
-+ let close = rest[open..].find(')')? + open;
-+ let arguments = parse_numbers(&rest[open + 1..close])?;
-+
-+ transform *= match (name, arguments.as_slice()) {
-+ ("matrix", &[a, b, c, d, e, f]) => Affine::new([a, b, c, d, e, f]),
-+ ("translate", &[tx]) => Affine::translate((tx, 0.)),
-+ ("translate", &[tx, ty]) => Affine::translate((tx, ty)),
-+ ("scale", &[s]) => Affine::scale(s),
-+ ("scale", &[sx, sy]) => Affine::scale_non_uniform(sx, sy),
-+ ("rotate", &[degrees]) => Affine::rotate(degrees.to_radians()),
-+ ("rotate", &[degrees, cx, cy]) => {
-+ Affine::rotate_about(degrees.to_radians(), Point::new(cx, cy))
-+ },
-+ ("skewX", &[degrees]) => skew(degrees.to_radians(), 0.),
-+ ("skewY", &[degrees]) => skew(0., degrees.to_radians()),
-+ _ => return None,
-+ };
-+
-+ // Transform functions are separated by optional comma-wsp.
-+ rest = rest[close + 1..].trim_start_matches([' ', '\t', '\r', '\n']);
-+ rest = rest.strip_prefix(',').unwrap_or(rest);
-+ rest = rest.trim_start();
-+ }
-+ Some(transform)
-+}
-+
-+/// `skewX`/`skewY` as a shear matrix. kurbo has no skew constructor.
-+fn skew(x_radians: f64, y_radians: f64) -> Affine {
-+ Affine::new([1., y_radians.tan(), x_radians.tan(), 1., 0., 0.])
-+}
-+
-+/// The complete argument list, or `None` if anything in it is not a number.
-+fn parse_numbers(input: &str) -> Option> {
-+ let mut parser = NumberListParser::new(input);
-+ let mut numbers = Vec::new();
-+ while let Some(number) = parser.next_number() {
-+ numbers.push(number as f64);
-+ }
-+ parser.at_end().then_some(numbers)
-+}
-+
-+#[cfg(test)]
-+mod test {
-+ use kurbo::{Affine, Point};
-+
-+ use super::parse_transform_list;
-+
-+ fn apply(list: &str, point: (f64, f64)) -> (f64, f64) {
-+ let transform = parse_transform_list(list).expect("parses");
-+ let mapped = transform * Point::new(point.0, point.1);
-+ ((mapped.x * 1e6).round() / 1e6, (mapped.y * 1e6).round() / 1e6)
-+ }
-+
-+ #[test]
-+ fn empty_and_absent_lists_are_the_identity() {
-+ assert_eq!(parse_transform_list(""), Some(Affine::IDENTITY));
-+ assert_eq!(parse_transform_list(" "), Some(Affine::IDENTITY));
-+ }
-+
-+ #[test]
-+ fn individual_functions() {
-+ assert_eq!(apply("translate(10, 20)", (1., 2.)), (11., 22.));
-+ // A single translate argument leaves y alone.
-+ assert_eq!(apply("translate(10)", (1., 2.)), (11., 2.));
-+ assert_eq!(apply("scale(2)", (3., 4.)), (6., 8.));
-+ assert_eq!(apply("scale(2, 3)", (3., 4.)), (6., 12.));
-+ assert_eq!(apply("rotate(90)", (1., 0.)), (0., 1.));
-+ // rotate with an origin rotates about that point, leaving it fixed.
-+ assert_eq!(apply("rotate(90, 5, 5)", (5., 5.)), (5., 5.));
-+ assert_eq!(apply("rotate(180, 5, 5)", (6., 5.)), (4., 5.));
-+ assert_eq!(apply("matrix(1, 0, 0, 1, 7, 8)", (0., 0.)), (7., 8.));
-+ // skewX shifts x by y*tan(a); at 45 degrees that is y itself.
-+ assert_eq!(apply("skewX(45)", (0., 3.)), (3., 3.));
-+ assert_eq!(apply("skewY(45)", (3., 0.)), (3., 3.));
-+ }
-+
-+ #[test]
-+ fn lists_compose_left_to_right() {
-+ // The leftmost function is applied last to the coordinate system,
-+ // i.e. outermost: translate-then-scale scales the offset too.
-+ assert_eq!(apply("translate(10, 0) scale(2)", (1., 0.)), (12., 0.));
-+ assert_eq!(apply("scale(2) translate(10, 0)", (1., 0.)), (22., 0.));
-+ // Separators are optional and comma-wsp.
-+ assert_eq!(apply("translate(10,0),scale(2)", (1., 0.)), (12., 0.));
-+ assert_eq!(apply("translate(10 0) scale(2)", (1., 0.)), (12., 0.));
-+ }
-+
-+ #[test]
-+ fn malformed_lists_are_rejected_whole() {
-+ // An unknown function invalidates the attribute, it is not skipped.
-+ assert_eq!(parse_transform_list("wobble(2)"), None);
-+ // So does a wrong argument count.
-+ assert_eq!(parse_transform_list("matrix(1, 2, 3)"), None);
-+ assert_eq!(parse_transform_list("rotate(1, 2)"), None);
-+ assert_eq!(parse_transform_list("translate()"), None);
-+ // Or a non-numeric argument, or unbalanced parentheses.
-+ assert_eq!(parse_transform_list("scale(2px)"), None);
-+ assert_eq!(parse_transform_list("scale(2"), None);
-+ assert_eq!(parse_transform_list("translate(1,2) garbage"), None);
-+ }
-+}
-diff --git a/components/layout/svg/tree.rs b/components/layout/svg/tree.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..ba8b6a3bc192128a3f0506518590fe82b53abbad
---- /dev/null
-+++ b/components/layout/svg/tree.rs
-@@ -0,0 +1,182 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! The rendering tree for a native SVG subtree.
-+//!
-+//! Walks the DOM under an `` and produces outlines and resolved paints in
-+//! the viewport's coordinate system. This is the whole point of the native
-+//! path: the subtree keeps its computed styles instead of being serialized to
-+//! markup and re-parsed with no CSS context, so selector-driven styling,
-+//! inheritance and — once the damage path is wired up — animation all work
-+//! without anything SVG-specific.
-+//!
-+//! Groups stay groups rather than being flattened, because group `opacity`
-+//! composites the group as a unit: flattening it into each child would let
-+//! overlapping siblings show through one another.
-+
-+use kurbo::Affine;
-+use layout_api::LayoutElement as _;
-+use layout_api::LayoutNode as _;
-+use script::layout_dom::{ServoLayoutElement, ServoLayoutNode};
-+use style::properties::ComputedValues;
-+use web_atoms::local_name;
-+
-+use super::SVGViewport;
-+use super::paint::PercentageBasis;
-+use super::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
-+use super::resolve;
-+
-+/// Builds the tree for an `` element's subtree.
-+///
-+/// `viewport_size` is the element's used content-box size in CSS pixels, so
-+/// this runs at fragment construction rather than box construction — the
-+/// `viewBox` transform and every percentage length need the used size.
-+pub(crate) fn build(
-+ svg_root: ServoLayoutNode<'_>,
-+ viewport: &SVGViewport,
-+ viewport_size: (f64, f64),
-+) -> SVGTree {
-+ // Inside the viewBox, percentages resolve against the viewBox, not
-+ // against the CSS box: that is the whole point of establishing a new
-+ // viewport. Without a viewBox, user space is the CSS box.
-+ let (user_width, user_height) = match viewport.view_box {
-+ Some(view_box) => (view_box.width as f64, view_box.height as f64),
-+ None => viewport_size,
-+ };
-+ let basis = PercentageBasis::for_viewport(user_width, user_height);
-+
-+ let transform = viewport.transform(euclid::default::Size2D::new(
-+ viewport_size.0 as f32,
-+ viewport_size.1 as f32,
-+ ));
-+ let root_transform = Affine::new([
-+ transform.m11 as f64,
-+ transform.m12 as f64,
-+ transform.m21 as f64,
-+ transform.m22 as f64,
-+ transform.m31 as f64,
-+ transform.m32 as f64,
-+ ]);
-+
-+ let mut root = SVGGroup {
-+ transform: root_transform,
-+ opacity: 1.,
-+ children: Vec::new(),
-+ };
-+ append_children(svg_root, basis, &mut root.children);
-+ SVGTree { root }
-+}
-+
-+fn append_children(parent: ServoLayoutNode<'_>, basis: PercentageBasis, out: &mut Vec) {
-+ for child in parent.dom_children() {
-+ let Some(element) = child.as_element() else {
-+ // Text nodes only matter inside ``, which is out of scope.
-+ continue;
-+ };
-+ let Some(style) = primary_style(element) else {
-+ // The style system skipped this element entirely.
-+ continue;
-+ };
-+ if let Some(node) = build_node(child, element, &style, basis) {
-+ out.push(node);
-+ }
-+ }
-+}
-+
-+fn build_node(
-+ node: ServoLayoutNode<'_>,
-+ element: ServoLayoutElement<'_>,
-+ style: &ComputedValues,
-+ basis: PercentageBasis,
-+) -> Option {
-+ let name = element.local_name();
-+
-+ // Elements that never render, and whose subtrees never render with them.
-+ // `` and `` content only renders through ``, and the
-+ // metadata elements never render at all.
-+ if matches!(
-+ name,
-+ name if *name == local_name!("defs") ||
-+ *name == local_name!("symbol") ||
-+ *name == local_name!("title") ||
-+ *name == local_name!("desc") ||
-+ *name == local_name!("metadata") ||
-+ *name == local_name!("style") ||
-+ *name == local_name!("script") ||
-+ *name == local_name!("linearGradient") ||
-+ *name == local_name!("radialGradient") ||
-+ *name == local_name!("pattern") ||
-+ *name == local_name!("clipPath") ||
-+ *name == local_name!("mask") ||
-+ *name == local_name!("marker")
-+ ) {
-+ return None;
-+ }
-+
-+ // `` is a bare DOM stub in Servo — it does no shadow instancing — so
-+ // there is nothing under it to walk. Rendering it means resolving its
-+ // href against the document and re-walking the referenced subtree, which
-+ // needs an id lookup layout does not currently have. Skipped rather than
-+ // approximated, so a missing icon is obvious.
-+ if *name == local_name!("use") {
-+ return None;
-+ }
-+
-+ let transform = resolve::transform(element);
-+ let opacity = resolve::opacity(style);
-+ // A group with zero opacity, and everything under it, is invisible.
-+ if opacity == 0. {
-+ return None;
-+ }
-+
-+ // ``, `` and a nested `` are containers. A nested ``
-+ // establishes its own viewport, which is not handled yet; treating it as
-+ // a plain group at least renders its children in the parent's space.
-+ if *name == local_name!("g") || *name == local_name!("a") || *name == local_name!("svg") {
-+ let mut group = SVGGroup {
-+ transform,
-+ opacity,
-+ children: Vec::new(),
-+ };
-+ append_children(node, basis, &mut group.children);
-+ return (!group.children.is_empty()).then_some(SVGNode::Group(group));
-+ }
-+
-+ // `visibility` is checked only on shapes: it is inherited, so a hidden
-+ // group's children compute `hidden` themselves, and `visibility: visible`
-+ // on a child of a hidden group must still show.
-+ if !resolve::is_visible(style) {
-+ return None;
-+ }
-+
-+ let path = resolve::shape(element, style, basis)?;
-+ let current_color = style.get_inherited_text().clone_color();
-+ let (fill_paint, fill_rule) = resolve::fill(style, ¤t_color);
-+ let stroke = resolve::stroke(style, ¤t_color, basis);
-+
-+ // A `` has no interior, so it is never filled however `fill`
-+ // computes. A `` is filled, as if its last point joined its
-+ // first — an open outline, but not an unfillable one.
-+ let fill = (*name != local_name!("line"))
-+ .then(|| fill_paint.color().map(|color| (color, fill_rule)))
-+ .flatten();
-+
-+ let shape = SVGShape {
-+ path,
-+ transform,
-+ opacity,
-+ fill,
-+ stroke: stroke.is_visible().then_some(stroke),
-+ };
-+ (shape.fill.is_some() || shape.stroke.is_some()).then_some(SVGNode::Shape(shape))
-+}
-+
-+/// The element's primary computed style, or `None` if the style system never
-+/// gave it one.
-+fn primary_style(element: ServoLayoutElement<'_>) -> Option> {
-+ element
-+ .style_data()
-+ .map(|style_data| style_data.element_data.borrow())
-+ .and_then(|data| data.styles.get_primary().cloned())
-+}
-diff --git a/components/layout/svg/viewbox.rs b/components/layout/svg/viewbox.rs
-index d940a11267d03302319fc174fba461ca5a9176ac..9bc7267dc92077761bf87a3393ce2b321bcc3a95 100644
---- a/components/layout/svg/viewbox.rs
-+++ b/components/layout/svg/viewbox.rs
-@@ -15,6 +15,8 @@
- //! ratio at all. That is a pre-existing bug on the rasterization path; the
- //! native path uses the number grammar below instead.
-
-+use super::number::NumberListParser;
-+
- /// A parsed `viewBox`, guaranteed to have non-negative width and height.
- ///
- /// A negative width or height is an error per spec (the element is not
-@@ -159,110 +161,6 @@ impl PreserveAspectRatio {
- }
- }
-
--/// A comma-wsp separated list of SVG ``s.
--///
--/// SVG's number grammar is not CSS's: it accepts a leading `+`, a bare `.5`,
--/// and an exponent, but not units. `str::parse::` additionally accepts
--/// `inf`, `nan` and hex floats, so the token is scanned by hand first.
--struct NumberListParser<'a> {
-- input: &'a str,
-- position: usize,
-- /// Set once a number has been read, after which a separator is required.
-- expect_separator: bool,
--}
--
--impl<'a> NumberListParser<'a> {
-- fn new(input: &'a str) -> Self {
-- Self {
-- input,
-- position: 0,
-- expect_separator: false,
-- }
-- }
--
-- fn rest(&self) -> &'a str {
-- &self.input[self.position..]
-- }
--
-- /// comma-wsp: at most one comma, surrounded by any amount of whitespace.
-- fn skip_comma_wsp(&mut self) -> bool {
-- let mut saw_separator = false;
-- let mut seen_comma = false;
-- for character in self.rest().chars() {
-- match character {
-- ' ' | '\t' | '\r' | '\n' => {},
-- ',' if !seen_comma => seen_comma = true,
-- _ => break,
-- }
-- saw_separator = true;
-- self.position += character.len_utf8();
-- }
-- saw_separator
-- }
--
-- fn at_end(&mut self) -> bool {
-- self.skip_comma_wsp();
-- self.rest().is_empty()
-- }
--
-- fn next_number(&mut self) -> Option {
-- let had_separator = self.skip_comma_wsp();
-- if self.expect_separator && !had_separator {
-- return None;
-- }
--
-- let rest = self.rest();
-- let mut end = 0;
-- let bytes = rest.as_bytes();
--
-- if matches!(bytes.first(), Some(b'+' | b'-')) {
-- end += 1;
-- }
-- let integer_digits = bytes[end..]
-- .iter()
-- .take_while(|byte| byte.is_ascii_digit())
-- .count();
-- end += integer_digits;
--
-- let mut fraction_digits = 0;
-- if bytes.get(end) == Some(&b'.') {
-- end += 1;
-- fraction_digits = bytes[end..]
-- .iter()
-- .take_while(|byte| byte.is_ascii_digit())
-- .count();
-- end += fraction_digits;
-- }
-- if integer_digits == 0 && fraction_digits == 0 {
-- return None;
-- }
--
-- if matches!(bytes.get(end), Some(b'e' | b'E')) {
-- let mut exponent_end = end + 1;
-- if matches!(bytes.get(exponent_end), Some(b'+' | b'-')) {
-- exponent_end += 1;
-- }
-- let exponent_digits = bytes[exponent_end..]
-- .iter()
-- .take_while(|byte| byte.is_ascii_digit())
-- .count();
-- // A trailing `e` with no digits is not part of the number; leave it
-- // for the caller to reject as trailing garbage.
-- if exponent_digits > 0 {
-- end = exponent_end + exponent_digits;
-- }
-- }
--
-- let number: f32 = rest[..end].parse().ok()?;
-- if !number.is_finite() {
-- return None;
-- }
-- self.position += end;
-- self.expect_separator = true;
-- Some(number)
-- }
--}
--
- #[cfg(test)]
- mod test {
- use super::{Align, MeetOrSlice, PreserveAspectRatio, ViewBox};
diff --git a/servo-patches/0011-layout-SVG-hit-testing-against-outlines-not-bounding.patch b/servo-patches/0011-layout-SVG-hit-testing-against-outlines-not-bounding.patch
deleted file mode 100644
index 07fb6f0..0000000
--- a/servo-patches/0011-layout-SVG-hit-testing-against-outlines-not-bounding.patch
+++ /dev/null
@@ -1,395 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Sat, 22 Aug 2026 16:16:35 +0100
-Subject: [PATCH 11/24] layout: SVG hit testing against outlines, not bounding
- boxes
-
-Point-in-shape testing for the native path: fill regions honour
-fill-rule, stroke regions are the stroke outline (so a , which
-encloses no area, is still hittable), and the topmost shape in paint
-order wins. A non-invertible transform collapses to no hit rather than
-to a degenerate outline. Dashes are deliberately ignored when building
-the hit region -- a dashed stroke is hit along its whole length, not
-only on the dashes.
-
-The pointer-events keyword set is NOT implemented, and cannot be
-without a stylo change: SVG 2 defines visiblePainted, visibleFill,
-visibleStroke, visible, painted, fill, stroke and all, and every one of
-them is behind #[cfg(feature = "gecko")] in stylo's PointerEvents enum.
-Servo is left with auto and none, so the region rules here are
-implemented and tested but nothing can select between them from CSS
-yet. Ungating them is the same shape of one-line stylo change as
-stylo-0001 in this series.
-
-8 tests, covering the cases where bounding-box testing would give the
-wrong answer.
----
- components/layout/svg/hittest.rs | 346 +++++++++++++++++++++++++++++++
- components/layout/svg/mod.rs | 1 +
- 2 files changed, 347 insertions(+)
- create mode 100644 components/layout/svg/hittest.rs
-
-diff --git a/components/layout/svg/hittest.rs b/components/layout/svg/hittest.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..7e9900e66543179c3f387bc5f0ee7a9536b553dd
---- /dev/null
-+++ b/components/layout/svg/hittest.rs
-@@ -0,0 +1,346 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! Hit testing against a painted SVG scene.
-+//!
-+//! SVG hit testing is not bounding-box testing: a point inside a shape's box
-+//! but outside its outline misses, a point on a stroke hits even where the
-+//! fill does not reach, and `fill-rule` decides what "inside" means for a
-+//! self-overlapping path. All of that is geometry, so like [`super::render`]
-+//! this is pure and pixel-free.
-+//!
-+//! What is *not* here is the `pointer-events` keyword set. SVG 2 defines
-+//! `visiblePainted`, `visibleFill`, `visibleStroke`, `visible`, `painted`,
-+//! `fill`, `stroke` and `all`, and Servo supports none of them: every one is
-+//! behind `#[cfg(feature = "gecko")]` in stylo's `PointerEvents`, leaving
-+//! Servo with only `auto` and `none`. So the region rules below are
-+//! implemented and tested, but nothing can currently select between them from
-+//! CSS. See `stylo-0002` in the patch series.
-+//!
-+//!
-+
-+use kurbo::{Affine, BezPath, Cap, Join, Point, Shape as _, Stroke as KurboStroke, StrokeOpts};
-+
-+use super::paint::{FillRule, LineCap, LineJoin, Stroke};
-+use super::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
-+
-+/// Which regions of a shape respond to pointer events.
-+#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+pub(crate) struct HitRegions {
-+ pub fill: bool,
-+ pub stroke: bool,
-+}
-+
-+impl HitRegions {
-+ /// `pointer-events: auto` on SVG content behaves as `visiblePainted`:
-+ /// only regions that are actually painted are hit.
-+ pub(crate) const PAINTED: Self = Self {
-+ fill: true,
-+ stroke: true,
-+ };
-+ pub(crate) const NONE: Self = Self {
-+ fill: false,
-+ stroke: false,
-+ };
-+}
-+
-+/// The topmost shape in `tree` under `point`, which is in the same space the
-+/// tree was built in (the SVG viewport's coordinate system, CSS pixels).
-+///
-+/// Returns the shape's index in paint order, counting depth-first, so callers
-+/// can map back to a DOM node using the same traversal the builder used.
-+pub(crate) fn hit_test(tree: &SVGTree, point: Point, regions: HitRegions) -> Option {
-+ let mut index = 0;
-+ let mut hit = None;
-+ visit_group(&tree.root, Affine::IDENTITY, point, regions, &mut index, &mut hit);
-+ hit
-+}
-+
-+fn visit_group(
-+ group: &SVGGroup,
-+ parent_transform: Affine,
-+ point: Point,
-+ regions: HitRegions,
-+ index: &mut usize,
-+ hit: &mut Option,
-+) {
-+ let transform = parent_transform * group.transform;
-+ // Walk in paint order and keep overwriting: the last shape to claim the
-+ // point is the topmost one, which is what a hit test should return.
-+ for child in &group.children {
-+ match child {
-+ SVGNode::Group(child) => {
-+ visit_group(child, transform, point, regions, index, hit)
-+ },
-+ SVGNode::Shape(shape) => {
-+ let current = *index;
-+ *index += 1;
-+ if shape_contains(shape, transform, point, regions) {
-+ *hit = Some(current);
-+ }
-+ },
-+ }
-+ }
-+}
-+
-+fn shape_contains(
-+ shape: &SVGShape,
-+ parent_transform: Affine,
-+ point: Point,
-+ regions: HitRegions,
-+) -> bool {
-+ let transform = parent_transform * shape.transform;
-+ // Test in the shape's own space rather than transforming the outline, so
-+ // a non-invertible transform (`scale(0)`) collapses to no hit instead of
-+ // producing a degenerate outline.
-+ let Some(local) = invert(transform).map(|inverse| inverse * point) else {
-+ return false;
-+ };
-+
-+ if regions.fill &&
-+ let Some((_, rule)) = shape.fill &&
-+ contains_with_rule(&shape.path, local, rule)
-+ {
-+ return true;
-+ }
-+
-+ if regions.stroke &&
-+ let Some(stroke) = &shape.stroke
-+ {
-+ // The stroke region is the shape's stroke *outline*, filled. This is
-+ // why a ``, which encloses no area, is still hittable.
-+ let outline = kurbo::stroke(
-+ shape.path.iter(),
-+ &to_kurbo_stroke(stroke),
-+ &StrokeOpts::default(),
-+ STROKE_TOLERANCE,
-+ );
-+ if outline.contains(local) {
-+ return true;
-+ }
-+ }
-+
-+ false
-+}
-+
-+fn contains_with_rule(path: &BezPath, point: Point, rule: FillRule) -> bool {
-+ let winding = path.winding(point);
-+ match rule {
-+ FillRule::NonZero => winding != 0,
-+ FillRule::EvenOdd => winding % 2 != 0,
-+ }
-+}
-+
-+fn invert(transform: Affine) -> Option {
-+ (transform.determinant().abs() > f64::EPSILON).then(|| transform.inverse())
-+}
-+
-+/// Dashes are deliberately dropped when building the hit region: a dashed
-+/// stroke is hit along its whole length, not only on the dashes.
-+fn to_kurbo_stroke(stroke: &Stroke) -> KurboStroke {
-+ let cap = match stroke.line_cap {
-+ LineCap::Butt => Cap::Butt,
-+ LineCap::Round => Cap::Round,
-+ LineCap::Square => Cap::Square,
-+ };
-+ KurboStroke {
-+ width: stroke.width,
-+ join: match stroke.line_join {
-+ LineJoin::Miter => Join::Miter,
-+ LineJoin::Round => Join::Round,
-+ LineJoin::Bevel => Join::Bevel,
-+ },
-+ miter_limit: stroke.miter_limit,
-+ start_cap: cap,
-+ end_cap: cap,
-+ dash_pattern: Default::default(),
-+ dash_offset: 0.,
-+ }
-+}
-+
-+const STROKE_TOLERANCE: f64 = 0.1;
-+
-+#[cfg(test)]
-+mod test {
-+ use kurbo::{Affine, Point};
-+
-+ use super::{HitRegions, hit_test};
-+ use crate::svg::geometry;
-+ use crate::svg::paint::{FillRule, Paint, Rgba, Stroke};
-+ use crate::svg::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
-+
-+ const RED: Rgba = Rgba {
-+ red: 255,
-+ green: 0,
-+ blue: 0,
-+ alpha: 255,
-+ };
-+
-+ fn tree(children: Vec) -> SVGTree {
-+ SVGTree {
-+ root: SVGGroup {
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ children,
-+ },
-+ }
-+ }
-+
-+ fn filled(path: kurbo::BezPath, rule: FillRule) -> SVGNode {
-+ SVGNode::Shape(SVGShape {
-+ path,
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ fill: Some((RED, rule)),
-+ stroke: None,
-+ })
-+ }
-+
-+ fn at(scene: &SVGTree, x: f64, y: f64) -> Option {
-+ hit_test(scene, Point::new(x, y), HitRegions::PAINTED)
-+ }
-+
-+ #[test]
-+ fn hits_inside_the_outline_not_the_bounding_box() {
-+ // A triangle: the top-left corner of its bounding box is outside it.
-+ let triangle = geometry::polygon(&[(0., 0.), (10., 10.), (0., 10.)], true).expect("renders");
-+ let scene = tree(vec![filled(triangle, FillRule::NonZero)]);
-+ assert_eq!(at(&scene, 2., 8.), Some(0));
-+ // Inside the bounding box, outside the triangle.
-+ assert_eq!(at(&scene, 8., 2.), None);
-+ // Outside the bounding box entirely.
-+ assert_eq!(at(&scene, 20., 20.), None);
-+ }
-+
-+ #[test]
-+ fn fill_rule_decides_what_inside_means() {
-+ let mut path = geometry::rect(0., 0., 8., 8., None, None).expect("renders");
-+ path.extend(geometry::rect(2., 2., 4., 4., None, None).expect("renders"));
-+ let nonzero = tree(vec![filled(path.clone(), FillRule::NonZero)]);
-+ let evenodd = tree(vec![filled(path, FillRule::EvenOdd)]);
-+ // The inner square is solid under non-zero and a hole under even-odd.
-+ assert_eq!(at(&nonzero, 4., 4.), Some(0));
-+ assert_eq!(at(&evenodd, 4., 4.), None);
-+ // The outer ring hits under both.
-+ assert_eq!(at(&evenodd, 1., 1.), Some(0));
-+ }
-+
-+ #[test]
-+ fn the_topmost_shape_wins() {
-+ let scene = tree(vec![
-+ filled(geometry::rect(0., 0., 10., 10., None, None).expect("renders"), FillRule::NonZero),
-+ filled(geometry::rect(0., 0., 5., 5., None, None).expect("renders"), FillRule::NonZero),
-+ ]);
-+ // Where they overlap, the later (topmost) shape is returned.
-+ assert_eq!(at(&scene, 2., 2.), Some(1));
-+ // Where only the first covers, it is.
-+ assert_eq!(at(&scene, 8., 8.), Some(0));
-+ }
-+
-+ #[test]
-+ fn strokes_are_hittable_where_fills_are_not() {
-+ // A line encloses no area at all, so only its stroke can be hit.
-+ let scene = tree(vec![SVGNode::Shape(SVGShape {
-+ path: geometry::line(0., 5., 10., 5.),
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ fill: None,
-+ stroke: Some(Stroke {
-+ paint: Paint::Color(RED),
-+ width: 4.,
-+ ..Default::default()
-+ }),
-+ })]);
-+ assert_eq!(at(&scene, 5., 5.), Some(0));
-+ // Just inside the 4-wide stroke, then just outside it.
-+ assert_eq!(at(&scene, 5., 6.5), Some(0));
-+ assert_eq!(at(&scene, 5., 8.), None);
-+ }
-+
-+ #[test]
-+ fn dashed_strokes_are_hit_along_their_whole_length() {
-+ let scene = tree(vec![SVGNode::Shape(SVGShape {
-+ path: geometry::line(0., 5., 20., 5.),
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ fill: None,
-+ stroke: Some(Stroke {
-+ paint: Paint::Color(RED),
-+ width: 4.,
-+ dash_array: vec![2., 8.],
-+ ..Default::default()
-+ }),
-+ })]);
-+ // x=5 falls in a gap between dashes, but the line is still hittable
-+ // there: hit regions ignore dashing.
-+ assert_eq!(at(&scene, 5., 5.), Some(0));
-+ }
-+
-+ #[test]
-+ fn transforms_move_the_hit_region_with_the_shape() {
-+ let mut shape = match filled(
-+ geometry::rect(0., 0., 4., 4., None, None).expect("renders"),
-+ FillRule::NonZero,
-+ ) {
-+ SVGNode::Shape(shape) => shape,
-+ _ => unreachable!(),
-+ };
-+ shape.transform = Affine::translate((10., 0.));
-+ let scene = SVGTree {
-+ root: SVGGroup {
-+ transform: Affine::translate((0., 10.)),
-+ opacity: 1.,
-+ children: vec![SVGNode::Shape(shape)],
-+ },
-+ };
-+ assert_eq!(at(&scene, 12., 12.), Some(0));
-+ assert_eq!(at(&scene, 2., 2.), None);
-+ }
-+
-+ #[test]
-+ fn a_collapsed_transform_cannot_be_hit() {
-+ let mut shape = match filled(
-+ geometry::rect(0., 0., 4., 4., None, None).expect("renders"),
-+ FillRule::NonZero,
-+ ) {
-+ SVGNode::Shape(shape) => shape,
-+ _ => unreachable!(),
-+ };
-+ // scale(0) is not invertible; the shape occupies no area.
-+ shape.transform = Affine::scale(0.);
-+ let scene = tree(vec![SVGNode::Shape(shape)]);
-+ assert_eq!(at(&scene, 0., 0.), None);
-+ }
-+
-+ #[test]
-+ fn regions_can_be_restricted() {
-+ let scene = tree(vec![SVGNode::Shape(SVGShape {
-+ path: geometry::rect(0., 0., 10., 10., None, None).expect("renders"),
-+ transform: Affine::IDENTITY,
-+ opacity: 1.,
-+ fill: Some((RED, FillRule::NonZero)),
-+ stroke: Some(Stroke {
-+ paint: Paint::Color(RED),
-+ width: 4.,
-+ ..Default::default()
-+ }),
-+ })]);
-+ let fill_only = HitRegions {
-+ fill: true,
-+ stroke: false,
-+ };
-+ let stroke_only = HitRegions {
-+ fill: false,
-+ stroke: true,
-+ };
-+ // The centre is fill but not stroke.
-+ assert_eq!(hit_test(&scene, Point::new(5., 5.), fill_only), Some(0));
-+ assert_eq!(hit_test(&scene, Point::new(5., 5.), stroke_only), None);
-+ // Just outside the edge is stroke but not fill.
-+ assert_eq!(hit_test(&scene, Point::new(-1., 5.), stroke_only), Some(0));
-+ assert_eq!(hit_test(&scene, Point::new(-1., 5.), fill_only), None);
-+ // `pointer-events: none` never hits.
-+ assert_eq!(hit_test(&scene, Point::new(5., 5.), HitRegions::NONE), None);
-+ }
-+}
-diff --git a/components/layout/svg/mod.rs b/components/layout/svg/mod.rs
-index ddd25b9d00d50e60cd7d5dc058eee9b697189d32..c0b822b833756104d1ca12678563a6e72d12db63 100644
---- a/components/layout/svg/mod.rs
-+++ b/components/layout/svg/mod.rs
-@@ -26,6 +26,7 @@ use euclid::default::{Size2D, Transform2D};
- use servo_config::pref;
-
- pub(crate) mod geometry;
-+pub(crate) mod hittest;
- pub(crate) mod number;
- pub(crate) mod paint;
- pub(crate) mod render;
diff --git a/servo-patches/0012-layout-SVG-image-registry.patch b/servo-patches/0012-layout-SVG-image-registry.patch
deleted file mode 100644
index 0ed8e1b..0000000
--- a/servo-patches/0012-layout-SVG-image-registry.patch
+++ /dev/null
@@ -1,549 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Sat, 22 Aug 2026 21:12:31 +0100
-Subject: [PATCH 12/24] layout: SVG image registry
-
-Per-node ImageKey caching for natively painted SVG, keyed on a content
-hash of the scene, so an unchanged SVG costs nothing on reflow and an
-animating one costs exactly one upload per changed frame. The next patch
-connects it to fragment construction.
-
-The split between allocation, rendering and upload is forced by
-CrossProcessPaintApi holding Cells and therefore being !Sync while
-LayoutContext must be Sync, because layout runs in parallel. Rendering
-is pure and can happen on any layout thread; only the upload has to
-reach the paint API, and so has to happen on the layout thread. The
-module documents that constraint, since it is the reason the code is
-shaped this way and not something a reader would otherwise guess.
-
-Also moves the viewport transform out of tree building and into
-painting. It depends on the element's used content-box size, which is
-not known when the tree is built, and keeping it at paint time means a
-resize repaints without rebuilding the tree.
----
- components/layout/svg/image.rs | 177 ++++++++++++++++++++++++++++++++
- components/layout/svg/mod.rs | 1 +
- components/layout/svg/paint.rs | 10 +-
- components/layout/svg/render.rs | 30 +++---
- components/layout/svg/scene.rs | 91 +++++++++++++++-
- components/layout/svg/tree.rs | 45 +++-----
- 6 files changed, 301 insertions(+), 53 deletions(-)
- create mode 100644 components/layout/svg/image.rs
-
-diff --git a/components/layout/svg/image.rs b/components/layout/svg/image.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..6c119d145e3effd4e35782d64ec7df08b9dad8e0
---- /dev/null
-+++ b/components/layout/svg/image.rs
-@@ -0,0 +1,177 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! Getting painted SVG pixels to the compositor.
-+//!
-+//! The rasterization path never needed this: it hands a `data:` URL to the
-+//! image cache, which rasterizes with resvg and registers the result. The
-+//! native path paints in layout, so layout has to own the `ImageKey` — the
-+//! same thing canvas, WebGL and WebGPU already do through `CrossProcessPaintApi`.
-+//!
-+//! Keys are cached per node and reused. A key is only re-uploaded when the
-+//! scene's content hash or its device size changes, so a reflow that does not
-+//! touch an SVG costs nothing, and an animating one costs exactly one upload
-+//! per changed frame.
-+//!
-+//! # Not wired up yet, and why
-+//!
-+//! This cannot be reached from `LayoutContext`. `CrossProcessPaintApi` holds
-+//! `Cell`s and so is `!Sync`, while `LayoutContext` must be `Sync` because
-+//! layout runs in parallel — putting one inside the other fails to compile
-+//! with `Cell cannot be shared between threads safely` at every
-+//! parallel-layout call site. So an image key cannot be minted or uploaded
-+//! from the code that builds fragments, which is where the used size (and
-+//! therefore the pixmap size) is first known.
-+//!
-+//! Rendering itself is fine in parallel: `render` is pure and touches nothing
-+//! shared. Only the registration has to happen on the layout thread. Three
-+//! ways out, in increasing order of cost:
-+//!
-+//! 1. Render during fragment construction, queue `(node, pixmap)` on a
-+//! `Mutex>` in `ImageResolver`, and drain it on the layout thread
-+//! between tree building and display-list construction — the point where
-+//! `LayoutThread` still owns `paint_api` and the key is still needed. This
-+//! is the same shape as the existing `pending_rasterization_images` queue,
-+//! so it follows precedent rather than inventing a mechanism. The fragment
-+//! has to reference the node rather than carry the key, so the display-list
-+//! builder can look the key up after the drain.
-+//! 2. Carry the `SVGTree` on the fragment and render *and* register during
-+//! display-list construction, which is serial and on the layout thread.
-+//! Simplest control flow, but it needs a new `Fragment` variant, and every
-+//! exhaustive match over `Fragment` then has to grow an arm.
-+//! 3. Make `CrossProcessPaintApi` `Sync`. Smallest call-site change and the
-+//! largest blast radius; an upstream decision, not one to take here.
-+//!
-+//! Option 1 is the recommendation. It is left undone deliberately: it is a
-+//! design choice with consequences for the fragment tree, and there is no way
-+//! to validate the resulting frame timing without measuring it.
-+
-+use std::collections::HashMap;
-+use std::collections::hash_map::Entry as MapEntry;
-+
-+use kurbo::Affine;
-+
-+use paint_api::{CrossProcessPaintApi, SerializableImageData};
-+use parking_lot::Mutex;
-+use servo_base::generic_channel::GenericSharedMemory;
-+use servo_base::id::WebViewId;
-+use style::dom::OpaqueNode;
-+use webrender_api::units::DeviceIntSize;
-+use webrender_api::{
-+ ImageDescriptor, ImageDescriptorFlags, ImageFormat, ImageKey,
-+};
-+
-+use super::render;
-+use super::scene::{SVGTree, content_hash};
-+
-+/// What was registered for a node last time.
-+struct Registration {
-+ key: ImageKey,
-+ size: (u16, u16),
-+ hash: u64,
-+}
-+
-+/// Owns the compositor image keys for natively painted SVG.
-+pub(crate) struct NativeSVGImages {
-+ paint_api: CrossProcessPaintApi,
-+ webview_id: WebViewId,
-+ registrations: Mutex>,
-+}
-+
-+impl NativeSVGImages {
-+ pub(crate) fn new(paint_api: CrossProcessPaintApi, webview_id: WebViewId) -> Self {
-+ Self {
-+ paint_api,
-+ webview_id,
-+ registrations: Mutex::new(HashMap::new()),
-+ }
-+ }
-+
-+ /// Renders `tree` at `size` device pixels and returns a key the display
-+ /// list can reference, reusing the previous upload when nothing changed.
-+ ///
-+ /// `base` is the user-space-to-device transform: the viewport's
-+ /// `viewBox` mapping composed with the device pixel ratio.
-+ ///
-+ /// Returns `None` for a zero-sized box, and if the compositor declines to
-+ /// mint a key — in which case the caller paints nothing rather than
-+ /// falling back to a stale image at the wrong size.
-+ pub(crate) fn image_key_for(
-+ &self,
-+ node: OpaqueNode,
-+ tree: &SVGTree,
-+ size: DeviceIntSize,
-+ base: Affine,
-+ ) -> Option {
-+ let width = u16::try_from(size.width).ok().filter(|width| *width > 0)?;
-+ let height = u16::try_from(size.height).ok().filter(|height| *height > 0)?;
-+ let hash = content_hash(tree);
-+
-+ let mut registrations = self.registrations.lock();
-+ match registrations.entry(node) {
-+ MapEntry::Occupied(mut occupied) => {
-+ let registration = occupied.get_mut();
-+ if registration.size == (width, height) && registration.hash == hash {
-+ return Some(registration.key);
-+ }
-+ let pixmap = render::render(tree, width, height, base);
-+ self.paint_api.update_image(
-+ registration.key,
-+ descriptor(width, height),
-+ image_data(&pixmap),
-+ None,
-+ );
-+ registration.size = (width, height);
-+ registration.hash = hash;
-+ Some(registration.key)
-+ },
-+ MapEntry::Vacant(vacant) => {
-+ let key = self.paint_api.generate_image_key_blocking(self.webview_id)?;
-+ let pixmap = render::render(tree, width, height, base);
-+ self.paint_api.add_image(
-+ key,
-+ descriptor(width, height),
-+ image_data(&pixmap),
-+ false,
-+ );
-+ vacant.insert(Registration {
-+ key,
-+ size: (width, height),
-+ hash,
-+ });
-+ Some(key)
-+ },
-+ }
-+ }
-+}
-+
-+impl Drop for NativeSVGImages {
-+ fn drop(&mut self) {
-+ // The keys outlive this layout pass in the compositor, so they have
-+ // to be handed back explicitly or every reflow leaks one per SVG.
-+ for registration in self.registrations.lock().values() {
-+ self.paint_api.delete_image(registration.key);
-+ }
-+ }
-+}
-+
-+fn descriptor(width: u16, height: u16) -> ImageDescriptor {
-+ ImageDescriptor::new(
-+ width as i32,
-+ height as i32,
-+ ImageFormat::RGBA8,
-+ // Never opaque: an SVG only covers the pixels its shapes cover, and
-+ // marking it opaque would let WebRender skip blending and paint the
-+ // uncovered pixels as black.
-+ ImageDescriptorFlags::empty(),
-+ )
-+}
-+
-+/// vello_cpu's `Pixmap` is premultiplied RGBA8, which is exactly WebRender's
-+/// `ImageFormat::RGBA8`, so the bytes go across untouched.
-+fn image_data(pixmap: &vello_cpu::Pixmap) -> SerializableImageData {
-+ SerializableImageData::Raw(GenericSharedMemory::from_vec(
-+ pixmap.data_as_u8_slice().to_vec(),
-+ ))
-+}
-diff --git a/components/layout/svg/mod.rs b/components/layout/svg/mod.rs
-index c0b822b833756104d1ca12678563a6e72d12db63..413ff3ec77b320fbd2b335ec412062a3ae236261 100644
---- a/components/layout/svg/mod.rs
-+++ b/components/layout/svg/mod.rs
-@@ -27,6 +27,7 @@ use servo_config::pref;
-
- pub(crate) mod geometry;
- pub(crate) mod hittest;
-+pub(crate) mod image;
- pub(crate) mod number;
- pub(crate) mod paint;
- pub(crate) mod render;
-diff --git a/components/layout/svg/paint.rs b/components/layout/svg/paint.rs
-index e387654133c314325e6561ba2ff85fa824a90b6b..d6a481a151e4499739c9f9443291cb93d05211b9 100644
---- a/components/layout/svg/paint.rs
-+++ b/components/layout/svg/paint.rs
-@@ -9,7 +9,7 @@
- //! these types, and nothing here knows about stylo.
-
- /// Straight-alpha RGBA in the sRGB space, which is what `vello_cpu` takes.
--#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+#[derive(Clone, Copy, Debug, Default, Hash, PartialEq)]
- pub(crate) struct Rgba {
- pub red: u8,
- pub green: u8,
-@@ -42,7 +42,7 @@ impl Rgba {
- /// A resolved paint source. Paint servers (gradients, patterns) are not
- /// resolved yet; they arrive as `None` so that a referencing shape does not
- /// silently paint solid black.
--#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+#[derive(Clone, Copy, Debug, Default, Hash, PartialEq)]
- pub(crate) enum Paint {
- #[default]
- None,
-@@ -59,14 +59,14 @@ impl Paint {
- }
- }
-
--#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+#[derive(Clone, Copy, Debug, Default, Hash, PartialEq)]
- pub(crate) enum FillRule {
- #[default]
- NonZero,
- EvenOdd,
- }
-
--#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+#[derive(Clone, Copy, Debug, Default, Hash, PartialEq)]
- pub(crate) enum LineCap {
- #[default]
- Butt,
-@@ -74,7 +74,7 @@ pub(crate) enum LineCap {
- Square,
- }
-
--#[derive(Clone, Copy, Debug, Default, PartialEq)]
-+#[derive(Clone, Copy, Debug, Default, Hash, PartialEq)]
- pub(crate) enum LineJoin {
- #[default]
- Miter,
-diff --git a/components/layout/svg/render.rs b/components/layout/svg/render.rs
-index 17574562c15874380870eddd856d9ea82faa79f8..8382b12667c84acfafc81d96b58d5f6a794d7785 100644
---- a/components/layout/svg/render.rs
-+++ b/components/layout/svg/render.rs
-@@ -22,15 +22,17 @@ use super::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
-
- /// Paints a tree into a new pixmap of `width` x `height` device pixels.
- ///
--/// `device_scale` maps CSS pixels to device pixels; the tree's own coordinates
--/// are in CSS pixels within the viewport, so the whole scene is scaled by it.
--/// The pixmap starts fully transparent — an SVG has no background of its own.
--pub(crate) fn render(tree: &SVGTree, width: u16, height: u16, device_scale: f64) -> Pixmap {
-+/// `base` maps the tree's own coordinates — SVG user space — onto device
-+/// pixels. It carries both the viewport's `viewBox`/`preserveAspectRatio`
-+/// transform, which depends on the element's used size and so is not known
-+/// when the tree is built, and the device pixel ratio. The pixmap starts fully
-+/// transparent: an SVG has no background of its own.
-+pub(crate) fn render(tree: &SVGTree, width: u16, height: u16, base: Affine) -> Pixmap {
- let mut context = RenderContext::new(width, height);
- let mut resources = Resources::new();
- let mut pixmap = Pixmap::new(width, height);
-
-- paint_group(&mut context, &tree.root, Affine::scale(device_scale));
-+ paint_group(&mut context, &tree.root, base);
-
- context.flush();
- context.render_to_pixmap(&mut resources, &mut pixmap);
-@@ -188,13 +190,13 @@ mod test {
-
- #[test]
- fn an_empty_tree_paints_nothing() {
-- let pixmap = render(&tree(vec![]), 8, 8, 1.);
-+ let pixmap = render(&tree(vec![]), 8, 8, Affine::IDENTITY);
- assert!(pixmap.data().iter().all(|pixel| pixel.a == 0));
- }
-
- #[test]
- fn a_filled_rect_lands_where_it_should() {
-- let pixmap = render(&tree(vec![filled_rect(2., 2., 4., 4., RED)]), 8, 8, 1.);
-+ let pixmap = render(&tree(vec![filled_rect(2., 2., 4., 4., RED)]), 8, 8, Affine::IDENTITY);
- // Inside the rect.
- assert_eq!(pixel(&pixmap, 3, 3), (255, 0, 0, 255));
- assert_eq!(pixel(&pixmap, 5, 5), (255, 0, 0, 255));
-@@ -209,7 +211,7 @@ mod test {
- fn the_device_scale_scales_the_scene() {
- // The same 2x2 rect at 2x covers 4x4 device pixels.
- let scene = tree(vec![filled_rect(0., 0., 2., 2., RED)]);
-- let pixmap = render(&scene, 8, 8, 2.);
-+ let pixmap = render(&scene, 8, 8, Affine::scale(2.));
- assert_eq!(pixel(&pixmap, 3, 3), (255, 0, 0, 255));
- assert_eq!(pixel(&pixmap, 4, 4).3, 0);
- }
-@@ -223,7 +225,7 @@ mod test {
- ]),
- 8,
- 8,
-- 1.,
-+ Affine::IDENTITY,
- );
- assert_eq!(pixel(&pixmap, 1, 1), (0, 0, 255, 255));
- assert_eq!(pixel(&pixmap, 6, 6), (255, 0, 0, 255));
-@@ -243,7 +245,7 @@ mod test {
- children: vec![SVGNode::Shape(shape)],
- },
- };
-- let pixmap = render(&scene, 8, 8, 1.);
-+ let pixmap = render(&scene, 8, 8, Affine::IDENTITY);
- // Translated 2 right by the shape and 4 down by the group.
- assert_eq!(pixel(&pixmap, 3, 5), (255, 0, 0, 255));
- assert_eq!(pixel(&pixmap, 1, 1).3, 0);
-@@ -264,7 +266,7 @@ mod test {
- ],
- },
- };
-- let pixmap = render(&scene, 8, 8, 1.);
-+ let pixmap = render(&scene, 8, 8, Affine::IDENTITY);
- let (red, green, blue, alpha) = pixel(&pixmap, 4, 4);
- assert_eq!(alpha, 128);
- assert_eq!((red, green), (0, 0));
-@@ -284,7 +286,7 @@ mod test {
- ..Default::default()
- }),
- })]);
-- let pixmap = render(&scene, 8, 8, 1.);
-+ let pixmap = render(&scene, 8, 8, Affine::IDENTITY);
- // A width-2 stroke straddles the edge, so it covers one pixel either
- // side of x=2: the interior centre stays empty.
- assert_eq!(pixel(&pixmap, 1, 4), (0, 0, 255, 255));
-@@ -310,7 +312,7 @@ mod test {
- })]),
- 8,
- 8,
-- 1.,
-+ Affine::IDENTITY,
- )
- };
- assert_eq!(pixel(&with_rule(FillRule::NonZero), 4, 4).3, 255);
-@@ -333,7 +335,7 @@ mod test {
- ..Default::default()
- }),
- })]);
-- let pixmap = render(&scene, 16, 8, 1.);
-+ let pixmap = render(&scene, 16, 8, Affine::IDENTITY);
- // On for [0,4), off for [4,8), on again for [8,12).
- assert_eq!(pixel(&pixmap, 1, 4).3, 255);
- assert_eq!(pixel(&pixmap, 5, 4).3, 0);
-diff --git a/components/layout/svg/scene.rs b/components/layout/svg/scene.rs
-index bd7ef4a6934cbaed3c3e88044bb66bebcc775c9e..f813a2829261feb23bd1faccb8e612701b323542 100644
---- a/components/layout/svg/scene.rs
-+++ b/components/layout/svg/scene.rs
-@@ -9,7 +9,9 @@
- //! real pixels without building Servo. [`super::tree`] is the DOM walk that
- //! produces these.
-
--use kurbo::{Affine, BezPath};
-+use std::hash::{Hash, Hasher};
-+
-+use kurbo::{Affine, BezPath, PathEl, Point};
-
- use super::paint::{FillRule, Rgba, Stroke};
-
-@@ -57,3 +59,90 @@ impl SVGTree {
- group_is_empty(&self.root)
- }
- }
-+
-+
-+/// A content hash of the whole scene.
-+///
-+/// Used to decide whether a re-layout actually changed the pixels, so an
-+/// unchanged SVG is not re-rendered and re-uploaded to the compositor on
-+/// every reflow. `f64` has no `Hash`, so coordinates are hashed by bit
-+/// pattern; that makes `-0.0` and `0.0` hash differently, which costs an
-+/// occasional redundant repaint and never a missed one — the safe direction.
-+pub(crate) fn content_hash(tree: &SVGTree) -> u64 {
-+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
-+ hash_group(&tree.root, &mut hasher);
-+ hasher.finish()
-+}
-+
-+fn hash_group(group: &SVGGroup, hasher: &mut impl Hasher) {
-+ hash_affine(&group.transform, hasher);
-+ group.opacity.to_bits().hash(hasher);
-+ group.children.len().hash(hasher);
-+ for child in &group.children {
-+ match child {
-+ SVGNode::Group(group) => {
-+ 0u8.hash(hasher);
-+ hash_group(group, hasher);
-+ },
-+ SVGNode::Shape(shape) => {
-+ 1u8.hash(hasher);
-+ hash_shape(shape, hasher);
-+ },
-+ }
-+ }
-+}
-+
-+fn hash_shape(shape: &SVGShape, hasher: &mut impl Hasher) {
-+ hash_path(&shape.path, hasher);
-+ hash_affine(&shape.transform, hasher);
-+ shape.opacity.to_bits().hash(hasher);
-+ match &shape.fill {
-+ None => 0u8.hash(hasher),
-+ Some((color, rule)) => {
-+ 1u8.hash(hasher);
-+ color.hash(hasher);
-+ rule.hash(hasher);
-+ },
-+ }
-+ match &shape.stroke {
-+ None => 0u8.hash(hasher),
-+ Some(stroke) => {
-+ 1u8.hash(hasher);
-+ stroke.paint.hash(hasher);
-+ stroke.width.to_bits().hash(hasher);
-+ stroke.line_cap.hash(hasher);
-+ stroke.line_join.hash(hasher);
-+ stroke.miter_limit.to_bits().hash(hasher);
-+ stroke.dash_offset.to_bits().hash(hasher);
-+ stroke.dash_array.len().hash(hasher);
-+ for dash in &stroke.dash_array {
-+ dash.to_bits().hash(hasher);
-+ }
-+ },
-+ }
-+}
-+
-+fn hash_path(path: &BezPath, hasher: &mut impl Hasher) {
-+ path.elements().len().hash(hasher);
-+ for element in path.elements() {
-+ match element {
-+ PathEl::MoveTo(a) => (0u8, hash_point(a)).hash(hasher),
-+ PathEl::LineTo(a) => (1u8, hash_point(a)).hash(hasher),
-+ PathEl::QuadTo(a, b) => (2u8, hash_point(a), hash_point(b)).hash(hasher),
-+ PathEl::CurveTo(a, b, c) => {
-+ (3u8, hash_point(a), hash_point(b), hash_point(c)).hash(hasher)
-+ },
-+ PathEl::ClosePath => 4u8.hash(hasher),
-+ }
-+ }
-+}
-+
-+fn hash_point(point: &Point) -> (u64, u64) {
-+ (point.x.to_bits(), point.y.to_bits())
-+}
-+
-+fn hash_affine(transform: &Affine, hasher: &mut impl Hasher) {
-+ for coefficient in transform.as_coeffs() {
-+ coefficient.to_bits().hash(hasher);
-+ }
-+}
-diff --git a/components/layout/svg/tree.rs b/components/layout/svg/tree.rs
-index ba8b6a3bc192128a3f0506518590fe82b53abbad..13347360063bdb8122c7843cd9e68009a5408410 100644
---- a/components/layout/svg/tree.rs
-+++ b/components/layout/svg/tree.rs
-@@ -22,45 +22,24 @@ use script::layout_dom::{ServoLayoutElement, ServoLayoutNode};
- use style::properties::ComputedValues;
- use web_atoms::local_name;
-
--use super::SVGViewport;
- use super::paint::PercentageBasis;
- use super::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
- use super::resolve;
-
--/// Builds the tree for an `` element's subtree.
-+/// Builds the tree for an `` element's subtree, in SVG user space.
- ///
--/// `viewport_size` is the element's used content-box size in CSS pixels, so
--/// this runs at fragment construction rather than box construction — the
--/// `viewBox` transform and every percentage length need the used size.
--pub(crate) fn build(
-- svg_root: ServoLayoutNode<'_>,
-- viewport: &SVGViewport,
-- viewport_size: (f64, f64),
--) -> SVGTree {
-- // Inside the viewBox, percentages resolve against the viewBox, not
-- // against the CSS box: that is the whole point of establishing a new
-- // viewport. Without a viewBox, user space is the CSS box.
-- let (user_width, user_height) = match viewport.view_box {
-- Some(view_box) => (view_box.width as f64, view_box.height as f64),
-- None => viewport_size,
-- };
-- let basis = PercentageBasis::for_viewport(user_width, user_height);
--
-- let transform = viewport.transform(euclid::default::Size2D::new(
-- viewport_size.0 as f32,
-- viewport_size.1 as f32,
-- ));
-- let root_transform = Affine::new([
-- transform.m11 as f64,
-- transform.m12 as f64,
-- transform.m21 as f64,
-- transform.m22 as f64,
-- transform.m31 as f64,
-- transform.m32 as f64,
-- ]);
--
-+/// The viewport transform is deliberately *not* baked in: it depends on the
-+/// element's used content-box size, which is not known at box construction.
-+/// [`super::render`] applies it, which also means a resize repaints without
-+/// rebuilding the tree.
-+///
-+/// `user_space_size` is the viewBox's size when there is one — inside a
-+/// viewBox, percentages resolve against it, not against the CSS box — and the
-+/// element's natural size otherwise.
-+pub(crate) fn build(svg_root: ServoLayoutNode<'_>, user_space_size: (f64, f64)) -> SVGTree {
-+ let basis = PercentageBasis::for_viewport(user_space_size.0, user_space_size.1);
- let mut root = SVGGroup {
-- transform: root_transform,
-+ transform: Affine::IDENTITY,
- opacity: 1.,
- children: Vec::new(),
- };
diff --git a/servo-patches/0013-layout-paint-native-SVG-and-let-its-descendants-anim.patch b/servo-patches/0013-layout-paint-native-SVG-and-let-its-descendants-anim.patch
deleted file mode 100644
index 27104ae..0000000
--- a/servo-patches/0013-layout-paint-native-SVG-and-let-its-descendants-anim.patch
+++ /dev/null
@@ -1,1000 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Sat, 22 Aug 2026 17:56:45 +0100
-Subject: [PATCH 13/24] layout: paint native SVG, and let its descendants
- animate
-
-Connects the renderer to the compositor and makes CSS animations run on
-SVG content. With layout.svg.native.enabled on, inline is now laid
-out and painted natively instead of being serialized and rasterized.
-
-The delivery split. CrossProcessPaintApi holds Cells and is !Sync, while
-LayoutContext must be Sync because layout runs in parallel, so the
-upload cannot happen where the pixels are produced. Keys come instead
-from ImageCache::get_image_key, which pops a pre-filled pool and is
-already reachable from ImageResolver; rendering is pure and happens on
-whatever layout thread got there; and the upload is queued and drained
-on the layout thread between tree building and display-list
-construction, the same shape as pending_rasterization_images. Because
-the key is allocated up front rather than at upload time, the fragment
-carries a real key on the first frame, with no blank paint and second
-reflow. Uploads are skipped entirely unless a content hash of the scene
-or its device size changed.
-
-Three things had to be fixed that the plan did not anticipate.
-
-`svg > * { display: none }` in servo.css prunes the style traversal, so
-nothing below a direct child of the ever gets computed styles.
-Invisible to the rasterization path, which re-parses serialized markup,
-but fatal here: a 's children simply did not exist, and a test page
-rendered its top-level shapes and dropped everything inside any group.
-Moved to its own stylesheet, applied only when the pref is off. Nothing
-is lost: an is replaced content, so layout builds no boxes for its
-descendants either way.
-
-The transform attribute was being read without consulting the CSS
-transform property, so a scripted or animated transform did nothing --
-which would also have silently defeated the animation work below. SVG 2
-makes the attribute a presentation attribute for the property, so the
-property is now consulted first and the attribute is the fallback.
-
-Animations::do_post_reflow_update cancels every animation on a node that
-is not "being rendered", and a boxless node reports exactly that. So an
-animation on an SVG descendant registered for a single tick and was then
-dropped -- visible as the animation set count going 1, 0. An SVG
-descendant on the native path is being rendered, it just has no CSS box,
-so node_rendering_type now says so. This is the phase the plan expected
-to be "mostly deletions"; it was not, and the reason is worth keeping.
-
-Validated against a real browser, not a trace:
-
- - Shapes, groups, transforms, stylesheet-driven fill: pixel-identical
- to the rasterization path bar antialiasing -- 0.13% of pixels differ
- at zero tolerance, 0.02% at tolerance 8, all on shape edges where
- vello and resvg disagree.
- - A 4s spinner sampled at +1.1s, +2.1s and +3.1s: with the pref off,
- 0 and 0 pixels change -- the original bug. With it on, 2016 and 2201
- pixels change. The spinner turns.
- - Computed transform on an animating SVG path now advances in lockstep
- with an equivalent HTML div; before, it stayed at the identity.
- - svg/ WPT, same build, pref off vs on: +1 test
- (painting/reftests/zero-scale-svg-transform-inside-g-tag.html),
- zero regressions across 1261 tests and 3925 subtests.
-
-Seam: 47 added lines across pre-existing layout and script files.
----
- components/layout/context.rs | 9 +
- components/layout/layout_impl.rs | 36 ++-
- components/layout/replaced.rs | 13 +-
- components/layout/stylesheets/servo.css | 9 -
- .../layout/stylesheets/svg-rasterized.css | 23 ++
- components/layout/svg/hittest.rs | 108 ++++++++-
- components/layout/svg/image.rs | 214 +++++++++---------
- components/layout/svg/mod.rs | 145 ++++++++++++
- components/layout/svg/resolve.rs | 54 ++++-
- components/layout/svg/tree.rs | 2 +-
- 10 files changed, 477 insertions(+), 136 deletions(-)
- create mode 100644 components/layout/stylesheets/svg-rasterized.css
-
-diff --git a/components/layout/context.rs b/components/layout/context.rs
-index 22b255c32ca1b6c960c540fa432a673eb63e258b..6d2071656729d70a32b7cda95f01915986adfac6 100644
---- a/components/layout/context.rs
-+++ b/components/layout/context.rs
-@@ -29,6 +29,8 @@ use style_traits::DevicePixel;
- use uuid::Uuid;
- use webrender_api::units::{DeviceIntSize, DeviceSize};
-
-+use crate::svg::image::{NativeSVGImages, PendingSVGPaint};
-+
- pub(crate) type CachedImageOrError = Result;
-
- pub(crate) struct LayoutContext<'a> {
-@@ -137,6 +139,12 @@ pub(crate) struct ImageResolver {
-
- /// The current animation timeline value used to properly initialize animating images.
- pub animation_timeline_value: f64,
-+
-+ /// Compositor image keys for natively painted SVG; see [`crate::svg::image`].
-+ pub native_svg_images: Arc,
-+
-+ /// SVG pixmaps rendered this reflow, awaiting upload by the layout thread.
-+ pub pending_svg_paints: Mutex>,
- }
-
- impl Drop for ImageResolver {
-@@ -149,6 +157,7 @@ impl Drop for ImageResolver {
- .lock()
- .is_empty()
- );
-+ assert!(self.pending_svg_paints.lock().is_empty());
- }
- }
- }
-diff --git a/components/layout/layout_impl.rs b/components/layout/layout_impl.rs
-index 96ba4ec64ec4456fc04954219a9cff8d436a7e0f..df60ec06d8c2117f12504353c698564b3f8a5a9f 100644
---- a/components/layout/layout_impl.rs
-+++ b/components/layout/layout_impl.rs
-@@ -82,6 +82,7 @@ use webrender_api::ExternalScrollId;
- use webrender_api::units::{DevicePixel, LayoutVector2D};
-
- use crate::accessibility_tree::AccessibilityTree;
-+use crate::svg::image::NativeSVGImages;
- use crate::context::{CachedImageOrError, ImageResolver, LayoutContext};
- use crate::display_list::{DisplayListBuilder, HitTest, PaintTimingHandler, StackingContextTree};
- use crate::dom::NodeExt;
-@@ -113,6 +114,7 @@ static HTML_MODE_CSS: &[u8] = include_bytes!("./stylesheets/html-mode.css");
-
- /// A CSS file to style the Servo browser.
- static SERVO_CSS: &[u8] = include_bytes!("./stylesheets/servo.css");
-+static SVG_RASTERIZED_CSS: &[u8] = include_bytes!("./stylesheets/svg-rasterized.css");
-
- /// A CSS file to style the presentational hints.
- static PRESENTATIONAL_HINTS_CSS: &[u8] = include_bytes!("./stylesheets/presentational-hints.css");
-@@ -206,6 +208,9 @@ pub struct LayoutThread {
- /// Cross-process access to the `Paint` API.
- paint_api: CrossProcessPaintApi,
-
-+ /// Compositor image keys for natively painted SVG; see [`crate::svg::image`].
-+ native_svg_images: Arc,
-+
- /// Debug options, copied from configuration to this `LayoutThread` in order
- /// to avoid having to constantly access the thread-safe global options.
- debug: DiagnosticsLogging,
-@@ -246,7 +251,8 @@ impl Drop for LayoutThread {
- .font_context
- .collect_unused_webrender_resources(true /* all */);
- self.paint_api
-- .remove_unused_font_resources(self.webview_id.into(), keys, instance_keys)
-+ .remove_unused_font_resources(self.webview_id.into(), keys, instance_keys);
-+ self.native_svg_images.release_all(&self.paint_api);
- }
- }
-
-@@ -340,7 +346,17 @@ impl Layout for LayoutThread {
- let Some(node) = node else {
- return NodeRenderingType::NotRendered;
- };
-- node.rendering_type()
-+ match node.rendering_type() {
-+ // Safety: this is a synchronous script query, so no layout
-+ // workers are running.
-+ NodeRenderingType::NotRendered => {
-+ #[expect(unsafe_code)]
-+ unsafe {
-+ crate::svg::rendering_type_in_native_subtree(node)
-+ }
-+ },
-+ rendering_type => rendering_type,
-+ }
- })
- }
-
-@@ -813,6 +829,7 @@ impl LayoutThread {
- box_tree: Default::default(),
- fragment_tree: Default::default(),
- stacking_context_tree: Default::default(),
-+ native_svg_images: Default::default(),
- paint_api: config.paint_api,
- stylist: Stylist::new(device, QuirksMode::NoQuirks),
- resolved_images_cache: Default::default(),
-@@ -1008,11 +1025,17 @@ impl LayoutThread {
- pending_svg_elements_for_serialization: Mutex::default(),
- animating_images: reflow_request.animating_images.clone(),
- animation_timeline_value: reflow_request.animation_timeline_value,
-+ native_svg_images: self.native_svg_images.clone(),
-+ pending_svg_paints: Mutex::default(),
- });
- let mut reflow_statistics = Default::default();
-
- let (mut reflow_phases_run, iframe_sizes, changed_web_fonts) = self
- .restyle_and_build_trees(&mut reflow_request, document, root_element, &image_resolver);
-+ NativeSVGImages::flush(
-+ &self.paint_api,
-+ std::mem::take(&mut *image_resolver.pending_svg_paints.lock()),
-+ );
- if self.build_stacking_context_tree_for_reflow(&reflow_request) {
- reflow_phases_run.insert(ReflowPhasesRun::BuiltStackingContextTree);
- }
-@@ -1653,7 +1676,7 @@ fn get_ua_stylesheets(shared_lock: &SharedRwLock) -> Rc {
- .get_or_init(|| {
- // FIXME: presentational-hints.css should be at author origin with zero specificity.
- // (Does it make a difference?)
-- let user_agent_stylesheets = vec![
-+ let mut user_agent_stylesheets = vec![
- parse_ua_stylesheet(shared_lock, "user-agent.css", USER_AGENT_CSS),
- parse_ua_stylesheet(shared_lock, "servo.css", SERVO_CSS),
- parse_ua_stylesheet(
-@@ -1662,6 +1685,13 @@ fn get_ua_stylesheets(shared_lock: &SharedRwLock) -> Rc {
- PRESENTATIONAL_HINTS_CSS,
- ),
- ];
-+ if !crate::svg::native_svg_enabled() {
-+ user_agent_stylesheets.push(parse_ua_stylesheet(
-+ shared_lock,
-+ "svg-rasterized.css",
-+ SVG_RASTERIZED_CSS,
-+ ));
-+ }
-
- let html_mode_stylesheet =
- parse_ua_stylesheet(shared_lock, "html-mode.css", HTML_MODE_CSS);
-diff --git a/components/layout/replaced.rs b/components/layout/replaced.rs
-index ffa6ce767b0566ce505c19bc9e063fb28166cc73..24e4b67d6b080510878e08d4d252e739cd3a7b3d 100644
---- a/components/layout/replaced.rs
-+++ b/components/layout/replaced.rs
-@@ -46,7 +46,7 @@ use crate::layout_box_base::{IndependentFormattingContextLayoutResult, LayoutBox
- use crate::sizing::{
- ComputeInlineContentSizes, InlineContentSizesResult, LazySize, SizeConstraint,
- };
--use crate::svg::{SVGViewport, native_svg_enabled};
-+use crate::svg::{self, NativeSVG, SVGViewport, native_svg_enabled};
- use crate::style_ext::{AspectRatio, Clamp, ComputedValuesExt, LayoutStyle};
- use crate::{ConstraintSpace, ContainingBlock};
-
-@@ -153,6 +153,9 @@ pub(crate) enum ReplacedContentKind {
- SVGElement {
- vector_image: Option,
- has_viewbox: bool,
-+ /// The natively painted scene; see [`crate::svg`].
-+ #[ignore_malloc_size_of = "kurbo outlines behind an Arc"]
-+ native: Option>,
- },
- Audio,
- }
-@@ -346,9 +349,12 @@ impl ReplacedContents {
- _ => unreachable!("SVG element can't contain a raster image."),
- });
-
-+ let native = svg::build_native(node, viewport, &natural_size);
-+
- (
- ReplacedContentKind::SVGElement {
- vector_image,
-+ native,
- has_viewbox: svg_data.view_box.is_some(),
- },
- natural_size,
-@@ -622,7 +628,12 @@ impl ReplacedContents {
- ReplacedContentKind::SVGElement {
- vector_image,
- has_viewbox,
-+ native,
- } => {
-+ if let Some(native) = native {
-+ return svg::make_fragments(layout_context, native, base, clip);
-+ }
-+
- let Some(vector_image) = vector_image else {
- return vec![];
- };
-diff --git a/components/layout/stylesheets/servo.css b/components/layout/stylesheets/servo.css
-index c6aac0890585778f01adcc4437b338ab8047d2e1..2b6df4588a1af1a2d33b191550d4a024230593a6 100644
---- a/components/layout/stylesheets/servo.css
-+++ b/components/layout/stylesheets/servo.css
-@@ -428,15 +428,6 @@ details[open]::details-content {
- display: block;
- }
-
--/*
-- * Until servo supports svg properly, make sure to at least prevent svg
-- * children from being layed out and rendered like usual html.
-- * https://github.com/servo/servo/issues/10646
-- */
--svg > * {
-- display: none;
--}
--
- *|*::-servo-anonymous-box {
- unicode-bidi: inherit;
- direction: inherit;
-diff --git a/components/layout/stylesheets/svg-rasterized.css b/components/layout/stylesheets/svg-rasterized.css
-new file mode 100644
-index 0000000000000000000000000000000000000000..30e2a5b21b022b2775c7cab296c1200c4eb3e320
---- /dev/null
-+++ b/components/layout/stylesheets/svg-rasterized.css
-@@ -0,0 +1,23 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+/*
-+ * Applied only when `layout.svg.native.enabled` is off, i.e. when an is
-+ * rendered by serializing its subtree and rasterizing it.
-+ *
-+ * Until servo supports svg properly, make sure to at least prevent svg
-+ * children from being layed out and rendered like usual html.
-+ * https://github.com/servo/servo/issues/10646
-+ *
-+ * This cannot apply on the native path. `display: none` prunes the style
-+ * traversal, so the subtree below each direct child of the never gets
-+ * computed styles at all — which is invisible to the rasterization path,
-+ * because it re-parses serialized markup, but fatal to native layout, where a
-+ * 's children would simply not exist. Nothing is lost by dropping it: an
-+ * is replaced content, so layout never builds boxes for its descendants
-+ * either way.
-+ */
-+svg > * {
-+ display: none;
-+}
-diff --git a/components/layout/svg/hittest.rs b/components/layout/svg/hittest.rs
-index 7e9900e66543179c3f387bc5f0ee7a9536b553dd..0e271775448940299a1a001970f45000c29079f7 100644
---- a/components/layout/svg/hittest.rs
-+++ b/components/layout/svg/hittest.rs
-@@ -10,13 +10,11 @@
- //! self-overlapping path. All of that is geometry, so like [`super::render`]
- //! this is pure and pixel-free.
- //!
--//! What is *not* here is the `pointer-events` keyword set. SVG 2 defines
--//! `visiblePainted`, `visibleFill`, `visibleStroke`, `visible`, `painted`,
--//! `fill`, `stroke` and `all`, and Servo supports none of them: every one is
--//! behind `#[cfg(feature = "gecko")]` in stylo's `PointerEvents`, leaving
--//! Servo with only `auto` and `none`. So the region rules below are
--//! implemented and tested, but nothing can currently select between them from
--//! CSS. See `stylo-0002` in the patch series.
-+//! The `pointer-events` keyword set needs `stylo-0002` in the patch series:
-+//! SVG 2's `visiblePainted`, `visibleFill`, `visibleStroke`, `visible`,
-+//! `painted`, `fill`, `stroke` and `all` are all `#[cfg(feature = "gecko")]`
-+//! in upstream stylo, leaving Servo with `auto` and `none`. With that patch
-+//! applied, [`regions_for`] maps the full set onto the rules below.
- //!
- //!
-
-@@ -25,6 +23,25 @@ use kurbo::{Affine, BezPath, Cap, Join, Point, Shape as _, Stroke as KurboStroke
- use super::paint::{FillRule, LineCap, LineJoin, Stroke};
- use super::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
-
-+/// `pointer-events`, mirrored locally.
-+///
-+/// stylo's `PointerEvents` is the source of truth; `resolve.rs` converts. The
-+/// duplication buys the mapping table below being a pure function, which is
-+/// where the actual subtlety lives.
-+#[derive(Clone, Copy, Debug, PartialEq)]
-+pub(crate) enum PointerEvents {
-+ Auto,
-+ None,
-+ VisiblePainted,
-+ VisibleFill,
-+ VisibleStroke,
-+ Visible,
-+ Painted,
-+ Fill,
-+ Stroke,
-+ All,
-+}
-+
- /// Which regions of a shape respond to pointer events.
- #[derive(Clone, Copy, Debug, Default, PartialEq)]
- pub(crate) struct HitRegions {
-@@ -45,6 +62,42 @@ impl HitRegions {
- };
- }
-
-+/// The regions a shape exposes, per its computed `pointer-events` and
-+/// `visibility`.
-+///
-+/// The keyword set splits along two axes: whether `visibility: hidden` content
-+/// can still be hit (the `visible*` values say no), and whether a region has
-+/// to be *painted* to be hit (the `*painted` values and `auto` say yes, so a
-+/// `fill: none` shape is transparent to the pointer). The `fill`/`stroke`/
-+/// `all` values ignore both and hit the geometry regardless — which is what
-+/// makes an invisible `` usable as a hit target.
-+pub(crate) fn regions_for(
-+ pointer_events: PointerEvents,
-+ visible: bool,
-+ has_fill: bool,
-+ has_stroke: bool,
-+) -> HitRegions {
-+ let (needs_visible, needs_paint, fill, stroke) = match pointer_events {
-+ PointerEvents::None => return HitRegions::NONE,
-+ // On SVG content `auto` is defined to behave as `visiblePainted`.
-+ PointerEvents::Auto | PointerEvents::VisiblePainted => (true, true, true, true),
-+ PointerEvents::VisibleFill => (true, false, true, false),
-+ PointerEvents::VisibleStroke => (true, false, false, true),
-+ PointerEvents::Visible => (true, false, true, true),
-+ PointerEvents::Painted => (false, true, true, true),
-+ PointerEvents::Fill => (false, false, true, false),
-+ PointerEvents::Stroke => (false, false, false, true),
-+ PointerEvents::All => (false, false, true, true),
-+ };
-+ if needs_visible && !visible {
-+ return HitRegions::NONE;
-+ }
-+ HitRegions {
-+ fill: fill && (!needs_paint || has_fill),
-+ stroke: stroke && (!needs_paint || has_stroke),
-+ }
-+}
-+
- /// The topmost shape in `tree` under `point`, which is in the same space the
- /// tree was built in (the SVG viewport's coordinate system, CSS pixels).
- ///
-@@ -165,7 +218,7 @@ const STROKE_TOLERANCE: f64 = 0.1;
- mod test {
- use kurbo::{Affine, Point};
-
-- use super::{HitRegions, hit_test};
-+ use super::{HitRegions, PointerEvents, hit_test, regions_for};
- use crate::svg::geometry;
- use crate::svg::paint::{FillRule, Paint, Rgba, Stroke};
- use crate::svg::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
-@@ -313,6 +366,45 @@ mod test {
- assert_eq!(at(&scene, 0., 0.), None);
- }
-
-+ #[test]
-+ fn pointer_events_maps_onto_regions() {
-+ let painted = |events| regions_for(events, true, true, true);
-+ // `auto` behaves as `visiblePainted` on SVG content.
-+ assert_eq!(painted(PointerEvents::Auto), HitRegions::PAINTED);
-+ assert_eq!(painted(PointerEvents::VisiblePainted), HitRegions::PAINTED);
-+ assert_eq!(painted(PointerEvents::None), HitRegions::NONE);
-+
-+ // The fill/stroke split.
-+ assert_eq!(
-+ painted(PointerEvents::VisibleFill),
-+ HitRegions { fill: true, stroke: false }
-+ );
-+ assert_eq!(
-+ painted(PointerEvents::VisibleStroke),
-+ HitRegions { fill: false, stroke: true }
-+ );
-+
-+ // `visibility: hidden` kills the `visible*` values but not the rest.
-+ assert_eq!(regions_for(PointerEvents::VisiblePainted, false, true, true), HitRegions::NONE);
-+ assert_eq!(regions_for(PointerEvents::Visible, false, true, true), HitRegions::NONE);
-+ assert_eq!(regions_for(PointerEvents::Painted, false, true, true), HitRegions::PAINTED);
-+ assert_eq!(regions_for(PointerEvents::All, false, true, true), HitRegions::PAINTED);
-+
-+ // The `*painted` values need actual paint; `all`/`fill`/`stroke` do
-+ // not, which is what makes an unpainted usable as a target.
-+ assert_eq!(regions_for(PointerEvents::Auto, true, false, false), HitRegions::NONE);
-+ assert_eq!(regions_for(PointerEvents::All, true, false, false), HitRegions::PAINTED);
-+ assert_eq!(
-+ regions_for(PointerEvents::Visible, true, false, false),
-+ HitRegions::PAINTED
-+ );
-+ // `painted` with only a stroke exposes only the stroke.
-+ assert_eq!(
-+ regions_for(PointerEvents::Painted, true, false, true),
-+ HitRegions { fill: false, stroke: true }
-+ );
-+ }
-+
- #[test]
- fn regions_can_be_restricted() {
- let scene = tree(vec![SVGNode::Shape(SVGShape {
-diff --git a/components/layout/svg/image.rs b/components/layout/svg/image.rs
-index 6c119d145e3effd4e35782d64ec7df08b9dad8e0..05e4fc3c4d930ef17d4c0f47ff7fa2af06b6e297 100644
---- a/components/layout/svg/image.rs
-+++ b/components/layout/svg/image.rs
-@@ -6,100 +6,86 @@
- //!
- //! The rasterization path never needed this: it hands a `data:` URL to the
- //! image cache, which rasterizes with resvg and registers the result. The
--//! native path paints in layout, so layout has to own the `ImageKey` — the
--//! same thing canvas, WebGL and WebGPU already do through `CrossProcessPaintApi`.
-+//! native path paints in layout, so layout has to own the `ImageKey`.
- //!
--//! Keys are cached per node and reused. A key is only re-uploaded when the
--//! scene's content hash or its device size changes, so a reflow that does not
--//! touch an SVG costs nothing, and an animating one costs exactly one upload
--//! per changed frame.
--//!
--//! # Not wired up yet, and why
-+//! # Why this is split in two
- //!
--//! This cannot be reached from `LayoutContext`. `CrossProcessPaintApi` holds
--//! `Cell`s and so is `!Sync`, while `LayoutContext` must be `Sync` because
--//! layout runs in parallel — putting one inside the other fails to compile
--//! with `Cell cannot be shared between threads safely` at every
--//! parallel-layout call site. So an image key cannot be minted or uploaded
--//! from the code that builds fragments, which is where the used size (and
--//! therefore the pixmap size) is first known.
-+//! `CrossProcessPaintApi` holds `Cell`s and is therefore `!Sync`, while
-+//! `LayoutContext` must be `Sync` because layout runs in parallel. So the
-+//! upload cannot happen where the pixels are produced. It is split along that
-+//! line:
- //!
--//! Rendering itself is fine in parallel: `render` is pure and touches nothing
--//! shared. Only the registration has to happen on the layout thread. Three
--//! ways out, in increasing order of cost:
-+//! * **Allocation and rendering** happen during fragment construction, on
-+//! whatever layout thread got there. Neither needs the paint API — keys come
-+//! from [`ImageCache::get_image_key`], which pops a pre-filled pool and only
-+//! falls back to IPC when it is empty, and rendering is pure.
-+//! * **Upload** is queued and drained on the layout thread between tree
-+//! building and display-list construction, which is the same shape as the
-+//! existing `pending_rasterization_images` queue.
- //!
--//! 1. Render during fragment construction, queue `(node, pixmap)` on a
--//! `Mutex>` in `ImageResolver`, and drain it on the layout thread
--//! between tree building and display-list construction — the point where
--//! `LayoutThread` still owns `paint_api` and the key is still needed. This
--//! is the same shape as the existing `pending_rasterization_images` queue,
--//! so it follows precedent rather than inventing a mechanism. The fragment
--//! has to reference the node rather than carry the key, so the display-list
--//! builder can look the key up after the drain.
--//! 2. Carry the `SVGTree` on the fragment and render *and* register during
--//! display-list construction, which is serial and on the layout thread.
--//! Simplest control flow, but it needs a new `Fragment` variant, and every
--//! exhaustive match over `Fragment` then has to grow an arm.
--//! 3. Make `CrossProcessPaintApi` `Sync`. Smallest call-site change and the
--//! largest blast radius; an upstream decision, not one to take here.
-+//! Because the key is allocated up front rather than at upload time, the
-+//! fragment carries a real key on the first frame — no blank paint followed by
-+//! a second reflow, which is how the rasterization path behaves.
- //!
--//! Option 1 is the recommendation. It is left undone deliberately: it is a
--//! design choice with consequences for the fragment tree, and there is no way
--//! to validate the resulting frame timing without measuring it.
-+//! Keys are cached per node and reused. An upload is queued only when the
-+//! scene's content hash or its device size changes, so a reflow that does not
-+//! touch an SVG costs nothing and an animating one costs exactly one upload
-+//! per changed frame.
-
- use std::collections::HashMap;
--use std::collections::hash_map::Entry as MapEntry;
-
- use kurbo::Affine;
--
-+use net_traits::image_cache::ImageCache;
- use paint_api::{CrossProcessPaintApi, SerializableImageData};
- use parking_lot::Mutex;
- use servo_base::generic_channel::GenericSharedMemory;
--use servo_base::id::WebViewId;
- use style::dom::OpaqueNode;
- use webrender_api::units::DeviceIntSize;
--use webrender_api::{
-- ImageDescriptor, ImageDescriptorFlags, ImageFormat, ImageKey,
--};
-+use webrender_api::{ImageDescriptor, ImageDescriptorFlags, ImageFormat, ImageKey};
-
- use super::render;
- use super::scene::{SVGTree, content_hash};
-
- /// What was registered for a node last time.
-+#[derive(Clone, Copy)]
- struct Registration {
- key: ImageKey,
- size: (u16, u16),
- hash: u64,
- }
-
--/// Owns the compositor image keys for natively painted SVG.
-+/// An upload waiting to be handed to the compositor on the layout thread.
-+pub(crate) struct PendingSVGPaint {
-+ key: ImageKey,
-+ descriptor: ImageDescriptor,
-+ data: SerializableImageData,
-+ /// Whether the compositor has seen this key before, which decides between
-+ /// `add_image` and `update_image`.
-+ is_new: bool,
-+}
-+
-+/// Compositor image keys for natively painted SVG.
-+///
-+/// Held by the `LayoutThread` rather than by `ImageResolver`, which is rebuilt
-+/// every reflow: the whole point is to reuse a key across reflows instead of
-+/// re-uploading unchanged pixels.
-+#[derive(Default)]
- pub(crate) struct NativeSVGImages {
-- paint_api: CrossProcessPaintApi,
-- webview_id: WebViewId,
- registrations: Mutex>,
- }
-
- impl NativeSVGImages {
-- pub(crate) fn new(paint_api: CrossProcessPaintApi, webview_id: WebViewId) -> Self {
-- Self {
-- paint_api,
-- webview_id,
-- registrations: Mutex::new(HashMap::new()),
-- }
-- }
--
-- /// Renders `tree` at `size` device pixels and returns a key the display
-- /// list can reference, reusing the previous upload when nothing changed.
-- ///
-- /// `base` is the user-space-to-device transform: the viewport's
-- /// `viewBox` mapping composed with the device pixel ratio.
-+ /// Renders `tree` if it changed and returns the key the display list
-+ /// should reference, queueing any upload onto `pending`.
- ///
-- /// Returns `None` for a zero-sized box, and if the compositor declines to
-- /// mint a key — in which case the caller paints nothing rather than
-- /// falling back to a stale image at the wrong size.
-+ /// Returns `None` for a zero-sized box, and when the image cache cannot
-+ /// supply a key — in which case the caller paints nothing rather than
-+ /// showing a stale image at the wrong size.
- pub(crate) fn image_key_for(
- &self,
- node: OpaqueNode,
-+ image_cache: &dyn ImageCache,
-+ pending: &Mutex>,
- tree: &SVGTree,
- size: DeviceIntSize,
- base: Affine,
-@@ -108,50 +94,64 @@ impl NativeSVGImages {
- let height = u16::try_from(size.height).ok().filter(|height| *height > 0)?;
- let hash = content_hash(tree);
-
-- let mut registrations = self.registrations.lock();
-- match registrations.entry(node) {
-- MapEntry::Occupied(mut occupied) => {
-- let registration = occupied.get_mut();
-- if registration.size == (width, height) && registration.hash == hash {
-- return Some(registration.key);
-- }
-- let pixmap = render::render(tree, width, height, base);
-- self.paint_api.update_image(
-- registration.key,
-- descriptor(width, height),
-- image_data(&pixmap),
-- None,
-- );
-- registration.size = (width, height);
-- registration.hash = hash;
-- Some(registration.key)
-- },
-- MapEntry::Vacant(vacant) => {
-- let key = self.paint_api.generate_image_key_blocking(self.webview_id)?;
-- let pixmap = render::render(tree, width, height, base);
-- self.paint_api.add_image(
-- key,
-- descriptor(width, height),
-- image_data(&pixmap),
-- false,
-- );
-- vacant.insert(Registration {
-- key,
-- size: (width, height),
-- hash,
-- });
-- Some(key)
-+ // Nothing changed: reuse the key and skip both the render and the
-+ // upload. This is the common case on any reflow that did not touch
-+ // this SVG.
-+ let existing = self.registrations.lock().get(&node).copied();
-+ if let Some(registration) = existing &&
-+ registration.size == (width, height) &&
-+ registration.hash == hash
-+ {
-+ return Some(registration.key);
-+ }
-+
-+ // Render outside the lock: this is the expensive part, and holding a
-+ // registry-wide mutex across it would serialize SVG painting across
-+ // all layout threads. A given node is laid out at most once per pass,
-+ // so two threads cannot be here for the same node at the same time.
-+ let key = match existing {
-+ Some(registration) => registration.key,
-+ None => image_cache.get_image_key()?,
-+ };
-+ let pixmap = render::render(tree, width, height, base);
-+
-+ self.registrations.lock().insert(
-+ node,
-+ Registration {
-+ key,
-+ size: (width, height),
-+ hash,
- },
-+ );
-+ pending.lock().push(PendingSVGPaint {
-+ key,
-+ descriptor: descriptor(width, height),
-+ data: SerializableImageData::Raw(GenericSharedMemory::from_vec(
-+ pixmap.data_as_u8_slice().to_vec(),
-+ )),
-+ is_new: existing.is_none(),
-+ });
-+ Some(key)
-+ }
-+
-+ /// Hands queued uploads to the compositor. Must run on the layout thread,
-+ /// which is the only place `CrossProcessPaintApi` can be touched, and
-+ /// before the display list is sent so the images exist when it is drawn.
-+ pub(crate) fn flush(paint_api: &CrossProcessPaintApi, pending: Vec) {
-+ for paint in pending {
-+ if paint.is_new {
-+ paint_api.add_image(paint.key, paint.descriptor, paint.data, false);
-+ } else {
-+ paint_api.update_image(paint.key, paint.descriptor, paint.data, None);
-+ }
- }
- }
--}
-
--impl Drop for NativeSVGImages {
-- fn drop(&mut self) {
-- // The keys outlive this layout pass in the compositor, so they have
-- // to be handed back explicitly or every reflow leaks one per SVG.
-- for registration in self.registrations.lock().values() {
-- self.paint_api.delete_image(registration.key);
-+ /// Releases every key back to the compositor. Called when the layout
-+ /// thread goes away; without it each pipeline leaks one key per SVG.
-+ pub(crate) fn release_all(&self, paint_api: &CrossProcessPaintApi) {
-+ for registration in self.registrations.lock().drain() {
-+ paint_api.delete_image(registration.1.key);
- }
- }
- }
-@@ -162,16 +162,10 @@ fn descriptor(width: u16, height: u16) -> ImageDescriptor {
- height as i32,
- ImageFormat::RGBA8,
- // Never opaque: an SVG only covers the pixels its shapes cover, and
-- // marking it opaque would let WebRender skip blending and paint the
-- // uncovered pixels as black.
-+ // claiming otherwise would let WebRender skip blending and paint the
-+ // uncovered pixels black. vello_cpu's `Pixmap` is premultiplied
-+ // RGBA8, which is exactly what `ImageFormat::RGBA8` means here, so the
-+ // bytes go across untouched.
- ImageDescriptorFlags::empty(),
- )
- }
--
--/// vello_cpu's `Pixmap` is premultiplied RGBA8, which is exactly WebRender's
--/// `ImageFormat::RGBA8`, so the bytes go across untouched.
--fn image_data(pixmap: &vello_cpu::Pixmap) -> SerializableImageData {
-- SerializableImageData::Raw(GenericSharedMemory::from_vec(
-- pixmap.data_as_u8_slice().to_vec(),
-- ))
--}
-diff --git a/components/layout/svg/mod.rs b/components/layout/svg/mod.rs
-index 413ff3ec77b320fbd2b335ec412062a3ae236261..34a7cbe71558cf5c58e07fc3f726083288652b2f 100644
---- a/components/layout/svg/mod.rs
-+++ b/components/layout/svg/mod.rs
-@@ -22,8 +22,17 @@
- // caller yet. Remove this once that lands.
- #![expect(dead_code)]
-
-+use std::sync::Arc;
-+
-+use app_units::Au;
- use euclid::default::{Size2D, Transform2D};
-+use kurbo::Affine;
- use servo_config::pref;
-+use webrender_api::units::DeviceIntSize;
-+
-+use crate::context::LayoutContext;
-+use crate::fragment_tree::{BaseFragment, Fragment, ImageFragment};
-+use crate::geom::PhysicalRect;
-
- pub(crate) mod geometry;
- pub(crate) mod hittest;
-@@ -177,3 +186,139 @@ mod test {
- assert_eq!(SVGViewport::new(Some("0 0 -1 1"), None).aspect_ratio(), None);
- }
- }
-+
-+/// A natively painted ``: the scene in user space, plus the viewport that
-+/// maps it onto the element's content box.
-+#[derive(Debug)]
-+pub(crate) struct NativeSVG {
-+ pub tree: scene::SVGTree,
-+ pub viewport: SVGViewport,
-+}
-+
-+/// Builds the native scene for an ``, or `None` when the pref is off.
-+///
-+/// Inside a `viewBox`, percentages resolve against the viewBox rather than
-+/// against the CSS box — that is what establishing a viewport means — so the
-+/// scene can be built here, before the used size is known. Without one, user
-+/// space is the CSS box and the natural size is the best basis available at
-+/// box construction.
-+pub(crate) fn build_native(
-+ node: script::layout_dom::ServoLayoutNode<'_>,
-+ viewport: SVGViewport,
-+ natural_size: &crate::replaced::NaturalSizes,
-+) -> Option> {
-+ native_svg_enabled().then(|| {
-+ let user_space = match viewport.view_box {
-+ Some(view_box) => (view_box.width as f64, view_box.height as f64),
-+ None => (
-+ natural_size.width.map_or(0., |width| width.to_f32_px() as f64),
-+ natural_size.height.map_or(0., |height| height.to_f32_px() as f64),
-+ ),
-+ };
-+ Arc::new(NativeSVG {
-+ tree: tree::build(node, user_space),
-+ viewport,
-+ })
-+ })
-+}
-+
-+/// Paints `native` at the element's used size and returns the fragment that
-+/// references the result.
-+///
-+/// Returns no fragments when the scene is empty, when the box has collapsed to
-+/// nothing, or when no image key could be obtained — in each case painting
-+/// nothing is right, and better than showing a stale frame at the wrong size.
-+pub(crate) fn make_fragments(
-+ layout_context: &LayoutContext,
-+ native: &NativeSVG,
-+ base: BaseFragment,
-+ clip: PhysicalRect,
-+) -> Vec {
-+ // The tag is the identity the image key is cached against; a fragment
-+ // without one is not a DOM node and cannot own a key.
-+ let Some(node) = base.tag.map(|tag| tag.node) else {
-+ return vec![];
-+ };
-+ let rect = base.rect();
-+ if native.tree.is_empty() {
-+ return vec![];
-+ }
-+
-+ let device_pixel_ratio = layout_context.style_context.device_pixel_ratio().0;
-+ let device_size = DeviceIntSize::new(
-+ rect.size.width.scale_by(device_pixel_ratio).to_px(),
-+ rect.size.height.scale_by(device_pixel_ratio).to_px(),
-+ );
-+
-+ // User space -> content box -> device pixels. The viewport transform is
-+ // applied here rather than baked into the tree because it depends on the
-+ // used size, which is only known now.
-+ let viewport_transform = native.viewport.transform(euclid::default::Size2D::new(
-+ rect.size.width.to_f32_px(),
-+ rect.size.height.to_f32_px(),
-+ ));
-+ let base_transform = Affine::scale(device_pixel_ratio as f64) *
-+ Affine::new([
-+ viewport_transform.m11 as f64,
-+ viewport_transform.m12 as f64,
-+ viewport_transform.m21 as f64,
-+ viewport_transform.m22 as f64,
-+ viewport_transform.m31 as f64,
-+ viewport_transform.m32 as f64,
-+ ]);
-+
-+ let resolver = &layout_context.image_resolver;
-+ let Some(image_key) = resolver.native_svg_images.image_key_for(
-+ node,
-+ &*resolver.image_cache,
-+ &resolver.pending_svg_paints,
-+ &native.tree,
-+ device_size,
-+ base_transform,
-+ ) else {
-+ return vec![];
-+ };
-+
-+ vec![Fragment::Image(Arc::new(ImageFragment {
-+ base,
-+ clip,
-+ image_key: Some(image_key),
-+ showing_broken_image_icon: false,
-+ url: None,
-+ }))]
-+}
-+
-+/// Whether `node` is painted by an enclosing native SVG viewport.
-+///
-+/// Boxless nodes are normally "not being rendered", and `Animations`
-+/// (`do_post_reflow_update`) cancels every animation on a node that reports
-+/// that — which is why a CSS animation on an SVG descendant registers for a
-+/// single tick and is then dropped. Inside a natively painted `` the
-+/// descendant genuinely is rendered, it just has no CSS box of its own, so it
-+/// has to say so or it can never animate.
-+///
-+/// # Safety
-+///
-+/// Walks DOM parents, so it may only be called from the script thread with no
-+/// layout workers running. The one caller, `Layout::node_rendering_type`, is a
-+/// synchronous script query and satisfies that.
-+#[expect(unsafe_code)]
-+pub(crate) unsafe fn rendering_type_in_native_subtree(
-+ node: script::layout_dom::ServoLayoutNode<'_>,
-+) -> layout_api::NodeRenderingType {
-+ use layout_api::{LayoutElementType, LayoutNode as _, LayoutNodeType, NodeRenderingType};
-+
-+ if !native_svg_enabled() {
-+ return NodeRenderingType::NotRendered;
-+ }
-+
-+ // The walk is short in practice and only runs for nodes with no box.
-+ let mut ancestor = unsafe { node.dangerous_dom_parent() };
-+ while let Some(current) = ancestor {
-+ if current.type_id() == Some(LayoutNodeType::Element(LayoutElementType::SVGSVGElement)) {
-+ return NodeRenderingType::Rendered;
-+ }
-+ ancestor = unsafe { current.dangerous_dom_parent() };
-+ }
-+ NodeRenderingType::NotRendered
-+}
-diff --git a/components/layout/svg/resolve.rs b/components/layout/svg/resolve.rs
-index 194f2a5a671319b4121167830c7428e67dd04e53..30d11b97af94bdf89c137b0b609a8b9f3fa7050a 100644
---- a/components/layout/svg/resolve.rs
-+++ b/components/layout/svg/resolve.rs
-@@ -19,6 +19,7 @@ use layout_api::LayoutElement as _;
- use style::color::{AbsoluteColor, ColorSpace};
- use style::computed_values::stroke_linecap::T as StrokeLinecap;
- use style::computed_values::stroke_linejoin::T as StrokeLinejoin;
-+use style::computed_values::pointer_events::T as StyloPointerEvents;
- use style::computed_values::visibility::T as Visibility;
- use style::properties::ComputedValues;
- use style::values::computed::{DProperty, Length, LengthPercentage};
-@@ -30,6 +31,7 @@ use style::values::generics::length::{GenericLengthPercentageOrAuto, GenericSize
- use style::values::generics::svg::{SVGLength, SVGPaintKind, SVGStrokeDashArray};
-
- use super::geometry::{self, PathSegment};
-+use super::hittest::PointerEvents;
- use super::paint::{FillRule, LineCap, LineJoin, Paint, PercentageBasis, Rgba, Stroke};
- use super::transform::parse_transform_list;
- use script::layout_dom::ServoLayoutElement;
-@@ -89,10 +91,35 @@ pub(crate) fn shape(
- }
- }
-
--/// The element's own `transform` attribute, or `None` if it is malformed —
--/// which per spec means the element is rendered untransformed rather than not
--/// at all, so callers substitute the identity.
--pub(crate) fn transform(element: ServoLayoutElement<'_>) -> Affine {
-+/// The element's own transform.
-+///
-+/// SVG 2 makes `transform` a presentation attribute for the CSS `transform`
-+/// property, so the property wins where both are present. Servo does not
-+/// synthesize that hint (see [`super::transform`]), so the two sources are
-+/// consulted in cascade order by hand: the computed property first, falling
-+/// back to parsing the attribute.
-+///
-+/// A malformed attribute yields the identity rather than dropping the element,
-+/// which is what the spec asks for.
-+pub(crate) fn transform(element: ServoLayoutElement<'_>, style: &ComputedValues) -> Affine {
-+ let computed = &style.get_box().transform;
-+ if !computed.0.is_empty() {
-+ // A transform needing a reference box (percentage translations) or a
-+ // third dimension has no meaning here, so those fall through to the
-+ // attribute rather than being applied wrongly.
-+ if let Ok((matrix, is_3d)) = computed.to_transform_3d_matrix(None) &&
-+ !is_3d
-+ {
-+ return Affine::new([
-+ matrix.m11 as f64,
-+ matrix.m12 as f64,
-+ matrix.m21 as f64,
-+ matrix.m22 as f64,
-+ matrix.m41 as f64,
-+ matrix.m42 as f64,
-+ ]);
-+ }
-+ }
- element
- .attribute_as_str(&ns!(), &local_name!("transform"))
- .and_then(parse_transform_list)
-@@ -332,3 +359,22 @@ fn path_segments(data: &style::values::specified::svg_path::SVGPathData) -> Vec<
- })
- .collect()
- }
-+
-+/// stylo's `pointer-events` to the local mirror in [`super::hittest`].
-+///
-+/// The SVG keywords only exist with `stylo-0002` applied; upstream stylo gates
-+/// all of them behind `#[cfg(feature = "gecko")]`.
-+pub(crate) fn pointer_events(style: &ComputedValues) -> PointerEvents {
-+ match style.get_inherited_ui().pointer_events {
-+ StyloPointerEvents::Auto => PointerEvents::Auto,
-+ StyloPointerEvents::None => PointerEvents::None,
-+ StyloPointerEvents::Visiblepainted => PointerEvents::VisiblePainted,
-+ StyloPointerEvents::Visiblefill => PointerEvents::VisibleFill,
-+ StyloPointerEvents::Visiblestroke => PointerEvents::VisibleStroke,
-+ StyloPointerEvents::Visible => PointerEvents::Visible,
-+ StyloPointerEvents::Painted => PointerEvents::Painted,
-+ StyloPointerEvents::Fill => PointerEvents::Fill,
-+ StyloPointerEvents::Stroke => PointerEvents::Stroke,
-+ StyloPointerEvents::All => PointerEvents::All,
-+ }
-+}
-diff --git a/components/layout/svg/tree.rs b/components/layout/svg/tree.rs
-index 13347360063bdb8122c7843cd9e68009a5408410..ba3ad6dba8d78b307131a7486923b370f33f0842 100644
---- a/components/layout/svg/tree.rs
-+++ b/components/layout/svg/tree.rs
-@@ -102,7 +102,7 @@ fn build_node(
- return None;
- }
-
-- let transform = resolve::transform(element);
-+ let transform = resolve::transform(element, style);
- let opacity = resolve::opacity(style);
- // A group with zero opacity, and everything under it, is invisible.
- if opacity == 0. {
diff --git a/servo-patches/0014-layout-honor-transform-origin-and-fix-viewBox-s-intr.patch b/servo-patches/0014-layout-honor-transform-origin-and-fix-viewBox-s-intr.patch
deleted file mode 100644
index fb85f55..0000000
--- a/servo-patches/0014-layout-honor-transform-origin-and-fix-viewBox-s-intr.patch
+++ /dev/null
@@ -1,796 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Sat, 22 Aug 2026 18:22:12 +0100
-Subject: [PATCH 14/24] layout: honor transform-origin, and fix viewBox's
- intrinsic ratio
-MIME-Version: 1.0
-Content-Type: text/plain; charset=UTF-8
-Content-Transfer-Encoding: 8bit
-
-Two fixes and a refactor, all found by running the thing rather than by
-reading it.
-
-transform-origin was ignored. The SVG transform *attribute* is defined
-about the origin, but the CSS transform *property* rotates and scales
-about transform-origin, whose initial value is 50% 50% and whose
-reference box for SVG is the nearest viewport (transform-box: view-box,
-already the initial value). Ignoring it is not a subtle error: a
-headed run of an animated spinner showed the arc missing entirely,
-because rotate() was swinging it around the viewport corner and off the
-canvas. Every headless test had passed, because none of them animated a
-rotation about a non-zero origin.
-
-Worth noting the rasterization path is *worse* here, not merely
-different: it drops transform-origin too (see patch 0006), so the same
-page renders completely blank under it. A two-rect probe with different
-origins renders correctly natively and renders nothing rasterized.
-
-SVGElementData::ratio_from_view_box parsed viewBox with parse_integer
-and parse_unsigned_integer, so viewBox="0 0 24.5 12.25" contributed no
-intrinsic ratio at all -- measured: a 200px-wide with that viewBox
-laid out 150px tall (the no-ratio default) instead of 100px. Commas
-failed for the same reason, so viewBox="0,0,24,24" was also broken. This
-is on the *rasterization* path, i.e. the default one, so it is worth
-submitting upstream on its own. The number-list parser moves from
-components/layout/svg/ up into layout_api so both paths share one
-implementation rather than growing a second.
-
-Also moves the layout-coupled parts of the SVG module (build_native,
-make_fragments, rendering_type_in_native_subtree) out of mod.rs into
-svg/integration.rs. They had accumulated there and broken the property
-the module is organised around: everything with real edge cases stays a
-pure function of numbers, and only the bridge touches stylo, the DOM and
-the fragment tree. The unit-test harness enforces this by construction —
-it simply cannot compile the bridge — which is how the regression was
-caught.
-
-svg/ WPT, same build, pref off vs on: +1 test, zero regressions across
-1261 tests and 3925 subtests. Which test improves varies between runs
-(the reftest suite is not stable run to run); the net and the absence of
-regressions are the reliable part.
----
- components/layout/layout_impl.rs | 2 +-
- components/layout/replaced.rs | 4 +-
- components/layout/svg/integration.rs | 151 +++++++++++++++++++++++++
- components/layout/svg/mod.rs | 138 +---------------------
- components/layout/svg/number.rs | 113 +-----------------
- components/layout/svg/resolve.rs | 24 +++-
- components/layout/svg/tree.rs | 2 +-
- components/shared/layout/lib.rs | 37 +++---
- components/shared/layout/svg_number.rs | 117 +++++++++++++++++++
- 9 files changed, 320 insertions(+), 268 deletions(-)
- create mode 100644 components/layout/svg/integration.rs
- create mode 100644 components/shared/layout/svg_number.rs
-
-diff --git a/components/layout/layout_impl.rs b/components/layout/layout_impl.rs
-index df60ec06d8c2117f12504353c698564b3f8a5a9f..45eb3b853f1bc85a4525a043ba077a0450d37dd6 100644
---- a/components/layout/layout_impl.rs
-+++ b/components/layout/layout_impl.rs
-@@ -352,7 +352,7 @@ impl Layout for LayoutThread {
- NodeRenderingType::NotRendered => {
- #[expect(unsafe_code)]
- unsafe {
-- crate::svg::rendering_type_in_native_subtree(node)
-+ crate::svg::integration::rendering_type_in_native_subtree(node)
- }
- },
- rendering_type => rendering_type,
-diff --git a/components/layout/replaced.rs b/components/layout/replaced.rs
-index 24e4b67d6b080510878e08d4d252e739cd3a7b3d..53298f785bcf62d17768c078edfbaa4d9e1b2671 100644
---- a/components/layout/replaced.rs
-+++ b/components/layout/replaced.rs
-@@ -349,7 +349,7 @@ impl ReplacedContents {
- _ => unreachable!("SVG element can't contain a raster image."),
- });
-
-- let native = svg::build_native(node, viewport, &natural_size);
-+ let native = svg::integration::build_native(node, viewport, &natural_size);
-
- (
- ReplacedContentKind::SVGElement {
-@@ -631,7 +631,7 @@ impl ReplacedContents {
- native,
- } => {
- if let Some(native) = native {
-- return svg::make_fragments(layout_context, native, base, clip);
-+ return svg::integration::make_fragments(layout_context, native, base, clip);
- }
-
- let Some(vector_image) = vector_image else {
-diff --git a/components/layout/svg/integration.rs b/components/layout/svg/integration.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..53f793ab3db771f757cf541706c3ccab69cd57b5
---- /dev/null
-+++ b/components/layout/svg/integration.rs
-@@ -0,0 +1,151 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! Where native SVG meets the rest of layout.
-+//!
-+//! Everything in this module touches `LayoutContext`, the fragment tree or the
-+//! DOM, which is why it is not in [`super`]: the rest of the SVG code is pure
-+//! and testable without building Servo, and keeping that true means the
-+//! coupling lives in one place. See `scripts/servo-svg-unit-tests.sh`.
-+
-+use std::sync::Arc;
-+
-+use app_units::Au;
-+use kurbo::Affine;
-+use script::layout_dom::ServoLayoutNode;
-+use webrender_api::units::DeviceIntSize;
-+
-+use super::{NativeSVG, SVGViewport, native_svg_enabled, tree};
-+use crate::context::LayoutContext;
-+use crate::fragment_tree::{BaseFragment, Fragment, ImageFragment};
-+use crate::geom::PhysicalRect;
-+use crate::replaced::NaturalSizes;
-+
-+/// Builds the native scene for an ``, or `None` when the pref is off.
-+///
-+/// Inside a `viewBox`, percentages resolve against the viewBox rather than
-+/// against the CSS box — that is what establishing a viewport means — so the
-+/// scene can be built here, before the used size is known. Without one, user
-+/// space is the CSS box and the natural size is the best basis available at
-+/// box construction.
-+pub(crate) fn build_native(
-+ node: ServoLayoutNode<'_>,
-+ viewport: SVGViewport,
-+ natural_size: &NaturalSizes,
-+) -> Option> {
-+ native_svg_enabled().then(|| {
-+ let user_space = match viewport.view_box {
-+ Some(view_box) => (view_box.width as f64, view_box.height as f64),
-+ None => (
-+ natural_size.width.map_or(0., |width| width.to_f32_px() as f64),
-+ natural_size.height.map_or(0., |height| height.to_f32_px() as f64),
-+ ),
-+ };
-+ Arc::new(NativeSVG {
-+ tree: tree::build(node, user_space),
-+ viewport,
-+ })
-+ })
-+}
-+
-+/// Paints `native` at the element's used size and returns the fragment that
-+/// references the result.
-+///
-+/// Returns no fragments when the scene is empty, when the box has collapsed to
-+/// nothing, or when no image key could be obtained — in each case painting
-+/// nothing is right, and better than showing a stale frame at the wrong size.
-+pub(crate) fn make_fragments(
-+ layout_context: &LayoutContext,
-+ native: &NativeSVG,
-+ base: BaseFragment,
-+ clip: PhysicalRect,
-+) -> Vec {
-+ // The tag is the identity the image key is cached against; a fragment
-+ // without one is not a DOM node and cannot own a key.
-+ let Some(node) = base.tag.map(|tag| tag.node) else {
-+ return vec![];
-+ };
-+ let rect = base.rect();
-+ if native.tree.is_empty() {
-+ return vec![];
-+ }
-+
-+ let device_pixel_ratio = layout_context.style_context.device_pixel_ratio().0;
-+ let device_size = DeviceIntSize::new(
-+ rect.size.width.scale_by(device_pixel_ratio).to_px(),
-+ rect.size.height.scale_by(device_pixel_ratio).to_px(),
-+ );
-+
-+ // User space -> content box -> device pixels. The viewport transform is
-+ // applied here rather than baked into the tree because it depends on the
-+ // used size, which is only known now.
-+ let viewport_transform = native.viewport.transform(euclid::default::Size2D::new(
-+ rect.size.width.to_f32_px(),
-+ rect.size.height.to_f32_px(),
-+ ));
-+ let base_transform = Affine::scale(device_pixel_ratio as f64) *
-+ Affine::new([
-+ viewport_transform.m11 as f64,
-+ viewport_transform.m12 as f64,
-+ viewport_transform.m21 as f64,
-+ viewport_transform.m22 as f64,
-+ viewport_transform.m31 as f64,
-+ viewport_transform.m32 as f64,
-+ ]);
-+
-+ let resolver = &layout_context.image_resolver;
-+ let Some(image_key) = resolver.native_svg_images.image_key_for(
-+ node,
-+ &*resolver.image_cache,
-+ &resolver.pending_svg_paints,
-+ &native.tree,
-+ device_size,
-+ base_transform,
-+ ) else {
-+ return vec![];
-+ };
-+
-+ vec![Fragment::Image(Arc::new(ImageFragment {
-+ base,
-+ clip,
-+ image_key: Some(image_key),
-+ showing_broken_image_icon: false,
-+ url: None,
-+ }))]
-+}
-+
-+/// Whether `node` is painted by an enclosing native SVG viewport.
-+///
-+/// Boxless nodes are normally "not being rendered", and `Animations`
-+/// (`do_post_reflow_update`) cancels every animation on a node that reports
-+/// that — which is why a CSS animation on an SVG descendant registers for a
-+/// single tick and is then dropped. Inside a natively painted `` the
-+/// descendant genuinely is rendered, it just has no CSS box of its own, so it
-+/// has to say so or it can never animate.
-+///
-+/// # Safety
-+///
-+/// Walks DOM parents, so it may only be called from the script thread with no
-+/// layout workers running. The one caller, `Layout::node_rendering_type`, is a
-+/// synchronous script query and satisfies that.
-+#[expect(unsafe_code)]
-+pub(crate) unsafe fn rendering_type_in_native_subtree(
-+ node: ServoLayoutNode<'_>,
-+) -> layout_api::NodeRenderingType {
-+ use layout_api::{LayoutElementType, LayoutNode as _, LayoutNodeType, NodeRenderingType};
-+
-+ if !native_svg_enabled() {
-+ return NodeRenderingType::NotRendered;
-+ }
-+
-+ // The walk is short in practice and only runs for nodes with no box.
-+ let mut ancestor = unsafe { node.dangerous_dom_parent() };
-+ while let Some(current) = ancestor {
-+ if current.type_id() == Some(LayoutNodeType::Element(LayoutElementType::SVGSVGElement)) {
-+ return NodeRenderingType::Rendered;
-+ }
-+ ancestor = unsafe { current.dangerous_dom_parent() };
-+ }
-+ NodeRenderingType::NotRendered
-+}
-diff --git a/components/layout/svg/mod.rs b/components/layout/svg/mod.rs
-index 34a7cbe71558cf5c58e07fc3f726083288652b2f..e1e57090aeab0e1c2b4a4425b8663f837e23142b 100644
---- a/components/layout/svg/mod.rs
-+++ b/components/layout/svg/mod.rs
-@@ -22,20 +22,12 @@
- // caller yet. Remove this once that lands.
- #![expect(dead_code)]
-
--use std::sync::Arc;
--
--use app_units::Au;
- use euclid::default::{Size2D, Transform2D};
--use kurbo::Affine;
- use servo_config::pref;
--use webrender_api::units::DeviceIntSize;
--
--use crate::context::LayoutContext;
--use crate::fragment_tree::{BaseFragment, Fragment, ImageFragment};
--use crate::geom::PhysicalRect;
-
- pub(crate) mod geometry;
- pub(crate) mod hittest;
-+pub(crate) mod integration;
- pub(crate) mod image;
- pub(crate) mod number;
- pub(crate) mod paint;
-@@ -194,131 +186,3 @@ pub(crate) struct NativeSVG {
- pub tree: scene::SVGTree,
- pub viewport: SVGViewport,
- }
--
--/// Builds the native scene for an ``, or `None` when the pref is off.
--///
--/// Inside a `viewBox`, percentages resolve against the viewBox rather than
--/// against the CSS box — that is what establishing a viewport means — so the
--/// scene can be built here, before the used size is known. Without one, user
--/// space is the CSS box and the natural size is the best basis available at
--/// box construction.
--pub(crate) fn build_native(
-- node: script::layout_dom::ServoLayoutNode<'_>,
-- viewport: SVGViewport,
-- natural_size: &crate::replaced::NaturalSizes,
--) -> Option> {
-- native_svg_enabled().then(|| {
-- let user_space = match viewport.view_box {
-- Some(view_box) => (view_box.width as f64, view_box.height as f64),
-- None => (
-- natural_size.width.map_or(0., |width| width.to_f32_px() as f64),
-- natural_size.height.map_or(0., |height| height.to_f32_px() as f64),
-- ),
-- };
-- Arc::new(NativeSVG {
-- tree: tree::build(node, user_space),
-- viewport,
-- })
-- })
--}
--
--/// Paints `native` at the element's used size and returns the fragment that
--/// references the result.
--///
--/// Returns no fragments when the scene is empty, when the box has collapsed to
--/// nothing, or when no image key could be obtained — in each case painting
--/// nothing is right, and better than showing a stale frame at the wrong size.
--pub(crate) fn make_fragments(
-- layout_context: &LayoutContext,
-- native: &NativeSVG,
-- base: BaseFragment,
-- clip: PhysicalRect,
--) -> Vec {
-- // The tag is the identity the image key is cached against; a fragment
-- // without one is not a DOM node and cannot own a key.
-- let Some(node) = base.tag.map(|tag| tag.node) else {
-- return vec![];
-- };
-- let rect = base.rect();
-- if native.tree.is_empty() {
-- return vec![];
-- }
--
-- let device_pixel_ratio = layout_context.style_context.device_pixel_ratio().0;
-- let device_size = DeviceIntSize::new(
-- rect.size.width.scale_by(device_pixel_ratio).to_px(),
-- rect.size.height.scale_by(device_pixel_ratio).to_px(),
-- );
--
-- // User space -> content box -> device pixels. The viewport transform is
-- // applied here rather than baked into the tree because it depends on the
-- // used size, which is only known now.
-- let viewport_transform = native.viewport.transform(euclid::default::Size2D::new(
-- rect.size.width.to_f32_px(),
-- rect.size.height.to_f32_px(),
-- ));
-- let base_transform = Affine::scale(device_pixel_ratio as f64) *
-- Affine::new([
-- viewport_transform.m11 as f64,
-- viewport_transform.m12 as f64,
-- viewport_transform.m21 as f64,
-- viewport_transform.m22 as f64,
-- viewport_transform.m31 as f64,
-- viewport_transform.m32 as f64,
-- ]);
--
-- let resolver = &layout_context.image_resolver;
-- let Some(image_key) = resolver.native_svg_images.image_key_for(
-- node,
-- &*resolver.image_cache,
-- &resolver.pending_svg_paints,
-- &native.tree,
-- device_size,
-- base_transform,
-- ) else {
-- return vec![];
-- };
--
-- vec![Fragment::Image(Arc::new(ImageFragment {
-- base,
-- clip,
-- image_key: Some(image_key),
-- showing_broken_image_icon: false,
-- url: None,
-- }))]
--}
--
--/// Whether `node` is painted by an enclosing native SVG viewport.
--///
--/// Boxless nodes are normally "not being rendered", and `Animations`
--/// (`do_post_reflow_update`) cancels every animation on a node that reports
--/// that — which is why a CSS animation on an SVG descendant registers for a
--/// single tick and is then dropped. Inside a natively painted `` the
--/// descendant genuinely is rendered, it just has no CSS box of its own, so it
--/// has to say so or it can never animate.
--///
--/// # Safety
--///
--/// Walks DOM parents, so it may only be called from the script thread with no
--/// layout workers running. The one caller, `Layout::node_rendering_type`, is a
--/// synchronous script query and satisfies that.
--#[expect(unsafe_code)]
--pub(crate) unsafe fn rendering_type_in_native_subtree(
-- node: script::layout_dom::ServoLayoutNode<'_>,
--) -> layout_api::NodeRenderingType {
-- use layout_api::{LayoutElementType, LayoutNode as _, LayoutNodeType, NodeRenderingType};
--
-- if !native_svg_enabled() {
-- return NodeRenderingType::NotRendered;
-- }
--
-- // The walk is short in practice and only runs for nodes with no box.
-- let mut ancestor = unsafe { node.dangerous_dom_parent() };
-- while let Some(current) = ancestor {
-- if current.type_id() == Some(LayoutNodeType::Element(LayoutElementType::SVGSVGElement)) {
-- return NodeRenderingType::Rendered;
-- }
-- ancestor = unsafe { current.dangerous_dom_parent() };
-- }
-- NodeRenderingType::NotRendered
--}
-diff --git a/components/layout/svg/number.rs b/components/layout/svg/number.rs
-index c6c0170c9992a2e1df32be1e0bb146fd66ea4532..055a40d9dc883cfca0aa18668c439aea30dcc439 100644
---- a/components/layout/svg/number.rs
-+++ b/components/layout/svg/number.rs
-@@ -2,113 +2,10 @@
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-
--//! SVG's `` list grammar, used by `viewBox` and by the `points`
--//! attribute of `` and ``.
-+//! SVG's `` list grammar.
- //!
--//! This is not CSS's number grammar, so it cannot go through stylo: it accepts
--//! a leading `+`, a bare `.5` and an exponent, but no units, and separates
--//! items with "comma-wsp" — any amount of whitespace around at most one comma.
--//! `str::parse::` is not a substitute either; it accepts `inf`, `nan` and
--//! hex floats, so each token is scanned by hand before being handed to it.
-+//! The parser lives in `layout_api` because `SVGElementData::ratio_from_view_box`
-+//! needs it too; this is only a re-export so the SVG modules can keep saying
-+//! `super::number::NumberListParser`.
-
--/// A comma-wsp separated list of SVG ``s.
--pub(crate) struct NumberListParser<'a> {
-- input: &'a str,
-- position: usize,
-- /// Set once a number has been read, after which a separator is required
-- /// before the next one. Without this, `24 24px` and `1 2e` would parse as
-- /// two numbers with trailing garbage silently dropped.
-- expect_separator: bool,
--}
--
--impl<'a> NumberListParser<'a> {
-- pub(crate) fn new(input: &'a str) -> Self {
-- Self {
-- input,
-- position: 0,
-- expect_separator: false,
-- }
-- }
--
-- fn rest(&self) -> &'a str {
-- &self.input[self.position..]
-- }
--
-- /// comma-wsp: at most one comma, surrounded by any amount of whitespace.
-- fn skip_comma_wsp(&mut self) -> bool {
-- let mut saw_separator = false;
-- let mut seen_comma = false;
-- for character in self.rest().chars() {
-- match character {
-- ' ' | '\t' | '\r' | '\n' => {},
-- ',' if !seen_comma => seen_comma = true,
-- _ => break,
-- }
-- saw_separator = true;
-- self.position += character.len_utf8();
-- }
-- saw_separator
-- }
--
-- pub(crate) fn at_end(&mut self) -> bool {
-- self.skip_comma_wsp();
-- self.rest().is_empty()
-- }
--
-- pub(crate) fn next_number(&mut self) -> Option {
-- let had_separator = self.skip_comma_wsp();
-- if self.expect_separator && !had_separator {
-- return None;
-- }
--
-- let rest = self.rest();
-- let bytes = rest.as_bytes();
-- let mut end = 0;
--
-- if matches!(bytes.first(), Some(b'+' | b'-')) {
-- end += 1;
-- }
-- let integer_digits = bytes[end..]
-- .iter()
-- .take_while(|byte| byte.is_ascii_digit())
-- .count();
-- end += integer_digits;
--
-- let mut fraction_digits = 0;
-- if bytes.get(end) == Some(&b'.') {
-- end += 1;
-- fraction_digits = bytes[end..]
-- .iter()
-- .take_while(|byte| byte.is_ascii_digit())
-- .count();
-- end += fraction_digits;
-- }
-- if integer_digits == 0 && fraction_digits == 0 {
-- return None;
-- }
--
-- if matches!(bytes.get(end), Some(b'e' | b'E')) {
-- let mut exponent_end = end + 1;
-- if matches!(bytes.get(exponent_end), Some(b'+' | b'-')) {
-- exponent_end += 1;
-- }
-- let exponent_digits = bytes[exponent_end..]
-- .iter()
-- .take_while(|byte| byte.is_ascii_digit())
-- .count();
-- // A trailing `e` with no digits is not part of the number; leave it
-- // for the caller to reject as trailing garbage.
-- if exponent_digits > 0 {
-- end = exponent_end + exponent_digits;
-- }
-- }
--
-- let number: f32 = rest[..end].parse().ok()?;
-- if !number.is_finite() {
-- return None;
-- }
-- self.position += end;
-- self.expect_separator = true;
-- Some(number)
-- }
--}
-+pub(crate) use layout_api::svg_number::NumberListParser;
-diff --git a/components/layout/svg/resolve.rs b/components/layout/svg/resolve.rs
-index 30d11b97af94bdf89c137b0b609a8b9f3fa7050a..97620009598b6a44fc6c8b24359ed6633ed1fc1c 100644
---- a/components/layout/svg/resolve.rs
-+++ b/components/layout/svg/resolve.rs
-@@ -101,8 +101,13 @@ pub(crate) fn shape(
- ///
- /// A malformed attribute yields the identity rather than dropping the element,
- /// which is what the spec asks for.
--pub(crate) fn transform(element: ServoLayoutElement<'_>, style: &ComputedValues) -> Affine {
-- let computed = &style.get_box().transform;
-+pub(crate) fn transform(
-+ element: ServoLayoutElement<'_>,
-+ style: &ComputedValues,
-+ basis: PercentageBasis,
-+) -> Affine {
-+ let box_style = style.get_box();
-+ let computed = &box_style.transform;
- if !computed.0.is_empty() {
- // A transform needing a reference box (percentage translations) or a
- // third dimension has no meaning here, so those fall through to the
-@@ -110,7 +115,7 @@ pub(crate) fn transform(element: ServoLayoutElement<'_>, style: &ComputedValues)
- if let Ok((matrix, is_3d)) = computed.to_transform_3d_matrix(None) &&
- !is_3d
- {
-- return Affine::new([
-+ let matrix = Affine::new([
- matrix.m11 as f64,
- matrix.m12 as f64,
- matrix.m21 as f64,
-@@ -118,8 +123,21 @@ pub(crate) fn transform(element: ServoLayoutElement<'_>, style: &ComputedValues)
- matrix.m41 as f64,
- matrix.m42 as f64,
- ]);
-+ // Unlike the attribute, the CSS property rotates and scales about
-+ // `transform-origin`. Its initial value is 50% 50%, and SVG's
-+ // reference box is the nearest viewport (`transform-box:
-+ // view-box`, which is already the initial value), so percentages
-+ // resolve against the viewBox. Ignoring this is not a subtle
-+ // error: an animated `rotate()` swings the element around the
-+ // viewport corner and straight off the canvas.
-+ let origin = &box_style.transform_origin;
-+ let x = resolve(&origin.horizontal, basis.width);
-+ let y = resolve(&origin.vertical, basis.height);
-+ return Affine::translate((x, y)) * matrix * Affine::translate((-x, -y));
- }
- }
-+ // The `transform` attribute is defined about the origin, whatever
-+ // `transform-origin` says.
- element
- .attribute_as_str(&ns!(), &local_name!("transform"))
- .and_then(parse_transform_list)
-diff --git a/components/layout/svg/tree.rs b/components/layout/svg/tree.rs
-index ba3ad6dba8d78b307131a7486923b370f33f0842..98e3be730d0c419e784e742d05312c3d3b928939 100644
---- a/components/layout/svg/tree.rs
-+++ b/components/layout/svg/tree.rs
-@@ -102,7 +102,7 @@ fn build_node(
- return None;
- }
-
-- let transform = resolve::transform(element, style);
-+ let transform = resolve::transform(element, style, basis);
- let opacity = resolve::opacity(style);
- // A group with zero opacity, and everything under it, is invisible.
- if opacity == 0. {
-diff --git a/components/shared/layout/lib.rs b/components/shared/layout/lib.rs
-index d4960578b612e52c2f07426bd5d2ff4935c5f214..5cfe3303ec7a63693d0711dd7df328644bb8a6b8 100644
---- a/components/shared/layout/lib.rs
-+++ b/components/shared/layout/lib.rs
-@@ -13,6 +13,9 @@ mod layout_dom;
- mod layout_element;
- mod layout_node;
- mod pseudo_element_chain;
-+pub mod svg_number;
-+
-+use crate::svg_number::NumberListParser;
-
- use std::any::Any;
- use std::ops::Range;
-@@ -58,7 +61,7 @@ use servo_base::id::{BrowsingContextId, PipelineId, WebViewId};
- use servo_url::{ImmutableOrigin, ServoUrl};
- use style::Atom;
- use style::animation::DocumentAnimationSet;
--use style::attr::{AttrValue, parse_integer, parse_unsigned_integer};
-+use style::attr::AttrValue;
- use style::context::QuirksMode;
- use style::data::ElementDataWrapper;
- use style::device::Device;
-@@ -67,7 +70,6 @@ use style::invalidation::element::restyle_hints::RestyleHint;
- use style::properties::style_structs::Font;
- use style::properties::{ComputedValues, PropertyId};
- use style::selector_parser::{PseudoElement, RestyleDamage, Snapshot};
--use style::str::char_is_whitespace;
- use style::stylesheets::{DocumentStyleSheet, Stylesheet};
- use style::stylist::Stylist;
- #[cfg(debug_assertions)]
-@@ -170,23 +172,26 @@ pub struct SVGElementData<'dom> {
- }
-
- impl SVGElementData<'_> {
-+ /// The intrinsic aspect ratio contributed by `viewBox`.
-+ ///
-+ ///
- pub fn ratio_from_view_box(&self) -> Option {
-- let mut iter = self.view_box?.chars();
-- let _min_x = parse_integer(&mut iter).ok()?;
-- let _min_y = parse_integer(&mut iter).ok()?;
--
-- let width = parse_unsigned_integer(&mut iter).ok()?;
-- if width == 0 {
-+ // `viewBox` is a list of four SVG ``s, not integers: this used
-+ // to parse with `parse_integer`/`parse_unsigned_integer`, so
-+ // `viewBox="0 0 24.5 12.25"` contributed no ratio at all and the
-+ // element sized as if it had no `viewBox`. The separators are
-+ // comma-wsp too, so `viewBox="0,0,24,24"` failed for the same reason.
-+ let mut parser = NumberListParser::new(self.view_box?);
-+ let _min_x = parser.next_number()?;
-+ let _min_y = parser.next_number()?;
-+ let width = parser.next_number()?;
-+ let height = parser.next_number()?;
-+ if !parser.at_end() {
- return None;
- }
--
-- let height = parse_unsigned_integer(&mut iter).ok()?;
-- if height == 0 {
-- return None;
-- }
--
-- let mut iter = iter.skip_while(|c| char_is_whitespace(*c));
-- iter.next().is_none().then(|| width as f32 / height as f32)
-+ // Negative is an error and zero disables rendering; neither yields a
-+ // usable ratio.
-+ (width > 0. && height > 0.).then(|| width / height)
- }
- }
-
-diff --git a/components/shared/layout/svg_number.rs b/components/shared/layout/svg_number.rs
-new file mode 100644
-index 0000000000000000000000000000000000000000..9f6595ea87b310642731e43b0705ff93de48d77b
---- /dev/null
-+++ b/components/shared/layout/svg_number.rs
-@@ -0,0 +1,117 @@
-+/* This Source Code Form is subject to the terms of the Mozilla Public
-+ * License, v. 2.0. If a copy of the MPL was not distributed with this
-+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-+
-+//! SVG's `` list grammar, used by `viewBox` and by the `points`
-+//! attribute of `` and ``.
-+//!
-+//! Lives in `layout_api` rather than in layout because both the native SVG
-+//! code and `SVGElementData::ratio_from_view_box` need it.
-+//!
-+//! This is not CSS's number grammar, so it cannot go through stylo: it accepts
-+//! a leading `+`, a bare `.5` and an exponent, but no units, and separates
-+//! items with "comma-wsp" — any amount of whitespace around at most one comma.
-+//! `str::parse::` is not a substitute either; it accepts `inf`, `nan` and
-+//! hex floats, so each token is scanned by hand before being handed to it.
-+
-+/// A comma-wsp separated list of SVG ``s.
-+pub struct NumberListParser<'a> {
-+ input: &'a str,
-+ position: usize,
-+ /// Set once a number has been read, after which a separator is required
-+ /// before the next one. Without this, `24 24px` and `1 2e` would parse as
-+ /// two numbers with trailing garbage silently dropped.
-+ expect_separator: bool,
-+}
-+
-+impl<'a> NumberListParser<'a> {
-+ pub fn new(input: &'a str) -> Self {
-+ Self {
-+ input,
-+ position: 0,
-+ expect_separator: false,
-+ }
-+ }
-+
-+ fn rest(&self) -> &'a str {
-+ &self.input[self.position..]
-+ }
-+
-+ /// comma-wsp: at most one comma, surrounded by any amount of whitespace.
-+ fn skip_comma_wsp(&mut self) -> bool {
-+ let mut saw_separator = false;
-+ let mut seen_comma = false;
-+ for character in self.rest().chars() {
-+ match character {
-+ ' ' | '\t' | '\r' | '\n' => {},
-+ ',' if !seen_comma => seen_comma = true,
-+ _ => break,
-+ }
-+ saw_separator = true;
-+ self.position += character.len_utf8();
-+ }
-+ saw_separator
-+ }
-+
-+ pub fn at_end(&mut self) -> bool {
-+ self.skip_comma_wsp();
-+ self.rest().is_empty()
-+ }
-+
-+ pub fn next_number(&mut self) -> Option {
-+ let had_separator = self.skip_comma_wsp();
-+ if self.expect_separator && !had_separator {
-+ return None;
-+ }
-+
-+ let rest = self.rest();
-+ let bytes = rest.as_bytes();
-+ let mut end = 0;
-+
-+ if matches!(bytes.first(), Some(b'+' | b'-')) {
-+ end += 1;
-+ }
-+ let integer_digits = bytes[end..]
-+ .iter()
-+ .take_while(|byte| byte.is_ascii_digit())
-+ .count();
-+ end += integer_digits;
-+
-+ let mut fraction_digits = 0;
-+ if bytes.get(end) == Some(&b'.') {
-+ end += 1;
-+ fraction_digits = bytes[end..]
-+ .iter()
-+ .take_while(|byte| byte.is_ascii_digit())
-+ .count();
-+ end += fraction_digits;
-+ }
-+ if integer_digits == 0 && fraction_digits == 0 {
-+ return None;
-+ }
-+
-+ if matches!(bytes.get(end), Some(b'e' | b'E')) {
-+ let mut exponent_end = end + 1;
-+ if matches!(bytes.get(exponent_end), Some(b'+' | b'-')) {
-+ exponent_end += 1;
-+ }
-+ let exponent_digits = bytes[exponent_end..]
-+ .iter()
-+ .take_while(|byte| byte.is_ascii_digit())
-+ .count();
-+ // A trailing `e` with no digits is not part of the number; leave it
-+ // for the caller to reject as trailing garbage.
-+ if exponent_digits > 0 {
-+ end = exponent_end + exponent_digits;
-+ }
-+ }
-+
-+ let number: f32 = rest[..end].parse().ok()?;
-+ if !number.is_finite() {
-+ return None;
-+ }
-+ self.position += end;
-+ self.expect_separator = true;
-+ Some(number)
-+ }
-+}
diff --git a/servo-patches/0015-layout-honor-pathLength-when-scaling-dash-patterns.patch b/servo-patches/0015-layout-honor-pathLength-when-scaling-dash-patterns.patch
deleted file mode 100644
index fb0020a..0000000
--- a/servo-patches/0015-layout-honor-pathLength-when-scaling-dash-patterns.patch
+++ /dev/null
@@ -1,175 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Claude
-Date: Sat, 22 Aug 2026 19:16:02 +0100
-Subject: [PATCH 15/24] layout: honor pathLength when scaling dash patterns
-
-pathLength renormalizes every distance-along-path computation so the
-path is treated as being that long. It is what makes the standard "draw
-a line on" trick work: pathLength="1" with stroke-dasharray: 1 1 is one
-dash covering the whole path and one gap of the same size, whatever the
-real length, so animating stroke-dashoffset from 1 to -1 sweeps it.
-
-Servo has never had it -- it is a commented-out line in
-SVGGeometryElement.webidl -- and ignoring it does not merely misplace
-the dashes, it changes their count. Measured on a 100-unit line with
-pathLength="1" and stroke-dasharray: 1 1: fifty one-unit dashes instead
-of one, rendering as a fine comb. On a long path those dashes go
-sub-pixel and read as a solid, slightly translucent stroke that never
-appears to move, which is how this presents in practice: an indicator
-that looks fully drawn and frozen while its computed stroke-dashoffset
-is advancing correctly. Style was never the problem; the geometry it
-fed was measured against the wrong length.
-
-The scale factor is pure and tested; the perimeter is only measured when
-the attribute is present and there is a dash pattern to rescale, since
-it walks every segment.
-
-After: the same test path renders one dash that grows from 5px to 80px
-and then begins erasing -- the sweep -- instead of a static comb.
-
-Credit for the diagnosis to the prototype review in
-docs/plans/servo-svg-layout.md section 5.
----
- components/layout/svg/paint.rs | 41 ++++++++++++++++++++++++++++++++
- components/layout/svg/resolve.rs | 14 +++++++++++
- components/layout/svg/tree.rs | 26 +++++++++++++++++---
- 3 files changed, 78 insertions(+), 3 deletions(-)
-
-diff --git a/components/layout/svg/paint.rs b/components/layout/svg/paint.rs
-index d6a481a151e4499739c9f9443291cb93d05211b9..27b55ed5dcd26b0d79b3e4d289aa29c63b16c2c7 100644
---- a/components/layout/svg/paint.rs
-+++ b/components/layout/svg/paint.rs
-@@ -135,6 +135,30 @@ pub(crate) fn normalize_dash_array(values: &[f64]) -> Vec {
- values.to_vec()
- }
-
-+/// The factor converting distances expressed in `pathLength` units into user
-+/// units, per .
-+///
-+/// `pathLength` renormalizes every distance-along-path computation so the path
-+/// is treated as being that long. It is what makes the standard "draw a line
-+/// on" trick work: `pathLength="1"` with `stroke-dasharray: 1 1` is one dash
-+/// covering the whole path and one gap of the same size, whatever the path's
-+/// real length, so animating `stroke-dashoffset` from 1 to -1 sweeps it.
-+///
-+/// Ignoring it does not merely misplace the dashes, it changes their count:
-+/// against a 100-unit path, `1 1` becomes fifty one-unit dashes rather than
-+/// one. On a long path they go sub-pixel and read as a solid, slightly
-+/// translucent stroke that never appears to move.
-+///
-+/// A zero or negative `pathLength` is an error and is ignored.
-+pub(crate) fn path_length_scale(actual_length: f64, declared: Option) -> f64 {
-+ match declared {
-+ Some(declared) if declared > 0. && actual_length > 0. && actual_length.is_finite() => {
-+ actual_length / declared
-+ },
-+ _ => 1.,
-+ }
-+}
-+
- /// The three bases SVG resolves percentage lengths against, per
- /// .
- #[derive(Clone, Copy, Debug, PartialEq)]
-@@ -217,6 +241,23 @@ mod test {
- assert_eq!(normalize_dash_array(&[f64::NAN, 1.]), Vec::::new());
- }
-
-+ #[test]
-+ fn path_length_rescales_dash_distances() {
-+ use super::path_length_scale;
-+
-+ // A 100-unit path declared as 1 unit long: every dash distance is
-+ // multiplied by 100, so `stroke-dasharray: 1 1` covers it exactly.
-+ assert_eq!(path_length_scale(100., Some(1.)), 100.);
-+ assert_eq!(path_length_scale(250., Some(100.)), 2.5);
-+ // Absent, zero, negative or nonsensical values leave distances alone.
-+ assert_eq!(path_length_scale(100., None), 1.);
-+ assert_eq!(path_length_scale(100., Some(0.)), 1.);
-+ assert_eq!(path_length_scale(100., Some(-5.)), 1.);
-+ // A degenerate path cannot be renormalized either.
-+ assert_eq!(path_length_scale(0., Some(1.)), 1.);
-+ assert_eq!(path_length_scale(f64::INFINITY, Some(1.)), 1.);
-+ }
-+
- #[test]
- fn percentage_bases_follow_the_viewport() {
- let basis = PercentageBasis::for_viewport(300., 400.);
-diff --git a/components/layout/svg/resolve.rs b/components/layout/svg/resolve.rs
-index 97620009598b6a44fc6c8b24359ed6633ed1fc1c..706051f1a8d1987c808ff1110a371c7ca53183df 100644
---- a/components/layout/svg/resolve.rs
-+++ b/components/layout/svg/resolve.rs
-@@ -298,6 +298,20 @@ impl AsLengthPercentage for NonNegative {
- }
- }
-
-+/// The `pathLength` attribute, if it is a valid number.
-+///
-+/// Not a CSS property in SVG 2, so it comes off the element rather than the
-+/// cascade.
-+pub(crate) fn path_length(element: ServoLayoutElement<'_>) -> Option {
-+ element
-+ .attribute_as_str(&ns!(), &local_name!("pathLength"))
-+ .and_then(|value| {
-+ let mut parser = super::number::NumberListParser::new(value);
-+ let number = parser.next_number()?;
-+ parser.at_end().then_some(number as f64)
-+ })
-+}
-+
- /// A bare number attribute, defaulting to 0 as SVG's geometry attributes do.
- fn attribute_number(element: ServoLayoutElement<'_>, name: &LocalName) -> f64 {
- element
-diff --git a/components/layout/svg/tree.rs b/components/layout/svg/tree.rs
-index 98e3be730d0c419e784e742d05312c3d3b928939..3b5f0b147a47fae6eb43fcb8542f1e161f340a73 100644
---- a/components/layout/svg/tree.rs
-+++ b/components/layout/svg/tree.rs
-@@ -15,14 +15,14 @@
- //! composites the group as a unit: flattening it into each child would let
- //! overlapping siblings show through one another.
-
--use kurbo::Affine;
-+use kurbo::{Affine, Shape as _};
- use layout_api::LayoutElement as _;
- use layout_api::LayoutNode as _;
- use script::layout_dom::{ServoLayoutElement, ServoLayoutNode};
- use style::properties::ComputedValues;
- use web_atoms::local_name;
-
--use super::paint::PercentageBasis;
-+use super::paint::{self, PercentageBasis};
- use super::scene::{SVGGroup, SVGNode, SVGShape, SVGTree};
- use super::resolve;
-
-@@ -132,7 +132,23 @@ fn build_node(
- let path = resolve::shape(element, style, basis)?;
- let current_color = style.get_inherited_text().clone_color();
- let (fill_paint, fill_rule) = resolve::fill(style, ¤t_color);
-- let stroke = resolve::stroke(style, ¤t_color, basis);
-+ let mut stroke = resolve::stroke(style, ¤t_color, basis);
-+
-+ // `pathLength` renormalizes distances along the path, which for painting
-+ // means the dash pattern. Only measure the path when the attribute is
-+ // actually present and there is a dash pattern to rescale — `perimeter`
-+ // walks every segment.
-+ if !stroke.dash_array.is_empty() &&
-+ let Some(declared) = resolve::path_length(element)
-+ {
-+ let scale = paint::path_length_scale(path.perimeter(PERIMETER_ACCURACY), Some(declared));
-+ if scale != 1. {
-+ for dash in &mut stroke.dash_array {
-+ *dash *= scale;
-+ }
-+ stroke.dash_offset *= scale;
-+ }
-+ }
-
- // A `` has no interior, so it is never filled however `fill`
- // computes. A `` is filled, as if its last point joined its
-@@ -159,3 +175,7 @@ fn primary_style(element: ServoLayoutElement<'_>) -> Option
-Date: Sat, 22 Aug 2026 21:03:44 +0100
-Subject: [PATCH 16/24] script: implement getBBox and getTotalLength
-MIME-Version: 1.0
-Content-Type: text/plain; charset=UTF-8
-Content-Transfer-Encoding: 8bit
-
-Both were commented out in the WebIDL, so calling either threw
-TypeError. They are cheap now: layout already resolves SVG geometry into
-kurbo paths for painting, so a bounding box and a perimeter are one call
-each. The geometry is resolved on demand from computed style rather than
-read off the painted tree, which does not record which DOM node each
-shape came from.
-
-getBBox on a container is the union of its children's boxes, each mapped
-through that child's own transform, and the element is its own
-viewport for this purpose. Returning an empty rect for containers would
-have been silently wrong for the commonest shape in real content, a
-shape wrapped in a .
-
-getTotalLength reports the length already renormalized by pathLength,
-since reporting the raw length would contradict what the dash pattern
-does with the same attribute.
-
-Two things this does not do. getBBox's options argument (stroke,
-markers, clipped) is parsed and ignored — only the default fill box is
-computed, so a non-default request returns the same box rather than a
-wrong one. And with layout.svg.native.enabled off, only direct children
-of the resolve: svg > * { display: none } gives them computed
-styles but prunes the traversal below, so anything inside a reports
-an empty box.
-
-Fixes a latent crash while here. Both this query and the Phase 4
-node_rendering_type check walk DOM parents, and every walk that does not
-find an reaches the Document, where type_id panics with "Layout
-should not traverse nodes of type Document". The animation walk has had
-this bug since it landed; it went unnoticed because it only runs for
-boxless nodes, and the tests that exercised it all had an
-ancestor. Both now stop at the first non-element.
-
-svg/ WPT, same build, pref off vs on: +188 subtests, zero regressions
-(was +1). Almost all of it is idlharness finally seeing the two
-operations, plus getBBox-04 — containers with children added and removed
-— which now passes outright.
----
- components/layout/layout_impl.rs | 15 ++
- components/layout/svg/integration.rs | 143 ++++++++++++++++++
- components/layout/svg/tree.rs | 2 +-
- .../script/dom/svg/svggeometryelement.rs | 23 +++
- .../script/dom/svg/svggraphicselement.rs | 40 +++++
- components/script/dom/window/window.rs | 9 ++
- .../script_bindings/codegen/Bindings.conf | 4 +
- .../webidls/SVGGeometryElement.webidl | 2 +-
- .../webidls/SVGGraphicsElement.webidl | 15 +-
- components/shared/layout/lib.rs | 20 +++
- 10 files changed, 264 insertions(+), 9 deletions(-)
-
-diff --git a/components/layout/layout_impl.rs b/components/layout/layout_impl.rs
-index 45eb3b853f1bc85a4525a043ba077a0450d37dd6..e5ed40bb512a74e9dac9ceaf424e0677ef422c76 100644
---- a/components/layout/layout_impl.rs
-+++ b/components/layout/layout_impl.rs
-@@ -476,6 +476,18 @@ impl Layout for LayoutThread {
- })
- }
-
-+ #[servo_tracing::instrument(skip_all)]
-+ fn query_svg_geometry(&self, node: TrustedNodeAddress) -> Option {
-+ with_layout_state(|| {
-+ let node = unsafe { ServoLayoutNode::new(&node) };
-+ // Safety: a synchronous script query, so no layout workers run.
-+ #[expect(unsafe_code)]
-+ unsafe {
-+ crate::svg::integration::svg_geometry(node)
-+ }
-+ })
-+ }
-+
- #[servo_tracing::instrument(skip_all)]
- fn query_element_inner_outer_text(&self, node: layout_api::TrustedNodeAddress) -> String {
- with_layout_state(|| {
-@@ -1958,6 +1970,9 @@ impl ReflowPhases {
- QueryMsg::PaddingQuery |
- QueryMsg::ResolvedFontStyleQuery |
- QueryMsg::ScrollParentQuery |
-+ // Geometry is resolved from computed style, so styling is the
-+ // only phase it needs.
-+ QueryMsg::SVGGeometryQuery |
- QueryMsg::StyleQuery => Self::empty(),
- },
- ReflowGoal::UpdateScrollNode(..) | ReflowGoal::UpdateTheRendering => {
-diff --git a/components/layout/svg/integration.rs b/components/layout/svg/integration.rs
-index 53f793ab3db771f757cf541706c3ccab69cd57b5..d2fae1d7582eea53272adcd4818cbdd4f09e4a3b 100644
---- a/components/layout/svg/integration.rs
-+++ b/components/layout/svg/integration.rs
-@@ -16,6 +16,8 @@ use kurbo::Affine;
- use script::layout_dom::ServoLayoutNode;
- use webrender_api::units::DeviceIntSize;
-
-+use web_atoms::{LocalName, local_name, ns};
-+
- use super::{NativeSVG, SVGViewport, native_svg_enabled, tree};
- use crate::context::LayoutContext;
- use crate::fragment_tree::{BaseFragment, Fragment, ImageFragment};
-@@ -142,6 +144,11 @@ pub(crate) unsafe fn rendering_type_in_native_subtree(
- // The walk is short in practice and only runs for nodes with no box.
- let mut ancestor = unsafe { node.dangerous_dom_parent() };
- while let Some(current) = ancestor {
-+ // `type_id` panics on a Document, which every walk reaches if it does
-+ // not find an `