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 @@ - + hi"##; + 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#"{body}"# + ); + 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(' -1; } - const svg = Svg.fromSrcSync(this.src); - - if (svg) { - if (this._asset.loadFromBytesSync(svg.width, svg.height, svg.data as any)) { + if (isSvg) { + if (this._asset.loadSvgSync(src)) { this.width = this._asset.width; this.height = this._asset.height; this.complete = true; @@ -373,7 +366,8 @@ export class HTMLImageElement extends HTMLElement { const svg = Svg.fromSrcSync(this.src); if (svg) { - if (this._asset.loadFromBytesSync(svg.width, svg.height, svg.data as any)) { + // Only the old rasterizer fetches URLs synchronously; its pixels are premultiplied. + if (this._asset.loadFromBytesSync(svg.width, svg.height, new Uint8Array(svg.data), true)) { this.width = this._asset.width; this.height = this._asset.height; this.complete = true; @@ -403,11 +397,8 @@ export class HTMLImageElement extends HTMLElement { this._dispatchDecode(true); }) .catch((e) => { - Svg.fromSrc(this.src) - .then((svg) => { - const data = svg.data; - return this._asset.loadFromBytes(svg.width, svg.height, data as any); - }) + this._asset + .loadSvg(this.src) .then((done: boolean) => { this.width = this._asset.width; this.height = this._asset.height; @@ -439,21 +430,17 @@ export class HTMLImageElement extends HTMLElement { this._dispatchDecode(true); } else { // try svg ? - const svg = Svg.fromSrcSync(this.src); + let isSvg = false; + try { + isSvg = this._asset.loadSvgSync(this.src); + } catch (e) {} - if (svg) { - if (this._asset.loadFromBytesSync(svg.width, svg.height, svg.data as any)) { - this.width = this._asset.width; - this.height = this._asset.height; - this.complete = true; - this._loading = false; - this._dispatchDecode(true); - } else { - this.dispatchEvent({ type: 'error', target: this }); - this._onerror?.(); - this._loading = false; - this._dispatchDecode(); - } + if (isSvg) { + this.width = this._asset.width; + this.height = this._asset.height; + this.complete = true; + this._loading = false; + this._dispatchDecode(true); } else { this.dispatchEvent({ type: 'error', target: this }); this._onerror?.(); @@ -472,11 +459,8 @@ export class HTMLImageElement extends HTMLElement { this._dispatchDecode(true); }) .catch((e) => { - Svg.fromSrc(this.src) - .then((svg) => { - const data = svg.data; - return this._asset.loadFromBytes(svg.width, svg.height, data as any); - }) + this._asset + .loadSvg(this.src) .then((done: boolean) => { this.width = this._asset.width; this.height = this._asset.height; diff --git a/packages/canvas-polyfill/DOM/Text.ts b/packages/canvas-polyfill/DOM/Text.ts index c75517a08..b79359a16 100644 --- a/packages/canvas-polyfill/DOM/Text.ts +++ b/packages/canvas-polyfill/DOM/Text.ts @@ -1,7 +1,6 @@ import { CharacterData } from './CharacterData'; import { Node } from './Node'; export class Text extends CharacterData { - __domNode; nodeType = Node.TEXT_NODE; constructor(data) { super(data); diff --git a/packages/canvas-polyfill/DOM/svg/SVGElement.ts b/packages/canvas-polyfill/DOM/svg/SVGElement.ts index 2156858eb..7a80b5beb 100644 --- a/packages/canvas-polyfill/DOM/svg/SVGElement.ts +++ b/packages/canvas-polyfill/DOM/svg/SVGElement.ts @@ -22,17 +22,14 @@ export class SVGElement extends Element { set className(value: unknown) {} - _appendChild(view, redraw = true) { + _appendChild(view) { if (view?.nativeElement?.__domElement) { this.nativeValue?.__domElement?.appendChild?.(view.nativeElement.__domElement); - if (redraw) { - (this.nativeValue).__redraw(); - } (this.nativeValue)?.addChild?.(view.nativeElement); return view; } else if (view instanceof Text) { const dom = this.__domElement ?? this.nativeElement.__domElement; - const text = dom.ownerDocument?.createTextNode?.(view.data); + const text = dom.createTextNode?.(view.data); view.__domNode = text; dom?.appendChild?.(text); return view; @@ -45,19 +42,29 @@ export class SVGElement extends Element { } appendChild(view) { - return this._appendChild(view, true); + return this._appendChild(view); } append(views: Array) { for (const view of arguments) { - this._appendChild(view, false); + this._appendChild(view); } - (this.nativeValue).__redraw(); } insertBefore(view) {} - removeChild(view) {} + removeChild(view) { + const child = view?.nativeElement?.__domElement ?? view?.__domNode; + if (!child) { + return null; + } + const parent = this.__domElement ?? this.nativeElement?.__domElement; + if (!parent?.removeChild?.(child)) { + return null; + } + (this.nativeValue)?.removeChild?.(view.nativeElement); + return view; + } setAttribute(key, value) { const dom = this.__domElement ?? this.nativeElement.__domElement; @@ -76,6 +83,18 @@ export class SVGElement extends Element { return (super.getAttribute(key) as never) ?? null; } + get textContent(): string { + const dom: any = this.__domElement ?? this.nativeElement?.__domElement; + return dom?.textContent ?? ''; + } + + set textContent(value: string) { + const dom: any = this.__domElement ?? this.nativeElement?.__domElement; + if (dom) { + dom.textContent = value; + } + } + removeAttribute(key) { const dom = this.__domElement ?? this.nativeElement.__domElement; if (dom) { diff --git a/packages/canvas-polyfill/DOM/svg/SVGTSpanElement.ts b/packages/canvas-polyfill/DOM/svg/SVGTSpanElement.ts new file mode 100644 index 000000000..c55bb1d4b --- /dev/null +++ b/packages/canvas-polyfill/DOM/svg/SVGTSpanElement.ts @@ -0,0 +1,8 @@ +import { SVGTextPositioningElement } from './SVGTextPositioningElement'; +import { TSpan } from '@nativescript/canvas-svg'; +export class SVGTSpanElement extends SVGTextPositioningElement { + constructor() { + super('tspan'); + this.nativeElement = new TSpan() as never; + } +} diff --git a/packages/canvas-polyfill/DOM/svg/index.ts b/packages/canvas-polyfill/DOM/svg/index.ts index ec62df245..d724a9175 100644 --- a/packages/canvas-polyfill/DOM/svg/index.ts +++ b/packages/canvas-polyfill/DOM/svg/index.ts @@ -22,6 +22,7 @@ export * from './SVGMatrix'; export * from './SVGTransform'; export * from './SVGAnimatedRect'; export * from './SVGTextElement'; +export * from './SVGTSpanElement'; export * from './SVGStopElement'; export * from './SVGUseElement'; export * from './SVGAnimatedTransformList'; diff --git a/packages/canvas-polyfill/package.json b/packages/canvas-polyfill/package.json index 2c342728a..e4c2ff69c 100644 --- a/packages/canvas-polyfill/package.json +++ b/packages/canvas-polyfill/package.json @@ -1,6 +1,6 @@ { "name": "@nativescript/canvas-polyfill", - "version": "3.0.0-alpha.11", + "version": "3.0.0-alpha.12", "description": "Polyfill for making NativeScript compatible with web libs like pixi.js, three.js, phaser.js, babylon.js, etc....", "main": "index", "typings": "global.d.ts", diff --git a/packages/canvas-polyfill/window.ts b/packages/canvas-polyfill/window.ts index 24a06dd39..5a59b1ff3 100644 --- a/packages/canvas-polyfill/window.ts +++ b/packages/canvas-polyfill/window.ts @@ -39,7 +39,7 @@ import { Navigator } from './navigator'; (global as any).window.HTMLUnknownElement = (global as any).HTMLUnknownElement = (global as any).HTMLUnknownElement || HTMLUnknownElement; // svg -import { SVGMarkerElement, SVGAnimatedTransformList, SVGUseElement, SVGStopElement, SVGRadialGradientElement, SVGLinearGradientElement, SVGGradientElement, SVGTextElement, SVGPolygonElement, SVGEllipseElement, SVGImageElement, SVGAnimatedRect, SVGPointList, SVGTransformList, SVGTransform, SVGRect, SVGNumber, SVGMatrix, SVGPoint, SVGAngle, SVGCircleElement, SVGElement, SVGSVGElement, SVGGraphicsElement, SVGMaskElement, SVGLineElement, SVGLength, SVGAnimatedLength, SVGPolylineElement, SVGGElement, SVGPathElement, SVGRectElement, SVGAnimatedString } from './DOM/svg'; +import { SVGMarkerElement, SVGAnimatedTransformList, SVGUseElement, SVGStopElement, SVGRadialGradientElement, SVGLinearGradientElement, SVGGradientElement, SVGTextElement, SVGTSpanElement, SVGPolygonElement, SVGEllipseElement, SVGImageElement, SVGAnimatedRect, SVGPointList, SVGTransformList, SVGTransform, SVGRect, SVGNumber, SVGMatrix, SVGPoint, SVGAngle, SVGCircleElement, SVGElement, SVGSVGElement, SVGGraphicsElement, SVGMaskElement, SVGLineElement, SVGLength, SVGAnimatedLength, SVGPolylineElement, SVGGElement, SVGPathElement, SVGRectElement, SVGAnimatedString } from './DOM/svg'; (global as any).window.SVGCircleElement = (global as any).SVGCircleElement = (global as any).SVGCircleElement || SVGCircleElement; (global as any).window.SVGSVGElement = (global as any).SVGSVGElement = (global as any).SVGSVGElement || SVGSVGElement; @@ -69,6 +69,7 @@ import { SVGMarkerElement, SVGAnimatedTransformList, SVGUseElement, SVGStopEleme (global as any).window.SVGImageElement = (global as any).SVGImageElement = (global as any).SVGImageElement || SVGImageElement; (global as any).window.SVGEllipseElement = (global as any).SVGEllipseElement = (global as any).SVGEllipseElement || SVGEllipseElement; (global as any).window.SVGTextElement = (global as any).SVGTextElement = (global as any).SVGTextElement || SVGTextElement; +(global as any).window.SVGTSpanElement = (global as any).SVGTSpanElement = (global as any).SVGTSpanElement || SVGTSpanElement; (global as any).window.SVGGradientElement = (global as any).SVGGradientElement = (global as any).SVGGradientElement || SVGGradientElement; (global as any).window.SVGRadialGradientElement = (global as any).SVGRadialGradientElement = (global as any).SVGRadialGradientElement || SVGRadialGradientElement; diff --git a/packages/canvas-svg/Elements/Circle.ts b/packages/canvas-svg/Elements/Circle.ts index c58cc003c..32da117d2 100644 --- a/packages/canvas-svg/Elements/Circle.ts +++ b/packages/canvas-svg/Elements/Circle.ts @@ -1,6 +1,6 @@ import { Property } from '@nativescript/core'; import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export const cxProperty = new Property({ name: 'cx', }); @@ -19,7 +19,7 @@ export class Circle extends SVGItem { r: any; constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('circle'); } } diff --git a/packages/canvas-svg/Elements/ClipPath.ts b/packages/canvas-svg/Elements/ClipPath.ts index 39b384de8..536557c95 100644 --- a/packages/canvas-svg/Elements/ClipPath.ts +++ b/packages/canvas-svg/Elements/ClipPath.ts @@ -1,9 +1,9 @@ import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class ClipPath extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('clipPath'); } } diff --git a/packages/canvas-svg/Elements/Defs.ts b/packages/canvas-svg/Elements/Defs.ts index f643f71b5..bcbd3bae1 100644 --- a/packages/canvas-svg/Elements/Defs.ts +++ b/packages/canvas-svg/Elements/Defs.ts @@ -1,10 +1,10 @@ import { AddChildFromBuilder } from '@nativescript/core'; import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Defs extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('defs'); } } diff --git a/packages/canvas-svg/Elements/Ellipse.ts b/packages/canvas-svg/Elements/Ellipse.ts index 35cd62a55..ffa22caca 100644 --- a/packages/canvas-svg/Elements/Ellipse.ts +++ b/packages/canvas-svg/Elements/Ellipse.ts @@ -13,7 +13,7 @@ export const rxProperty = new Property({ export const ryProperty = new Property({ name: 'ry', }); -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Ellipse extends SVGItem { cx: any; @@ -23,7 +23,7 @@ export class Ellipse extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('ellipse'); } } diff --git a/packages/canvas-svg/Elements/G.ts b/packages/canvas-svg/Elements/G.ts index 2f0cfe9b5..251755a3c 100644 --- a/packages/canvas-svg/Elements/G.ts +++ b/packages/canvas-svg/Elements/G.ts @@ -1,5 +1,5 @@ import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class G extends SVGItem { transform: string; @@ -8,7 +8,7 @@ export class G extends SVGItem { __children = []; constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('g'); } __redraw() { diff --git a/packages/canvas-svg/Elements/Image.ts b/packages/canvas-svg/Elements/Image.ts index 4ee608e77..c0a5d65f8 100644 --- a/packages/canvas-svg/Elements/Image.ts +++ b/packages/canvas-svg/Elements/Image.ts @@ -25,7 +25,7 @@ function getMIMEforBase64String(b64) { return mime; } -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Image extends SVGItem { xlink: { href?: string } = {}; @@ -39,6 +39,6 @@ export class Image extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('image'); } } diff --git a/packages/canvas-svg/Elements/Line.ts b/packages/canvas-svg/Elements/Line.ts index d7a0b8f26..dacc7d8ef 100644 --- a/packages/canvas-svg/Elements/Line.ts +++ b/packages/canvas-svg/Elements/Line.ts @@ -13,7 +13,7 @@ export const x2Property = new Property({ export const y2Property = new Property({ name: 'y2', }); -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Line extends SVGItem { x1: any; @@ -23,7 +23,7 @@ export class Line extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('line'); } } diff --git a/packages/canvas-svg/Elements/LinearGradient.ts b/packages/canvas-svg/Elements/LinearGradient.ts index f0e6e503e..e27c1f45d 100644 --- a/packages/canvas-svg/Elements/LinearGradient.ts +++ b/packages/canvas-svg/Elements/LinearGradient.ts @@ -1,5 +1,5 @@ import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class LinearGradient extends SVGItem { _views: any[]; @@ -12,6 +12,6 @@ export class LinearGradient extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('linearGradient'); } } diff --git a/packages/canvas-svg/Elements/Path.ts b/packages/canvas-svg/Elements/Path.ts index e135a2e3c..5c31f8a8c 100644 --- a/packages/canvas-svg/Elements/Path.ts +++ b/packages/canvas-svg/Elements/Path.ts @@ -4,14 +4,14 @@ import { SVGItem } from './SVGItem'; export const dProperty = new Property({ name: 'd', }); -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Path extends SVGItem { public d: string; constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('path'); } } diff --git a/packages/canvas-svg/Elements/Pattern.ts b/packages/canvas-svg/Elements/Pattern.ts index 647e5f937..74aa01031 100644 --- a/packages/canvas-svg/Elements/Pattern.ts +++ b/packages/canvas-svg/Elements/Pattern.ts @@ -1,9 +1,9 @@ import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Pattern extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('pattern'); } } diff --git a/packages/canvas-svg/Elements/Polygon.ts b/packages/canvas-svg/Elements/Polygon.ts index 441541867..7b94476b5 100644 --- a/packages/canvas-svg/Elements/Polygon.ts +++ b/packages/canvas-svg/Elements/Polygon.ts @@ -4,14 +4,14 @@ import { SVGItem } from './SVGItem'; export const pointsProperty = new Property({ name: 'points', }); -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Polygon extends SVGItem { points: any; constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('polygon'); } } diff --git a/packages/canvas-svg/Elements/Polyline.ts b/packages/canvas-svg/Elements/Polyline.ts index 168eedfcd..6af7ce768 100644 --- a/packages/canvas-svg/Elements/Polyline.ts +++ b/packages/canvas-svg/Elements/Polyline.ts @@ -4,14 +4,14 @@ import { SVGItem } from './SVGItem'; export const pointsProperty = new Property({ name: 'points', }); -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Polyline extends SVGItem { points: any; constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('polyline'); } } diff --git a/packages/canvas-svg/Elements/RadialGradient.ts b/packages/canvas-svg/Elements/RadialGradient.ts index 19248dad1..ad52cb5e3 100644 --- a/packages/canvas-svg/Elements/RadialGradient.ts +++ b/packages/canvas-svg/Elements/RadialGradient.ts @@ -1,5 +1,5 @@ import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class RadialGradient extends SVGItem { _views: any[]; @@ -13,6 +13,6 @@ export class RadialGradient extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('radialGradient'); } } diff --git a/packages/canvas-svg/Elements/Rect.ts b/packages/canvas-svg/Elements/Rect.ts index 80a5c04c0..9d05309ca 100644 --- a/packages/canvas-svg/Elements/Rect.ts +++ b/packages/canvas-svg/Elements/Rect.ts @@ -1,5 +1,5 @@ import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Rect extends SVGItem { x: any = 0; @@ -9,6 +9,6 @@ export class Rect extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('rect'); } } diff --git a/packages/canvas-svg/Elements/Stop.ts b/packages/canvas-svg/Elements/Stop.ts index ea543895e..1811833b3 100644 --- a/packages/canvas-svg/Elements/Stop.ts +++ b/packages/canvas-svg/Elements/Stop.ts @@ -1,11 +1,11 @@ import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Stop extends SVGItem { offset: any; constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('stop'); } } diff --git a/packages/canvas-svg/Elements/Symbol.ts b/packages/canvas-svg/Elements/Symbol.ts index 7b244a105..abe232d88 100644 --- a/packages/canvas-svg/Elements/Symbol.ts +++ b/packages/canvas-svg/Elements/Symbol.ts @@ -1,9 +1,9 @@ import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Symbol extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('symbol'); } } diff --git a/packages/canvas-svg/Elements/TSpan.ts b/packages/canvas-svg/Elements/TSpan.ts new file mode 100644 index 000000000..003521b05 --- /dev/null +++ b/packages/canvas-svg/Elements/TSpan.ts @@ -0,0 +1,37 @@ +import { Property } from '@nativescript/core'; +import { SVGItem } from './SVGItem'; +import { createSvgElement } from '../NativeNode'; + +export const xProperty = new Property({ + name: 'x', +}); + +export const yProperty = new Property({ + name: 'y', +}); + +export const dxProperty = new Property({ + name: 'dx', +}); + +export const dyProperty = new Property({ + name: 'dy', +}); + +export class TSpan extends SVGItem { + x: any; + y: any; + dx: any; + dy: any; + text: string; + + constructor() { + super(); + this.__domElement = createSvgElement('tspan'); + } +} + +xProperty.register(TSpan); +yProperty.register(TSpan); +dxProperty.register(TSpan); +dyProperty.register(TSpan); diff --git a/packages/canvas-svg/Elements/Text.ts b/packages/canvas-svg/Elements/Text.ts index 9889b97cb..da106682e 100644 --- a/packages/canvas-svg/Elements/Text.ts +++ b/packages/canvas-svg/Elements/Text.ts @@ -16,7 +16,7 @@ export const dxProperty = new Property({ export const dyProperty = new Property({ name: 'dy', }); -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Text extends SVGItem { x: any; @@ -27,7 +27,7 @@ export class Text extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('text'); } } diff --git a/packages/canvas-svg/Elements/Use.ts b/packages/canvas-svg/Elements/Use.ts index 6876acca3..94bc8341a 100644 --- a/packages/canvas-svg/Elements/Use.ts +++ b/packages/canvas-svg/Elements/Use.ts @@ -1,9 +1,9 @@ import { SVGItem } from './SVGItem'; -import { DOMParser } from '@xmldom/xmldom'; +import { createSvgElement } from '../NativeNode'; export class Use extends SVGItem { constructor() { super(); - this.__domElement = new DOMParser().parseFromString('', 'image/svg+xml').documentElement; + this.__domElement = createSvgElement('use'); } } diff --git a/packages/canvas-svg/Elements/index.ts b/packages/canvas-svg/Elements/index.ts index d7c5a89f0..b47ccf484 100644 --- a/packages/canvas-svg/Elements/index.ts +++ b/packages/canvas-svg/Elements/index.ts @@ -2,6 +2,7 @@ export { Circle } from './Circle'; export { G } from './G'; export { Path } from './Path'; export { Text } from './Text'; +export { TSpan } from './TSpan'; export { Ellipse } from './Ellipse'; export { Line } from './Line'; export { Polygon } from './Polygon'; diff --git a/packages/canvas-svg/NativeNode.ts b/packages/canvas-svg/NativeNode.ts new file mode 100644 index 000000000..225ac118e --- /dev/null +++ b/packages/canvas-svg/NativeNode.ts @@ -0,0 +1,324 @@ +import { Helpers } from './helpers'; + +declare const SVGModule: any; + +export class SvgNodeWrapper { + __native: any; + ownerDocument: SvgDocumentWrapper | null; + __parent: SvgNodeWrapper | null = null; + private __childList: SvgNodeWrapper[] = []; + private __id: string | null = null; + + constructor(native: any, ownerDocument: SvgDocumentWrapper | null = null) { + this.__native = native; + this.ownerDocument = ownerDocument; + } + + get nodeName(): string { + return this.__native.tagName(); + } + + get childNodes() { + const list = this.__childList; + return { + get length() { + return list.length; + }, + item(i: number) { + return list[i] ?? null; + }, + }; + } + + get id(): string | null { + return this.__id; + } + + set id(value: unknown) { + this.setAttribute('id', value); + } + + setAttribute(name: string, value: unknown) { + const stringValue = String(value); + if (name === 'id') { + // Skia keeps ids in a map it only fills while parsing, not on the node. + const doc = this.ownerDocument; + if (doc && this.__id && this.__id !== stringValue) { + doc.__native.unregisterId(this.__id); + } + this.__id = stringValue; + doc?.__native.registerId(stringValue, this.__native); + this.__invalidate(name); + return; + } + this.__native.setAttribute(name, stringValue); + this.__invalidate(name); + } + + /** Records an id this node already carries natively, without re-registering it. */ + __adoptId(id: string) { + this.__id = id; + } + + /** Whether this node is the promoted layer, or inside it. */ + __isInPromotedLayer(): boolean { + const layerId = this.ownerDocument?.layerId; + if (!layerId) { + return false; + } + let node: SvgNodeWrapper | null = this; + while (node) { + if (node.__id === layerId) { + return true; + } + node = node.__parent; + } + return false; + } + + // Nodes live outside the view tree, so changes reach the view through the document. The + // node and attribute are passed along so the view can drop anything it caches about them. + private __invalidate(attribute?: string) { + // Mutating the promoted subtree is exactly what the cached backdrop is meant to + // survive; anything else means the static content changed. + if (!this.__isInPromotedLayer()) { + this.ownerDocument?.invalidateBackdrop(); + } + if (this.ownerDocument) { + this.ownerDocument.revision++; + } + this.ownerDocument?.owner?.__invalidate?.(this, attribute); + } + + getAttribute(name: string): string | null { + if (name === 'id') { + return this.__id; + } + return this.__native.getAttribute(name) ?? null; + } + + removeAttribute(name: string) {} + + // Built from tracked children: Skia cannot list a text element's children. + get textContent(): string { + const own = this.__native.text(); + if (own != null) { + return own; + } + let text = ''; + for (const child of this.__childList) { + text += child.textContent; + } + return text; + } + + set textContent(value: unknown) { + const text = value == null ? '' : String(value); + if (this.__native.setText(text)) { + this.__invalidate(); + return; + } + for (let i = this.__childList.length - 1; i >= 0; i--) { + this.removeChild(this.__childList[i]); + } + if (text.length > 0) { + const node = createSvgTextNode(text); + if (node) { + this.appendChild(node); + } + } + } + + createTextNode(text: string): SvgNodeWrapper | null { + return createSvgTextNode(text); + } + + appendChild(child: SvgNodeWrapper) { + if (this.__native.appendChild(child.__native)) { + this.__childList.push(child); + child.__parent = this; + if (!child.ownerDocument) { + child.__adopt(this.ownerDocument); + } + this.__invalidate(); + } + return child; + } + + // Ids are usually set before there's a document to register them with. + private __adopt(document: SvgDocumentWrapper | null) { + this.ownerDocument = document; + if (document && this.__id) { + document.__native.registerId(this.__id, this.__native); + } + for (const child of this.__childList) { + if (!child.ownerDocument) { + child.__adopt(document); + } + } + } + + removeChild(child: SvgNodeWrapper) { + const index = this.__childList.indexOf(child); + if (index === -1) { + return null; + } + const removedNative = this.__native.removeChild(index); + if (removedNative) { + this.__childList.splice(index, 1); + child.__parent = null; + child.__native = removedNative; + this.__invalidate(); + } + return child; + } +} + +// Standalone creation — no owning document yet. +export function createSvgElement(tag: string): SvgNodeWrapper | null { + Helpers.initialize(); + const native = SVGModule.createElement(tag); + return native ? new SvgNodeWrapper(native) : null; +} + +export function createSvgTextNode(text: string): SvgNodeWrapper | null { + Helpers.initialize(); + const native = SVGModule.createTextNode(text); + return native ? new SvgNodeWrapper(native) : null; +} + +export const ANIMATION_RUNNING = 1; +export const ANIMATION_CHANGED = 2; + +export class SvgDocumentWrapper { + __native: any; + rootElement: SvgNodeWrapper; + // The Svg view to invalidate when any node in this document changes. Nodes are not in + // the view tree, so this is their only route back to something that can redraw. + owner: { __invalidate?(node?: unknown, attribute?: string): void } | null = null; + layerId: string | null = null; + /** Bumped on any visual change. */ + revision = 0; + private __nativePointer = 0; + private __containerWidth = 0; + private __containerHeight = 0; + + constructor(src?: string) { + Helpers.initialize(); + this.__native = typeof src === 'string' ? SVGModule.createSVGDocument(src) : SVGModule.createSVGDocument(); + this.rootElement = new SvgNodeWrapper(this.__native.root(), this); + } + + createElement(tag: string): SvgNodeWrapper | null { + const native = this.__native.createElement(tag); + return native ? new SvgNodeWrapper(native, this) : null; + } + + createTextNode(text: string): SvgNodeWrapper | null { + const native = this.__native.createTextNode(text); + return native ? new SvgNodeWrapper(native, this) : null; + } + + getElementById(id: string): SvgNodeWrapper | null { + const native = this.__native.getElementById(id); + if (!native) { + return null; + } + const node = new SvgNodeWrapper(native, this); + node.__adoptId(id); + return node; + } + + /** + * Promotes the node with this id out of the static content: redraws then cost one + * `renderNode` over a cached raster instead of walking the whole tree. Pass null to clear. + * + * The promoted node composites last, so anything that should paint over it will appear + * beneath it. Promote the topmost animating subtree, as a compositor would. + */ + setLayer(id: string | null) { + this.layerId = id; + this.__native.setLayer(id ?? undefined); + } + + invalidateBackdrop() { + this.__native.invalidateBackdrop(); + } + + // Deduped here, not on the view: a shared document is laid out by whichever view drew last. + setContainerSize(width: number, height: number) { + if (this.__containerWidth === width && this.__containerHeight === height) { + return; + } + this.__containerWidth = width; + this.__containerHeight = height; + this.__native.setContainerSize(width, height); + } + + /** Direct node mutations must be followed by `invalidateFrames`. */ + setFrameSharing(enabled: boolean) { + this.__native.setFrameSharing(enabled); + } + + invalidateFrames() { + this.__native.invalidateFrames(); + } + + /** + * Whether this document carries SMIL animation (``, ``, + * ``, ``). Only true for a document parsed from a source that contained + * them. They cannot be created through `createElement`, because they are not elements as + * far as the renderer is concerned, they are a schedule of attribute writes. + */ + get hasAnimations(): boolean { + return this.__native.hasAnimations(); + } + + /** Seconds until every animation has finished, or Infinity when one repeats forever. */ + get animationDuration(): number { + const duration = this.__native.animationDuration(); + return duration < 0 ? Infinity : duration; + } + + get currentTime(): number { + return this.__native.currentTime(); + } + + /** + * Moves the animation clock and writes every animated attribute. Returns whether anything is + * still animating; false means the document has gone static and the caller should stop + * asking for frames. + */ + /** + * Moves the animation clock, returning what that did: `running` is the cue to schedule + * another frame, `changed` whether anything actually moved. A frame where nothing moved + * does not need redrawing: discrete animations hold a value for most of their duration, + * so on a heavy document this is the difference between rasterizing every frame and + * rasterizing only the ones that differ. + * + * Returned as a bitmask rather than an object because this runs every frame and the loop + * is deliberately allocation-free. + */ + setCurrentTime(seconds: number): number { + const state = this.__native.setCurrentTime(seconds); + if ((state & ANIMATION_CHANGED) !== 0) { + this.revision++; + } + return state; + } + + /** + * The native SvgDocument, for platform renderers that draw it themselves. Read once, since + * it is fixed for the document's lifetime, and this sits on the per-frame redraw path. + */ + get nativePointer(): number { + if (this.__nativePointer === 0) { + this.__nativePointer = this.__native.nativePointer(); + } + return this.__nativePointer; + } + + renderToBuffer(buffer: Uint8Array, width: number, height: number, scale: number = 1) { + this.__native.renderToBuffer(buffer, width, height, scale); + } +} diff --git a/packages/canvas-svg/README.md b/packages/canvas-svg/README.md index fa0ff190b..277221248 100644 --- a/packages/canvas-svg/README.md +++ b/packages/canvas-svg/README.md @@ -1,12 +1,131 @@ # @nativescript/canvas-svg -```javascript +Renders SVG natively on iOS and Android with Skia, including SMIL and CSS animations and a live DOM you can script. + +```bash npm install @nativescript/canvas-svg ``` ## Usage -// TODO +### From a source + +`src` accepts an app relative path, an absolute path, a URL, or inline markup. + +```xml + + + +``` + +```ts +import { Svg } from '@nativescript/canvas-svg'; + +const view = new Svg(); +view.src = '~/assets/rocket.svg'; +``` + +Animations in the source start as soon as the view is loaded and pause while it is off screen. + +### Building it with the DOM + +With `@nativescript/canvas-polyfill` installed you can build and change an SVG the same way you would in a browser. The `nativeElement` of the root `` is the view to add to your layout. + +```ts +require('@nativescript/canvas-polyfill'); + +const NS = 'http://www.w3.org/2000/svg'; +const svg = document.createElementNS(NS, 'svg'); +svg.setAttribute('width', '150'); +svg.setAttribute('height', '150'); + +const circle = document.createElementNS(NS, 'circle'); +circle.setAttribute('cx', '75'); +circle.setAttribute('cy', '75'); +circle.setAttribute('r', '40'); +circle.setAttribute('fill', 'gold'); +svg.appendChild(circle); + +const label = document.createElementNS(NS, 'text'); +label.setAttribute('x', '10'); +label.setAttribute('y', '140'); +label.textContent = 'Hello'; +svg.appendChild(label); + +layout.addChild(svg.nativeElement); + +// Later changes are batched into a single redraw on the next frame. +circle.setAttribute('fill', 'orange'); +label.textContent = 'Updated'; +``` + +Supported on elements: `setAttribute`, `getAttribute`, `appendChild`, `append`, `removeChild` and `textContent`. Animating by changing attributes in a `requestAnimationFrame` loop works too. + +## Properties + +| Property | Default | Description | +| --- | --- | --- | +| `src` | | Path, URL or inline markup to render. | +| `gpu` | `true` | Rasterize on the GPU (Metal on iOS, Vulkan or GL on Android). Falls back to the CPU automatically if no GPU context can be created. | +| `threaded` | `true` | Rasterize off the UI thread. The frame is recorded on the UI thread and drawn on one render thread shared by every threaded view, so a heavy SVG does not hold up the rest of the UI. | +| `shareSrc` | `true` | Views with the same `src` share one parsed document, one animation clock and one recording per frame, like `` tags pointing at the same file on the web. Shared copies animate in step. Set it to `false` to give a view its own copy. | +| `backend` | `auto` | Force `gl`, `vulkan` or `metal`. Only needed to work around a driver problem. | +| `surfaceType` | `texture` | Android only. `texture` behaves like a normal view. `surface` is faster to composite but cannot be transformed or overlapped. | + +## Events + +| Event | When | +| --- | --- | +| `animationEnd` | Every animation in the document has finished. Never fires for one that repeats forever. | +| `contextLost` | The GPU context could not be rebuilt and drawing has moved to the CPU. | +| `contextRestored` | A lost GPU context was rebuilt and GPU drawing has resumed. | + +```ts +view.on('animationEnd', () => console.log('done')); +``` + +## Animation + +Skia's SVG renderer does not animate, so this plugin includes its own animation engine. Both common export formats work. + +**SMIL:** ``, ``, `` (including `rotate="auto"`) and ``, with `values`, `from`/`to`/`by`, `keyTimes`, `keySplines`, `calcMode`, `repeatCount`, `fill="freeze"`, `additive` and `accumulate`. A `begin` that waits for an event (such as `click`) does not start on its own. + +**CSS:** `@keyframes` in a `