diff --git a/Cargo.toml b/Cargo.toml
index b1a0ab052..754bbf8ce 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ members = [
"crates/playground",
"crates/canvas-c",
"crates/canvas-svg",
+ "crates/canvas-svg-c",
"crates/canvas-napi",
"crates/canvas-svg-ios",
"crates/canvas-svg-android",
@@ -72,9 +73,11 @@ canvas-2d = { path = "./crates/canvas-2d" }
canvas-core = { path = "./crates/canvas-core" }
canvas-webgl = { path = "./crates/canvas-webgl" }
canvas-svg = { path = "./crates/canvas-svg" }
+canvas-svg-c = { path = "./crates/canvas-svg-c" }
gl-bindings = { path = "./crates/gl-bindings" }
canvas-c = { path = "./crates/canvas-c" }
-skia-safe = { git = "https://github.com/triniwiz/rust-skia", rev = "1eafe30", features = ["textlayout"] }
+quick-xml = "0.34"
+skia-safe = { git = "https://github.com/triniwiz/rust-skia", rev = "b9d29071cd8876b2447d58a14346b9fe6a5d21e2", features = ["textlayout"] }
itertools = "0.14.0"
ustr = "1.1.0"
wgpu-core = { git = "https://github.com/triniwiz/wgpu", rev = "e5c0aa02", features = ["wgsl"] }
diff --git a/Makefile b/Makefile
index 66decc7cb..2d79007e6 100644
--- a/Makefile
+++ b/Makefile
@@ -26,6 +26,8 @@ svg: GENERATE_IOS_SVG GENERATE_VISIONOS_SVG GENERATE_TVOS_SVG
android-svg: GENERATE_ANDROID_SVG
+apple: ios visionos tvos
+
# Host-side tests. The workspace pins `-C panic=abort` for the Apple host
# targets in .cargo/config.toml (needed by the macOS dylib build) and libtest
# cannot link against that, so the rustflags are replaced for this run.
@@ -47,9 +49,14 @@ GENERATE_V8_HEADERS:
GENERATE_ANDROID_V8_STUB:
./tools/scripts/build-android-v8-stub.sh
+# Match Xcode's pre-build env: cargo tracks SDKROOT and the deployment targets, so a mismatch
+# rebuilds the whole tree (std too) on every switch. tvOS also needs it because cc has no default.
+apple_sdk = $(if $(findstring visionos-sim,$1),xrsimulator,$(if $(findstring visionos,$1),xros,$(if $(findstring tvos-sim,$1),appletvsimulator,$(if $(findstring tvos,$1),appletvos,$(if $(or $(findstring ios-sim,$1),$(findstring x86_64-apple-ios,$1)),iphonesimulator,iphoneos)))))
+apple_env = SDKROOT="$$(xcrun --sdk $(call apple_sdk,$1) --show-sdk-path)" IPHONEOS_DEPLOYMENT_TARGET=12.0 TVOS_DEPLOYMENT_TARGET=12.0 XROS_DEPLOYMENT_TARGET=1.0
+
.PHONY: $(ARCHS_IOS)
$(ARCHS_IOS): %:
- RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
+ $(call apple_env,$@) RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
cargo +nightly build -Z build-std='std,panic_abort' \
--target $@ --release -p canvas-ios
@@ -57,18 +64,13 @@ $(XCFRAMEWORK): $(ARCHS_IOS)
.PHONY: $(ARCHS_VISIONOS)
$(ARCHS_VISIONOS): %:
- RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
+ $(call apple_env,$@) RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
cargo +nightly build -Z build-std='std,panic_abort' \
--target $@ --release -p canvas-ios
-# TVOS_DEPLOYMENT_TARGET must be set explicitly: rustc defaults to 12.0 but the
-# `cc` crate has no built-in tvOS default and falls back to the SDK version, so
-# ring's C/asm objects come out tagged minos 26.4 and the framework link (12.0)
-# warns on every one of them. iOS/visionOS need no equivalent -- cc knows those.
.PHONY: $(ARCHS_TVOS)
$(ARCHS_TVOS): %:
- TVOS_DEPLOYMENT_TARGET=12.0 \
- RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
+ $(call apple_env,$@) RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
cargo +nightly build -Z build-std='std,panic_abort' \
--target $@ --release -p canvas-ios
@@ -81,7 +83,7 @@ GENERATE_ANDROID: $(ARCHS_ANDROID)
.PHONY: $(addsuffix _svg,$(ARCHS_IOS))
$(addsuffix _svg,$(ARCHS_IOS)): %_svg:
- RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
+ $(call apple_env,$*) RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
cargo +nightly build -Z build-std='std,panic_abort' \
--target $* --release -p canvas-svg-ios
@@ -90,7 +92,7 @@ GENERATE_IOS_SVG: $(addsuffix _svg,$(ARCHS_IOS))
.PHONY: $(addsuffix _svg,$(ARCHS_VISIONOS))
$(addsuffix _svg,$(ARCHS_VISIONOS)): %_svg:
- RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
+ $(call apple_env,$*) RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
cargo +nightly build -Z build-std='std,panic_abort' \
--target $* --release -p canvas-svg-ios
@@ -99,8 +101,7 @@ GENERATE_VISIONOS_SVG: $(addsuffix _svg,$(ARCHS_VISIONOS))
.PHONY: $(addsuffix _svg,$(ARCHS_TVOS))
$(addsuffix _svg,$(ARCHS_TVOS)): %_svg:
- TVOS_DEPLOYMENT_TARGET=12.0 \
- RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
+ $(call apple_env,$*) RUSTFLAGS="-Zlocation-detail=none -Zunstable-options -Cpanic=immediate-abort" \
cargo +nightly build -Z build-std='std,panic_abort' \
--target $* --release -p canvas-svg-ios
@@ -111,7 +112,7 @@ GENERATE_TVOS_SVG: $(addsuffix _svg,$(ARCHS_TVOS))
.PHONY: $(addsuffix _svg,$(ARCHS_ANDROID))
$(addsuffix _svg,$(ARCHS_ANDROID)): %_svg:
- ./tools/scripts/build-svg-android.sh $* svg
+ ./tools/scripts/build-svg-android.sh $*
.PHONY: GENERATE_ANDROID_SVG
GENERATE_ANDROID_SVG: $(addsuffix _svg,$(ARCHS_ANDROID))
diff --git a/apps/demo/src/assets/file-assets/svg/rocket.svg b/apps/demo/src/assets/file-assets/svg/rocket.svg
new file mode 100644
index 000000000..1acaf3a12
--- /dev/null
+++ b/apps/demo/src/assets/file-assets/svg/rocket.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/demo/src/assets/file-assets/svg/solar-system-animation.svg b/apps/demo/src/assets/file-assets/svg/solar-system-animation.svg
new file mode 100644
index 000000000..1e798dc4f
--- /dev/null
+++ b/apps/demo/src/assets/file-assets/svg/solar-system-animation.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/demo/src/plugin-demos/canvas-svg.xml b/apps/demo/src/plugin-demos/canvas-svg.xml
index 376d51488..7d6075daf 100644
--- a/apps/demo/src/plugin-demos/canvas-svg.xml
+++ b/apps/demo/src/plugin-demos/canvas-svg.xml
@@ -4,7 +4,7 @@
-
+
"##;
+ let rewritten = text(&extract(source).source);
+ assert!(rewritten.contains(""), "{rewritten}");
+ assert!(rewritten.contains(r#"xmlns="http://www.w3.org/2000/svg""#));
+ assert!(rewritten.contains("hi"));
+ assert!(!rewritten.contains(">,
+ pub key_splines: Option>,
+}
+
+impl Default for Timing {
+ fn default() -> Self {
+ Self {
+ begin: 0.0,
+ dur: f64::INFINITY,
+ repeat: 1.0,
+ freeze: false,
+ additive: false,
+ accumulate: false,
+ calc_mode: CalcMode::Linear,
+ key_times: None,
+ key_splines: None,
+ }
+ }
+}
+
+impl Timing {
+ fn active_duration(&self) -> f64 {
+ if !self.dur.is_finite() || !self.repeat.is_finite() {
+ return f64::INFINITY;
+ }
+ self.dur * self.repeat
+ }
+
+ /// `None` before it begins or after it ends without `fill="freeze"`.
+ pub fn sample(&self, time: f64) -> Option {
+ let local = time - self.begin;
+ if local < 0.0 {
+ return None;
+ }
+
+ // No `dur` holds the final keyframe forever, which is ``.
+ if !self.dur.is_finite() {
+ return Some(Sample {
+ fraction: 1.0,
+ iteration: 0.0,
+ });
+ }
+
+ let active = self.active_duration();
+ if local >= active {
+ if !self.freeze {
+ return None;
+ }
+ return Some(Sample {
+ fraction: 1.0,
+ iteration: (self.repeat - 1.0).max(0.0).floor(),
+ });
+ }
+
+ let progress = local / self.dur;
+ let iteration = progress.floor();
+ Some(Sample {
+ fraction: progress - iteration,
+ iteration,
+ })
+ }
+
+ pub fn end(&self) -> Option {
+ let active = self.active_duration();
+ active.is_finite().then(|| self.begin + active)
+ }
+
+ /// Applies `keySplines` to a bare fraction, for `` whose keyframes are a path.
+ pub fn ease(&self, fraction: f64) -> f64 {
+ if self.calc_mode != CalcMode::Spline {
+ return fraction;
+ }
+ self.key_splines
+ .as_ref()
+ .and_then(|splines| splines.first())
+ .map_or(fraction, |spline| solve_bezier(*spline, fraction))
+ }
+
+ /// A frozen animation past its end is constant, so it is not running.
+ pub fn is_running(&self, time: f64) -> bool {
+ let active = self.active_duration();
+ if !active.is_finite() {
+ return true;
+ }
+ time < self.begin + active
+ }
+
+ pub fn interpolate(&self, frames: &[Value], fraction: f64) -> Value {
+ match frames.len() {
+ 0 => Value::Discrete(String::new()),
+ 1 => frames[0].clone(),
+ _ => {
+ let times = self.resolved_key_times(frames);
+ let fraction = fraction.clamp(0.0, 1.0);
+
+ let mut index = 0;
+ while index + 2 < times.len() && fraction >= times[index + 1] {
+ index += 1;
+ }
+
+ if self.calc_mode == CalcMode::Discrete {
+ // Without keyTimes, n values are n equal steps (not n-1 segments).
+ let step = ((fraction * frames.len() as f64).floor() as usize)
+ .min(frames.len() - 1);
+ let step = if self.key_times.is_some() { index } else { step };
+ return frames[step].clone();
+ }
+
+ let span = times[index + 1] - times[index];
+ let local = if span > 0.0 {
+ (fraction - times[index]) / span
+ } else {
+ 0.0
+ };
+ let eased = match self.calc_mode {
+ CalcMode::Spline => self
+ .key_splines
+ .as_ref()
+ .and_then(|splines| splines.get(index))
+ .map_or(local, |spline| solve_bezier(*spline, local)),
+ _ => local,
+ };
+ frames[index].lerp(&frames[index + 1], eased)
+ }
+ }
+ }
+
+ fn resolved_key_times(&self, frames: &[Value]) -> Vec {
+ if let Some(times) = &self.key_times {
+ if times.len() == frames.len() {
+ return times.clone();
+ }
+ }
+ if self.calc_mode == CalcMode::Paced {
+ if let Some(times) = paced_key_times(frames) {
+ return times;
+ }
+ }
+ even_key_times(frames.len())
+ }
+}
+
+fn even_key_times(count: usize) -> Vec {
+ if count < 2 {
+ return vec![0.0];
+ }
+ let last = (count - 1) as f64;
+ (0..count).map(|i| i as f64 / last).collect()
+}
+
+/// Key times proportional to distance between values. `None` (even spacing) for non-numeric values.
+fn paced_key_times(frames: &[Value]) -> Option> {
+ let mut distances = Vec::with_capacity(frames.len());
+ distances.push(0.0);
+ let mut total = 0.0;
+
+ for pair in frames.windows(2) {
+ let (a, b) = (pair[0].numbers()?, pair[1].numbers()?);
+ if a.len() != b.len() {
+ return None;
+ }
+ let distance = a
+ .iter()
+ .zip(b)
+ .map(|(x, y)| (y - x).powi(2))
+ .sum::()
+ .sqrt();
+ total += distance;
+ distances.push(total);
+ }
+
+ if total <= 0.0 {
+ return None;
+ }
+ Some(distances.into_iter().map(|d| d / total).collect())
+}
+
+/// y of the cubic bezier (0,0),(x1,y1),(x2,y2),(1,1) at `x`. Newton, then bisection if too flat.
+fn solve_bezier([x1, y1, x2, y2]: [f64; 4], x: f64) -> f64 {
+ const EPSILON: f64 = 1e-6;
+
+ if x <= 0.0 {
+ return 0.0;
+ }
+ if x >= 1.0 {
+ return 1.0;
+ }
+
+ let curve = |a: f64, b: f64, t: f64| {
+ let inv = 1.0 - t;
+ 3.0 * inv * inv * t * a + 3.0 * inv * t * t * b + t * t * t
+ };
+ let slope = |a: f64, b: f64, t: f64| {
+ let inv = 1.0 - t;
+ 3.0 * inv * inv * a + 6.0 * inv * t * (b - a) + 3.0 * t * t * (1.0 - b)
+ };
+
+ let mut t = x;
+ for _ in 0..8 {
+ let error = curve(x1, x2, t) - x;
+ if error.abs() < EPSILON {
+ return curve(y1, y2, t);
+ }
+ let derivative = slope(x1, x2, t);
+ if derivative.abs() < EPSILON {
+ break;
+ }
+ t -= error / derivative;
+ }
+
+ let (mut low, mut high) = (0.0, 1.0);
+ t = x;
+ for _ in 0..32 {
+ let value = curve(x1, x2, t);
+ if (value - x).abs() < EPSILON {
+ break;
+ }
+ if value < x {
+ low = t;
+ } else {
+ high = t;
+ }
+ t = (low + high) / 2.0;
+ }
+ curve(y1, y2, t)
+}
+
+/// A SMIL clock value in seconds; `None` for `indefinite` or anything unrecognised.
+pub fn clock(input: &str) -> Option {
+ let value = input.trim();
+ if value.is_empty() || value.eq_ignore_ascii_case("indefinite") {
+ return None;
+ }
+
+ if value.contains(':') {
+ let mut seconds = 0.0;
+ for part in value.split(':') {
+ seconds = seconds * 60.0 + part.trim().parse::().ok()?;
+ }
+ return Some(seconds);
+ }
+
+ // Longest suffix first: "ms" also ends in "s".
+ for (suffix, scale) in [("ms", 0.001), ("min", 60.0), ("h", 3600.0), ("s", 1.0)] {
+ if let Some(number) = value.strip_suffix(suffix) {
+ return number.trim().parse::().ok().map(|n| n * scale);
+ }
+ }
+ value.parse::().ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn frames(values: &[&str]) -> Vec {
+ values.iter().map(|v| Value::parse(v)).collect()
+ }
+
+ #[test]
+ fn parses_clock_values() {
+ assert_eq!(clock("5"), Some(5.0));
+ assert_eq!(clock("5s"), Some(5.0));
+ assert_eq!(clock("250ms"), Some(0.25));
+ assert_eq!(clock("1.5min"), Some(90.0));
+ assert_eq!(clock("01:30"), Some(90.0));
+ assert_eq!(clock("1:00:00"), Some(3600.0));
+ assert_eq!(clock("indefinite"), None);
+ }
+
+ #[test]
+ fn does_not_start_before_begin_or_linger_after_end() {
+ let timing = Timing {
+ begin: 1.0,
+ dur: 2.0,
+ ..Timing::default()
+ };
+ assert!(timing.sample(0.5).is_none());
+ assert!((timing.sample(2.0).unwrap().fraction - 0.5).abs() < 1e-9);
+ assert!(timing.sample(3.5).is_none());
+ assert!(!timing.is_running(3.0));
+ }
+
+ #[test]
+ fn freeze_holds_the_last_value_forever() {
+ let timing = Timing {
+ dur: 2.0,
+ freeze: true,
+ ..Timing::default()
+ };
+ let sample = timing.sample(100.0).unwrap();
+ assert_eq!(sample.fraction, 1.0);
+ assert!(!timing.is_running(100.0));
+ }
+
+ #[test]
+ fn repeats_wrap_and_count() {
+ let timing = Timing {
+ dur: 2.0,
+ repeat: 3.0,
+ ..Timing::default()
+ };
+ let sample = timing.sample(5.0).unwrap();
+ assert_eq!(sample.iteration, 2.0);
+ assert!((sample.fraction - 0.5).abs() < 1e-9);
+ assert!(timing.sample(6.0).is_none());
+ }
+
+ #[test]
+ fn indefinite_repeat_never_settles() {
+ let timing = Timing {
+ dur: 1.0,
+ repeat: f64::INFINITY,
+ ..Timing::default()
+ };
+ assert!(timing.is_running(1_000_000.0));
+ }
+
+ #[test]
+ fn key_times_reshape_the_interpolation() {
+ let timing = Timing {
+ key_times: Some(vec![0.0, 0.9, 1.0]),
+ ..Timing::default()
+ };
+ let values = frames(&["0", "10", "20"]);
+ assert_eq!(timing.interpolate(&values, 0.45).to_attribute(), "5");
+ }
+
+ #[test]
+ fn discrete_steps_without_blending() {
+ let timing = Timing {
+ calc_mode: CalcMode::Discrete,
+ ..Timing::default()
+ };
+ let values = frames(&["red", "green", "blue"]);
+ assert_eq!(timing.interpolate(&values, 0.0), values[0]);
+ assert_eq!(timing.interpolate(&values, 0.5), values[1]);
+ assert_eq!(timing.interpolate(&values, 0.99), values[2]);
+ }
+
+ #[test]
+ fn paced_spaces_by_distance_not_by_count() {
+ let timing = Timing {
+ calc_mode: CalcMode::Paced,
+ ..Timing::default()
+ };
+ let values = frames(&["0", "10", "100"]);
+ assert_eq!(timing.interpolate(&values, 0.5).to_attribute(), "50");
+ }
+
+ #[test]
+ fn spline_easing_is_monotonic_and_hits_both_ends() {
+ let timing = Timing {
+ calc_mode: CalcMode::Spline,
+ key_splines: Some(vec![[0.42, 0.0, 0.58, 1.0]]),
+ ..Timing::default()
+ };
+ let values = frames(&["0", "100"]);
+ assert_eq!(timing.interpolate(&values, 0.0).to_attribute(), "0");
+ assert_eq!(timing.interpolate(&values, 1.0).to_attribute(), "100");
+ let quarter = timing.interpolate(&values, 0.25).numbers().unwrap()[0];
+ assert!(quarter < 25.0, "expected ease-in, got {quarter}");
+ }
+}
diff --git a/crates/canvas-svg/src/smil/value.rs b/crates/canvas-svg/src/smil/value.rs
new file mode 100644
index 000000000..81e07524a
--- /dev/null
+++ b/crates/canvas-svg/src/smil/value.rs
@@ -0,0 +1,235 @@
+//! Animatable attribute values: number lists and colours blend, anything else only steps.
+
+use super::color::{self, Rgba};
+
+#[derive(Clone, PartialEq, Debug)]
+pub enum Value {
+ /// One or more numbers sharing a unit, e.g. `10px`, `0 0 100 100`, `4,2`.
+ Numbers { values: Vec, unit: String },
+ Color(Rgba),
+ /// Not interpolable (`d`, `none`, `url(#grad)`, `visible`); steps at the keyframe boundary.
+ Discrete(String),
+}
+
+impl Value {
+ pub fn parse(input: &str) -> Self {
+ let trimmed = input.trim();
+
+ if let Some(rgba) = color::parse(trimmed) {
+ return Value::Color(rgba);
+ }
+
+ match parse_numbers(trimmed) {
+ Some((values, unit)) if !values.is_empty() => Value::Numbers { values, unit },
+ _ => Value::Discrete(trimmed.to_owned()),
+ }
+ }
+
+ /// `self` at `t == 0`, `other` at `t == 1`. Mismatched shapes step at the halfway point.
+ pub fn lerp(&self, other: &Value, t: f64) -> Value {
+ match (self, other) {
+ (
+ Value::Numbers { values: a, unit },
+ Value::Numbers {
+ values: b,
+ unit: other_unit,
+ },
+ ) if a.len() == b.len() => Value::Numbers {
+ values: a.iter().zip(b).map(|(x, y)| x + (y - x) * t).collect(),
+ // Prefer whichever end has a unit so `0` -> `100%` animates in percent.
+ unit: if unit.is_empty() {
+ other_unit.clone()
+ } else {
+ unit.clone()
+ },
+ },
+ (Value::Color(a), Value::Color(b)) => Value::Color(a.lerp(*b, t)),
+ _ => {
+ if t < 0.5 {
+ self.clone()
+ } else {
+ other.clone()
+ }
+ }
+ }
+ }
+
+ /// For `additive="sum"` and `accumulate="sum"`. Only number lists add; otherwise `other` wins.
+ pub fn add(&self, other: &Value) -> Value {
+ match (self, other) {
+ (Value::Numbers { values: a, unit }, Value::Numbers { values: b, .. })
+ if a.len() == b.len() =>
+ {
+ Value::Numbers {
+ values: a.iter().zip(b).map(|(x, y)| x + y).collect(),
+ unit: unit.clone(),
+ }
+ }
+ _ => other.clone(),
+ }
+ }
+
+ /// Multiplies every number by `n`, for the repeat count in `accumulate="sum"`.
+ pub fn scale(&self, n: f64) -> Value {
+ match self {
+ Value::Numbers { values, unit } => Value::Numbers {
+ values: values.iter().map(|v| v * n).collect(),
+ unit: unit.clone(),
+ },
+ other => other.clone(),
+ }
+ }
+
+ /// Back to something Skia's attribute parser accepts.
+ pub fn to_attribute(&self) -> String {
+ match self {
+ Value::Numbers { values, unit } => {
+ let numbers = values
+ .iter()
+ .map(|v| format_number(*v))
+ .collect::>()
+ .join(" ");
+ if values.len() == 1 {
+ format!("{numbers}{unit}")
+ } else {
+ numbers
+ }
+ }
+ Value::Color(rgba) => rgba.to_css(),
+ Value::Discrete(text) => text.clone(),
+ }
+ }
+
+ pub fn numbers(&self) -> Option<&[f64]> {
+ match self {
+ Value::Numbers { values, .. } => Some(values),
+ _ => None,
+ }
+ }
+}
+
+/// Snaps near-integers so an exact endpoint prints `10`, not `10.0000000001`.
+fn format_number(value: f64) -> String {
+ if value.is_finite() && (value - value.round()).abs() < 1e-9 {
+ return format!("{}", value.round() as i64);
+ }
+ let text = format!("{value:.6}");
+ let trimmed = text.trim_end_matches('0').trim_end_matches('.');
+ trimmed.to_owned()
+}
+
+/// Numbers plus their shared unit. Anything else returns `None`, so `url(#x)` and path data
+/// stay `Discrete`.
+fn parse_numbers(input: &str) -> Option<(Vec, String)> {
+ let bytes = input.as_bytes();
+ let mut values = Vec::new();
+ let mut unit = String::new();
+ let mut index = 0;
+
+ while index < bytes.len() {
+ if bytes[index].is_ascii_whitespace() || bytes[index] == b',' {
+ index += 1;
+ continue;
+ }
+ // Nothing may follow a unit.
+ if !unit.is_empty() {
+ return None;
+ }
+
+ let start = index;
+ if matches!(bytes[index], b'+' | b'-') {
+ index += 1;
+ }
+ let digits_start = index;
+ while index < bytes.len() && bytes[index].is_ascii_digit() {
+ index += 1;
+ }
+ if index < bytes.len() && bytes[index] == b'.' {
+ index += 1;
+ while index < bytes.len() && bytes[index].is_ascii_digit() {
+ index += 1;
+ }
+ }
+ if index == digits_start {
+ return None;
+ }
+ // Exponent only if digits follow: `10em` starts the same way.
+ if index < bytes.len() && matches!(bytes[index], b'e' | b'E') {
+ let mut probe = index + 1;
+ if probe < bytes.len() && matches!(bytes[probe], b'+' | b'-') {
+ probe += 1;
+ }
+ if probe < bytes.len() && bytes[probe].is_ascii_digit() {
+ while probe < bytes.len() && bytes[probe].is_ascii_digit() {
+ probe += 1;
+ }
+ index = probe;
+ }
+ }
+
+ values.push(input[start..index].parse::().ok()?);
+
+ let unit_start = index;
+ while index < bytes.len()
+ && (bytes[index].is_ascii_alphabetic() || bytes[index] == b'%')
+ {
+ index += 1;
+ }
+ if index > unit_start {
+ unit = input[unit_start..index].to_owned();
+ }
+ }
+
+ Some((values, unit))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn numbers(input: &str) -> Vec {
+ Value::parse(input).numbers().unwrap().to_vec()
+ }
+
+ #[test]
+ fn splits_numbers_from_their_unit() {
+ assert_eq!(numbers("10"), vec![10.0]);
+ assert_eq!(numbers(" -2.5px "), vec![-2.5]);
+ assert_eq!(numbers("0 0 100 100"), vec![0.0, 0.0, 100.0, 100.0]);
+ assert_eq!(numbers("4,2"), vec![4.0, 2.0]);
+ assert_eq!(Value::parse("50%").to_attribute(), "50%");
+ }
+
+ #[test]
+ fn leaves_non_numeric_values_alone() {
+ assert!(matches!(Value::parse("none"), Value::Discrete(_)));
+ assert!(matches!(Value::parse("url(#grad)"), Value::Discrete(_)));
+ assert!(matches!(Value::parse("M0 0 L10 10"), Value::Discrete(_)));
+ assert!(matches!(Value::parse("visible"), Value::Discrete(_)));
+ }
+
+ #[test]
+ fn interpolates_only_matching_shapes() {
+ let a = Value::parse("0");
+ let b = Value::parse("10");
+ assert_eq!(a.lerp(&b, 0.25).to_attribute(), "2.5");
+
+ let pair = Value::parse("0 0");
+ assert_eq!(pair.lerp(&b, 0.4).to_attribute(), "0 0");
+ assert_eq!(pair.lerp(&b, 0.6).to_attribute(), "10");
+ }
+
+ #[test]
+ fn colors_interpolate_as_colors() {
+ let red = Value::parse("red");
+ let blue = Value::parse("#0000ff");
+ assert_eq!(red.lerp(&blue, 0.5).to_attribute(), "rgb(128,0,128)");
+ }
+
+ #[test]
+ fn exact_endpoints_do_not_pick_up_float_noise() {
+ let a = Value::parse("0");
+ let b = Value::parse("360");
+ assert_eq!(a.lerp(&b, 1.0).to_attribute(), "360");
+ }
+}
diff --git a/crates/canvas-svg/tests/attributes.rs b/crates/canvas-svg/tests/attributes.rs
new file mode 100644
index 000000000..a661ed117
--- /dev/null
+++ b/crates/canvas-svg/tests/attributes.rs
@@ -0,0 +1,56 @@
+fn set(tag: &str, attrs: &[(&str, &str)]) {
+ let mut node = canvas_svg::create_element(tag).expect("element");
+ for (k, v) in attrs {
+ canvas_svg::set_attribute(&mut node, k, v);
+ }
+}
+
+#[test]
+fn ellipse_attrs() {
+ set("ellipse", &[("cx", "30"), ("cy", "90"), ("rx", "25"), ("ry", "15"), ("fill", "purple")]);
+}
+
+#[test]
+fn line_attrs() {
+ set("line", &[("x1", "70"), ("y1", "70"), ("x2", "140"), ("y2", "110"), ("stroke", "red"), ("stroke-width", "3")]);
+}
+
+#[test]
+fn polygon_points() {
+ set("polygon", &[("points", "75,120 100,145 50,145"), ("fill", "lime"), ("stroke", "black")]);
+}
+
+#[test]
+fn polyline_points() {
+ set("polyline", &[("points", "5,148 20,130 35,148 50,130"), ("fill", "none"), ("stroke", "blue"), ("stroke-width", "2")]);
+}
+
+#[test]
+fn path_d() {
+ set("path", &[("d", "M10 10 L70 10 L40 60 Z"), ("fill", "teal")]);
+}
+
+#[test]
+fn g_transform() {
+ set("g", &[("transform", "translate(80,10)")]);
+}
+
+#[test]
+fn use_href() {
+ set("use", &[("href", "#sharedCircle"), ("x", "30"), ("fill", "orange")]);
+}
+
+#[test]
+fn text_attrs() {
+ set("text", &[("x", "5"), ("y", "30"), ("font-size", "16"), ("fill", "darkblue")]);
+}
+
+#[test]
+fn tspan_attrs() {
+ set("tspan", &[("fill", "red")]);
+}
+
+#[test]
+fn text_node() {
+ let _ = canvas_svg::create_text_node("I love SVG!");
+}
diff --git a/crates/canvas-svg/tests/composited_transform.rs b/crates/canvas-svg/tests/composited_transform.rs
new file mode 100644
index 000000000..d5d195073
--- /dev/null
+++ b/crates/canvas-svg/tests/composited_transform.rs
@@ -0,0 +1,126 @@
+//! A promoted subtree that only moves is composited from its cached raster. Subpixel offsets
+//! resample edges, so those are checked against a tolerance rather than for equality.
+
+use canvas_svg::SvgDocument;
+
+/// A static background plus an expensive masked group that only translates.
+const SOURCE: &str = r##""##;
+
+fn pixels(doc: &mut SvgDocument, size: i32) -> Vec {
+ let mut surface = skia_safe::surfaces::raster_n32_premul((size, size)).expect("surface");
+ surface.canvas().clear(skia_safe::Color::TRANSPARENT);
+ doc.render_frame(surface.canvas(), size, size, 1.0);
+ let info = skia_safe::ImageInfo::new(
+ (size, size),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut out = vec![0u8; (size * size * 4) as usize];
+ assert!(surface.read_pixels(&info, &mut out, (size * 4) as usize, (0, 0)));
+ out
+}
+
+fn load(layer: Option<&str>) -> SvgDocument {
+ let mut doc = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ doc.set_container_size(200.0, 200.0);
+ doc.set_layer(layer);
+ doc
+}
+
+/// How far the two renderings are apart, as a fraction of all bytes.
+fn divergence(a: &[u8], b: &[u8]) -> f64 {
+ a.iter().zip(b).filter(|(x, y)| x != y).count() as f64 / a.len() as f64
+}
+
+#[test]
+fn a_whole_pixel_move_is_bit_identical() {
+ let (mut plain, mut promoted) = (load(None), load(Some("mover")));
+ // Each t lands on a whole-pixel translation, e.g. 0.5s is translate(30,20).
+ for t in [0.0f64, 0.5, 1.0, 1.5] {
+ plain.advance(t);
+ promoted.advance(t);
+ assert_eq!(
+ pixels(&mut plain, 200),
+ pixels(&mut promoted, 200),
+ "a whole-pixel move should composite exactly, at t={t}s"
+ );
+ }
+}
+
+#[test]
+fn a_subpixel_move_only_resamples_edges() {
+ let (mut plain, mut promoted) = (load(None), load(Some("mover")));
+ for step in 0..24 {
+ let t = step as f64 / 12.0;
+ plain.advance(t);
+ promoted.advance(t);
+ let d = divergence(&pixels(&mut plain, 200), &pixels(&mut promoted, 200));
+ assert!(
+ d < 0.01,
+ "compositing diverged by {:.2}% at t={t}s, more than edge resampling",
+ d * 100.0
+ );
+ }
+}
+
+#[test]
+fn changing_the_subtree_itself_re_renders() {
+ let source = SOURCE.replace(
+ r#""#,
+ r#"
+
+ "#,
+ );
+ let mut plain = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ plain.set_container_size(200.0, 200.0);
+ let mut promoted = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ promoted.set_container_size(200.0, 200.0);
+ promoted.set_layer(Some("mover"));
+
+ for step in 0..12 {
+ let t = step as f64 / 6.0;
+ plain.advance(t);
+ promoted.advance(t);
+ let d = divergence(&pixels(&mut plain, 200), &pixels(&mut promoted, 200));
+ assert!(
+ d < 0.01,
+ "a colour change inside the layer was served from a stale raster at t={t}s ({:.1}% apart)",
+ d * 100.0
+ );
+ }
+}
+
+#[test]
+fn a_scaling_layer_falls_back_to_rendering() {
+ let source = SOURCE.replace(r#"type="translate""#, r#"type="scale""#)
+ .replace(r#"values="0 0; 60 40; 0 0""#, r#"values="1; 1.5; 1""#);
+ let mut plain = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ plain.set_container_size(200.0, 200.0);
+ let mut promoted = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ promoted.set_container_size(200.0, 200.0);
+ promoted.set_layer(Some("mover"));
+
+ for step in 0..12 {
+ let t = step as f64 / 6.0;
+ plain.advance(t);
+ promoted.advance(t);
+ assert_eq!(
+ pixels(&mut plain, 200),
+ pixels(&mut promoted, 200),
+ "a scaled layer must fall back to rendering, not be resampled, at t={t}s"
+ );
+ }
+}
diff --git a/crates/canvas-svg/tests/css_animation.rs b/crates/canvas-svg/tests/css_animation.rs
new file mode 100644
index 000000000..0226f8dce
--- /dev/null
+++ b/crates/canvas-svg/tests/css_animation.rs
@@ -0,0 +1,82 @@
+//! Documents animated with CSS `@keyframes` rather than SMIL.
+
+use canvas_svg::SvgDocument;
+
+/// A cheap fingerprint of what was drawn, so two frames can be told apart.
+fn frame_hash(doc: &mut SvgDocument, width: i32, height: i32) -> u64 {
+ let mut surface = skia_safe::surfaces::raster_n32_premul((width, height)).expect("surface");
+ surface.canvas().clear(skia_safe::Color::TRANSPARENT);
+ doc.render_frame(surface.canvas(), width, height, 1.0);
+ let info = skia_safe::ImageInfo::new(
+ (width, height),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut pixels = vec![0u8; (width * height * 4) as usize];
+ assert!(surface.read_pixels(&info, &mut pixels, (width * 4) as usize, (0, 0)));
+ pixels.iter().enumerate().fold(0u64, |acc, (index, byte)| {
+ acc.wrapping_mul(31).wrapping_add((*byte as u64) ^ (index as u64 & 0xff))
+ })
+}
+
+#[test]
+fn the_solar_system_export_animates() {
+ let source = std::fs::read(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../../apps/demo/src/assets/file-assets/svg/solar-system-animation.svg"
+ ))
+ .expect("solar-system-animation.svg");
+
+ // It is a CSS-animated export: no SMIL elements anywhere in it.
+ let text = String::from_utf8_lossy(&source);
+ assert!(!text.contains("
+
+
+ "##;
+
+ let mut doc = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ doc.set_container_size(100.0, 100.0);
+ assert_eq!(doc.animation_count(), 1);
+
+ // Halfway through, the rect should have moved ~30 units right.
+ doc.advance(0.0);
+ let start = frame_hash(&mut doc, 100, 100);
+ doc.advance(1.0);
+ let middle = frame_hash(&mut doc, 100, 100);
+ assert_ne!(start, middle, "the transform never reached the element");
+}
+
+#[test]
+fn a_document_with_no_stylesheet_is_unaffected() {
+ let source = r##""##;
+ let doc = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ assert!(!doc.has_animations());
+}
diff --git a/crates/canvas-svg/tests/dirty_frames.rs b/crates/canvas-svg/tests/dirty_frames.rs
new file mode 100644
index 000000000..2048b2d98
--- /dev/null
+++ b/crates/canvas-svg/tests/dirty_frames.rs
@@ -0,0 +1,51 @@
+//! `advance` must report unchanged frames so the loop can skip redrawing them.
+
+use canvas_svg::SvgDocument;
+
+#[test]
+fn a_discrete_animation_reports_change_only_at_its_steps() {
+ // Three steps over 3s: the value changes at 0s, 1s and 2s and holds in between.
+ let source = r##""##;
+ let mut doc = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+
+ // Walk a whole cycle in 100ms steps and count how many frames actually changed.
+ let mut changed = 0;
+ let mut total = 0;
+ for step in 0..30 {
+ let applied = doc.advance(step as f64 * 0.1);
+ total += 1;
+ if applied.changed {
+ changed += 1;
+ }
+ assert!(applied.running, "an indefinite repeat never stops");
+ }
+ println!("{changed} of {total} frames changed");
+ assert!(changed >= 3, "each discrete step should register as a change");
+ assert!(
+ changed < total / 2,
+ "a discrete animation should hold its value for most frames, not redraw every one"
+ );
+}
+
+#[test]
+fn a_frozen_animation_stops_reporting_change() {
+ let source = r##""##;
+ let mut doc = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+
+ doc.advance(0.0);
+ assert!(doc.advance(0.6).changed, "the set should land");
+ // Once frozen the value never moves again, so no frame after it needs redrawing.
+ for step in 0..10 {
+ let applied = doc.advance(0.7 + step as f64 * 0.1);
+ assert!(!applied.changed, "a frozen value must not report a change");
+ }
+}
diff --git a/crates/canvas-svg/tests/layer_promotion_smil.rs b/crates/canvas-svg/tests/layer_promotion_smil.rs
new file mode 100644
index 000000000..1040da6bf
--- /dev/null
+++ b/crates/canvas-svg/tests/layer_promotion_smil.rs
@@ -0,0 +1,89 @@
+//! The cached backdrop must survive animated writes that land inside the promoted layer.
+
+use canvas_svg::SvgDocument;
+
+/// A static background plus a moving dot, with the moving part inside `#moving`.
+const SOURCE: &str = r##""##;
+
+fn render(doc: &mut SvgDocument, size: i32) -> usize {
+ let mut surface = skia_safe::surfaces::raster_n32_premul((size, size)).expect("surface");
+ surface.canvas().clear(skia_safe::Color::TRANSPARENT);
+ doc.render_frame(surface.canvas(), size, size, 1.0);
+ let info = skia_safe::ImageInfo::new(
+ (size, size),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut pixels = vec![0u8; (size * size * 4) as usize];
+ assert!(surface.read_pixels(&info, &mut pixels, (size * 4) as usize, (0, 0)));
+ pixels.chunks(4).filter(|p| p[3] != 0).count()
+}
+
+#[test]
+fn an_animation_inside_the_promoted_layer_keeps_the_backdrop() {
+ let mut doc = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ doc.set_container_size(100.0, 100.0);
+ doc.set_layer(Some("moving"));
+
+ doc.set_current_time(0.0);
+ render(&mut doc, 100);
+ assert!(doc.has_backdrop(), "the first frame should have captured a backdrop");
+
+ // Advancing the clock moves the dot, which lives inside `#moving`.
+ for t in [0.2f64, 0.4, 0.6, 0.8] {
+ doc.set_current_time(t);
+ assert!(
+ doc.has_backdrop(),
+ "the backdrop was thrown away at t={t}s by a write inside the promoted layer"
+ );
+ render(&mut doc, 100);
+ }
+}
+
+#[test]
+fn an_animation_outside_the_promoted_layer_still_invalidates_it() {
+ // Promote the moving group, but animate something that is *not* inside it.
+ let source = SOURCE.replace(
+ r#""#,
+ r#"
+
+ "#,
+ );
+ let mut doc = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ doc.set_container_size(100.0, 100.0);
+ doc.set_layer(Some("moving"));
+
+ doc.set_current_time(0.0);
+ render(&mut doc, 100);
+ assert!(doc.has_backdrop());
+
+ doc.set_current_time(0.5);
+ assert!(
+ !doc.has_backdrop(),
+ "a write outside the promoted layer must invalidate the cached backdrop"
+ );
+}
+
+#[test]
+fn promotion_does_not_change_the_picture() {
+ let mut plain = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ plain.set_container_size(100.0, 100.0);
+ let mut promoted = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ promoted.set_container_size(100.0, 100.0);
+ promoted.set_layer(Some("moving"));
+
+ for t in [0.0f64, 0.3, 0.7, 1.1] {
+ plain.set_current_time(t);
+ promoted.set_current_time(t);
+ let (a, b) = (render(&mut plain, 100), render(&mut promoted, 100));
+ assert_eq!(a, b, "promotion changed the rendering at t={t}s");
+ }
+}
diff --git a/crates/canvas-svg/tests/layer_realistic.rs b/crates/canvas-svg/tests/layer_realistic.rs
new file mode 100644
index 000000000..a6cf87499
--- /dev/null
+++ b/crates/canvas-svg/tests/layer_realistic.rs
@@ -0,0 +1,69 @@
+//! Expensive static content plus a small element that animates every frame.
+
+use std::time::Instant;
+
+const SRC: &str = r##""##;
+
+struct Target { surface: skia_safe::Surface, info: skia_safe::ImageInfo }
+impl Target {
+ fn new(w: i32, h: i32) -> Self {
+ let info = skia_safe::ImageInfo::new_n32_premul(skia_safe::ISize::new(w, h), None);
+ Self { surface: skia_safe::surfaces::raster(&info, None, None).unwrap(), info }
+ }
+ fn draw(&mut self, doc: &mut canvas_svg::SvgDocument, scale: f32) {
+ let (w, h) = (self.info.width(), self.info.height());
+ let canvas = self.surface.canvas();
+ canvas.clear(skia_safe::Color::TRANSPARENT);
+ let r = canvas.save();
+ doc.render_frame(canvas, w, h, scale);
+ canvas.restore_to_count(r);
+ }
+ fn pixels(&mut self) -> Vec {
+ let (w, h) = (self.info.width(), self.info.height());
+ let image = self.surface.image_snapshot();
+ let mut out = vec![0u8; (w * h * 4) as usize];
+ assert!(image.read_pixels(&self.info, &mut out, (w * 4) as usize, skia_safe::IPoint::new(0, 0), skia_safe::image::CachingHint::Allow));
+ out
+ }
+}
+
+fn bench(label: &str, doc: &mut canvas_svg::SvgDocument, t: &mut Target, scale: f32) -> Vec {
+ t.draw(doc, scale);
+ let runs = 20;
+ let start = Instant::now();
+ for i in 0..runs {
+ let mut ball = doc.get_element_by_id("ball").unwrap().typed();
+ canvas_svg::set_attribute(&mut ball, "cx", &format!("{}", 40 + i * 5));
+ t.draw(doc, scale);
+ }
+ println!("{label}: {:.2} ms/frame", start.elapsed().as_secs_f64() * 1000.0 / runs as f64);
+ t.pixels()
+}
+
+#[test]
+fn promoting_the_animated_element() {
+ let (w, h, scale) = (900, 900, 3.0);
+ let mut t = Target::new(w, h);
+
+ let mut plain = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).unwrap();
+ plain.set_container_size(300.0, 300.0);
+ let full = bench("whole document", &mut plain, &mut t, scale);
+
+ let mut layered = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).unwrap();
+ layered.set_container_size(300.0, 300.0);
+ layered.set_layer(Some("ball"));
+ let composed = bench("promoted #ball", &mut layered, &mut t, scale);
+
+ let differing = full.chunks(4).zip(composed.chunks(4)).filter(|(a, b)| a != b).count();
+ println!("pixels differing after animating: {differing}/{}", (w * h) as usize);
+}
diff --git a/crates/canvas-svg/tests/layers.rs b/crates/canvas-svg/tests/layers.rs
new file mode 100644
index 000000000..d261f350d
--- /dev/null
+++ b/crates/canvas-svg/tests/layers.rs
@@ -0,0 +1,76 @@
+//! Layer promotion must match a plain full render.
+
+const SRC: &str = r#""#;
+
+fn render(doc: &mut canvas_svg::SvgDocument, w: i32, h: i32) -> Vec {
+ let info = skia_safe::ImageInfo::new_n32_premul(skia_safe::ISize::new(w, h), None);
+ let mut surface = skia_safe::surfaces::raster(&info, None, None).unwrap();
+ let canvas = surface.canvas();
+ canvas.clear(skia_safe::Color::TRANSPARENT);
+ doc.render_frame(canvas, w, h, 1.0);
+ let image = surface.image_snapshot();
+ let mut pixels = vec![0u8; (w * h * 4) as usize];
+ assert!(image.read_pixels(
+ &info,
+ &mut pixels,
+ (w * 4) as usize,
+ skia_safe::IPoint::new(0, 0),
+ skia_safe::image::CachingHint::Allow
+ ));
+ pixels
+}
+
+#[test]
+fn promoted_layer_matches_a_full_render() {
+ let mut plain = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).unwrap();
+ plain.set_container_size(100.0, 100.0);
+ let expected = render(&mut plain, 100, 100);
+
+ let mut layered = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).unwrap();
+ layered.set_container_size(100.0, 100.0);
+ layered.set_layer(Some("dot"));
+ let first = render(&mut layered, 100, 100);
+ // Second frame goes through the cached backdrop rather than a fresh capture.
+ let cached = render(&mut layered, 100, 100);
+
+ assert_eq!(expected, first, "first layered frame differs from a full render");
+ assert_eq!(expected, cached, "cached layered frame differs from a full render");
+}
+
+#[test]
+fn moving_the_promoted_node_still_composites_correctly() {
+ let mut layered = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).unwrap();
+ layered.set_container_size(100.0, 100.0);
+ layered.set_layer(Some("dot"));
+ let _ = render(&mut layered, 100, 100);
+
+ // Move the dot; the backdrop must stay valid and the dot must not ghost.
+ let mut dot = layered.get_element_by_id("dot").unwrap().typed();
+ canvas_svg::set_attribute(&mut dot, "cx", "70");
+ let moved = render(&mut layered, 100, 100);
+
+ let mut plain = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).unwrap();
+ plain.set_container_size(100.0, 100.0);
+ let mut pdot = plain.get_element_by_id("dot").unwrap().typed();
+ canvas_svg::set_attribute(&mut pdot, "cx", "70");
+ let expected = render(&mut plain, 100, 100);
+
+ assert_eq!(expected, moved, "moved layer does not match a full render");
+}
+
+#[test]
+fn clearing_the_layer_returns_to_whole_document_rendering() {
+ let mut doc = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).unwrap();
+ doc.set_container_size(100.0, 100.0);
+ doc.set_layer(Some("dot"));
+ let _ = render(&mut doc, 100, 100);
+ doc.set_layer(None);
+ assert!(doc.layer().is_none());
+
+ let mut plain = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).unwrap();
+ plain.set_container_size(100.0, 100.0);
+ assert_eq!(render(&mut plain, 100, 100), render(&mut doc, 100, 100));
+}
diff --git a/crates/canvas-svg/tests/parsed_docs.rs b/crates/canvas-svg/tests/parsed_docs.rs
new file mode 100644
index 000000000..fa172257e
--- /dev/null
+++ b/crates/canvas-svg/tests/parsed_docs.rs
@@ -0,0 +1,25 @@
+
+const SRC: &str = r#""#;
+
+#[test]
+fn get_element_by_id_on_a_parsed_document() {
+ let mut doc = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).expect("parses");
+ // Parsed ids live only in Skia's id map; ours is filled by register_id.
+ println!("box => {}", doc.get_element_by_id("box").is_some());
+ println!("grp => {}", doc.get_element_by_id("grp").is_some());
+ println!("dot => {}", doc.get_element_by_id("dot").is_some());
+}
+
+#[test]
+fn ids_from_the_parser_resolve() {
+ let mut doc = canvas_svg::SvgDocument::from_bytes(SRC.as_bytes()).expect("parses");
+ assert!(doc.get_element_by_id("box").is_some(), "top-level id");
+ assert!(doc.get_element_by_id("grp").is_some(), "group id");
+ assert!(doc.get_element_by_id("dot").is_some(), "nested id");
+ assert!(doc.get_element_by_id("nope").is_none());
+}
diff --git a/crates/canvas-svg/tests/persistent_canvas.rs b/crates/canvas-svg/tests/persistent_canvas.rs
new file mode 100644
index 000000000..a2c11b103
--- /dev/null
+++ b/crates/canvas-svg/tests/persistent_canvas.rs
@@ -0,0 +1,44 @@
+//! GPU backends reuse one surface across frames, so `render_frame` must not leave its matrix
+//! changes on the canvas.
+
+use canvas_svg::SvgDocument;
+
+const SOURCE: &str = r##""##;
+
+fn opaque(surface: &mut skia_safe::Surface, size: i32) -> usize {
+ let info = skia_safe::ImageInfo::new(
+ (size, size),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut pixels = vec![0u8; (size * size * 4) as usize];
+ assert!(surface.read_pixels(&info, &mut pixels, (size * 4) as usize, (0, 0)));
+ pixels.chunks(4).filter(|p| p[3] != 0).count()
+}
+
+#[test]
+fn repeated_frames_on_one_canvas_are_stable() {
+ let size = 300;
+ let scale = 3.0;
+ let mut doc = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ doc.set_container_size(size as f32 / scale, size as f32 / scale);
+
+ let mut surface = skia_safe::surfaces::raster_n32_premul((size, size)).expect("surface");
+
+ let mut counts = Vec::new();
+ for _ in 0..6 {
+ surface.canvas().clear(skia_safe::Color::TRANSPARENT);
+ doc.render_frame(surface.canvas(), size, size, scale);
+ counts.push(opaque(&mut surface, size));
+ }
+
+ println!("per-frame opaque pixels: {counts:?}");
+ assert!(counts[0] > 0, "nothing drew on the first frame");
+ assert!(
+ counts.iter().all(|c| *c == counts[0]),
+ "frames diverged on a reused canvas, the matrix is leaking between frames: {counts:?}"
+ );
+}
diff --git a/crates/canvas-svg/tests/recorded_frames.rs b/crates/canvas-svg/tests/recorded_frames.rs
new file mode 100644
index 000000000..1f46d26f1
--- /dev/null
+++ b/crates/canvas-svg/tests/recorded_frames.rs
@@ -0,0 +1,130 @@
+//! The document's thread records a display list; another thread rasterizes it.
+
+use canvas_svg::{FrameSlot, SvgDocument};
+use std::sync::Arc;
+
+const SOURCE: &str = r##""##;
+
+fn surface(size: i32) -> skia_safe::Surface {
+ skia_safe::surfaces::raster_n32_premul((size, size)).expect("surface")
+}
+
+fn read(surface: &mut skia_safe::Surface, size: i32) -> Vec {
+ let info = skia_safe::ImageInfo::new(
+ (size, size),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut out = vec![0u8; (size * size * 4) as usize];
+ assert!(surface.read_pixels(&info, &mut out, (size * 4) as usize, (0, 0)));
+ out
+}
+
+fn direct(doc: &mut SvgDocument, size: i32) -> Vec {
+ let mut s = surface(size);
+ s.canvas().clear(skia_safe::Color::TRANSPARENT);
+ doc.render_frame(s.canvas(), size, size, 1.0);
+ read(&mut s, size)
+}
+
+fn replayed(frame: &canvas_svg::RecordedFrame, size: i32) -> Vec {
+ let mut s = surface(size);
+ s.canvas().clear(skia_safe::Color::TRANSPARENT);
+ frame.replay(s.canvas());
+ read(&mut s, size)
+}
+
+#[test]
+fn a_replayed_frame_matches_rendering_directly() {
+ let mut doc = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ doc.set_container_size(120.0, 120.0);
+
+ for step in 0..8 {
+ let t = step as f64 / 4.0;
+ doc.advance(t);
+ let recorded = doc.record(120, 120, 1.0).expect("record");
+ assert_eq!(
+ direct(&mut doc, 120),
+ replayed(&recorded, 120),
+ "replaying the display list differed from rendering at t={t}s"
+ );
+ }
+}
+
+#[test]
+fn a_frame_recorded_here_rasterizes_on_another_thread() {
+ let mut doc = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ doc.set_container_size(120.0, 120.0);
+ doc.advance(0.5);
+
+ let expected = direct(&mut doc, 120);
+ let recorded = doc.record(120, 120, 1.0).expect("record");
+
+ // `doc` stays here; only the display list crosses.
+ let handle = std::thread::spawn(move || replayed(&recorded, 120));
+ let produced = handle.join().expect("render thread panicked");
+
+ assert_eq!(expected, produced, "off-thread raster differed from on-thread render");
+}
+
+#[test]
+fn the_slot_keeps_only_the_newest_frame() {
+ let mut doc = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ doc.set_container_size(120.0, 120.0);
+ let slot = Arc::new(FrameSlot::new());
+
+ // Three frames produced before the renderer gets a turn: the first two are already stale.
+ doc.advance(0.0);
+ slot.commit(doc.record(120, 120, 1.0).unwrap());
+ doc.advance(0.5);
+ slot.commit(doc.record(120, 120, 1.0).unwrap());
+ doc.advance(1.0);
+ slot.commit(doc.record(120, 120, 1.0).unwrap());
+
+ doc.advance(1.0);
+ let newest = direct(&mut doc, 120);
+
+ let taken = slot.take().expect("a frame was committed");
+ assert_eq!(newest, replayed(&taken, 120), "the slot handed back a stale frame");
+ assert!(!slot.has_frame(), "taking a frame should empty the slot");
+}
+
+#[test]
+fn a_render_thread_consumes_frames_as_they_are_committed() {
+ let slot = Arc::new(FrameSlot::new());
+ let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
+
+ let renderer = {
+ let slot = Arc::clone(&slot);
+ let done = Arc::clone(&done);
+ std::thread::spawn(move || {
+ let mut rendered = 0usize;
+ while !done.load(std::sync::atomic::Ordering::Acquire) || slot.has_frame() {
+ if let Some(frame) = slot.take() {
+ let mut s = surface(120);
+ frame.replay(s.canvas());
+ rendered += 1;
+ }
+ std::thread::yield_now();
+ }
+ rendered
+ })
+ };
+
+ let mut doc = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ doc.set_container_size(120.0, 120.0);
+ for step in 0..30 {
+ doc.advance(step as f64 / 30.0);
+ slot.commit(doc.record(120, 120, 1.0).expect("record"));
+ }
+ done.store(true, std::sync::atomic::Ordering::Release);
+
+ let rendered = renderer.join().expect("render thread panicked");
+ assert!(rendered > 0, "the render thread never picked up a frame");
+}
diff --git a/crates/canvas-svg/tests/roundtrip.rs b/crates/canvas-svg/tests/roundtrip.rs
new file mode 100644
index 000000000..8e41ccf25
--- /dev/null
+++ b/crates/canvas-svg/tests/roundtrip.rs
@@ -0,0 +1,66 @@
+//! Reading back what `set_attribute` wrote through Skia's own attribute parser.
+
+fn set_get(tag: &str, name: &str, value: &str) -> Option {
+ let mut node = canvas_svg::create_element(tag).expect("element");
+ assert!(canvas_svg::set_attribute(&mut node, name, value), "{tag}/{name} not handled");
+ canvas_svg::get_attribute(&node, name)
+}
+
+#[test]
+fn lengths() {
+ assert_eq!(set_get("rect", "width", "120").as_deref(), Some("120"));
+ assert_eq!(set_get("circle", "r", "60").as_deref(), Some("60"));
+ assert_eq!(set_get("svg", "height", "300").as_deref(), Some("300"));
+}
+
+#[test]
+fn polygon_points() {
+ assert_eq!(
+ set_get("polygon", "points", "75,120 100,145 50,145").as_deref(),
+ Some("75,120 100,145 50,145")
+ );
+}
+
+#[test]
+fn href_accepts_both_spellings() {
+ // Skia only knows `xlink:href`; `set_attribute` maps the modern spelling onto it.
+ assert_eq!(set_get("use", "href", "#shared").as_deref(), Some("#shared"));
+ assert_eq!(set_get("use", "xlink:href", "#shared").as_deref(), Some("#shared"));
+}
+
+#[test]
+fn attributes_the_old_hand_rolled_dispatch_did_not_reach() {
+ let mut node = canvas_svg::create_element("g").unwrap();
+ assert!(canvas_svg::set_attribute(&mut node, "transform", "translate(80,10)"));
+
+ let mut svg = canvas_svg::create_element("svg").unwrap();
+ assert!(canvas_svg::set_attribute(&mut svg, "viewBox", "0 0 100 100"));
+
+ let mut rect = canvas_svg::create_element("rect").unwrap();
+ assert!(canvas_svg::set_attribute(&mut rect, "style", "fill:lime;stroke:purple"));
+
+ let mut blur = canvas_svg::create_element("filter").unwrap();
+ assert!(canvas_svg::set_attribute(&mut blur, "filterUnits", "userSpaceOnUse"));
+}
+
+#[test]
+fn use_resolves_a_programmatically_registered_id() {
+ let mut doc = canvas_svg::SvgDocument::new();
+
+ let mut circle = canvas_svg::create_element("circle").unwrap();
+ for (k, v) in [("cx", "20"), ("cy", "20"), ("r", "10"), ("fill", "orange")] {
+ canvas_svg::set_attribute(&mut circle, k, v);
+ }
+ doc.register_id("shared", circle.clone().into_node());
+
+ assert!(doc.get_element_by_id("shared").is_some());
+ assert!(doc.get_element_by_id("nope").is_none());
+
+ doc.unregister_id("shared");
+ assert!(doc.get_element_by_id("shared").is_none());
+}
+
+#[test]
+fn svg_view_box() {
+ assert_eq!(set_get("svg", "viewBox", "0 0 300 300").as_deref(), Some("0 0 300 300"));
+}
diff --git a/crates/canvas-svg/tests/shared_frames.rs b/crates/canvas-svg/tests/shared_frames.rs
new file mode 100644
index 000000000..7fd4df62c
--- /dev/null
+++ b/crates/canvas-svg/tests/shared_frames.rs
@@ -0,0 +1,131 @@
+//! Views of one document share a recording per geometry.
+
+use canvas_svg::SvgDocument;
+
+const SOURCE: &str = r##""##;
+
+fn shared() -> SvgDocument {
+ let mut doc = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ doc.set_frame_sharing(true);
+ doc
+}
+
+fn draw(doc: &mut SvgDocument, size: i32, scale: f32) -> Vec {
+ let mut s = skia_safe::surfaces::raster_n32_premul((size, size)).expect("surface");
+ s.canvas().clear(skia_safe::Color::TRANSPARENT);
+ doc.draw(s.canvas(), size, size, scale);
+ let info = skia_safe::ImageInfo::new(
+ (size, size),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut out = vec![0u8; (size * size * 4) as usize];
+ assert!(s.read_pixels(&info, &mut out, (size * 4) as usize, (0, 0)));
+ out
+}
+
+fn direct(doc: &mut SvgDocument, size: i32, scale: f32) -> Vec {
+ doc.set_frame_sharing(false);
+ let pixels = draw(doc, size, scale);
+ doc.set_frame_sharing(true);
+ pixels
+}
+
+#[test]
+fn views_of_the_same_size_share_one_recording() {
+ let mut doc = shared();
+ doc.set_container_size(120.0, 120.0);
+ doc.advance(0.5);
+
+ let a = doc.frame(240, 240, 2.0).expect("record");
+ let b = doc.frame(240, 240, 2.0).expect("cached");
+ assert_eq!(
+ a.picture_id(),
+ b.picture_id(),
+ "a second view of the same geometry recorded again instead of reusing the frame"
+ );
+}
+
+#[test]
+fn a_shared_frame_matches_rendering_directly() {
+ let mut doc = shared();
+ doc.set_container_size(120.0, 120.0);
+ for step in 0..8 {
+ let t = step as f64 / 4.0;
+ doc.advance(t);
+ let replayed = draw(&mut doc, 120, 1.0);
+ assert_eq!(direct(&mut doc, 120, 1.0), replayed, "shared replay differed at t={t}s");
+ }
+}
+
+#[test]
+fn advancing_the_clock_invalidates_the_recording() {
+ let mut doc = shared();
+ doc.set_container_size(120.0, 120.0);
+ doc.advance(0.0);
+ let before = doc.frame(120, 120, 1.0).expect("record").picture_id();
+ doc.advance(0.5);
+ let after = doc.frame(120, 120, 1.0).expect("record").picture_id();
+ assert_ne!(before, after, "the frame was reused after the clock moved");
+}
+
+#[test]
+fn an_unchanged_tick_keeps_the_recording() {
+ let source = r##""##;
+ let mut doc = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ doc.set_frame_sharing(true);
+ doc.set_container_size(120.0, 120.0);
+ doc.advance(0.1);
+ let first = doc.frame(120, 120, 1.0).expect("record").picture_id();
+ doc.advance(0.2);
+ let second = doc.frame(120, 120, 1.0).expect("record").picture_id();
+ assert_eq!(first, second, "a tick that changed nothing threw the recording away");
+}
+
+/// A `100%` root lays out differently per container size, so those must not share.
+#[test]
+fn views_of_different_sizes_get_their_own_layout() {
+ let mut doc = shared();
+ doc.advance(0.5);
+
+ doc.set_container_size(120.0, 120.0);
+ let big = draw(&mut doc, 120, 1.0);
+ doc.set_container_size(60.0, 60.0);
+ let small = draw(&mut doc, 60, 1.0);
+
+ doc.set_container_size(120.0, 120.0);
+ assert_eq!(big, draw(&mut doc, 120, 1.0), "the large view picked up the small view's frame");
+ assert_eq!(big, direct(&mut doc, 120, 1.0));
+ doc.set_container_size(60.0, 60.0);
+ assert_eq!(small, direct(&mut doc, 60, 1.0));
+}
+
+/// A direct mutation is invisible to the document, so its owner has to invalidate.
+#[test]
+fn invalidating_after_a_mutation_records_again() {
+ let mut doc = shared();
+ doc.set_container_size(120.0, 120.0);
+ let before = doc.frame(120, 120, 1.0).expect("record").picture_id();
+ doc.invalidate_frames();
+ let after = doc.frame(120, 120, 1.0).expect("record").picture_id();
+ assert_ne!(before, after);
+}
+
+#[test]
+fn sharing_off_never_caches() {
+ let mut doc = SvgDocument::from_bytes(SOURCE.as_bytes()).expect("parse");
+ doc.set_container_size(120.0, 120.0);
+ let a = doc.frame(120, 120, 1.0).expect("record").picture_id();
+ let b = doc.frame(120, 120, 1.0).expect("record").picture_id();
+ assert_ne!(a, b, "an unshared document must record every frame");
+}
diff --git a/crates/canvas-svg/tests/smil.rs b/crates/canvas-svg/tests/smil.rs
new file mode 100644
index 000000000..5f51b5c0b
--- /dev/null
+++ b/crates/canvas-svg/tests/smil.rs
@@ -0,0 +1,340 @@
+//! SMIL end to end: extraction, Skia's parser, the clock and the attribute writes.
+
+use canvas_svg::SvgDocument;
+
+fn document(source: &str) -> SvgDocument {
+ SvgDocument::from_bytes(source.as_bytes()).expect("well-formed test document")
+}
+
+fn attribute(document: &mut SvgDocument, id: &str, name: &str) -> Option {
+ let node = document.get_element_by_id(id)?;
+ canvas_svg::get_attribute(&node.typed(), name)
+}
+
+#[test]
+fn a_document_without_smil_reports_no_animation() {
+ let mut doc = document(r#""#);
+ assert!(!doc.has_animations());
+ // Driving the clock on a static document is a no-op, not an error.
+ assert!(!doc.set_current_time(1.0));
+}
+
+#[test]
+fn the_animated_element_survives_extraction_and_is_still_addressable() {
+ let mut doc = document(
+ r#""#,
+ );
+ assert!(doc.has_animations());
+ assert_eq!(doc.animation_count(), 1);
+ assert!(doc.get_element_by_id("box").is_some());
+}
+
+#[test]
+fn interpolates_an_attribute_across_the_duration() {
+ let mut doc = document(
+ r#""#,
+ );
+
+ doc.set_current_time(0.0);
+ assert_eq!(attribute(&mut doc, "box", "width").as_deref(), Some("10"));
+
+ doc.set_current_time(1.0);
+ assert_eq!(attribute(&mut doc, "box", "width").as_deref(), Some("15"));
+
+ doc.set_current_time(2.0);
+ assert_eq!(attribute(&mut doc, "box", "width").as_deref(), Some("20"));
+}
+
+#[test]
+fn an_element_with_no_id_of_its_own_still_animates() {
+ // The extractor has to invent an id here, and then find the element by it.
+ let mut doc = document(
+ r#""#,
+ );
+ doc.set_current_time(1.0);
+ assert_eq!(
+ attribute(&mut doc, "__nsc_smil_0", "width").as_deref(),
+ Some("20")
+ );
+}
+
+#[test]
+fn freeze_holds_and_remove_reverts() {
+ let source = |fill: &str| {
+ format!(
+ r#""#
+ )
+ };
+
+ let mut frozen = document(&source("freeze"));
+ frozen.set_current_time(5.0);
+ assert_eq!(attribute(&mut frozen, "box", "width").as_deref(), Some("20"));
+
+ let mut removed = document(&source("remove"));
+ removed.set_current_time(0.5);
+ assert_eq!(
+ attribute(&mut removed, "box", "width").as_deref(),
+ Some("15")
+ );
+ // Past the end the element goes back to the value it was authored with.
+ removed.set_current_time(5.0);
+ assert_eq!(
+ attribute(&mut removed, "box", "width").as_deref(),
+ Some("10")
+ );
+}
+
+#[test]
+fn a_to_animation_starts_from_the_elements_own_value() {
+ let mut doc = document(
+ r#""#,
+ );
+ doc.set_current_time(0.5);
+ assert_eq!(attribute(&mut doc, "dot", "r").as_deref(), Some("9"));
+}
+
+#[test]
+fn colors_blend_rather_than_step() {
+ let mut doc = document(
+ r#""#,
+ );
+ doc.set_current_time(1.0);
+ let fill = attribute(&mut doc, "box", "fill").expect("fill");
+ // Skia normalises the colour; it only has to be the midpoint grey.
+ assert!(
+ fill.contains("128") || fill.eq_ignore_ascii_case("#808080"),
+ "expected a mid grey, got {fill}"
+ );
+}
+
+#[test]
+fn transform_animation_composes_with_the_elements_own_transform() {
+ let mut doc = document(
+ r#""#,
+ );
+ doc.set_current_time(1.0);
+ let transform = attribute(&mut doc, "knob", "transform").expect("transform");
+
+ // Skia reads back `translate(10,20) rotate(45)` as a matrix; the authored 10,20 must
+ // survive, which it wouldn't if the animation replaced the transform instead of adding.
+ let numbers: Vec = transform
+ .trim_start_matches("matrix(")
+ .trim_end_matches(')')
+ .split(',')
+ .map(|n| n.trim().parse().expect("matrix component"))
+ .collect();
+ assert_eq!(numbers.len(), 6, "{transform}");
+
+ let half = std::f32::consts::FRAC_1_SQRT_2; // cos(45°)
+ for (index, expected) in [half, half, -half, half, 10.0, 20.0].iter().enumerate() {
+ assert!(
+ (numbers[index] - expected).abs() < 1e-4,
+ "component {index} of {transform}: expected {expected}"
+ );
+ }
+}
+
+#[test]
+fn several_animations_on_one_element_all_land() {
+ let mut doc = document(
+ r#""#,
+ );
+ doc.set_current_time(0.5);
+ assert_eq!(attribute(&mut doc, "box", "x").as_deref(), Some("5"));
+ assert_eq!(attribute(&mut doc, "box", "y").as_deref(), Some("10"));
+}
+
+#[test]
+fn a_begin_offset_delays_the_start() {
+ let mut doc = document(
+ r#""#,
+ );
+ doc.set_current_time(0.5);
+ assert_eq!(attribute(&mut doc, "box", "width").as_deref(), Some("10"));
+ doc.set_current_time(1.5);
+ assert_eq!(attribute(&mut doc, "box", "width").as_deref(), Some("15"));
+}
+
+#[test]
+fn set_applies_its_value_for_the_whole_active_duration() {
+ let mut doc = document(
+ r#""#,
+ );
+ doc.set_current_time(0.5);
+ let before = attribute(&mut doc, "box", "fill").expect("fill");
+ doc.set_current_time(1.5);
+ let during = attribute(&mut doc, "box", "fill").expect("fill");
+ assert_ne!(before, during, "the set should have taken effect");
+}
+
+#[test]
+fn an_indefinite_repeat_never_reports_itself_finished() {
+ let mut doc = document(
+ r#""#,
+ );
+ assert_eq!(doc.animation_duration(), None);
+ assert!(doc.set_current_time(1_000.0), "should still be running");
+}
+
+#[test]
+fn a_finite_animation_eventually_stops_asking_for_frames() {
+ let mut doc = document(
+ r#""#,
+ );
+ assert_eq!(doc.animation_duration(), Some(1.0));
+ assert!(doc.set_current_time(0.5));
+ assert!(!doc.set_current_time(1.5));
+}
+
+#[test]
+fn rendering_an_animated_document_at_successive_times_actually_changes_pixels() {
+ let mut doc = document(
+ r#""#,
+ );
+ doc.set_container_size(40.0, 40.0);
+
+ let snapshot = |doc: &mut SvgDocument, time: f64| -> Vec {
+ doc.set_current_time(time);
+ let info = skia_safe::ImageInfo::new_n32_premul(skia_safe::ISize::new(40, 40), None);
+ let mut surface = skia_safe::surfaces::raster(&info, None, None).expect("raster surface");
+ surface.canvas().clear(skia_safe::Color::TRANSPARENT);
+ doc.render_frame(surface.canvas(), 40, 40, 1.0);
+ let image = surface.image_snapshot();
+ let mut pixels = vec![0u8; 40 * 40 * 4];
+ assert!(
+ image.read_pixels(
+ &info,
+ &mut pixels,
+ 40 * 4,
+ skia_safe::IPoint::new(0, 0),
+ skia_safe::image::CachingHint::Disallow,
+ ),
+ "read_pixels"
+ );
+ pixels
+ };
+
+ let start = snapshot(&mut doc, 0.0);
+ let middle = snapshot(&mut doc, 1.0);
+ assert_ne!(start, middle, "the rect should have moved");
+}
+
+/// `rocket.svg`: exported animation built from `` on path data plus a
+/// discrete `` on `visibility`.
+const ROCKET: &str = include_str!("../../../apps/demo/src/assets/file-assets/svg/rocket.svg");
+
+#[test]
+fn rocket_svg_carries_a_lot_of_animation() {
+ let doc = document(ROCKET);
+ assert!(doc.has_animations());
+ assert!(
+ doc.animation_count() > 50,
+ "expected the exported animation to survive extraction, got {}",
+ doc.animation_count()
+ );
+ // It repeats forever, so it has no end.
+ assert_eq!(doc.animation_duration(), None);
+}
+
+/// Skia ignores the root group's `visibility="hidden"`, so it paints from the first frame.
+#[test]
+fn rocket_svg_paints_and_keeps_changing() {
+ let mut doc = document(ROCKET);
+ doc.set_container_size(300.0, 300.0);
+
+ let info = skia_safe::ImageInfo::new_n32_premul(skia_safe::ISize::new(300, 300), None);
+ let mut surface = skia_safe::surfaces::raster(&info, None, None).expect("raster surface");
+
+ let painted = |doc: &mut SvgDocument, surface: &mut skia_safe::Surface, time: f64| -> usize {
+ doc.set_current_time(time);
+ surface.canvas().clear(skia_safe::Color::TRANSPARENT);
+ doc.render_frame(surface.canvas(), 300, 300, 1.0);
+ let mut pixels = vec![0u8; 300 * 300 * 4];
+ assert!(surface.image_snapshot().read_pixels(
+ &info,
+ &mut pixels,
+ 300 * 4,
+ skia_safe::IPoint::new(0, 0),
+ skia_safe::image::CachingHint::Disallow,
+ ));
+ pixels.chunks_exact(4).filter(|p| p[3] != 0).count()
+ };
+
+ let counts: Vec = (0..12)
+ .map(|step| painted(&mut doc, &mut surface, step as f64 * 0.25))
+ .collect();
+
+ assert!(
+ counts.iter().all(|n| *n > 1000),
+ "the document should paint at every moment, got {counts:?}"
+ );
+ assert!(
+ counts.iter().any(|n| *n != counts[0]),
+ "nothing moved across three seconds: {counts:?}"
+ );
+}
+
+#[test]
+fn rocket_svg_animates_without_falling_over() {
+ let mut doc = document(ROCKET);
+ doc.set_container_size(300.0, 300.0);
+
+ let info = skia_safe::ImageInfo::new_n32_premul(skia_safe::ISize::new(300, 300), None);
+ let mut surface = skia_safe::surfaces::raster(&info, None, None).expect("raster surface");
+
+ // Two and a half seconds at 30fps, over the stretch where the exported ``s fire.
+ let mut frames = Vec::new();
+ for step in 0..75 {
+ let time = step as f64 / 30.0;
+ doc.set_current_time(time);
+ surface.canvas().clear(skia_safe::Color::TRANSPARENT);
+ doc.render_frame(surface.canvas(), 300, 300, 1.0);
+
+ let mut pixels = vec![0u8; 300 * 300 * 4];
+ assert!(surface.image_snapshot().read_pixels(
+ &info,
+ &mut pixels,
+ 300 * 4,
+ skia_safe::IPoint::new(0, 0),
+ skia_safe::image::CachingHint::Disallow,
+ ));
+ frames.push(pixels);
+ }
+
+ // The frames must not all be identical.
+ let distinct = frames
+ .iter()
+ .filter(|frame| *frame != &frames[0])
+ .count();
+ assert!(distinct > 0, "every frame was identical, nothing animated");
+}
diff --git a/crates/canvas-svg/tests/text_content.rs b/crates/canvas-svg/tests/text_content.rs
new file mode 100644
index 000000000..8ed93e07f
--- /dev/null
+++ b/crates/canvas-svg/tests/text_content.rs
@@ -0,0 +1,115 @@
+//! Text nodes. Skia can't remove a text element's children, so removal hides the fragment.
+
+use canvas_svg::{create_text_node, set_text, text, SvgDocument, SvgElementHandle};
+
+const W: i32 = 240;
+const H: i32 = 60;
+
+fn doc_with_empty_text() -> (SvgDocument, SvgElementHandle) {
+ let source = format!(
+ r#""#
+ );
+ let mut doc = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ doc.set_container_size(W as f32, H as f32);
+ let node = doc.get_element_by_id("t").expect("text");
+ (doc, SvgElementHandle::new(node.typed()))
+}
+
+fn doc_with(body: &str) -> SvgDocument {
+ let source = format!(
+ r#""#
+ );
+ let mut doc = SvgDocument::from_bytes(source.as_bytes()).expect("parse");
+ doc.set_container_size(W as f32, H as f32);
+ doc
+}
+
+fn literal(value: &str) -> SvgElementHandle {
+ SvgElementHandle::new(create_text_node(value))
+}
+
+fn pixels(doc: &mut SvgDocument) -> Vec {
+ let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("surface");
+ surface.canvas().clear(skia_safe::Color::WHITE);
+ doc.render_frame(surface.canvas(), W, H, 1.0);
+ let info = skia_safe::ImageInfo::new(
+ (W, H),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut out = vec![0u8; (W * H * 4) as usize];
+ assert!(surface.read_pixels(&info, &mut out, (W * 4) as usize, (0, 0)));
+ out
+}
+
+#[test]
+fn a_text_node_reads_and_rewrites_its_text() {
+ let mut node = create_text_node("before");
+ assert_eq!(text(&node).as_deref(), Some("before"));
+ assert!(set_text(&mut node, "after"));
+ assert_eq!(text(&node).as_deref(), Some("after"));
+}
+
+#[test]
+fn only_text_nodes_have_text() {
+ let mut rect = canvas_svg::create_element("rect").unwrap();
+ assert_eq!(text(&rect), None);
+ assert!(!set_text(&mut rect, "x"));
+}
+
+/// The demo's `label.textContent = ...`: rewriting the text node must reach the screen.
+#[test]
+fn rewriting_a_text_node_renders_the_new_text() {
+ let (mut doc, mut label) = doc_with_empty_text();
+ let mut node = literal("before mutation");
+ label.append_child(&node).unwrap();
+ let before = pixels(&mut doc);
+
+ assert!(set_text(&mut node.node, "after mutation"));
+ let after = pixels(&mut doc);
+
+ assert_ne!(before, after, "the text did not change on screen");
+ assert_eq!(after, pixels(&mut doc_with("after mutation")));
+}
+
+/// A removed fragment must render as if it had never been there.
+#[test]
+fn removing_from_a_text_element_renders_as_if_it_were_gone() {
+ let (mut doc, mut label) = doc_with_empty_text();
+ label.append_child(&literal("keep ")).unwrap();
+ label.append_child(&literal("drop")).unwrap();
+
+ let removed = label.remove_child(1).expect("remove");
+ assert_eq!(text(&removed).as_deref(), Some("drop"));
+ assert_eq!(label.children.len(), 1);
+ assert_eq!(pixels(&mut doc), pixels(&mut doc_with("keep ")));
+}
+
+/// What `textContent = ...` does from JS: remove every child, then append one text node.
+#[test]
+fn replacing_every_child_renders_only_the_new_text() {
+ let (mut doc, mut label) = doc_with_empty_text();
+ label.append_child(&literal("before ")).unwrap();
+ let mut span = SvgElementHandle::new(canvas_svg::create_element("tspan").unwrap());
+ span.append_child(&literal("mutation")).unwrap();
+ label.append_child(&span).unwrap();
+
+ while !label.children.is_empty() {
+ label.remove_child(label.children.len() - 1).unwrap();
+ }
+ label.append_child(&literal("after mutation")).unwrap();
+
+ assert_eq!(pixels(&mut doc), pixels(&mut doc_with("after mutation")));
+}
+
+/// Plain containers still remove for real.
+#[test]
+fn removing_from_a_group_still_detaches() {
+ let mut group = SvgElementHandle::new(canvas_svg::create_element("g").unwrap());
+ group
+ .append_child(&SvgElementHandle::new(canvas_svg::create_element("rect").unwrap()))
+ .unwrap();
+ group.remove_child(0).expect("remove");
+ assert!(group.children.is_empty());
+}
diff --git a/packages/canvas-babylon/package.json b/packages/canvas-babylon/package.json
index 8f4b50d34..af27308bf 100644
--- a/packages/canvas-babylon/package.json
+++ b/packages/canvas-babylon/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-babylon",
- "version": "3.0.0-alpha.11",
+ "version": "3.0.0-alpha.12",
"description": "",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-chartjs/package.json b/packages/canvas-chartjs/package.json
index cd66756a0..3b22afd64 100644
--- a/packages/canvas-chartjs/package.json
+++ b/packages/canvas-chartjs/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-chartjs",
- "version": "3.0.0-alpha.11",
+ "version": "3.0.0-alpha.12",
"description": "ChartJS",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-media/package.json b/packages/canvas-media/package.json
index 8bd63fb63..d9e702a1f 100644
--- a/packages/canvas-media/package.json
+++ b/packages/canvas-media/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-media",
- "version": "3.0.0-alpha.11",
+ "version": "3.0.0-alpha.12",
"description": "Canvas media",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-phaser-ce/package.json b/packages/canvas-phaser-ce/package.json
index 4b86bee11..16ca7dd65 100644
--- a/packages/canvas-phaser-ce/package.json
+++ b/packages/canvas-phaser-ce/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-phaser-ce",
- "version": "3.0.0-alpha.11",
+ "version": "3.0.0-alpha.12",
"description": "Tools for using Phaser-ce to build native 2D games in NativeScript 👾",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-phaser/package.json b/packages/canvas-phaser/package.json
index b776b1839..ce3ddf3ee 100644
--- a/packages/canvas-phaser/package.json
+++ b/packages/canvas-phaser/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-phaser",
- "version": "3.0.0-alpha.11",
+ "version": "3.0.0-alpha.12",
"description": "Build awesome 2D games with Phaser.js and NativeScript",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-pixi/package.json b/packages/canvas-pixi/package.json
index cff78de5b..2be626598 100644
--- a/packages/canvas-pixi/package.json
+++ b/packages/canvas-pixi/package.json
@@ -1,6 +1,6 @@
{
"name": "@nativescript/canvas-pixi",
- "version": "3.0.0-alpha.11",
+ "version": "3.0.0-alpha.12",
"description": "Plugin for using pixi.js in NativeScript",
"main": "index",
"typings": "index.d.ts",
diff --git a/packages/canvas-polyfill/DOM/CharacterData.ts b/packages/canvas-polyfill/DOM/CharacterData.ts
index 3ba0ec3ab..e9a2feeb7 100644
--- a/packages/canvas-polyfill/DOM/CharacterData.ts
+++ b/packages/canvas-polyfill/DOM/CharacterData.ts
@@ -2,6 +2,8 @@ import { Node } from './Node';
export class CharacterData extends Node {
private _data: string;
+ /** The native text node, once this has been appended into an SVG. */
+ __domNode;
constructor(data: string) {
super('');
this._data = data;
@@ -11,6 +13,29 @@ export class CharacterData extends Node {
return this._data;
}
+ set data(value: string) {
+ this._data = value == null ? '' : String(value);
+ if (this.__domNode) {
+ this.__domNode.textContent = this._data;
+ }
+ }
+
+ get textContent() {
+ return this.data;
+ }
+
+ set textContent(value: string) {
+ this.data = value;
+ }
+
+ get nodeValue() {
+ return this.data;
+ }
+
+ set nodeValue(value: string) {
+ this.data = value;
+ }
+
get length() {
return this._data.length;
}
diff --git a/packages/canvas-polyfill/DOM/Document.ts b/packages/canvas-polyfill/DOM/Document.ts
index 331e171b6..6d2011566 100644
--- a/packages/canvas-polyfill/DOM/Document.ts
+++ b/packages/canvas-polyfill/DOM/Document.ts
@@ -81,9 +81,11 @@ export class Document extends Node {
return new SVGLineElement();
case 'text':
return new SVGTextElement();
- case 'radialGradient':
+ case 'tspan':
+ return new SVGTSpanElement();
+ case 'radialgradient':
return new SVGRadialGradientElement();
- case 'linearGradient':
+ case 'lineargradient':
return new SVGLinearGradientElement();
case 'use':
return new SVGUseElement();
diff --git a/packages/canvas-polyfill/DOM/HTMLImageElement.ts b/packages/canvas-polyfill/DOM/HTMLImageElement.ts
index fd30657ad..6d4249119 100644
--- a/packages/canvas-polyfill/DOM/HTMLImageElement.ts
+++ b/packages/canvas-polyfill/DOM/HTMLImageElement.ts
@@ -205,18 +205,13 @@ export class HTMLImageElement extends HTMLElement {
})
.then((res) => {
if (typeof res === 'string') {
- return Svg.fromSrc(res)
- .then((svg) => {
- const data = svg.data;
- return this._asset.loadFromBytes(svg.width, svg.height, data as any);
- })
- .then((done: boolean) => {
- this.width = this._asset.width;
- this.height = this._asset.height;
- this.complete = done;
- this._loading = false;
- this._dispatchDecode(done);
- });
+ return this._asset.loadSvg(res).then((done: boolean) => {
+ this.width = this._asset.width;
+ this.height = this._asset.height;
+ this.complete = done;
+ this._loading = false;
+ this._dispatchDecode(done);
+ });
}
})
.catch((e) => {
@@ -243,10 +238,8 @@ export class HTMLImageElement extends HTMLElement {
isSvg = src.indexOf('