From 2e45e6487af738b1f4f7a06a0796c23702566139 Mon Sep 17 00:00:00 2001
From: Madison Steiner <8176115+mh0pe@users.noreply.github.com>
Date: Tue, 21 Jul 2026 20:45:22 -0700
Subject: [PATCH 1/5] webapi: add live SVG collections
---
src/browser/Frame.zig | 15 +
src/browser/js/bridge.zig | 4 +
.../tests/element/svg/collections.html | 201 ++++++++
src/browser/webapi/DOMMatrixReadOnly.zig | 431 ++++++++++++------
src/browser/webapi/DOMPoint.zig | 17 +-
src/browser/webapi/DOMPointReadOnly.zig | 45 ++
src/browser/webapi/element/svg/Graphics.zig | 41 ++
src/browser/webapi/element/svg/Polygon.zig | 21 +
src/browser/webapi/element/svg/Polyline.zig | 21 +
.../webapi/svg/AnimatedTransformList.zig | 46 ++
src/browser/webapi/svg/PointList.zig | 353 ++++++++++++++
src/browser/webapi/svg/StringList.zig | 179 ++++++++
src/browser/webapi/svg/Transform.zig | 159 +++++--
src/browser/webapi/svg/TransformList.zig | 302 ++++++++++++
14 files changed, 1648 insertions(+), 187 deletions(-)
create mode 100644 src/browser/tests/element/svg/collections.html
create mode 100644 src/browser/webapi/svg/AnimatedTransformList.zig
create mode 100644 src/browser/webapi/svg/PointList.zig
create mode 100644 src/browser/webapi/svg/StringList.zig
create mode 100644 src/browser/webapi/svg/TransformList.zig
diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index a9e7adfa3..477081bd0 100644
--- a/src/browser/Frame.zig
+++ b/src/browser/Frame.zig
@@ -89,6 +89,11 @@ pub const BUF_SIZE = 1024;
const Frame = @This();
+pub const SvgCollectionCleanup = struct {
+ context: *anyopaque,
+ callback: *const fn (*anyopaque, *Page) void,
+};
+
// This is the "id" of the frame. It can be re-used from frame-to-frame, e.g.
// when navigating.
_frame_id: u32,
@@ -148,6 +153,7 @@ _element_namespace_uris: Element.NamespaceUriLookup = .empty,
_svg_animated_lengths: AnimatedLength.Lookup = .empty,
_svg_animated_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty,
_svg_animated_strings: AnimatedString.Lookup = .empty,
+_svg_collection_cleanups: std.ArrayList(SvgCollectionCleanup) = .empty,
// Same as above, but for Nodes (slot assigments apply to both Element AND
// Text nodes)
@@ -503,6 +509,11 @@ pub fn deinit(self: *Frame) void {
observers.deinit(self, page);
+ for (self._svg_collection_cleanups.items) |cleanup| {
+ cleanup.callback(cleanup.context, page);
+ }
+ self._svg_collection_cleanups = .empty;
+
var document = self.window._document;
document._selection.releaseRef(page);
@@ -535,6 +546,10 @@ pub fn deinit(self: *Frame) void {
page.releaseArena(self.local_arena);
}
+pub fn registerSvgCollectionCleanup(self: *Frame, cleanup: SvgCollectionCleanup) !void {
+ try self._svg_collection_cleanups.append(self.arena, cleanup);
+}
+
pub fn trackWorker(self: *Frame, worker: *Worker) !void {
try self.workers.append(self.arena, worker);
}
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index cdb6c021d..021bdb9e3 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -1076,6 +1076,10 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/svg/AnimatedLength.zig"),
@import("../webapi/svg/PreserveAspectRatio.zig"),
@import("../webapi/svg/AnimatedPreserveAspectRatio.zig"),
+ @import("../webapi/svg/PointList.zig"),
+ @import("../webapi/svg/TransformList.zig"),
+ @import("../webapi/svg/AnimatedTransformList.zig"),
+ @import("../webapi/svg/StringList.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextEncoderStream.zig"),
diff --git a/src/browser/tests/element/svg/collections.html b/src/browser/tests/element/svg/collections.html
new file mode 100644
index 000000000..f3dcadc3d
--- /dev/null
+++ b/src/browser/tests/element/svg/collections.html
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/DOMMatrixReadOnly.zig b/src/browser/webapi/DOMMatrixReadOnly.zig
index d0340f942..052414ab8 100644
--- a/src/browser/webapi/DOMMatrixReadOnly.zig
+++ b/src/browser/webapi/DOMMatrixReadOnly.zig
@@ -312,178 +312,274 @@ pub fn invertMatrix(m: [16]f64) ?[16]f64 {
return out;
}
+pub const TransformSyntax = enum { css, svg };
+
+pub const TransformKind = enum {
+ matrix,
+ matrix3d,
+ translate,
+ translate_x,
+ translate_y,
+ translate_z,
+ translate_3d,
+ scale,
+ scale_x,
+ scale_y,
+ scale_z,
+ scale_3d,
+ rotate,
+ rotate_x,
+ rotate_y,
+ rotate_z,
+ rotate_3d,
+ skew,
+ skew_x,
+ skew_y,
+ perspective,
+};
+
+pub const ParsedTransform = struct {
+ kind: TransformKind,
+ matrix: [16]f64,
+ values: [16]f64,
+ count: usize,
+ is_2d: bool,
+};
+
+pub const TransformFunction = struct {
+ name: []const u8,
+ arguments: []const u8,
+};
+
+pub const TransformFunctionIterator = struct {
+ input: []const u8,
+ index: usize = 0,
+ allow_comma: bool = false,
+
+ pub fn next(self: *TransformFunctionIterator) !?TransformFunction {
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
+ if (self.index == self.input.len) return null;
+ if (self.input[self.index] == ',') return error.SyntaxError;
+
+ const name_start = self.index;
+ while (self.index < self.input.len and
+ (std.ascii.isAlphabetic(self.input[self.index]) or std.ascii.isDigit(self.input[self.index])))
+ {
+ self.index += 1;
+ }
+ if (self.index == name_start or self.index >= self.input.len or self.input[self.index] != '(') {
+ return error.SyntaxError;
+ }
+ const name = self.input[name_start..self.index];
+ self.index += 1;
+
+ const arguments_start = self.index;
+ while (self.index < self.input.len and self.input[self.index] != ')') {
+ if (self.input[self.index] == '(') return error.SyntaxError;
+ self.index += 1;
+ }
+ if (self.index == self.input.len) return error.SyntaxError;
+ const arguments = self.input[arguments_start..self.index];
+ self.index += 1;
+
+ var had_whitespace = false;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
+ had_whitespace = true;
+ self.index += 1;
+ }
+ if (self.index < self.input.len and self.input[self.index] == ',') {
+ if (!self.allow_comma) return error.SyntaxError;
+ self.index += 1;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
+ if (self.index == self.input.len) return error.SyntaxError;
+ } else if (self.index < self.input.len and !had_whitespace) {
+ return error.SyntaxError;
+ }
+ return .{ .name = name, .arguments = arguments };
+ }
+};
+
// Parses a CSS (e.g. "matrix(1,0,0,1,10,20) scale(2)") and
// accumulates it into `m`. "none"/empty leave the matrix as identity.
pub fn parseTransformList(input: []const u8, m: *[16]f64, is_2d: *bool) !void {
const trimmed = std.mem.trim(u8, input, " \t\r\n");
- if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) {
- return;
- }
+ if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return;
- var i: usize = 0;
- while (i < trimmed.len) {
- // skip whitespace and separating commas
- while (i < trimmed.len and (std.ascii.isWhitespace(trimmed[i]) or trimmed[i] == ',')) : (i += 1) {}
- if (i >= trimmed.len) {
- break;
- }
-
- const name_start = i;
- while (i < trimmed.len and trimmed[i] != '(') : (i += 1) {}
- if (i >= trimmed.len) {
- return error.SyntaxError;
- }
- const name = std.mem.trim(u8, trimmed[name_start..i], " \t\r\n");
-
- i += 1; // consume '('
- const args_start = i;
- while (i < trimmed.len and trimmed[i] != ')') : (i += 1) {}
- if (i >= trimmed.len) {
- return error.SyntaxError;
- }
- const args = trimmed[args_start..i];
- i += 1; // consume ')'
-
- const func = try parseFunction(name, args, is_2d);
- m.* = multiplyMatrix(m.*, func);
+ var iterator = TransformFunctionIterator{ .input = trimmed };
+ while (try iterator.next()) |function| {
+ const parsed = try parseTransformFunction(function, .css);
+ m.* = multiplyMatrix(m.*, parsed.matrix);
+ if (!parsed.is_2d) is_2d.* = false;
}
}
-fn parseFunction(name: []const u8, args: []const u8, is_2d: *bool) ![16]f64 {
- var nums: [16]f64 = undefined;
+pub fn parseTransformFunction(function: TransformFunction, syntax: TransformSyntax) !ParsedTransform {
+ var values: [16]f64 = undefined;
var units: [16]ParsedValue.Unit = undefined;
- var count: usize = 0;
-
- var it = std.mem.splitScalar(u8, args, ',');
- while (it.next()) |raw| {
- const tok = std.mem.trim(u8, raw, " \t\r\n");
- if (tok.len == 0) {
- continue;
- }
- if (count >= 16) {
- return error.SyntaxError;
- }
- const parsed = try ParsedValue.parse(tok);
- nums[count] = parsed.value;
- units[count] = parsed.unit;
- count += 1;
- }
-
+ const count = try parseArguments(function.arguments, &values, &units);
+ const name = function.name;
const Eql = std.mem.eql;
+
if (Eql(u8, name, "matrix")) {
- if (count != 6) {
- return error.SyntaxError;
- }
- return .{
- nums[0], nums[1], 0, 0,
- nums[2], nums[3], 0, 0,
- 0, 0, 1, 0,
- nums[4], nums[5], 0, 1,
- };
+ try requireCount(count, 6, 6);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.matrix, .{
+ values[0], values[1], 0, 0,
+ values[2], values[3], 0, 0,
+ 0, 0, 1, 0,
+ values[4], values[5], 0, 1,
+ }, values, count, true);
}
if (Eql(u8, name, "matrix3d")) {
- if (count != 16) {
- return error.SyntaxError;
- }
- is_2d.* = false;
- return nums;
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 16, 16);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.matrix3d, values, values, count, false);
}
if (Eql(u8, name, "translate")) {
- const tx = nums[0];
- const ty = if (count > 1) nums[1] else 0;
- return translationMatrix(tx, ty, 0);
+ try requireCount(count, 1, 2);
+ if (syntax == .svg) try requireUnitless(units[0..count]);
+ const ty = if (count == 2) values[1] else 0;
+ return makeParsedTransform(.translate, translationMatrix(values[0], ty, 0), values, count, true);
}
-
if (Eql(u8, name, "translateX")) {
- return translationMatrix(nums[0], 0, 0);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ return makeParsedTransform(.translate_x, translationMatrix(values[0], 0, 0), values, count, true);
}
-
if (Eql(u8, name, "translateY")) {
- return translationMatrix(0, nums[0], 0);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ return makeParsedTransform(.translate_y, translationMatrix(0, values[0], 0), values, count, true);
}
-
if (Eql(u8, name, "translateZ")) {
- is_2d.* = false;
- return translationMatrix(0, 0, nums[0]);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ return makeParsedTransform(.translate_z, translationMatrix(0, 0, values[0]), values, count, false);
}
-
if (Eql(u8, name, "translate3d")) {
- is_2d.* = false;
- return translationMatrix(nums[0], nums[1], nums[2]);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 3, 3);
+ return makeParsedTransform(.translate_3d, translationMatrix(values[0], values[1], values[2]), values, count, false);
}
if (Eql(u8, name, "scale")) {
- const sx = nums[0];
- const sy = if (count > 1) nums[1] else sx;
- return scaleMatrix(sx, sy, 1);
+ try requireCount(count, 1, 2);
+ try requireUnitless(units[0..count]);
+ const sy = if (count == 2) values[1] else values[0];
+ return makeParsedTransform(.scale, scaleMatrix(values[0], sy, 1), values, count, true);
}
-
if (Eql(u8, name, "scaleX")) {
- return scaleMatrix(nums[0], 1, 1);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.scale_x, scaleMatrix(values[0], 1, 1), values, count, true);
}
-
if (Eql(u8, name, "scaleY")) {
- return scaleMatrix(1, nums[0], 1);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.scale_y, scaleMatrix(1, values[0], 1), values, count, true);
}
-
if (Eql(u8, name, "scaleZ")) {
- is_2d.* = false;
- return scaleMatrix(1, 1, nums[0]);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.scale_z, scaleMatrix(1, 1, values[0]), values, count, false);
}
-
if (Eql(u8, name, "scale3d")) {
- is_2d.* = false;
- return scaleMatrix(nums[0], nums[1], nums[2]);
- }
- if (Eql(u8, name, "rotate") or Eql(u8, name, "rotateZ")) {
- if (Eql(u8, name, "rotateZ")) is_2d.* = false;
- return rotateZMatrix(toRadians(nums[0], units[0]));
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 3, 3);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.scale_3d, scaleMatrix(values[0], values[1], values[2]), values, count, false);
}
- if (Eql(u8, name, "rotateX")) {
- is_2d.* = false;
- return rotateXMatrix(toRadians(nums[0], units[0]));
- }
-
- if (Eql(u8, name, "rotateY")) {
- is_2d.* = false;
- return rotateYMatrix(toRadians(nums[0], units[0]));
- }
-
- if (Eql(u8, name, "rotate3d")) {
- is_2d.* = false;
- if (count != 4) {
- return error.SyntaxError;
+ if (Eql(u8, name, "rotate")) {
+ try requireCount(count, 1, if (syntax == .svg) 3 else 1);
+ if (count != 1 and count != 3) return error.SyntaxError;
+ try requireAngle(units[0]);
+ if (syntax == .svg and units[0] != .none) return error.SyntaxError;
+ if (count == 3) try requireUnitless(units[1..3]);
+ var matrix = rotateZMatrix(toRadians(values[0], units[0]));
+ if (count == 3) {
+ matrix = multiplyMatrix(translationMatrix(values[1], values[2], 0), matrix);
+ matrix = multiplyMatrix(matrix, translationMatrix(-values[1], -values[2], 0));
}
- return axisAngleMatrix(nums[0], nums[1], nums[2], toRadians(nums[3], units[3]));
+ return makeParsedTransform(.rotate, matrix, values, count, true);
+ }
+ if (Eql(u8, name, "rotateX") or Eql(u8, name, "rotateY") or Eql(u8, name, "rotateZ")) {
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ try requireAngle(units[0]);
+ if (Eql(u8, name, "rotateX")) return makeParsedTransform(.rotate_x, rotateXMatrix(toRadians(values[0], units[0])), values, count, false);
+ if (Eql(u8, name, "rotateY")) return makeParsedTransform(.rotate_y, rotateYMatrix(toRadians(values[0], units[0])), values, count, false);
+ return makeParsedTransform(.rotate_z, rotateZMatrix(toRadians(values[0], units[0])), values, count, false);
+ }
+ if (Eql(u8, name, "rotate3d")) {
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 4, 4);
+ try requireUnitless(units[0..3]);
+ try requireAngle(units[3]);
+ return makeParsedTransform(.rotate_3d, axisAngleMatrix(values[0], values[1], values[2], toRadians(values[3], units[3])), values, count, false);
}
if (Eql(u8, name, "skew")) {
- const ax = toRadians(nums[0], units[0]);
- const ay = if (count > 1) toRadians(nums[1], units[1]) else 0;
- return skewMatrix(ax, ay);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 2);
+ try requireAngle(units[0]);
+ if (count == 2) try requireAngle(units[1]);
+ const ay = if (count == 2) toRadians(values[1], units[1]) else 0;
+ return makeParsedTransform(.skew, skewMatrix(toRadians(values[0], units[0]), ay), values, count, true);
}
-
- if (Eql(u8, name, "skewX")) {
- return skewMatrix(toRadians(nums[0], units[0]), 0);
- }
-
- if (Eql(u8, name, "skewY")) {
- return skewMatrix(0, toRadians(nums[0], units[0]));
+ if (Eql(u8, name, "skewX") or Eql(u8, name, "skewY")) {
+ try requireCount(count, 1, 1);
+ try requireAngle(units[0]);
+ if (syntax == .svg and units[0] != .none) return error.SyntaxError;
+ if (Eql(u8, name, "skewX")) return makeParsedTransform(.skew_x, skewMatrix(toRadians(values[0], units[0]), 0), values, count, true);
+ return makeParsedTransform(.skew_y, skewMatrix(0, toRadians(values[0], units[0])), values, count, true);
}
if (Eql(u8, name, "perspective")) {
- is_2d.* = false;
- var out = identity();
- if (nums[0] != 0) out[11] = -1.0 / nums[0];
- return out;
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ var matrix = identity();
+ if (values[0] != 0) matrix[11] = -1.0 / values[0];
+ return makeParsedTransform(.perspective, matrix, values, count, false);
}
return error.SyntaxError;
}
+fn makeParsedTransform(kind: TransformKind, matrix: [16]f64, values: [16]f64, count: usize, is_2d: bool) ParsedTransform {
+ return .{ .kind = kind, .matrix = matrix, .values = values, .count = count, .is_2d = is_2d };
+}
+
+fn requireCount(count: usize, minimum: usize, maximum: usize) !void {
+ if (count < minimum or count > maximum) return error.SyntaxError;
+}
+
+fn requireUnitless(units: []const ParsedValue.Unit) !void {
+ for (units) |unit| if (unit != .none) return error.SyntaxError;
+}
+
+fn requireAngle(unit: ParsedValue.Unit) !void {
+ if (unit == .other) return error.SyntaxError;
+}
+
+fn parseArguments(arguments: []const u8, values: *[16]f64, units: *[16]ParsedValue.Unit) !usize {
+ var scanner = ArgumentScanner{ .input = arguments };
+ var count: usize = 0;
+ while (try scanner.next()) |value| {
+ if (count == values.len) return error.SyntaxError;
+ values[count] = value.value;
+ units[count] = value.unit;
+ count += 1;
+ }
+ return count;
+}
+
pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 {
return switch (unit) {
.rad => value,
@@ -491,6 +587,7 @@ pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 {
.turn => value * std.math.tau,
// bare numbers in rotate()/skew() are interpreted as degrees
.deg, .none => value * std.math.pi / 180.0,
+ .other => value,
};
}
@@ -725,43 +822,75 @@ const ParsedValue = struct {
rad,
grad,
turn,
+ other,
};
+};
- // Parses a single CSS dimension token: a number with an optional unit suffix.
- // Length units are ignored (we don't resolve layout), so the numeric part is
- // taken verbatim; angle units are recorded so they can be normalised.
- fn parse(tok: []const u8) !ParsedValue {
- var end: usize = 0;
- while (end < tok.len) : (end += 1) {
- const c = tok[end];
- if ((c >= '0' and c <= '9') or c == '.' or c == '+' or c == '-' or c == 'e' or c == 'E') {
- // 'e'/'E' is ambiguous with exponents; only treat as exponent when
- // followed by a digit/sign.
- if ((c == 'e' or c == 'E') and end > 0) {
- if (end + 1 >= tok.len) break;
- const nx = tok[end + 1];
- if (!((nx >= '0' and nx <= '9') or nx == '+' or nx == '-')) break;
- }
- continue;
- }
- break;
+const ArgumentScanner = struct {
+ input: []const u8,
+ index: usize = 0,
+ first: bool = true,
+
+ fn next(self: *ArgumentScanner) !?ParsedValue {
+ var had_whitespace = false;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
+ had_whitespace = true;
+ self.index += 1;
}
- if (end == 0) {
+
+ if (!self.first and self.index < self.input.len and self.input[self.index] == ',') {
+ self.index += 1;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
+ if (self.index == self.input.len) return error.SyntaxError;
+ } else if (!self.first and self.index < self.input.len and !had_whitespace and
+ self.input[self.index] != '+' and self.input[self.index] != '-')
+ {
return error.SyntaxError;
}
- const value = try std.fmt.parseFloat(f64, tok[0..end]);
- const suffix = tok[end..];
- var unit: Unit = .none;
- if (std.ascii.eqlIgnoreCase(suffix, "deg")) {
- unit = .deg;
- } else if (std.ascii.eqlIgnoreCase(suffix, "rad")) {
- unit = .rad;
- } else if (std.ascii.eqlIgnoreCase(suffix, "grad")) {
- unit = .grad;
- } else if (std.ascii.eqlIgnoreCase(suffix, "turn")) {
- unit = .turn;
+ if (self.index == self.input.len) return null;
+ const start = self.index;
+ if (self.input[self.index] == '+' or self.input[self.index] == '-') self.index += 1;
+
+ var digits: usize = 0;
+ while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
+ if (self.index < self.input.len and self.input[self.index] == '.') {
+ self.index += 1;
+ while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
}
+ if (digits == 0) return error.SyntaxError;
+
+ if (self.index < self.input.len and (self.input[self.index] == 'e' or self.input[self.index] == 'E')) {
+ self.index += 1;
+ if (self.index < self.input.len and (self.input[self.index] == '+' or self.input[self.index] == '-')) self.index += 1;
+ const exponent_start = self.index;
+ while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) self.index += 1;
+ if (self.index == exponent_start) return error.SyntaxError;
+ }
+
+ const number_end = self.index;
+ while (self.index < self.input.len and
+ (std.ascii.isAlphabetic(self.input[self.index]) or self.input[self.index] == '%'))
+ {
+ self.index += 1;
+ }
+ const suffix = self.input[number_end..self.index];
+ const unit: ParsedValue.Unit = if (suffix.len == 0)
+ .none
+ else if (std.ascii.eqlIgnoreCase(suffix, "deg"))
+ .deg
+ else if (std.ascii.eqlIgnoreCase(suffix, "rad"))
+ .rad
+ else if (std.ascii.eqlIgnoreCase(suffix, "grad"))
+ .grad
+ else if (std.ascii.eqlIgnoreCase(suffix, "turn"))
+ .turn
+ else
+ .other;
+
+ const value = std.fmt.parseFloat(f64, self.input[start..number_end]) catch return error.SyntaxError;
+ if (!std.math.isFinite(value)) return error.SyntaxError;
+ self.first = false;
return .{ .value = value, .unit = unit };
}
};
diff --git a/src/browser/webapi/DOMPoint.zig b/src/browser/webapi/DOMPoint.zig
index 0df456604..753b28811 100644
--- a/src/browser/webapi/DOMPoint.zig
+++ b/src/browser/webapi/DOMPoint.zig
@@ -17,6 +17,7 @@
// along with this program. If not, see .
const js = @import("../js/js.zig");
+const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const RO = @import("DOMPointReadOnly.zig");
@@ -77,17 +78,17 @@ pub fn getW(self: *const DOMPoint) f64 {
return self._proto._w;
}
-pub fn setX(self: *DOMPoint, v: f64) void {
- self._proto._x = v;
+pub fn setX(self: *DOMPoint, v: f64, frame: *Frame) !void {
+ try self._proto.setCoordinate(.x, v, frame);
}
-pub fn setY(self: *DOMPoint, v: f64) void {
- self._proto._y = v;
+pub fn setY(self: *DOMPoint, v: f64, frame: *Frame) !void {
+ try self._proto.setCoordinate(.y, v, frame);
}
-pub fn setZ(self: *DOMPoint, v: f64) void {
- self._proto._z = v;
+pub fn setZ(self: *DOMPoint, v: f64, frame: *Frame) !void {
+ try self._proto.setCoordinate(.z, v, frame);
}
-pub fn setW(self: *DOMPoint, v: f64) void {
- self._proto._w = v;
+pub fn setW(self: *DOMPoint, v: f64, frame: *Frame) !void {
+ try self._proto.setCoordinate(.w, v, frame);
}
pub const JsApi = struct {
diff --git a/src/browser/webapi/DOMPointReadOnly.zig b/src/browser/webapi/DOMPointReadOnly.zig
index db91e5a3f..2a349650e 100644
--- a/src/browser/webapi/DOMPointReadOnly.zig
+++ b/src/browser/webapi/DOMPointReadOnly.zig
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
+const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const DOMPoint = @import("DOMPoint.zig");
const Matrix = @import("DOMMatrixReadOnly.zig");
@@ -38,6 +39,15 @@ _x: f64,
_y: f64,
_z: f64,
_w: f64,
+_attachment: ?Attachment = null,
+
+pub const Coordinate = enum { x, y, z, w };
+
+pub const Attachment = struct {
+ owner: *anyopaque,
+ read_only: bool,
+ mutate: *const fn (*anyopaque, *DOMPointReadOnly, Coordinate, f64, *Frame) anyerror!void,
+};
pub const Type = union(enum) {
generic,
@@ -132,6 +142,41 @@ pub fn getW(self: *const DOMPointReadOnly) f64 {
return self._w;
}
+pub fn setCoordinate(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64, frame: *Frame) !void {
+ if (self._attachment) |attachment| {
+ if (attachment.read_only) return error.NoModificationAllowed;
+ return attachment.mutate(attachment.owner, self, coordinate, value, frame);
+ }
+ self.setCoordinateRaw(coordinate, value);
+}
+
+pub fn setCoordinateRaw(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) void {
+ switch (coordinate) {
+ .x => self._x = value,
+ .y => self._y = value,
+ .z => self._z = value,
+ .w => self._w = value,
+ }
+}
+
+pub fn attach(self: *DOMPointReadOnly, attachment: Attachment) void {
+ self._attachment = attachment;
+}
+
+pub fn detach(self: *DOMPointReadOnly, owner: *anyopaque) void {
+ const attachment = self._attachment orelse return;
+ if (attachment.owner == owner) self._attachment = null;
+}
+
+pub fn isAttached(self: *const DOMPointReadOnly) bool {
+ return self._attachment != null;
+}
+
+pub fn isAttachedTo(self: *const DOMPointReadOnly, owner: *anyopaque) bool {
+ const attachment = self._attachment orelse return false;
+ return attachment.owner == owner;
+}
+
pub fn toJSON(self: *const DOMPointReadOnly) struct {
x: f64,
y: f64,
diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig
index ccb1386b9..cb9c27502 100644
--- a/src/browser/webapi/element/svg/Graphics.zig
+++ b/src/browser/webapi/element/svg/Graphics.zig
@@ -17,9 +17,12 @@
// along with this program. If not, see .
const js = @import("../../../js/js.zig");
+const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const SvgElement = @import("../Svg.zig");
+const AnimatedTransformList = @import("../../svg/AnimatedTransformList.zig");
+const StringList = @import("../../svg/StringList.zig");
pub const Svg = @import("Svg.zig");
pub const G = @import("G.zig");
@@ -32,6 +35,9 @@ pub const Geometry = @import("Geometry.zig");
const Graphics = @This();
_proto: *SvgElement,
_type: Type,
+_transform: ?*AnimatedTransformList = null,
+_required_extensions: ?*StringList = null,
+_system_language: ?*StringList = null,
pub const Type = union(enum) {
svg: *Svg,
@@ -72,4 +78,39 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
+
+ pub const transform = bridge.accessor(Graphics.getTransform, null, .{});
+ pub const requiredExtensions = bridge.accessor(Graphics.getRequiredExtensions, null, .{});
+ pub const systemLanguage = bridge.accessor(Graphics.getSystemLanguage, null, .{});
};
+
+pub fn getTransform(self: *Graphics, frame: *Frame) !*AnimatedTransformList {
+ if (self._transform == null) {
+ self._transform = try AnimatedTransformList.create(self.asElement(), frame);
+ }
+ return self._transform.?;
+}
+
+pub fn getRequiredExtensions(self: *Graphics, frame: *Frame) !*StringList {
+ if (self._required_extensions == null) {
+ self._required_extensions = try StringList.create(
+ self.asElement(),
+ .wrap("requiredExtensions"),
+ .whitespace,
+ frame,
+ );
+ }
+ return self._required_extensions.?;
+}
+
+pub fn getSystemLanguage(self: *Graphics, frame: *Frame) !*StringList {
+ if (self._system_language == null) {
+ self._system_language = try StringList.create(
+ self.asElement(),
+ .wrap("systemLanguage"),
+ .comma,
+ frame,
+ );
+ }
+ return self._system_language.?;
+}
diff --git a/src/browser/webapi/element/svg/Polygon.zig b/src/browser/webapi/element/svg/Polygon.zig
index 752917079..b81b2939c 100644
--- a/src/browser/webapi/element/svg/Polygon.zig
+++ b/src/browser/webapi/element/svg/Polygon.zig
@@ -17,14 +17,18 @@
// along with this program. If not, see .
const js = @import("../../../js/js.zig");
+const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const Geometry = @import("Geometry.zig");
+const PointList = @import("../../svg/PointList.zig");
const Polygon = @This();
_proto: *Geometry,
+_points: ?*PointList = null,
+_animated_points: ?*PointList = null,
pub fn asElement(self: *Polygon) *Element {
return self._proto.asElement();
@@ -41,4 +45,21 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
+
+ pub const points = bridge.accessor(Polygon.getPoints, null, .{});
+ pub const animatedPoints = bridge.accessor(Polygon.getAnimatedPoints, null, .{});
};
+
+pub fn getPoints(self: *Polygon, frame: *Frame) !*PointList {
+ if (self._points == null) {
+ self._points = try PointList.create(self.asElement(), false, frame);
+ }
+ return self._points.?;
+}
+
+pub fn getAnimatedPoints(self: *Polygon, frame: *Frame) !*PointList {
+ if (self._animated_points == null) {
+ self._animated_points = try PointList.create(self.asElement(), true, frame);
+ }
+ return self._animated_points.?;
+}
diff --git a/src/browser/webapi/element/svg/Polyline.zig b/src/browser/webapi/element/svg/Polyline.zig
index a232e4950..7e1b3b19f 100644
--- a/src/browser/webapi/element/svg/Polyline.zig
+++ b/src/browser/webapi/element/svg/Polyline.zig
@@ -17,14 +17,18 @@
// along with this program. If not, see .
const js = @import("../../../js/js.zig");
+const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const Geometry = @import("Geometry.zig");
+const PointList = @import("../../svg/PointList.zig");
const Polyline = @This();
_proto: *Geometry,
+_points: ?*PointList = null,
+_animated_points: ?*PointList = null,
pub fn asElement(self: *Polyline) *Element {
return self._proto.asElement();
@@ -41,4 +45,21 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
+
+ pub const points = bridge.accessor(Polyline.getPoints, null, .{});
+ pub const animatedPoints = bridge.accessor(Polyline.getAnimatedPoints, null, .{});
};
+
+pub fn getPoints(self: *Polyline, frame: *Frame) !*PointList {
+ if (self._points == null) {
+ self._points = try PointList.create(self.asElement(), false, frame);
+ }
+ return self._points.?;
+}
+
+pub fn getAnimatedPoints(self: *Polyline, frame: *Frame) !*PointList {
+ if (self._animated_points == null) {
+ self._animated_points = try PointList.create(self.asElement(), true, frame);
+ }
+ return self._animated_points.?;
+}
diff --git a/src/browser/webapi/svg/AnimatedTransformList.zig b/src/browser/webapi/svg/AnimatedTransformList.zig
new file mode 100644
index 000000000..8c62b1d12
--- /dev/null
+++ b/src/browser/webapi/svg/AnimatedTransformList.zig
@@ -0,0 +1,46 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+const js = @import("../../js/js.zig");
+const Frame = @import("../../Frame.zig");
+const Element = @import("../Element.zig");
+const TransformList = @import("TransformList.zig");
+
+const AnimatedTransformList = @This();
+
+_base_val: *TransformList,
+_anim_val: *TransformList,
+
+pub fn create(element: *Element, frame: *Frame) !*AnimatedTransformList {
+ const base_val = try TransformList.create(element, false, frame);
+ const anim_val = try TransformList.create(element, true, frame);
+ return frame._factory.create(AnimatedTransformList{
+ ._base_val = base_val,
+ ._anim_val = anim_val,
+ });
+}
+
+pub fn getBaseVal(self: *AnimatedTransformList) *TransformList {
+ return self._base_val;
+}
+
+pub fn getAnimVal(self: *AnimatedTransformList) *TransformList {
+ return self._anim_val;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(AnimatedTransformList);
+
+ pub const Meta = struct {
+ pub const name = "SVGAnimatedTransformList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const baseVal = bridge.accessor(AnimatedTransformList.getBaseVal, null, .{});
+ pub const animVal = bridge.accessor(AnimatedTransformList.getAnimVal, null, .{});
+};
diff --git a/src/browser/webapi/svg/PointList.zig b/src/browser/webapi/svg/PointList.zig
new file mode 100644
index 000000000..2fef4c8f5
--- /dev/null
+++ b/src/browser/webapi/svg/PointList.zig
@@ -0,0 +1,353 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+const std = @import("std");
+const lp = @import("lightpanda");
+
+const js = @import("../../js/js.zig");
+const Frame = @import("../../Frame.zig");
+const Page = @import("../../Page.zig");
+const DOMPoint = @import("../DOMPoint.zig");
+const DOMPointReadOnly = @import("../DOMPointReadOnly.zig");
+const Element = @import("../Element.zig");
+
+const PointList = @This();
+
+_element: *Element,
+_read_only: bool,
+_items: std.ArrayList(*DOMPoint) = .empty,
+_retired: std.ArrayList(*DOMPoint) = .empty,
+_snapshot: ?[]const u8 = null,
+
+pub fn create(element: *Element, read_only: bool, frame: *Frame) !*PointList {
+ const self = try frame._factory.create(PointList{
+ ._element = element,
+ ._read_only = read_only,
+ });
+ try frame.registerSvgCollectionCleanup(.{
+ .context = self,
+ .callback = PointList.cleanup,
+ });
+ return self;
+}
+
+fn cleanup(context: *anyopaque, page: *Page) void {
+ const self: *PointList = @ptrCast(@alignCast(context));
+ for (self._items.items) |point| {
+ point._proto.detach(self);
+ point._proto.releaseRef(page);
+ }
+ for (self._retired.items) |point| {
+ point._proto.releaseRef(page);
+ }
+ self._items.clearRetainingCapacity();
+ self._retired.clearRetainingCapacity();
+}
+
+pub fn getLength(self: *PointList, frame: *Frame) !u32 {
+ try self.sync(frame);
+ return @intCast(self._items.items.len);
+}
+
+pub fn getNumberOfItems(self: *PointList, frame: *Frame) !u32 {
+ return self.getLength(frame);
+}
+
+pub fn clear(self: *PointList, frame: *Frame) !void {
+ try self.requireMutable();
+ try self.sync(frame);
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ try self.setAttribute(&.{}, frame);
+ self.retireAllAssumeCapacity();
+}
+
+pub fn initialize(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
+ try self.requireMutable();
+ try self.sync(frame);
+
+ const prepared = try self.prepareItem(item, frame);
+ errdefer prepared._proto.releaseRef(frame._page);
+
+ try self._items.ensureTotalCapacity(frame.arena, 1);
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ try self.setAttribute(&.{prepared}, frame);
+ self.retireAllAssumeCapacity();
+ self._items.appendAssumeCapacity(prepared);
+ self.attach(prepared);
+ return prepared;
+}
+
+pub fn getItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint {
+ try self.sync(frame);
+ if (index >= self._items.items.len) return error.IndexSizeError;
+ return self._items.items[index];
+}
+
+pub fn insertItemBefore(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint {
+ try self.requireMutable();
+ try self.sync(frame);
+
+ const prepared = try self.prepareItem(item, frame);
+ errdefer prepared._proto.releaseRef(frame._page);
+ const at = @min(@as(usize, index), self._items.items.len);
+ const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len + 1);
+ @memcpy(next[0..at], self._items.items[0..at]);
+ next[at] = prepared;
+ @memcpy(next[at + 1 ..], self._items.items[at..]);
+
+ try self._items.ensureUnusedCapacity(frame.arena, 1);
+ try self.setAttribute(next, frame);
+ self._items.insertAssumeCapacity(at, prepared);
+ self.attach(prepared);
+ return prepared;
+}
+
+pub fn replaceItem(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint {
+ try self.requireMutable();
+ try self.sync(frame);
+ if (index >= self._items.items.len) return error.IndexSizeError;
+
+ const prepared = try self.prepareItem(item, frame);
+ errdefer prepared._proto.releaseRef(frame._page);
+ const next = try frame.local_arena.dupe(*DOMPoint, self._items.items);
+ next[index] = prepared;
+
+ try self._retired.ensureUnusedCapacity(frame.arena, 1);
+ try self.setAttribute(next, frame);
+ const replaced = self._items.items[index];
+ replaced._proto.detach(self);
+ self._retired.appendAssumeCapacity(replaced);
+ self._items.items[index] = prepared;
+ self.attach(prepared);
+ return prepared;
+}
+
+pub fn removeItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint {
+ try self.requireMutable();
+ try self.sync(frame);
+ if (index >= self._items.items.len) return error.IndexSizeError;
+
+ const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len - 1);
+ @memcpy(next[0..index], self._items.items[0..index]);
+ @memcpy(next[index..], self._items.items[index + 1 ..]);
+
+ try self._retired.ensureUnusedCapacity(frame.arena, 1);
+ try self.setAttribute(next, frame);
+ const removed = self._items.orderedRemove(index);
+ removed._proto.detach(self);
+ self._retired.appendAssumeCapacity(removed);
+ return removed;
+}
+
+pub fn appendItem(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
+ return self.insertItemBefore(item, std.math.maxInt(u32), frame);
+}
+
+fn requireMutable(self: *const PointList) !void {
+ if (self._read_only) return error.NoModificationAllowed;
+}
+
+fn prepareItem(_: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
+ if (!std.math.isFinite(item._proto._x) or !std.math.isFinite(item._proto._y)) return error.TypeError;
+ const prepared = if (item._proto.isAttached())
+ try DOMPoint.create(item._proto._x, item._proto._y, item._proto._z, item._proto._w, frame._page)
+ else
+ item;
+ prepared._proto.acquireRef();
+ return prepared;
+}
+
+fn attach(self: *PointList, point: *DOMPoint) void {
+ point._proto.attach(.{
+ .owner = self,
+ .read_only = self._read_only,
+ .mutate = PointList.mutatePoint,
+ });
+}
+
+fn mutatePoint(
+ context: *anyopaque,
+ point: *DOMPointReadOnly,
+ coordinate: DOMPointReadOnly.Coordinate,
+ value: f64,
+ frame: *Frame,
+) anyerror!void {
+ const self: *PointList = @ptrCast(@alignCast(context));
+ try self.sync(frame);
+
+ // An external attribute mutation detaches the old item during sync. The
+ // caller still owns that DOMPoint identity, but it no longer mutates the list.
+ if (!point.isAttachedTo(self)) {
+ point.setCoordinateRaw(coordinate, value);
+ return;
+ }
+
+ if (coordinate == .z or coordinate == .w) {
+ point.setCoordinateRaw(coordinate, value);
+ return;
+ }
+ if (!std.math.isFinite(value)) return error.TypeError;
+
+ const index = for (self._items.items, 0..) |candidate, i| {
+ if (candidate._proto == point) break i;
+ } else unreachable;
+
+ try self.setAttributeWithOverride(index, coordinate, value, frame);
+ point.setCoordinateRaw(coordinate, value);
+}
+
+fn sync(self: *PointList, frame: *Frame) !void {
+ const raw = self._element.getAttributeSafe(comptime .wrap("points")) orelse "";
+ if (self._snapshot) |snapshot| {
+ if (std.mem.eql(u8, snapshot, raw)) return;
+ }
+
+ const snapshot = try frame.arena.dupe(u8, raw);
+ var parsed = parse(raw, frame) catch |err| switch (err) {
+ error.SyntaxError => std.ArrayList(*DOMPoint).empty,
+ else => return err,
+ };
+ errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page);
+
+ try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ self.retireAllAssumeCapacity();
+ for (parsed.items) |point| {
+ self._items.appendAssumeCapacity(point);
+ self.attach(point);
+ }
+ parsed.clearRetainingCapacity();
+ self._snapshot = snapshot;
+}
+
+fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) {
+ var scanner = NumberScanner{ .input = raw };
+ var parsed: std.ArrayList(*DOMPoint) = .empty;
+ errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page);
+
+ while (try scanner.next()) |x| {
+ const y = (try scanner.next()) orelse return error.SyntaxError;
+ const point = try DOMPoint.create(x, y, 0, 1, frame._page);
+ point._proto.acquireRef();
+ parsed.append(frame.local_arena, point) catch |err| {
+ point._proto.releaseRef(frame._page);
+ return err;
+ };
+ }
+ return parsed;
+}
+
+fn retireAllAssumeCapacity(self: *PointList) void {
+ for (self._items.items) |point| {
+ point._proto.detach(self);
+ self._retired.appendAssumeCapacity(point);
+ }
+ self._items.clearRetainingCapacity();
+}
+
+fn setAttribute(self: *PointList, items: []const *DOMPoint, frame: *Frame) !void {
+ var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
+ const writer = &serialized.writer;
+ for (items, 0..) |point, i| {
+ if (i != 0) try writer.writeByte(' ');
+ try writer.print("{d},{d}", .{ point._proto._x, point._proto._y });
+ }
+ try self.commitAttribute(serialized.written(), frame);
+}
+
+fn setAttributeWithOverride(
+ self: *PointList,
+ index: usize,
+ coordinate: DOMPointReadOnly.Coordinate,
+ value: f64,
+ frame: *Frame,
+) !void {
+ var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
+ const writer = &serialized.writer;
+ for (self._items.items, 0..) |point, i| {
+ if (i != 0) try writer.writeByte(' ');
+ const x = if (i == index and coordinate == .x) value else point._proto._x;
+ const y = if (i == index and coordinate == .y) value else point._proto._y;
+ try writer.print("{d},{d}", .{ x, y });
+ }
+ try self.commitAttribute(serialized.written(), frame);
+}
+
+fn commitAttribute(self: *PointList, serialized: []const u8, frame: *Frame) !void {
+ const snapshot = try frame.arena.dupe(u8, serialized);
+ try self._element.setAttributeSafe(comptime .wrap("points"), .wrap(serialized), frame);
+ self._snapshot = snapshot;
+}
+
+const NumberScanner = struct {
+ input: []const u8,
+ index: usize = 0,
+ first: bool = true,
+
+ fn next(self: *NumberScanner) !?f64 {
+ var had_whitespace = false;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
+ had_whitespace = true;
+ self.index += 1;
+ }
+
+ if (!self.first and self.index < self.input.len and self.input[self.index] == ',') {
+ self.index += 1;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
+ if (self.index == self.input.len) return error.SyntaxError;
+ } else if (!self.first and self.index < self.input.len and !had_whitespace and
+ self.input[self.index] != '+' and self.input[self.index] != '-')
+ {
+ return error.SyntaxError;
+ }
+
+ if (self.index == self.input.len) return null;
+ const start = self.index;
+ if (self.input[self.index] == '+' or self.input[self.index] == '-') self.index += 1;
+
+ var digits: usize = 0;
+ while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
+ if (self.index < self.input.len and self.input[self.index] == '.') {
+ self.index += 1;
+ while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
+ }
+ if (digits == 0) return error.SyntaxError;
+
+ if (self.index < self.input.len and (self.input[self.index] == 'e' or self.input[self.index] == 'E')) {
+ self.index += 1;
+ if (self.index < self.input.len and (self.input[self.index] == '+' or self.input[self.index] == '-')) self.index += 1;
+ const exponent_start = self.index;
+ while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) self.index += 1;
+ if (self.index == exponent_start) return error.SyntaxError;
+ }
+
+ const value = std.fmt.parseFloat(f64, self.input[start..self.index]) catch return error.SyntaxError;
+ if (!std.math.isFinite(value)) return error.SyntaxError;
+ self.first = false;
+ return value;
+ }
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(PointList);
+
+ pub const Meta = struct {
+ pub const name = "SVGPointList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(PointList.getLength, null, .{});
+ pub const numberOfItems = bridge.accessor(PointList.getNumberOfItems, null, .{});
+ pub const clear = bridge.function(PointList.clear, .{});
+ pub const initialize = bridge.function(PointList.initialize, .{});
+ pub const getItem = bridge.function(PointList.getItem, .{});
+ pub const insertItemBefore = bridge.function(PointList.insertItemBefore, .{});
+ pub const replaceItem = bridge.function(PointList.replaceItem, .{});
+ pub const removeItem = bridge.function(PointList.removeItem, .{});
+ pub const appendItem = bridge.function(PointList.appendItem, .{});
+};
diff --git a/src/browser/webapi/svg/StringList.zig b/src/browser/webapi/svg/StringList.zig
new file mode 100644
index 000000000..8c866209b
--- /dev/null
+++ b/src/browser/webapi/svg/StringList.zig
@@ -0,0 +1,179 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+const std = @import("std");
+const lp = @import("lightpanda");
+
+const js = @import("../../js/js.zig");
+const Frame = @import("../../Frame.zig");
+const Element = @import("../Element.zig");
+
+const StringList = @This();
+
+pub const Delimiter = enum { whitespace, comma };
+
+_element: *Element,
+_attribute_name: lp.String,
+_delimiter: Delimiter,
+_items: std.ArrayList([]const u8) = .empty,
+_snapshot: ?[]const u8 = null,
+
+pub fn create(element: *Element, attribute_name: lp.String, delimiter: Delimiter, frame: *Frame) !*StringList {
+ return frame._factory.create(StringList{
+ ._element = element,
+ ._attribute_name = attribute_name,
+ ._delimiter = delimiter,
+ });
+}
+
+pub fn getLength(self: *StringList, frame: *Frame) !u32 {
+ try self.sync(frame);
+ return @intCast(self._items.items.len);
+}
+
+pub fn getNumberOfItems(self: *StringList, frame: *Frame) !u32 {
+ return self.getLength(frame);
+}
+
+pub fn clear(self: *StringList, frame: *Frame) !void {
+ try self.commit(&.{}, frame);
+}
+
+pub fn initialize(self: *StringList, item: []const u8, frame: *Frame) ![]const u8 {
+ try self.validateItem(item);
+ try self.commit(&.{item}, frame);
+ return self._items.items[0];
+}
+
+pub fn getItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 {
+ try self.sync(frame);
+ if (index >= self._items.items.len) return error.IndexSizeError;
+ return self._items.items[index];
+}
+
+pub fn insertItemBefore(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 {
+ try self.validateItem(item);
+ try self.sync(frame);
+ const at = @min(@as(usize, index), self._items.items.len);
+ const next = try frame.local_arena.alloc([]const u8, self._items.items.len + 1);
+ @memcpy(next[0..at], self._items.items[0..at]);
+ next[at] = item;
+ @memcpy(next[at + 1 ..], self._items.items[at..]);
+ try self.commit(next, frame);
+ return self._items.items[at];
+}
+
+pub fn replaceItem(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 {
+ try self.validateItem(item);
+ try self.sync(frame);
+ if (index >= self._items.items.len) return error.IndexSizeError;
+ const next = try frame.local_arena.dupe([]const u8, self._items.items);
+ next[index] = item;
+ try self.commit(next, frame);
+ return self._items.items[index];
+}
+
+pub fn removeItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 {
+ try self.sync(frame);
+ if (index >= self._items.items.len) return error.IndexSizeError;
+ const removed = try frame.local_arena.dupe(u8, self._items.items[index]);
+ const next = try frame.local_arena.alloc([]const u8, self._items.items.len - 1);
+ @memcpy(next[0..index], self._items.items[0..index]);
+ @memcpy(next[index..], self._items.items[index + 1 ..]);
+ try self.commit(next, frame);
+ return removed;
+}
+
+pub fn appendItem(self: *StringList, item: []const u8, frame: *Frame) ![]const u8 {
+ return self.insertItemBefore(item, std.math.maxInt(u32), frame);
+}
+
+fn validateItem(self: *const StringList, item: []const u8) !void {
+ if (item.len == 0) return error.SyntaxError;
+ for (item) |byte| {
+ if (std.ascii.isWhitespace(byte) or (self._delimiter == .comma and byte == ',')) {
+ return error.SyntaxError;
+ }
+ }
+}
+
+fn sync(self: *StringList, frame: *Frame) !void {
+ const raw = self._element.getAttributeSafe(self._attribute_name) orelse "";
+ if (self._snapshot) |snapshot| if (std.mem.eql(u8, snapshot, raw)) return;
+
+ const snapshot = try frame.arena.dupe(u8, raw);
+ var parsed = parse(snapshot, self._delimiter, frame.local_arena) catch |err| switch (err) {
+ error.SyntaxError => std.ArrayList([]const u8).empty,
+ else => return err,
+ };
+ try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
+ self._items.clearRetainingCapacity();
+ for (parsed.items) |item| self._items.appendAssumeCapacity(item);
+ parsed.clearRetainingCapacity();
+ self._snapshot = snapshot;
+}
+
+fn commit(self: *StringList, items: []const []const u8, frame: *Frame) !void {
+ var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
+ const writer = &serialized.writer;
+ for (items, 0..) |item, i| {
+ try self.validateItem(item);
+ if (i != 0) try writer.writeAll(if (self._delimiter == .comma) ", " else " ");
+ try writer.writeAll(item);
+ }
+
+ const serialized_bytes = serialized.written();
+ const snapshot = try frame.arena.dupe(u8, serialized_bytes);
+ var parsed = try parse(snapshot, self._delimiter, frame.local_arena);
+ try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
+ try self._element.setAttributeSafe(self._attribute_name, .wrap(serialized_bytes), frame);
+ self._items.clearRetainingCapacity();
+ for (parsed.items) |item| self._items.appendAssumeCapacity(item);
+ parsed.clearRetainingCapacity();
+ self._snapshot = snapshot;
+}
+
+fn parse(raw: []const u8, delimiter: Delimiter, allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
+ var items: std.ArrayList([]const u8) = .empty;
+ switch (delimiter) {
+ .whitespace => {
+ var iterator = std.mem.tokenizeAny(u8, raw, " \t\r\n\x0c");
+ while (iterator.next()) |item| try items.append(allocator, item);
+ },
+ .comma => {
+ if (std.mem.trim(u8, raw, " \t\r\n\x0c").len == 0) return items;
+ var iterator = std.mem.splitScalar(u8, raw, ',');
+ while (iterator.next()) |part| {
+ const item = std.mem.trim(u8, part, " \t\r\n\x0c");
+ if (item.len == 0) return error.SyntaxError;
+ for (item) |byte| if (std.ascii.isWhitespace(byte)) return error.SyntaxError;
+ try items.append(allocator, item);
+ }
+ },
+ }
+ return items;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(StringList);
+
+ pub const Meta = struct {
+ pub const name = "SVGStringList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(StringList.getLength, null, .{});
+ pub const numberOfItems = bridge.accessor(StringList.getNumberOfItems, null, .{});
+ pub const clear = bridge.function(StringList.clear, .{});
+ pub const initialize = bridge.function(StringList.initialize, .{});
+ pub const getItem = bridge.function(StringList.getItem, .{});
+ pub const insertItemBefore = bridge.function(StringList.insertItemBefore, .{});
+ pub const replaceItem = bridge.function(StringList.replaceItem, .{});
+ pub const removeItem = bridge.function(StringList.removeItem, .{});
+ pub const appendItem = bridge.function(StringList.appendItem, .{});
+};
diff --git a/src/browser/webapi/svg/Transform.zig b/src/browser/webapi/svg/Transform.zig
index fe6c09e30..09d6f13a9 100644
--- a/src/browser/webapi/svg/Transform.zig
+++ b/src/browser/webapi/svg/Transform.zig
@@ -43,7 +43,25 @@ pub const DOMMatrix2DInit = struct {
_type: u16 = 1,
_angle: f64 = 0,
+_cx: f64 = 0,
+_cy: f64 = 0,
_matrix: *DOMMatrix,
+_attachment: ?Attachment = null,
+
+pub const State = struct {
+ typ: u16,
+ angle: f64,
+ cx: f64,
+ cy: f64,
+ matrix: [16]f64,
+ is_2d: bool,
+};
+
+pub const Attachment = struct {
+ owner: *anyopaque,
+ read_only: bool,
+ mutate: *const fn (*anyopaque, *Transform, State, *Frame) anyerror!void,
+};
// The transform owns the matrix arena even when no JS wrapper currently
// references `matrix`. Forwarding the transform's bridge lifetime keeps the
@@ -58,15 +76,51 @@ pub fn releaseRef(self: *Transform, page: *Page) void {
pub fn detached(frame: *Frame) !*Transform {
const matrix = try DOMMatrix.create(RO.identity(), true, frame._page);
+ errdefer matrix._proto.deinit(frame._page);
return frame._factory.create(Transform{ ._matrix = matrix });
}
pub fn fromMatrix(init: ?DOMMatrix2DInit, frame: *Frame) !*Transform {
const parsed = try fixup2D(init orelse .{});
const matrix = try DOMMatrix.create(parsed.m, true, frame._page);
+ errdefer matrix._proto.deinit(frame._page);
return frame._factory.create(Transform{ ._matrix = matrix });
}
+pub fn fromParsed(parsed: RO.ParsedTransform, frame: *Frame) !*Transform {
+ const typ: u16 = switch (parsed.kind) {
+ .matrix => 1,
+ .translate => 2,
+ .scale => 3,
+ .rotate => 4,
+ .skew_x => 5,
+ .skew_y => 6,
+ else => return error.SyntaxError,
+ };
+ const matrix = try DOMMatrix.create(parsed.matrix, parsed.is_2d, frame._page);
+ errdefer matrix._proto.deinit(frame._page);
+ return frame._factory.create(Transform{
+ ._type = typ,
+ ._angle = if (typ >= 4) parsed.values[0] else 0,
+ ._cx = if (typ == 4 and parsed.count == 3) parsed.values[1] else 0,
+ ._cy = if (typ == 4 and parsed.count == 3) parsed.values[2] else 0,
+ ._matrix = matrix,
+ });
+}
+
+pub fn clone(self: *const Transform, frame: *Frame) !*Transform {
+ const current = self.getState();
+ const matrix = try DOMMatrix.create(current.matrix, current.is_2d, frame._page);
+ errdefer matrix._proto.deinit(frame._page);
+ return frame._factory.create(Transform{
+ ._type = current.typ,
+ ._angle = current.angle,
+ ._cx = current.cx,
+ ._cy = current.cy,
+ ._matrix = matrix,
+ });
+}
+
pub fn getType(self: *const Transform) u16 {
return self._type;
}
@@ -79,59 +133,106 @@ pub fn getAngle(self: *const Transform) f64 {
return self._angle;
}
-pub fn setMatrix(self: *Transform, init: ?DOMMatrix2DInit) !void {
+pub fn setMatrix(self: *Transform, init: ?DOMMatrix2DInit, frame: *Frame) !void {
const parsed = try fixup2D(init orelse .{});
- self.replaceMatrix(parsed.m, true);
- self._type = 1;
- self._angle = 0;
+ try self.applyState(.{ .typ = 1, .angle = 0, .cx = 0, .cy = 0, .matrix = parsed.m, .is_2d = true }, frame);
}
-pub fn setTranslate(self: *Transform, tx: f64, ty: f64) !void {
+pub fn setTranslate(self: *Transform, tx: f64, ty: f64, frame: *Frame) !void {
try ensureFinite(&.{ tx, ty });
- self.replaceMatrix(RO.translationMatrix(tx, ty, 0), true);
- self._type = 2;
- self._angle = 0;
+ try self.applyState(.{ .typ = 2, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.translationMatrix(tx, ty, 0), .is_2d = true }, frame);
}
-pub fn setScale(self: *Transform, sx: f64, sy: f64) !void {
+pub fn setScale(self: *Transform, sx: f64, sy: f64, frame: *Frame) !void {
try ensureFinite(&.{ sx, sy });
- self.replaceMatrix(RO.scaleMatrix(sx, sy, 1), true);
- self._type = 3;
- self._angle = 0;
+ try self.applyState(.{ .typ = 3, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.scaleMatrix(sx, sy, 1), .is_2d = true }, frame);
}
-pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void {
+pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64, frame: *Frame) !void {
try ensureFinite(&.{ angle, cx, cy });
const radians = angle * std.math.pi / 180.0;
var matrix = RO.translationMatrix(cx, cy, 0);
matrix = RO.multiplyMatrix(matrix, RO.rotateZMatrix(radians));
matrix = RO.multiplyMatrix(matrix, RO.translationMatrix(-cx, -cy, 0));
- self.replaceMatrix(matrix, true);
- self._type = 4;
- self._angle = angle;
+ try self.applyState(.{ .typ = 4, .angle = angle, .cx = cx, .cy = cy, .matrix = matrix, .is_2d = true }, frame);
}
-pub fn setSkewX(self: *Transform, angle: f64) !void {
+pub fn setSkewX(self: *Transform, angle: f64, frame: *Frame) !void {
try ensureFinite(&.{angle});
- self.replaceMatrix(RO.skewMatrix(angle * std.math.pi / 180.0, 0), true);
- self._type = 5;
- self._angle = angle;
+ try self.applyState(.{ .typ = 5, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(angle * std.math.pi / 180.0, 0), .is_2d = true }, frame);
}
-pub fn setSkewY(self: *Transform, angle: f64) !void {
+pub fn setSkewY(self: *Transform, angle: f64, frame: *Frame) !void {
try ensureFinite(&.{angle});
- self.replaceMatrix(RO.skewMatrix(0, angle * std.math.pi / 180.0), true);
- self._type = 6;
- self._angle = angle;
+ try self.applyState(.{ .typ = 6, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(0, angle * std.math.pi / 180.0), .is_2d = true }, frame);
}
-fn replaceMatrix(self: *Transform, matrix: [16]f64, is_2d: bool) void {
- self._matrix._proto._m = matrix;
- self._matrix._proto._is_2d = is_2d;
+pub fn getState(self: *const Transform) State {
+ return .{
+ .typ = self._type,
+ .angle = self._angle,
+ .cx = self._cx,
+ .cy = self._cy,
+ .matrix = self._matrix._proto._m,
+ .is_2d = self._matrix._proto._is_2d,
+ };
+}
+
+pub fn applyStateRaw(self: *Transform, state: State) void {
+ self._type = state.typ;
+ self._angle = state.angle;
+ self._cx = state.cx;
+ self._cy = state.cy;
+ self._matrix._proto._m = state.matrix;
+ self._matrix._proto._is_2d = state.is_2d;
+}
+
+fn applyState(self: *Transform, state: State, frame: *Frame) !void {
+ try ensureFinite(&state.matrix);
+ if (self._attachment) |attachment| {
+ if (attachment.read_only) return error.NoModificationAllowed;
+ return attachment.mutate(attachment.owner, self, state, frame);
+ }
+ self.applyStateRaw(state);
+}
+
+pub fn attach(self: *Transform, attachment: Attachment) void {
+ self._attachment = attachment;
+}
+
+pub fn detach(self: *Transform, owner: *anyopaque) void {
+ const attachment = self._attachment orelse return;
+ if (attachment.owner == owner) self._attachment = null;
+}
+
+pub fn isAttached(self: *const Transform) bool {
+ return self._attachment != null;
+}
+
+pub fn isAttachedTo(self: *const Transform, owner: *anyopaque) bool {
+ const attachment = self._attachment orelse return false;
+ return attachment.owner == owner;
+}
+
+pub fn writeState(state: State, writer: anytype) !void {
+ switch (state.typ) {
+ 1 => try writer.print("matrix({d} {d} {d} {d} {d} {d})", .{
+ state.matrix[0], state.matrix[1], state.matrix[4], state.matrix[5], state.matrix[12], state.matrix[13],
+ }),
+ 2 => try writer.print("translate({d} {d})", .{ state.matrix[12], state.matrix[13] }),
+ 3 => try writer.print("scale({d} {d})", .{ state.matrix[0], state.matrix[5] }),
+ 4 => if (state.cx == 0 and state.cy == 0)
+ try writer.print("rotate({d})", .{state.angle})
+ else
+ try writer.print("rotate({d} {d} {d})", .{ state.angle, state.cx, state.cy }),
+ 5 => try writer.print("skewX({d})", .{state.angle}),
+ 6 => try writer.print("skewY({d})", .{state.angle}),
+ else => return error.SyntaxError,
+ }
}
fn fixup2D(init: DOMMatrix2DInit) !RO.Parsed {
- return RO.fixupDict(.{
+ const parsed = try RO.fixupDict(.{
.a = init.a,
.b = init.b,
.c = init.c,
@@ -146,6 +247,8 @@ fn fixup2D(init: DOMMatrix2DInit) !RO.Parsed {
.m42 = init.m42,
.is2D = true,
});
+ try ensureFinite(&parsed.m);
+ return parsed;
}
fn ensureFinite(values: []const f64) !void {
diff --git a/src/browser/webapi/svg/TransformList.zig b/src/browser/webapi/svg/TransformList.zig
new file mode 100644
index 000000000..8acc51faa
--- /dev/null
+++ b/src/browser/webapi/svg/TransformList.zig
@@ -0,0 +1,302 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+const std = @import("std");
+
+const js = @import("../../js/js.zig");
+const Frame = @import("../../Frame.zig");
+const Page = @import("../../Page.zig");
+const DOMMatrixReadOnly = @import("../DOMMatrixReadOnly.zig");
+const Element = @import("../Element.zig");
+const Transform = @import("Transform.zig");
+
+const TransformList = @This();
+
+_element: *Element,
+_read_only: bool,
+_items: std.ArrayList(*Transform) = .empty,
+_retired: std.ArrayList(*Transform) = .empty,
+_snapshot: ?[]const u8 = null,
+
+pub fn create(element: *Element, read_only: bool, frame: *Frame) !*TransformList {
+ const self = try frame._factory.create(TransformList{
+ ._element = element,
+ ._read_only = read_only,
+ });
+ try frame.registerSvgCollectionCleanup(.{
+ .context = self,
+ .callback = TransformList.cleanup,
+ });
+ return self;
+}
+
+fn cleanup(context: *anyopaque, page: *Page) void {
+ const self: *TransformList = @ptrCast(@alignCast(context));
+ for (self._items.items) |transform| {
+ transform.detach(self);
+ transform.releaseRef(page);
+ }
+ for (self._retired.items) |transform| transform.releaseRef(page);
+ self._items.clearRetainingCapacity();
+ self._retired.clearRetainingCapacity();
+}
+
+pub fn getLength(self: *TransformList, frame: *Frame) !u32 {
+ try self.sync(frame);
+ return @intCast(self._items.items.len);
+}
+
+pub fn getNumberOfItems(self: *TransformList, frame: *Frame) !u32 {
+ return self.getLength(frame);
+}
+
+pub fn clear(self: *TransformList, frame: *Frame) !void {
+ try self.requireMutable();
+ try self.sync(frame);
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ try self.setAttribute(&.{}, frame);
+ self.retireAllAssumeCapacity();
+}
+
+pub fn initialize(self: *TransformList, item: *Transform, frame: *Frame) !*Transform {
+ try self.requireMutable();
+ try self.sync(frame);
+ const prepared = try self.prepareItem(item, frame);
+ errdefer prepared.releaseRef(frame._page);
+
+ try self._items.ensureTotalCapacity(frame.arena, 1);
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ try self.setAttribute(&.{prepared}, frame);
+ self.retireAllAssumeCapacity();
+ self._items.appendAssumeCapacity(prepared);
+ self.attach(prepared);
+ return prepared;
+}
+
+pub fn getItem(self: *TransformList, index: u32, frame: *Frame) !*Transform {
+ try self.sync(frame);
+ if (index >= self._items.items.len) return error.IndexSizeError;
+ return self._items.items[index];
+}
+
+pub fn insertItemBefore(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform {
+ try self.requireMutable();
+ try self.sync(frame);
+ const prepared = try self.prepareItem(item, frame);
+ errdefer prepared.releaseRef(frame._page);
+ const at = @min(@as(usize, index), self._items.items.len);
+ const next = try frame.local_arena.alloc(*Transform, self._items.items.len + 1);
+ @memcpy(next[0..at], self._items.items[0..at]);
+ next[at] = prepared;
+ @memcpy(next[at + 1 ..], self._items.items[at..]);
+
+ try self._items.ensureUnusedCapacity(frame.arena, 1);
+ try self.setAttribute(next, frame);
+ self._items.insertAssumeCapacity(at, prepared);
+ self.attach(prepared);
+ return prepared;
+}
+
+pub fn replaceItem(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform {
+ try self.requireMutable();
+ try self.sync(frame);
+ if (index >= self._items.items.len) return error.IndexSizeError;
+ const prepared = try self.prepareItem(item, frame);
+ errdefer prepared.releaseRef(frame._page);
+ const next = try frame.local_arena.dupe(*Transform, self._items.items);
+ next[index] = prepared;
+
+ try self._retired.ensureUnusedCapacity(frame.arena, 1);
+ try self.setAttribute(next, frame);
+ const replaced = self._items.items[index];
+ replaced.detach(self);
+ self._retired.appendAssumeCapacity(replaced);
+ self._items.items[index] = prepared;
+ self.attach(prepared);
+ return prepared;
+}
+
+pub fn removeItem(self: *TransformList, index: u32, frame: *Frame) !*Transform {
+ try self.requireMutable();
+ try self.sync(frame);
+ if (index >= self._items.items.len) return error.IndexSizeError;
+ const next = try frame.local_arena.alloc(*Transform, self._items.items.len - 1);
+ @memcpy(next[0..index], self._items.items[0..index]);
+ @memcpy(next[index..], self._items.items[index + 1 ..]);
+
+ try self._retired.ensureUnusedCapacity(frame.arena, 1);
+ try self.setAttribute(next, frame);
+ const removed = self._items.orderedRemove(index);
+ removed.detach(self);
+ self._retired.appendAssumeCapacity(removed);
+ return removed;
+}
+
+pub fn appendItem(self: *TransformList, item: *Transform, frame: *Frame) !*Transform {
+ return self.insertItemBefore(item, std.math.maxInt(u32), frame);
+}
+
+pub fn consolidate(self: *TransformList, frame: *Frame) !?*Transform {
+ try self.requireMutable();
+ try self.sync(frame);
+ if (self._items.items.len == 0) return null;
+
+ var matrix = DOMMatrixReadOnly.identity();
+ for (self._items.items) |item| matrix = DOMMatrixReadOnly.multiplyMatrix(matrix, item.getState().matrix);
+ for (matrix) |value| if (!std.math.isFinite(value)) return error.TypeError;
+ var values: [16]f64 = undefined;
+ values[0] = matrix[0];
+ values[1] = matrix[1];
+ values[2] = matrix[4];
+ values[3] = matrix[5];
+ values[4] = matrix[12];
+ values[5] = matrix[13];
+ const consolidated = try Transform.fromParsed(.{
+ .kind = .matrix,
+ .matrix = matrix,
+ .values = values,
+ .count = 6,
+ .is_2d = true,
+ }, frame);
+ consolidated.acquireRef();
+ errdefer consolidated.releaseRef(frame._page);
+
+ try self._items.ensureTotalCapacity(frame.arena, 1);
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ try self.setAttribute(&.{consolidated}, frame);
+ self.retireAllAssumeCapacity();
+ self._items.appendAssumeCapacity(consolidated);
+ self.attach(consolidated);
+ return consolidated;
+}
+
+fn requireMutable(self: *const TransformList) !void {
+ if (self._read_only) return error.NoModificationAllowed;
+}
+
+fn prepareItem(_: *TransformList, item: *Transform, frame: *Frame) !*Transform {
+ const prepared = if (item.isAttached()) try item.clone(frame) else item;
+ prepared.acquireRef();
+ return prepared;
+}
+
+fn attach(self: *TransformList, transform: *Transform) void {
+ transform.attach(.{
+ .owner = self,
+ .read_only = self._read_only,
+ .mutate = TransformList.mutateTransform,
+ });
+}
+
+fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform.State, frame: *Frame) anyerror!void {
+ const self: *TransformList = @ptrCast(@alignCast(context));
+ try self.sync(frame);
+ if (!transform.isAttachedTo(self)) {
+ transform.applyStateRaw(state);
+ return;
+ }
+ const index = for (self._items.items, 0..) |candidate, i| {
+ if (candidate == transform) break i;
+ } else unreachable;
+ try self.setAttributeWithOverride(index, state, frame);
+ transform.applyStateRaw(state);
+}
+
+fn sync(self: *TransformList, frame: *Frame) !void {
+ const raw = self._element.getAttributeSafe(comptime .wrap("transform")) orelse "";
+ if (self._snapshot) |snapshot| if (std.mem.eql(u8, snapshot, raw)) return;
+ const snapshot = try frame.arena.dupe(u8, raw);
+ var parsed = parse(raw, frame) catch |err| switch (err) {
+ error.SyntaxError => std.ArrayList(*Transform).empty,
+ else => return err,
+ };
+ errdefer for (parsed.items) |transform| transform.releaseRef(frame._page);
+
+ try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ self.retireAllAssumeCapacity();
+ for (parsed.items) |transform| {
+ self._items.appendAssumeCapacity(transform);
+ self.attach(transform);
+ }
+ parsed.clearRetainingCapacity();
+ self._snapshot = snapshot;
+}
+
+fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*Transform) {
+ var parsed: std.ArrayList(*Transform) = .empty;
+ errdefer for (parsed.items) |transform| transform.releaseRef(frame._page);
+ const trimmed = std.mem.trim(u8, raw, " \t\r\n");
+ if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return parsed;
+
+ var iterator = DOMMatrixReadOnly.TransformFunctionIterator{ .input = trimmed, .allow_comma = true };
+ while (try iterator.next()) |function| {
+ const value = try DOMMatrixReadOnly.parseTransformFunction(function, .svg);
+ const transform = try Transform.fromParsed(value, frame);
+ transform.acquireRef();
+ parsed.append(frame.local_arena, transform) catch |err| {
+ transform.releaseRef(frame._page);
+ return err;
+ };
+ }
+ return parsed;
+}
+
+fn retireAllAssumeCapacity(self: *TransformList) void {
+ for (self._items.items) |transform| {
+ transform.detach(self);
+ self._retired.appendAssumeCapacity(transform);
+ }
+ self._items.clearRetainingCapacity();
+}
+
+fn setAttribute(self: *TransformList, items: []const *Transform, frame: *Frame) !void {
+ var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
+ const writer = &serialized.writer;
+ for (items, 0..) |transform, i| {
+ if (i != 0) try writer.writeByte(' ');
+ try Transform.writeState(transform.getState(), writer);
+ }
+ try self.commitAttribute(serialized.written(), frame);
+}
+
+fn setAttributeWithOverride(self: *TransformList, index: usize, state: Transform.State, frame: *Frame) !void {
+ var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
+ const writer = &serialized.writer;
+ for (self._items.items, 0..) |transform, i| {
+ if (i != 0) try writer.writeByte(' ');
+ try Transform.writeState(if (i == index) state else transform.getState(), writer);
+ }
+ try self.commitAttribute(serialized.written(), frame);
+}
+
+fn commitAttribute(self: *TransformList, serialized: []const u8, frame: *Frame) !void {
+ const snapshot = try frame.arena.dupe(u8, serialized);
+ try self._element.setAttributeSafe(comptime .wrap("transform"), .wrap(serialized), frame);
+ self._snapshot = snapshot;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(TransformList);
+
+ pub const Meta = struct {
+ pub const name = "SVGTransformList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(TransformList.getLength, null, .{});
+ pub const numberOfItems = bridge.accessor(TransformList.getNumberOfItems, null, .{});
+ pub const clear = bridge.function(TransformList.clear, .{});
+ pub const initialize = bridge.function(TransformList.initialize, .{});
+ pub const getItem = bridge.function(TransformList.getItem, .{});
+ pub const insertItemBefore = bridge.function(TransformList.insertItemBefore, .{});
+ pub const replaceItem = bridge.function(TransformList.replaceItem, .{});
+ pub const removeItem = bridge.function(TransformList.removeItem, .{});
+ pub const appendItem = bridge.function(TransformList.appendItem, .{});
+ pub const consolidate = bridge.function(TransformList.consolidate, .{});
+};
From e4b6b3298d1d5a5db1aff7ce508eb0bc99e3798b Mon Sep 17 00:00:00 2001
From: Karl Seguin
Date: Fri, 24 Jul 2026 19:10:02 +0800
Subject: [PATCH 2/5] Optimize collections; Fix unsafe Frame reference
Applies the same dictionary lookup pattern we did in https://github.com/lightpanda-io/browser/pull/3034
to the new Graphics, Polyline and Polygon fields.
Caps total memory from snapshot and retired items by collecting them in a buffer
rather than the frame arena. This allows the memory to be re-used at each sync.
Remove the Frame dependency from DOMPointReadOnly and DOMPoint. These types are
also accessible from a Worker (which has no Frame). The frame is captured at
creation time which the callback can access.
---
src/browser/Frame.zig | 36 ++++----
src/browser/tests/worker/api-worker.js | 7 ++
src/browser/tests/worker/worker.html | 4 +
src/browser/webapi/DOMPoint.zig | 17 ++--
src/browser/webapi/DOMPointReadOnly.zig | 7 +-
src/browser/webapi/element/svg/Graphics.zig | 28 +-----
src/browser/webapi/element/svg/Polygon.zig | 12 +--
src/browser/webapi/element/svg/Polyline.zig | 12 +--
.../webapi/svg/AnimatedTransformList.zig | 19 ++++
src/browser/webapi/svg/PointList.zig | 89 ++++++++++++-------
src/browser/webapi/svg/StringList.zig | 84 +++++++++++------
src/browser/webapi/svg/Transform.zig | 30 +++----
src/browser/webapi/svg/TransformList.zig | 66 ++++++++------
13 files changed, 238 insertions(+), 173 deletions(-)
diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index 477081bd0..55ccb10f0 100644
--- a/src/browser/Frame.zig
+++ b/src/browser/Frame.zig
@@ -42,9 +42,6 @@ const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
-const AnimatedLength = @import("webapi/svg/AnimatedLength.zig");
-const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig");
-const AnimatedString = @import("webapi/svg/AnimatedString.zig");
const Window = @import("webapi/Window.zig");
const Location = @import("webapi/Location.zig");
const Document = @import("webapi/Document.zig");
@@ -65,8 +62,15 @@ const popover = @import("webapi/element/popover.zig");
const slotting = @import("webapi/element/slotting.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
-const HttpClient = @import("../network/HttpClient.zig");
+const PointList = @import("webapi/svg/PointList.zig");
+const StringList = @import("webapi/svg/StringList.zig");
+const AnimatedLength = @import("webapi/svg/AnimatedLength.zig");
+const AnimatedString = @import("webapi/svg/AnimatedString.zig");
+const AnimatedTransformList = @import("webapi/svg/AnimatedTransformList.zig");
+const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig");
+
const sys_url = @import("../sys/url.zig");
+const HttpClient = @import("../network/HttpClient.zig");
const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
@@ -89,11 +93,6 @@ pub const BUF_SIZE = 1024;
const Frame = @This();
-pub const SvgCollectionCleanup = struct {
- context: *anyopaque,
- callback: *const fn (*anyopaque, *Page) void,
-};
-
// This is the "id" of the frame. It can be re-used from frame-to-frame, e.g.
// when navigating.
_frame_id: u32,
@@ -153,7 +152,9 @@ _element_namespace_uris: Element.NamespaceUriLookup = .empty,
_svg_animated_lengths: AnimatedLength.Lookup = .empty,
_svg_animated_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty,
_svg_animated_strings: AnimatedString.Lookup = .empty,
-_svg_collection_cleanups: std.ArrayList(SvgCollectionCleanup) = .empty,
+_svg_animated_transform_lists: AnimatedTransformList.Lookup = .empty,
+_svg_point_lists: PointList.Lookup = .empty,
+_svg_string_lists: StringList.Lookup = .empty,
// Same as above, but for Nodes (slot assigments apply to both Element AND
// Text nodes)
@@ -509,10 +510,15 @@ pub fn deinit(self: *Frame) void {
observers.deinit(self, page);
- for (self._svg_collection_cleanups.items) |cleanup| {
- cleanup.callback(cleanup.context, page);
+ var svg_point_lists = self._svg_point_lists.valueIterator();
+ while (svg_point_lists.next()) |list| {
+ list.*.deinit(page);
+ }
+
+ var svg_transform_lists = self._svg_animated_transform_lists.valueIterator();
+ while (svg_transform_lists.next()) |list| {
+ list.*.deinit(page);
}
- self._svg_collection_cleanups = .empty;
var document = self.window._document;
document._selection.releaseRef(page);
@@ -546,10 +552,6 @@ pub fn deinit(self: *Frame) void {
page.releaseArena(self.local_arena);
}
-pub fn registerSvgCollectionCleanup(self: *Frame, cleanup: SvgCollectionCleanup) !void {
- try self._svg_collection_cleanups.append(self.arena, cleanup);
-}
-
pub fn trackWorker(self: *Frame, worker: *Worker) !void {
try self.workers.append(self.arena, worker);
}
diff --git a/src/browser/tests/worker/api-worker.js b/src/browser/tests/worker/api-worker.js
index 1f75d7e1c..803b8ace1 100644
--- a/src/browser/tests/worker/api-worker.js
+++ b/src/browser/tests/worker/api-worker.js
@@ -59,6 +59,11 @@
const loc_pathname = loc.pathname;
const loc_to_string = String(loc);
+ // DOMPoint mutation (setters must not require a Frame in a worker)
+ const point = new DOMPoint(1, 2);
+ point.x = 5;
+ point.w = 0.5;
+
postMessage({
ok: true,
results: {
@@ -90,6 +95,8 @@
loc_protocol,
loc_pathname,
loc_to_string,
+ point_x: point.x,
+ point_w: point.w,
},
});
} catch (e) {
diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html
index cd3512535..2b953aff1 100644
--- a/src/browser/tests/worker/worker.html
+++ b/src/browser/tests/worker/worker.html
@@ -247,6 +247,10 @@
testing.expectEqual(r.loc_href, r.loc_to_string);
testing.expectEqual('http:', r.loc_protocol);
testing.expectTrue(r.loc_pathname.endsWith('api-worker.js'));
+
+ // DOMPoint
+ testing.expectEqual(5, r.point_x);
+ testing.expectEqual(0.5, r.point_w);
});
}
diff --git a/src/browser/webapi/DOMPoint.zig b/src/browser/webapi/DOMPoint.zig
index 753b28811..d9356d3f2 100644
--- a/src/browser/webapi/DOMPoint.zig
+++ b/src/browser/webapi/DOMPoint.zig
@@ -17,7 +17,6 @@
// along with this program. If not, see .
const js = @import("../js/js.zig");
-const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const RO = @import("DOMPointReadOnly.zig");
@@ -78,17 +77,17 @@ pub fn getW(self: *const DOMPoint) f64 {
return self._proto._w;
}
-pub fn setX(self: *DOMPoint, v: f64, frame: *Frame) !void {
- try self._proto.setCoordinate(.x, v, frame);
+pub fn setX(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.x, v);
}
-pub fn setY(self: *DOMPoint, v: f64, frame: *Frame) !void {
- try self._proto.setCoordinate(.y, v, frame);
+pub fn setY(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.y, v);
}
-pub fn setZ(self: *DOMPoint, v: f64, frame: *Frame) !void {
- try self._proto.setCoordinate(.z, v, frame);
+pub fn setZ(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.z, v);
}
-pub fn setW(self: *DOMPoint, v: f64, frame: *Frame) !void {
- try self._proto.setCoordinate(.w, v, frame);
+pub fn setW(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.w, v);
}
pub const JsApi = struct {
diff --git a/src/browser/webapi/DOMPointReadOnly.zig b/src/browser/webapi/DOMPointReadOnly.zig
index 2a349650e..525d0c662 100644
--- a/src/browser/webapi/DOMPointReadOnly.zig
+++ b/src/browser/webapi/DOMPointReadOnly.zig
@@ -20,7 +20,6 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
-const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const DOMPoint = @import("DOMPoint.zig");
const Matrix = @import("DOMMatrixReadOnly.zig");
@@ -46,7 +45,7 @@ pub const Coordinate = enum { x, y, z, w };
pub const Attachment = struct {
owner: *anyopaque,
read_only: bool,
- mutate: *const fn (*anyopaque, *DOMPointReadOnly, Coordinate, f64, *Frame) anyerror!void,
+ mutate: *const fn (*anyopaque, *DOMPointReadOnly, Coordinate, f64) anyerror!void,
};
pub const Type = union(enum) {
@@ -142,10 +141,10 @@ pub fn getW(self: *const DOMPointReadOnly) f64 {
return self._w;
}
-pub fn setCoordinate(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64, frame: *Frame) !void {
+pub fn setCoordinate(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) !void {
if (self._attachment) |attachment| {
if (attachment.read_only) return error.NoModificationAllowed;
- return attachment.mutate(attachment.owner, self, coordinate, value, frame);
+ return attachment.mutate(attachment.owner, self, coordinate, value);
}
self.setCoordinateRaw(coordinate, value);
}
diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig
index cb9c27502..93d10c762 100644
--- a/src/browser/webapi/element/svg/Graphics.zig
+++ b/src/browser/webapi/element/svg/Graphics.zig
@@ -35,9 +35,6 @@ pub const Geometry = @import("Geometry.zig");
const Graphics = @This();
_proto: *SvgElement,
_type: Type,
-_transform: ?*AnimatedTransformList = null,
-_required_extensions: ?*StringList = null,
-_system_language: ?*StringList = null,
pub const Type = union(enum) {
svg: *Svg,
@@ -85,32 +82,13 @@ pub const JsApi = struct {
};
pub fn getTransform(self: *Graphics, frame: *Frame) !*AnimatedTransformList {
- if (self._transform == null) {
- self._transform = try AnimatedTransformList.create(self.asElement(), frame);
- }
- return self._transform.?;
+ return AnimatedTransformList.getOrCreate(self.asElement(), frame);
}
pub fn getRequiredExtensions(self: *Graphics, frame: *Frame) !*StringList {
- if (self._required_extensions == null) {
- self._required_extensions = try StringList.create(
- self.asElement(),
- .wrap("requiredExtensions"),
- .whitespace,
- frame,
- );
- }
- return self._required_extensions.?;
+ return StringList.getOrCreate(self.asElement(), .required_extensions, frame);
}
pub fn getSystemLanguage(self: *Graphics, frame: *Frame) !*StringList {
- if (self._system_language == null) {
- self._system_language = try StringList.create(
- self.asElement(),
- .wrap("systemLanguage"),
- .comma,
- frame,
- );
- }
- return self._system_language.?;
+ return StringList.getOrCreate(self.asElement(), .system_language, frame);
}
diff --git a/src/browser/webapi/element/svg/Polygon.zig b/src/browser/webapi/element/svg/Polygon.zig
index b81b2939c..9d55797e4 100644
--- a/src/browser/webapi/element/svg/Polygon.zig
+++ b/src/browser/webapi/element/svg/Polygon.zig
@@ -27,8 +27,6 @@ const PointList = @import("../../svg/PointList.zig");
const Polygon = @This();
_proto: *Geometry,
-_points: ?*PointList = null,
-_animated_points: ?*PointList = null,
pub fn asElement(self: *Polygon) *Element {
return self._proto.asElement();
@@ -51,15 +49,9 @@ pub const JsApi = struct {
};
pub fn getPoints(self: *Polygon, frame: *Frame) !*PointList {
- if (self._points == null) {
- self._points = try PointList.create(self.asElement(), false, frame);
- }
- return self._points.?;
+ return PointList.getOrCreate(self.asElement(), .base, frame);
}
pub fn getAnimatedPoints(self: *Polygon, frame: *Frame) !*PointList {
- if (self._animated_points == null) {
- self._animated_points = try PointList.create(self.asElement(), true, frame);
- }
- return self._animated_points.?;
+ return PointList.getOrCreate(self.asElement(), .animated, frame);
}
diff --git a/src/browser/webapi/element/svg/Polyline.zig b/src/browser/webapi/element/svg/Polyline.zig
index 7e1b3b19f..6ac253167 100644
--- a/src/browser/webapi/element/svg/Polyline.zig
+++ b/src/browser/webapi/element/svg/Polyline.zig
@@ -27,8 +27,6 @@ const PointList = @import("../../svg/PointList.zig");
const Polyline = @This();
_proto: *Geometry,
-_points: ?*PointList = null,
-_animated_points: ?*PointList = null,
pub fn asElement(self: *Polyline) *Element {
return self._proto.asElement();
@@ -51,15 +49,9 @@ pub const JsApi = struct {
};
pub fn getPoints(self: *Polyline, frame: *Frame) !*PointList {
- if (self._points == null) {
- self._points = try PointList.create(self.asElement(), false, frame);
- }
- return self._points.?;
+ return PointList.getOrCreate(self.asElement(), .base, frame);
}
pub fn getAnimatedPoints(self: *Polyline, frame: *Frame) !*PointList {
- if (self._animated_points == null) {
- self._animated_points = try PointList.create(self.asElement(), true, frame);
- }
- return self._animated_points.?;
+ return PointList.getOrCreate(self.asElement(), .animated, frame);
}
diff --git a/src/browser/webapi/svg/AnimatedTransformList.zig b/src/browser/webapi/svg/AnimatedTransformList.zig
index 8c62b1d12..0ec5670f6 100644
--- a/src/browser/webapi/svg/AnimatedTransformList.zig
+++ b/src/browser/webapi/svg/AnimatedTransformList.zig
@@ -5,8 +5,11 @@
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
+const std = @import("std");
+
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
+const Page = @import("../../Page.zig");
const Element = @import("../Element.zig");
const TransformList = @import("TransformList.zig");
@@ -15,6 +18,17 @@ const AnimatedTransformList = @This();
_base_val: *TransformList,
_anim_val: *TransformList,
+pub const Lookup = std.AutoHashMapUnmanaged(*Element, *AnimatedTransformList);
+
+pub fn getOrCreate(element: *Element, frame: *Frame) !*AnimatedTransformList {
+ const gop = try frame._svg_animated_transform_lists.getOrPut(frame.arena, element);
+ if (!gop.found_existing) {
+ errdefer _ = frame._svg_animated_transform_lists.remove(element);
+ gop.value_ptr.* = try create(element, frame);
+ }
+ return gop.value_ptr.*;
+}
+
pub fn create(element: *Element, frame: *Frame) !*AnimatedTransformList {
const base_val = try TransformList.create(element, false, frame);
const anim_val = try TransformList.create(element, true, frame);
@@ -24,6 +38,11 @@ pub fn create(element: *Element, frame: *Frame) !*AnimatedTransformList {
});
}
+pub fn deinit(self: *AnimatedTransformList, page: *Page) void {
+ self._base_val.deinit(page);
+ self._anim_val.deinit(page);
+}
+
pub fn getBaseVal(self: *AnimatedTransformList) *TransformList {
return self._base_val;
}
diff --git a/src/browser/webapi/svg/PointList.zig b/src/browser/webapi/svg/PointList.zig
index 2fef4c8f5..558a04c89 100644
--- a/src/browser/webapi/svg/PointList.zig
+++ b/src/browser/webapi/svg/PointList.zig
@@ -17,35 +17,47 @@ const Element = @import("../Element.zig");
const PointList = @This();
+_frame: *Frame,
_element: *Element,
_read_only: bool,
+_synced: bool = false,
+_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList(*DOMPoint) = .empty,
_retired: std.ArrayList(*DOMPoint) = .empty,
-_snapshot: ?[]const u8 = null,
-pub fn create(element: *Element, read_only: bool, frame: *Frame) !*PointList {
- const self = try frame._factory.create(PointList{
- ._element = element,
- ._read_only = read_only,
- });
- try frame.registerSvgCollectionCleanup(.{
- .context = self,
- .callback = PointList.cleanup,
- });
- return self;
+pub const Kind = enum { base, animated };
+
+pub const Key = struct {
+ element: *Element,
+ kind: Kind,
+};
+
+pub const Lookup = std.AutoHashMapUnmanaged(Key, *PointList);
+
+pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*PointList {
+ const key: Key = .{
+ .element = element,
+ .kind = kind,
+ };
+ const gop = try frame._svg_point_lists.getOrPut(frame.arena, key);
+ if (!gop.found_existing) {
+ errdefer _ = frame._svg_point_lists.remove(key);
+ gop.value_ptr.* = try frame._factory.create(PointList{
+ ._frame = frame,
+ ._element = element,
+ ._read_only = kind == .animated,
+ });
+ }
+ return gop.value_ptr.*;
}
-fn cleanup(context: *anyopaque, page: *Page) void {
- const self: *PointList = @ptrCast(@alignCast(context));
+pub fn deinit(self: *PointList, page: *Page) void {
for (self._items.items) |point| {
point._proto.detach(self);
point._proto.releaseRef(page);
}
- for (self._retired.items) |point| {
- point._proto.releaseRef(page);
- }
self._items.clearRetainingCapacity();
- self._retired.clearRetainingCapacity();
+ self.releaseRetired(page);
}
pub fn getLength(self: *PointList, frame: *Frame) !u32 {
@@ -60,9 +72,8 @@ pub fn getNumberOfItems(self: *PointList, frame: *Frame) !u32 {
pub fn clear(self: *PointList, frame: *Frame) !void {
try self.requireMutable();
try self.sync(frame);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ try self.retireAll(frame);
try self.setAttribute(&.{}, frame);
- self.retireAllAssumeCapacity();
}
pub fn initialize(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
@@ -72,10 +83,9 @@ pub fn initialize(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
const prepared = try self.prepareItem(item, frame);
errdefer prepared._proto.releaseRef(frame._page);
+ try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
try self.setAttribute(&.{prepared}, frame);
- self.retireAllAssumeCapacity();
self._items.appendAssumeCapacity(prepared);
self.attach(prepared);
return prepared;
@@ -174,9 +184,9 @@ fn mutatePoint(
point: *DOMPointReadOnly,
coordinate: DOMPointReadOnly.Coordinate,
value: f64,
- frame: *Frame,
) anyerror!void {
const self: *PointList = @ptrCast(@alignCast(context));
+ const frame = self._frame;
try self.sync(frame);
// An external attribute mutation detaches the old item during sync. The
@@ -201,27 +211,40 @@ fn mutatePoint(
}
fn sync(self: *PointList, frame: *Frame) !void {
+ self.releaseRetired(frame._page);
+
const raw = self._element.getAttributeSafe(comptime .wrap("points")) orelse "";
- if (self._snapshot) |snapshot| {
- if (std.mem.eql(u8, snapshot, raw)) return;
+ if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) {
+ return;
}
- const snapshot = try frame.arena.dupe(u8, raw);
+ self._synced = false;
var parsed = parse(raw, frame) catch |err| switch (err) {
error.SyntaxError => std.ArrayList(*DOMPoint).empty,
else => return err,
};
errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page);
+ self._snapshot.clearRetainingCapacity();
+ try self._snapshot.appendSlice(frame.arena, raw);
+ try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
- self.retireAllAssumeCapacity();
for (parsed.items) |point| {
self._items.appendAssumeCapacity(point);
self.attach(point);
}
parsed.clearRetainingCapacity();
- self._snapshot = snapshot;
+ self._synced = true;
+}
+
+// A retired item must outlive the operation that retired it: removeItem's
+// return value has no JS wrapper until the bridge wraps it after we return.
+// By the next operation, anything still reachable holds its own ref.
+fn releaseRetired(self: *PointList, page: *Page) void {
+ for (self._retired.items) |point| {
+ point._proto.releaseRef(page);
+ }
+ self._retired.clearRetainingCapacity();
}
fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) {
@@ -241,7 +264,9 @@ fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) {
return parsed;
}
-fn retireAllAssumeCapacity(self: *PointList) void {
+fn retireAll(self: *PointList, frame: *Frame) !void {
+ self._synced = false;
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
for (self._items.items) |point| {
point._proto.detach(self);
self._retired.appendAssumeCapacity(point);
@@ -278,9 +303,11 @@ fn setAttributeWithOverride(
}
fn commitAttribute(self: *PointList, serialized: []const u8, frame: *Frame) !void {
- const snapshot = try frame.arena.dupe(u8, serialized);
+ self._synced = false;
try self._element.setAttributeSafe(comptime .wrap("points"), .wrap(serialized), frame);
- self._snapshot = snapshot;
+ self._snapshot.clearRetainingCapacity();
+ try self._snapshot.appendSlice(frame.arena, serialized);
+ self._synced = true;
}
const NumberScanner = struct {
diff --git a/src/browser/webapi/svg/StringList.zig b/src/browser/webapi/svg/StringList.zig
index 8c866209b..56a3a10be 100644
--- a/src/browser/webapi/svg/StringList.zig
+++ b/src/browser/webapi/svg/StringList.zig
@@ -19,15 +19,51 @@ pub const Delimiter = enum { whitespace, comma };
_element: *Element,
_attribute_name: lp.String,
_delimiter: Delimiter,
+_synced: bool = false,
+_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList([]const u8) = .empty,
-_snapshot: ?[]const u8 = null,
-pub fn create(element: *Element, attribute_name: lp.String, delimiter: Delimiter, frame: *Frame) !*StringList {
- return frame._factory.create(StringList{
- ._element = element,
- ._attribute_name = attribute_name,
- ._delimiter = delimiter,
- });
+pub const Kind = enum {
+ required_extensions,
+ system_language,
+
+ fn attributeName(self: Kind) lp.String {
+ return switch (self) {
+ .required_extensions => .wrap("requiredExtensions"),
+ .system_language => .wrap("systemLanguage"),
+ };
+ }
+
+ fn delimiter(self: Kind) Delimiter {
+ return switch (self) {
+ .required_extensions => .whitespace,
+ .system_language => .comma,
+ };
+ }
+};
+
+pub const Key = struct {
+ element: *Element,
+ kind: Kind,
+};
+
+pub const Lookup = std.AutoHashMapUnmanaged(Key, *StringList);
+
+pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*StringList {
+ const key: Key = .{
+ .element = element,
+ .kind = kind,
+ };
+ const gop = try frame._svg_string_lists.getOrPut(frame.arena, key);
+ if (!gop.found_existing) {
+ errdefer _ = frame._svg_string_lists.remove(key);
+ gop.value_ptr.* = try frame._factory.create(StringList{
+ ._element = element,
+ ._attribute_name = kind.attributeName(),
+ ._delimiter = kind.delimiter(),
+ });
+ }
+ return gop.value_ptr.*;
}
pub fn getLength(self: *StringList, frame: *Frame) !u32 {
@@ -103,18 +139,8 @@ fn validateItem(self: *const StringList, item: []const u8) !void {
fn sync(self: *StringList, frame: *Frame) !void {
const raw = self._element.getAttributeSafe(self._attribute_name) orelse "";
- if (self._snapshot) |snapshot| if (std.mem.eql(u8, snapshot, raw)) return;
-
- const snapshot = try frame.arena.dupe(u8, raw);
- var parsed = parse(snapshot, self._delimiter, frame.local_arena) catch |err| switch (err) {
- error.SyntaxError => std.ArrayList([]const u8).empty,
- else => return err,
- };
- try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
- self._items.clearRetainingCapacity();
- for (parsed.items) |item| self._items.appendAssumeCapacity(item);
- parsed.clearRetainingCapacity();
- self._snapshot = snapshot;
+ if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) return;
+ try self.rebuild(raw, frame);
}
fn commit(self: *StringList, items: []const []const u8, frame: *Frame) !void {
@@ -127,14 +153,22 @@ fn commit(self: *StringList, items: []const []const u8, frame: *Frame) !void {
}
const serialized_bytes = serialized.written();
- const snapshot = try frame.arena.dupe(u8, serialized_bytes);
- var parsed = try parse(snapshot, self._delimiter, frame.local_arena);
- try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
try self._element.setAttributeSafe(self._attribute_name, .wrap(serialized_bytes), frame);
+ try self.rebuild(serialized_bytes, frame);
+}
+
+// `raw` must not alias `_snapshot`; `_items` are slices into `_snapshot`.
+fn rebuild(self: *StringList, raw: []const u8, frame: *Frame) !void {
+ self._synced = false;
+ self._snapshot.clearRetainingCapacity();
+ try self._snapshot.appendSlice(frame.arena, raw);
+ const parsed = parse(self._snapshot.items, self._delimiter, frame.local_arena) catch |err| switch (err) {
+ error.SyntaxError => std.ArrayList([]const u8).empty,
+ else => return err,
+ };
self._items.clearRetainingCapacity();
- for (parsed.items) |item| self._items.appendAssumeCapacity(item);
- parsed.clearRetainingCapacity();
- self._snapshot = snapshot;
+ try self._items.appendSlice(frame.arena, parsed.items);
+ self._synced = true;
}
fn parse(raw: []const u8, delimiter: Delimiter, allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
diff --git a/src/browser/webapi/svg/Transform.zig b/src/browser/webapi/svg/Transform.zig
index 09d6f13a9..a1e427593 100644
--- a/src/browser/webapi/svg/Transform.zig
+++ b/src/browser/webapi/svg/Transform.zig
@@ -60,7 +60,7 @@ pub const State = struct {
pub const Attachment = struct {
owner: *anyopaque,
read_only: bool,
- mutate: *const fn (*anyopaque, *Transform, State, *Frame) anyerror!void,
+ mutate: *const fn (*anyopaque, *Transform, State) anyerror!void,
};
// The transform owns the matrix arena even when no JS wrapper currently
@@ -133,38 +133,38 @@ pub fn getAngle(self: *const Transform) f64 {
return self._angle;
}
-pub fn setMatrix(self: *Transform, init: ?DOMMatrix2DInit, frame: *Frame) !void {
+pub fn setMatrix(self: *Transform, init: ?DOMMatrix2DInit) !void {
const parsed = try fixup2D(init orelse .{});
- try self.applyState(.{ .typ = 1, .angle = 0, .cx = 0, .cy = 0, .matrix = parsed.m, .is_2d = true }, frame);
+ try self.applyState(.{ .typ = 1, .angle = 0, .cx = 0, .cy = 0, .matrix = parsed.m, .is_2d = true });
}
-pub fn setTranslate(self: *Transform, tx: f64, ty: f64, frame: *Frame) !void {
+pub fn setTranslate(self: *Transform, tx: f64, ty: f64) !void {
try ensureFinite(&.{ tx, ty });
- try self.applyState(.{ .typ = 2, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.translationMatrix(tx, ty, 0), .is_2d = true }, frame);
+ try self.applyState(.{ .typ = 2, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.translationMatrix(tx, ty, 0), .is_2d = true });
}
-pub fn setScale(self: *Transform, sx: f64, sy: f64, frame: *Frame) !void {
+pub fn setScale(self: *Transform, sx: f64, sy: f64) !void {
try ensureFinite(&.{ sx, sy });
- try self.applyState(.{ .typ = 3, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.scaleMatrix(sx, sy, 1), .is_2d = true }, frame);
+ try self.applyState(.{ .typ = 3, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.scaleMatrix(sx, sy, 1), .is_2d = true });
}
-pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64, frame: *Frame) !void {
+pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void {
try ensureFinite(&.{ angle, cx, cy });
const radians = angle * std.math.pi / 180.0;
var matrix = RO.translationMatrix(cx, cy, 0);
matrix = RO.multiplyMatrix(matrix, RO.rotateZMatrix(radians));
matrix = RO.multiplyMatrix(matrix, RO.translationMatrix(-cx, -cy, 0));
- try self.applyState(.{ .typ = 4, .angle = angle, .cx = cx, .cy = cy, .matrix = matrix, .is_2d = true }, frame);
+ try self.applyState(.{ .typ = 4, .angle = angle, .cx = cx, .cy = cy, .matrix = matrix, .is_2d = true });
}
-pub fn setSkewX(self: *Transform, angle: f64, frame: *Frame) !void {
+pub fn setSkewX(self: *Transform, angle: f64) !void {
try ensureFinite(&.{angle});
- try self.applyState(.{ .typ = 5, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(angle * std.math.pi / 180.0, 0), .is_2d = true }, frame);
+ try self.applyState(.{ .typ = 5, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(angle * std.math.pi / 180.0, 0), .is_2d = true });
}
-pub fn setSkewY(self: *Transform, angle: f64, frame: *Frame) !void {
+pub fn setSkewY(self: *Transform, angle: f64) !void {
try ensureFinite(&.{angle});
- try self.applyState(.{ .typ = 6, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(0, angle * std.math.pi / 180.0), .is_2d = true }, frame);
+ try self.applyState(.{ .typ = 6, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(0, angle * std.math.pi / 180.0), .is_2d = true });
}
pub fn getState(self: *const Transform) State {
@@ -187,11 +187,11 @@ pub fn applyStateRaw(self: *Transform, state: State) void {
self._matrix._proto._is_2d = state.is_2d;
}
-fn applyState(self: *Transform, state: State, frame: *Frame) !void {
+fn applyState(self: *Transform, state: State) !void {
try ensureFinite(&state.matrix);
if (self._attachment) |attachment| {
if (attachment.read_only) return error.NoModificationAllowed;
- return attachment.mutate(attachment.owner, self, state, frame);
+ return attachment.mutate(attachment.owner, self, state);
}
self.applyStateRaw(state);
}
diff --git a/src/browser/webapi/svg/TransformList.zig b/src/browser/webapi/svg/TransformList.zig
index 8acc51faa..773b2557b 100644
--- a/src/browser/webapi/svg/TransformList.zig
+++ b/src/browser/webapi/svg/TransformList.zig
@@ -16,33 +16,29 @@ const Transform = @import("Transform.zig");
const TransformList = @This();
-_element: *Element,
+_frame: *Frame,
_read_only: bool,
+_element: *Element,
+_synced: bool = false,
+_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList(*Transform) = .empty,
_retired: std.ArrayList(*Transform) = .empty,
-_snapshot: ?[]const u8 = null,
pub fn create(element: *Element, read_only: bool, frame: *Frame) !*TransformList {
- const self = try frame._factory.create(TransformList{
+ return frame._factory.create(TransformList{
+ ._frame = frame,
._element = element,
._read_only = read_only,
});
- try frame.registerSvgCollectionCleanup(.{
- .context = self,
- .callback = TransformList.cleanup,
- });
- return self;
}
-fn cleanup(context: *anyopaque, page: *Page) void {
- const self: *TransformList = @ptrCast(@alignCast(context));
+pub fn deinit(self: *TransformList, page: *Page) void {
for (self._items.items) |transform| {
transform.detach(self);
transform.releaseRef(page);
}
- for (self._retired.items) |transform| transform.releaseRef(page);
self._items.clearRetainingCapacity();
- self._retired.clearRetainingCapacity();
+ self.releaseRetired(page);
}
pub fn getLength(self: *TransformList, frame: *Frame) !u32 {
@@ -57,9 +53,8 @@ pub fn getNumberOfItems(self: *TransformList, frame: *Frame) !u32 {
pub fn clear(self: *TransformList, frame: *Frame) !void {
try self.requireMutable();
try self.sync(frame);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ try self.retireAll(frame);
try self.setAttribute(&.{}, frame);
- self.retireAllAssumeCapacity();
}
pub fn initialize(self: *TransformList, item: *Transform, frame: *Frame) !*Transform {
@@ -68,10 +63,9 @@ pub fn initialize(self: *TransformList, item: *Transform, frame: *Frame) !*Trans
const prepared = try self.prepareItem(item, frame);
errdefer prepared.releaseRef(frame._page);
+ try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
try self.setAttribute(&.{prepared}, frame);
- self.retireAllAssumeCapacity();
self._items.appendAssumeCapacity(prepared);
self.attach(prepared);
return prepared;
@@ -165,10 +159,9 @@ pub fn consolidate(self: *TransformList, frame: *Frame) !?*Transform {
consolidated.acquireRef();
errdefer consolidated.releaseRef(frame._page);
+ try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
try self.setAttribute(&.{consolidated}, frame);
- self.retireAllAssumeCapacity();
self._items.appendAssumeCapacity(consolidated);
self.attach(consolidated);
return consolidated;
@@ -192,8 +185,9 @@ fn attach(self: *TransformList, transform: *Transform) void {
});
}
-fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform.State, frame: *Frame) anyerror!void {
+fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform.State) anyerror!void {
const self: *TransformList = @ptrCast(@alignCast(context));
+ const frame = self._frame;
try self.sync(frame);
if (!transform.isAttachedTo(self)) {
transform.applyStateRaw(state);
@@ -207,24 +201,38 @@ fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform.
}
fn sync(self: *TransformList, frame: *Frame) !void {
+ self.releaseRetired(frame._page);
+
const raw = self._element.getAttributeSafe(comptime .wrap("transform")) orelse "";
- if (self._snapshot) |snapshot| if (std.mem.eql(u8, snapshot, raw)) return;
- const snapshot = try frame.arena.dupe(u8, raw);
+ if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) return;
+
+ self._synced = false;
var parsed = parse(raw, frame) catch |err| switch (err) {
error.SyntaxError => std.ArrayList(*Transform).empty,
else => return err,
};
errdefer for (parsed.items) |transform| transform.releaseRef(frame._page);
+ self._snapshot.clearRetainingCapacity();
+ try self._snapshot.appendSlice(frame.arena, raw);
+ try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
- self.retireAllAssumeCapacity();
for (parsed.items) |transform| {
self._items.appendAssumeCapacity(transform);
self.attach(transform);
}
parsed.clearRetainingCapacity();
- self._snapshot = snapshot;
+ self._synced = true;
+}
+
+// A retired item must outlive the operation that retired it: removeItem's
+// return value has no JS wrapper until the bridge wraps it after we return.
+// By the next operation, anything still reachable holds its own ref.
+fn releaseRetired(self: *TransformList, page: *Page) void {
+ for (self._retired.items) |transform| {
+ transform.releaseRef(page);
+ }
+ self._retired.clearRetainingCapacity();
}
fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*Transform) {
@@ -246,7 +254,9 @@ fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*Transform) {
return parsed;
}
-fn retireAllAssumeCapacity(self: *TransformList) void {
+fn retireAll(self: *TransformList, frame: *Frame) !void {
+ self._synced = false;
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
for (self._items.items) |transform| {
transform.detach(self);
self._retired.appendAssumeCapacity(transform);
@@ -275,9 +285,11 @@ fn setAttributeWithOverride(self: *TransformList, index: usize, state: Transform
}
fn commitAttribute(self: *TransformList, serialized: []const u8, frame: *Frame) !void {
- const snapshot = try frame.arena.dupe(u8, serialized);
+ self._synced = false;
try self._element.setAttributeSafe(comptime .wrap("transform"), .wrap(serialized), frame);
- self._snapshot = snapshot;
+ self._snapshot.clearRetainingCapacity();
+ try self._snapshot.appendSlice(frame.arena, serialized);
+ self._synced = true;
}
pub const JsApi = struct {
From 29109a2dea43a7774cc4ddc91f17e85987517922 Mon Sep 17 00:00:00 2001
From: Madison Steiner <8176115+mh0pe@users.noreply.github.com>
Date: Sun, 26 Jul 2026 11:44:40 -0700
Subject: [PATCH 3/5] svg: make live collection mutations transactional
Keep SVGTransform.matrix synchronized with its owning transform and reflected attribute, including read-only animVal and 3D matrix state. Reserve collection storage before attribute commits so allocation failures cannot detach live objects or leave snapshots inconsistent.
---
.../tests/element/svg/collections.html | 75 +++++++
src/browser/webapi/DOMMatrix.zig | 188 +++++++++---------
src/browser/webapi/DOMMatrixReadOnly.zig | 47 ++++-
src/browser/webapi/svg/PointList.zig | 26 ++-
src/browser/webapi/svg/Transform.zig | 55 ++++-
src/browser/webapi/svg/TransformList.zig | 58 +++---
6 files changed, 317 insertions(+), 132 deletions(-)
diff --git a/src/browser/tests/element/svg/collections.html b/src/browser/tests/element/svg/collections.html
index f3dcadc3d..43de72eeb 100644
--- a/src/browser/tests/element/svg/collections.html
+++ b/src/browser/tests/element/svg/collections.html
@@ -7,6 +7,8 @@
+
+
+
+
diff --git a/src/browser/webapi/svg/StringList.zig b/src/browser/webapi/svg/StringList.zig
index 56a3a10be..68a66b05a 100644
--- a/src/browser/webapi/svg/StringList.zig
+++ b/src/browser/webapi/svg/StringList.zig
@@ -22,6 +22,8 @@ _delimiter: Delimiter,
_synced: bool = false,
_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList([]const u8) = .empty,
+_scratch_snapshot: std.ArrayList(u8) = .empty,
+_scratch_items: std.ArrayList([]const u8) = .empty,
pub const Kind = enum {
required_extensions,
@@ -140,7 +142,8 @@ fn validateItem(self: *const StringList, item: []const u8) !void {
fn sync(self: *StringList, frame: *Frame) !void {
const raw = self._element.getAttributeSafe(self._attribute_name) orelse "";
if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) return;
- try self.rebuild(raw, frame);
+ try self.prepare(raw, frame);
+ self.publishPrepared();
}
fn commit(self: *StringList, items: []const []const u8, frame: *Frame) !void {
@@ -153,21 +156,26 @@ fn commit(self: *StringList, items: []const []const u8, frame: *Frame) !void {
}
const serialized_bytes = serialized.written();
- try self._element.setAttributeSafe(self._attribute_name, .wrap(serialized_bytes), frame);
- try self.rebuild(serialized_bytes, frame);
+ try self.prepare(serialized_bytes, frame);
+ try self._element.setAttributeSafe(self._attribute_name, .wrap(self._scratch_snapshot.items), frame);
+ self.publishPrepared();
}
-// `raw` must not alias `_snapshot`; `_items` are slices into `_snapshot`.
-fn rebuild(self: *StringList, raw: []const u8, frame: *Frame) !void {
- self._synced = false;
- self._snapshot.clearRetainingCapacity();
- try self._snapshot.appendSlice(frame.arena, raw);
- const parsed = parse(self._snapshot.items, self._delimiter, frame.local_arena) catch |err| switch (err) {
+fn prepare(self: *StringList, raw: []const u8, frame: *Frame) !void {
+ self._scratch_snapshot.clearRetainingCapacity();
+ try self._scratch_snapshot.appendSlice(frame.arena, raw);
+ const parsed = parse(self._scratch_snapshot.items, self._delimiter, frame.local_arena) catch |err| switch (err) {
error.SyntaxError => std.ArrayList([]const u8).empty,
else => return err,
};
- self._items.clearRetainingCapacity();
- try self._items.appendSlice(frame.arena, parsed.items);
+ self._scratch_items.clearRetainingCapacity();
+ try self._scratch_items.appendSlice(frame.arena, parsed.items);
+}
+
+fn publishPrepared(self: *StringList) void {
+ self._synced = false;
+ std.mem.swap(std.ArrayList(u8), &self._snapshot, &self._scratch_snapshot);
+ std.mem.swap(std.ArrayList([]const u8), &self._items, &self._scratch_items);
self._synced = true;
}
From e50c3e9a3bb49c61d5a8c5c5d3bff2117e53ac94 Mon Sep 17 00:00:00 2001
From: Karl Seguin
Date: Mon, 27 Jul 2026 12:26:30 +0800
Subject: [PATCH 5/5] Align with Chrome/Firefox
1 - Split out test to have more granular units
2 - Try to make our test and implementation consistent with Chrome and Firefox
- Chrome and Firefox diverge here in a lot of ways (e.g. serialization),
and there isn't always a WPT tests to break the tie.
3 - Removed some defensive code around OOM. OOM isn't generally recoverable
4 - SVGTransform no longer pretends to be 3D. #D writes drop the z/w component
and stays SVG_TRANSFORM_MATRIX
Small improvement to WPT correctness with no regression (though, the WPT tests
here seem to be a little lacking from what I can tell, which probably helps
explain why Chrome and Firefox disagree on things).
---
.../tests/element/svg/collections.html | 801 ++++++++++++++----
src/browser/webapi/DOMMatrixReadOnly.zig | 34 +-
src/browser/webapi/DOMPointReadOnly.zig | 20 +-
src/browser/webapi/Element.zig | 4 +
src/browser/webapi/element/Attribute.zig | 12 +-
src/browser/webapi/element/svg/Svg.zig | 4 +-
src/browser/webapi/svg/PointList.zig | 39 +-
src/browser/webapi/svg/StringList.zig | 128 +--
src/browser/webapi/svg/Transform.zig | 49 +-
src/browser/webapi/svg/TransformList.zig | 61 +-
10 files changed, 803 insertions(+), 349 deletions(-)
diff --git a/src/browser/tests/element/svg/collections.html b/src/browser/tests/element/svg/collections.html
index 96e78095b..715157080 100644
--- a/src/browser/tests/element/svg/collections.html
+++ b/src/browser/tests/element/svg/collections.html
@@ -3,277 +3,718 @@
-
+
+
- const oldPoint = points.getItem(0);
- oldPoint.x = 5;
- testing.expectEqual('5,0 10,20', polygon.getAttribute('points'));
- testing.expectEqual(5, animated.getItem(0).x);
+
+
+
- polygon.setAttribute('points', '1,2,3');
+
+
+
+ const inserted = root.createSVGPoint();
+ inserted.x = 100;
+ inserted.y = 200;
+ points.insertItemBefore(inserted, 0);
+ testing.expectEqual('100 200 1 2 3 4 7 8', polyline.getAttribute('points'));
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/DOMMatrixReadOnly.zig b/src/browser/webapi/DOMMatrixReadOnly.zig
index d86f8dd66..e70658e25 100644
--- a/src/browser/webapi/DOMMatrixReadOnly.zig
+++ b/src/browser/webapi/DOMMatrixReadOnly.zig
@@ -56,9 +56,10 @@ pub const State = struct {
is_2d: bool,
};
+// The owner decides whether a write is allowed; a matrix that belongs to an
+// animVal SVGTransform is rejected by the transform, not here.
pub const Attachment = struct {
owner: *anyopaque,
- read_only: bool,
mutate: *const fn (*anyopaque, *DOMMatrixReadOnly, State) anyerror!void,
};
@@ -103,7 +104,6 @@ pub fn getState(self: *const DOMMatrixReadOnly) State {
pub fn applyState(self: *DOMMatrixReadOnly, state: State) !void {
if (self._attachment) |attachment| {
- if (attachment.read_only) return error.NoModificationAllowed;
return attachment.mutate(attachment.owner, self, state);
}
self.applyStateRaw(state);
@@ -118,16 +118,6 @@ pub fn attach(self: *DOMMatrixReadOnly, attachment: Attachment) void {
self._attachment = attachment;
}
-pub fn detach(self: *DOMMatrixReadOnly, owner: *anyopaque) void {
- const attachment = self._attachment orelse return;
- if (attachment.owner == owner) self._attachment = null;
-}
-
-pub fn isAttachedTo(self: *const DOMMatrixReadOnly, owner: *anyopaque) bool {
- const attachment = self._attachment orelse return false;
- return attachment.owner == owner;
-}
-
pub const DOMMatrixInit = struct {
a: ?f64 = null,
b: ?f64 = null,
@@ -462,7 +452,7 @@ pub fn parseTransformList(input: []const u8, m: *[16]f64, is_2d: *bool) !void {
pub fn parseTransformFunction(function: TransformFunction, syntax: TransformSyntax) !ParsedTransform {
var values: [16]f64 = undefined;
var units: [16]ParsedValue.Unit = undefined;
- const count = try parseArguments(function.arguments, &values, &units);
+ const count = try parseArguments(function.arguments, syntax, &values, &units);
const name = function.name;
const Eql = std.mem.eql;
@@ -478,6 +468,7 @@ pub fn parseTransformFunction(function: TransformFunction, syntax: TransformSynt
}
if (Eql(u8, name, "matrix3d")) {
+ if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 16, 16);
try requireUnitless(units[0..count]);
return makeParsedTransform(.matrix3d, values, values, count, false);
@@ -613,8 +604,8 @@ fn requireAngle(unit: ParsedValue.Unit) !void {
if (unit == .other) return error.SyntaxError;
}
-fn parseArguments(arguments: []const u8, values: *[16]f64, units: *[16]ParsedValue.Unit) !usize {
- var scanner = ArgumentScanner{ .input = arguments };
+fn parseArguments(arguments: []const u8, syntax: TransformSyntax, values: *[16]f64, units: *[16]ParsedValue.Unit) !usize {
+ var scanner = ArgumentScanner{ .input = arguments, .syntax = syntax };
var count: usize = 0;
while (try scanner.next()) |value| {
if (count == values.len) return error.SyntaxError;
@@ -873,6 +864,7 @@ const ParsedValue = struct {
const ArgumentScanner = struct {
input: []const u8,
+ syntax: TransformSyntax,
index: usize = 0,
first: bool = true,
@@ -887,10 +879,14 @@ const ArgumentScanner = struct {
self.index += 1;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
if (self.index == self.input.len) return error.SyntaxError;
- } else if (!self.first and self.index < self.input.len and !had_whitespace and
- self.input[self.index] != '+' and self.input[self.index] != '-')
- {
- return error.SyntaxError;
+ } else if (!self.first and self.index < self.input.len) {
+ // CSS separates arguments with a comma and nothing else. The SVG
+ // grammar also takes whitespace, or no separator at all when the
+ // next number carries its own sign.
+ if (self.syntax == .css) return error.SyntaxError;
+ if (!had_whitespace and self.input[self.index] != '+' and self.input[self.index] != '-') {
+ return error.SyntaxError;
+ }
}
if (self.index == self.input.len) return null;
diff --git a/src/browser/webapi/DOMPointReadOnly.zig b/src/browser/webapi/DOMPointReadOnly.zig
index 525d0c662..a0bf84a2f 100644
--- a/src/browser/webapi/DOMPointReadOnly.zig
+++ b/src/browser/webapi/DOMPointReadOnly.zig
@@ -40,11 +40,19 @@ _z: f64,
_w: f64,
_attachment: ?Attachment = null,
+// Sticky. An animVal item that a later external attribute change detaches from
+// its list must not turn into a writable orphan.
+_read_only: bool = false,
+
+// SVGPoint's coordinates are a restricted float: NaN and infinity are a
+// TypeError rather than a stored value. Only points that reach an SVG list are
+// restricted; a plain DOMPoint takes an unrestricted double.
+_restricted: bool = false,
+
pub const Coordinate = enum { x, y, z, w };
pub const Attachment = struct {
owner: *anyopaque,
- read_only: bool,
mutate: *const fn (*anyopaque, *DOMPointReadOnly, Coordinate, f64) anyerror!void,
};
@@ -142,8 +150,9 @@ pub fn getW(self: *const DOMPointReadOnly) f64 {
}
pub fn setCoordinate(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) !void {
+ if (self._read_only) return error.NoModificationAllowed;
+ if (self._restricted and !std.math.isFinite(value)) return error.TypeError;
if (self._attachment) |attachment| {
- if (attachment.read_only) return error.NoModificationAllowed;
return attachment.mutate(attachment.owner, self, coordinate, value);
}
self.setCoordinateRaw(coordinate, value);
@@ -158,8 +167,13 @@ pub fn setCoordinateRaw(self: *DOMPointReadOnly, coordinate: Coordinate, value:
}
}
-pub fn attach(self: *DOMPointReadOnly, attachment: Attachment) void {
+pub fn restrict(self: *DOMPointReadOnly) void {
+ self._restricted = true;
+}
+
+pub fn attach(self: *DOMPointReadOnly, attachment: Attachment, read_only: bool) void {
self._attachment = attachment;
+ if (read_only) self._read_only = true;
}
pub fn detach(self: *DOMPointReadOnly, owner: *anyopaque) void {
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig
index a81b82b66..665b9402c 100644
--- a/src/browser/webapi/Element.zig
+++ b/src/browser/webapi/Element.zig
@@ -856,6 +856,10 @@ pub fn removeAttribute(self: *Element, name: String, frame: *Frame) !void {
return self._attributes.delete(name, self, frame);
}
+pub fn removeAttributeSafe(self: *Element, name: String, frame: *Frame) void {
+ self._attributes.deleteSafe(name, self, frame);
+}
+
pub fn toggleAttribute(self: *Element, name: String, force: ?bool, frame: *Frame) !bool {
try Attribute.validateAttributeName(name);
const has = try self.hasAttribute(name, frame);
diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig
index 983c6aa5f..c2023a1ef 100644
--- a/src/browser/webapi/element/Attribute.zig
+++ b/src/browser/webapi/element/Attribute.zig
@@ -315,9 +315,17 @@ pub const List = struct {
pub fn delete(self: *List, name: String, element: *Element, frame: *Frame) !void {
const result = try self.getEntryAndNormalizedName(name, frame);
const entry = result.entry orelse return;
+ return self._delete(entry, result.normalized, element, frame);
+ }
+ pub fn deleteSafe(self: *List, name: String, element: *Element, frame: *Frame) void {
+ const entry = self.getEntryWithNormalizedName(name) orelse return;
+ self._delete(entry, name, element, frame);
+ }
+
+ fn _delete(self: *List, entry: *Entry, normalized: String, element: *Element, frame: *Frame) void {
const owner = element.ownerFrame(frame);
- const is_id = shouldAddToIdMap(result.normalized, element);
+ const is_id = shouldAddToIdMap(normalized, element);
const old_value = entry.value();
if (is_id) {
@@ -333,7 +341,7 @@ pub const List = struct {
self._len -= 1;
owner.domChanged();
- owner.attributeRemove(element, result.normalized, .wrap(old_value));
+ owner.attributeRemove(element, normalized, .wrap(old_value));
}
pub fn getNames(self: *const List, allocator: Allocator) ![][]const u8 {
diff --git a/src/browser/webapi/element/svg/Svg.zig b/src/browser/webapi/element/svg/Svg.zig
index ae6ae220b..42419255d 100644
--- a/src/browser/webapi/element/svg/Svg.zig
+++ b/src/browser/webapi/element/svg/Svg.zig
@@ -82,7 +82,9 @@ pub fn getPreserveAspectRatio(self: *Svg, frame: *Frame) !*AnimatedPreserveAspec
}
pub fn createSVGPoint(_: *Svg, frame: *Frame) !*DOMPoint {
- return DOMPoint.create(0, 0, 0, 1, frame._page);
+ const point = try DOMPoint.create(0, 0, 0, 1, frame._page);
+ point._proto.restrict();
+ return point;
}
pub fn createSVGMatrix(_: *Svg, frame: *Frame) !*DOMMatrix {
diff --git a/src/browser/webapi/svg/PointList.zig b/src/browser/webapi/svg/PointList.zig
index e412b8ed1..6c9a645a4 100644
--- a/src/browser/webapi/svg/PointList.zig
+++ b/src/browser/webapi/svg/PointList.zig
@@ -72,9 +72,8 @@ pub fn getNumberOfItems(self: *PointList, frame: *Frame) !u32 {
pub fn clear(self: *PointList, frame: *Frame) !void {
try self.requireMutable();
try self.sync(frame);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ try self.retireAll(frame);
try self.setAttribute(&.{}, frame);
- self.retireAllAssumeCapacity();
}
pub fn initialize(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
@@ -84,10 +83,9 @@ pub fn initialize(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
const prepared = try self.prepareItem(item, frame);
errdefer prepared._proto.releaseRef(frame._page);
+ try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
try self.setAttribute(&.{prepared}, frame);
- self.retireAllAssumeCapacity();
self._items.appendAssumeCapacity(prepared);
self.attach(prepared);
return prepared;
@@ -174,11 +172,11 @@ fn prepareItem(_: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
}
fn attach(self: *PointList, point: *DOMPoint) void {
+ point._proto.restrict();
point._proto.attach(.{
.owner = self,
- .read_only = self._read_only,
.mutate = PointList.mutatePoint,
- });
+ }, self._read_only);
}
fn mutatePoint(
@@ -220,20 +218,17 @@ fn sync(self: *PointList, frame: *Frame) !void {
return;
}
+ self._synced = false;
var parsed = parse(raw, frame) catch |err| switch (err) {
error.SyntaxError => std.ArrayList(*DOMPoint).empty,
else => return err,
};
errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page);
- try self._snapshot.ensureTotalCapacity(frame.arena, raw.len);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
- try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
-
- self._synced = false;
self._snapshot.clearRetainingCapacity();
- self._snapshot.appendSliceAssumeCapacity(raw);
- self.retireAllAssumeCapacity();
+ try self._snapshot.appendSlice(frame.arena, raw);
+ try self.retireAll(frame);
+ try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
for (parsed.items) |point| {
self._items.appendAssumeCapacity(point);
self.attach(point);
@@ -258,7 +253,9 @@ fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) {
errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page);
while (try scanner.next()) |x| {
- const y = (try scanner.next()) orelse return error.SyntaxError;
+ // A trailing coordinate with no pair truncates the list; only a
+ // malformed number invalidates the whole attribute.
+ const y = (try scanner.next()) orelse break;
const point = try DOMPoint.create(x, y, 0, 1, frame._page);
point._proto.acquireRef();
parsed.append(frame.local_arena, point) catch |err| {
@@ -269,7 +266,9 @@ fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) {
return parsed;
}
-fn retireAllAssumeCapacity(self: *PointList) void {
+fn retireAll(self: *PointList, frame: *Frame) !void {
+ self._synced = false;
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
for (self._items.items) |point| {
point._proto.detach(self);
self._retired.appendAssumeCapacity(point);
@@ -282,7 +281,7 @@ fn setAttribute(self: *PointList, items: []const *DOMPoint, frame: *Frame) !void
const writer = &serialized.writer;
for (items, 0..) |point, i| {
if (i != 0) try writer.writeByte(' ');
- try writer.print("{d},{d}", .{ point._proto._x, point._proto._y });
+ try writer.print("{d} {d}", .{ point._proto._x, point._proto._y });
}
try self.commitAttribute(serialized.written(), frame);
}
@@ -300,17 +299,16 @@ fn setAttributeWithOverride(
if (i != 0) try writer.writeByte(' ');
const x = if (i == index and coordinate == .x) value else point._proto._x;
const y = if (i == index and coordinate == .y) value else point._proto._y;
- try writer.print("{d},{d}", .{ x, y });
+ try writer.print("{d} {d}", .{ x, y });
}
try self.commitAttribute(serialized.written(), frame);
}
fn commitAttribute(self: *PointList, serialized: []const u8, frame: *Frame) !void {
- try self._snapshot.ensureTotalCapacity(frame.arena, serialized.len);
- try self._element.setAttributeSafe(comptime .wrap("points"), .wrap(serialized), frame);
self._synced = false;
+ try self._element.setAttributeSafe(comptime .wrap("points"), .wrap(serialized), frame);
self._snapshot.clearRetainingCapacity();
- self._snapshot.appendSliceAssumeCapacity(serialized);
+ try self._snapshot.appendSlice(frame.arena, serialized);
self._synced = true;
}
@@ -329,7 +327,6 @@ const NumberScanner = struct {
if (!self.first and self.index < self.input.len and self.input[self.index] == ',') {
self.index += 1;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
- if (self.index == self.input.len) return error.SyntaxError;
} else if (!self.first and self.index < self.input.len and !had_whitespace and
self.input[self.index] != '+' and self.input[self.index] != '-')
{
diff --git a/src/browser/webapi/svg/StringList.zig b/src/browser/webapi/svg/StringList.zig
index 68a66b05a..ccd217063 100644
--- a/src/browser/webapi/svg/StringList.zig
+++ b/src/browser/webapi/svg/StringList.zig
@@ -20,10 +20,11 @@ _element: *Element,
_attribute_name: lp.String,
_delimiter: Delimiter,
_synced: bool = false,
+// An absent attribute is an empty list, but an empty attribute is not: a
+// comma-separated value parses "" as a single empty token.
+_present: bool = false,
_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList([]const u8) = .empty,
-_scratch_snapshot: std.ArrayList(u8) = .empty,
-_scratch_items: std.ArrayList([]const u8) = .empty,
pub const Kind = enum {
required_extensions,
@@ -82,9 +83,8 @@ pub fn clear(self: *StringList, frame: *Frame) !void {
}
pub fn initialize(self: *StringList, item: []const u8, frame: *Frame) ![]const u8 {
- try self.validateItem(item);
try self.commit(&.{item}, frame);
- return self._items.items[0];
+ return item;
}
pub fn getItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 {
@@ -94,7 +94,6 @@ pub fn getItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 {
}
pub fn insertItemBefore(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 {
- try self.validateItem(item);
try self.sync(frame);
const at = @min(@as(usize, index), self._items.items.len);
const next = try frame.local_arena.alloc([]const u8, self._items.items.len + 1);
@@ -102,17 +101,16 @@ pub fn insertItemBefore(self: *StringList, item: []const u8, index: u32, frame:
next[at] = item;
@memcpy(next[at + 1 ..], self._items.items[at..]);
try self.commit(next, frame);
- return self._items.items[at];
+ return item;
}
pub fn replaceItem(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 {
- try self.validateItem(item);
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const next = try frame.local_arena.dupe([]const u8, self._items.items);
next[index] = item;
try self.commit(next, frame);
- return self._items.items[index];
+ return item;
}
pub fn removeItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 {
@@ -130,76 +128,82 @@ pub fn appendItem(self: *StringList, item: []const u8, frame: *Frame) ![]const u
return self.insertItemBefore(item, std.math.maxInt(u32), frame);
}
-fn validateItem(self: *const StringList, item: []const u8) !void {
- if (item.len == 0) return error.SyntaxError;
- for (item) |byte| {
- if (std.ascii.isWhitespace(byte) or (self._delimiter == .comma and byte == ',')) {
- return error.SyntaxError;
- }
- }
-}
-
fn sync(self: *StringList, frame: *Frame) !void {
- const raw = self._element.getAttributeSafe(self._attribute_name) orelse "";
- if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) return;
- try self.prepare(raw, frame);
- self.publishPrepared();
+ const raw = self._element.getAttributeSafe(self._attribute_name);
+ if (self._synced and self._present == (raw != null) and
+ std.mem.eql(u8, self._snapshot.items, raw orelse ""))
+ {
+ return;
+ }
+ return self.rebuild(raw, frame);
}
+// The list is authoritative: an item keeps its identity even when it contains
+// the delimiter, so we record where each one landed rather than reparsing our
+// own serialization.
fn commit(self: *StringList, items: []const []const u8, frame: *Frame) !void {
+ if (items.len == 0) {
+ self._synced = false;
+ self._element.removeAttributeSafe(self._attribute_name, frame);
+ self._present = false;
+ self._snapshot.clearRetainingCapacity();
+ self._items.clearRetainingCapacity();
+ self._synced = true;
+ return;
+ }
+
+ const separator: []const u8 = if (self._delimiter == .comma) "," else " ";
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
- for (items, 0..) |item, i| {
- try self.validateItem(item);
- if (i != 0) try writer.writeAll(if (self._delimiter == .comma) ", " else " ");
+ const bounds = try frame.local_arena.alloc([2]usize, items.len);
+ for (items, bounds, 0..) |item, *bound, i| {
+ if (i != 0) try writer.writeAll(separator);
+ const start = serialized.written().len;
try writer.writeAll(item);
+ bound.* = .{ start, serialized.written().len };
}
+ const bytes = serialized.written();
- const serialized_bytes = serialized.written();
- try self.prepare(serialized_bytes, frame);
- try self._element.setAttributeSafe(self._attribute_name, .wrap(self._scratch_snapshot.items), frame);
- self.publishPrepared();
-}
-
-fn prepare(self: *StringList, raw: []const u8, frame: *Frame) !void {
- self._scratch_snapshot.clearRetainingCapacity();
- try self._scratch_snapshot.appendSlice(frame.arena, raw);
- const parsed = parse(self._scratch_snapshot.items, self._delimiter, frame.local_arena) catch |err| switch (err) {
- error.SyntaxError => std.ArrayList([]const u8).empty,
- else => return err,
- };
- self._scratch_items.clearRetainingCapacity();
- try self._scratch_items.appendSlice(frame.arena, parsed.items);
-}
-
-fn publishPrepared(self: *StringList) void {
self._synced = false;
- std.mem.swap(std.ArrayList(u8), &self._snapshot, &self._scratch_snapshot);
- std.mem.swap(std.ArrayList([]const u8), &self._items, &self._scratch_items);
+ try self._element.setAttributeSafe(self._attribute_name, .wrap(bytes), frame);
+ self._present = true;
+ self._snapshot.clearRetainingCapacity();
+ try self._snapshot.appendSlice(frame.arena, bytes);
+ self._items.clearRetainingCapacity();
+ for (bounds) |bound| {
+ try self._items.append(frame.arena, self._snapshot.items[bound[0]..bound[1]]);
+ }
self._synced = true;
}
-fn parse(raw: []const u8, delimiter: Delimiter, allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
- var items: std.ArrayList([]const u8) = .empty;
- switch (delimiter) {
- .whitespace => {
- var iterator = std.mem.tokenizeAny(u8, raw, " \t\r\n\x0c");
- while (iterator.next()) |item| try items.append(allocator, item);
- },
- .comma => {
- if (std.mem.trim(u8, raw, " \t\r\n\x0c").len == 0) return items;
- var iterator = std.mem.splitScalar(u8, raw, ',');
- while (iterator.next()) |part| {
- const item = std.mem.trim(u8, part, " \t\r\n\x0c");
- if (item.len == 0) return error.SyntaxError;
- for (item) |byte| if (std.ascii.isWhitespace(byte)) return error.SyntaxError;
- try items.append(allocator, item);
- }
- },
+fn rebuild(self: *StringList, raw: ?[]const u8, frame: *Frame) !void {
+ self._synced = false;
+ self._present = raw != null;
+ self._snapshot.clearRetainingCapacity();
+ self._items.clearRetainingCapacity();
+
+ if (raw) |value| {
+ try self._snapshot.appendSlice(frame.arena, value);
+ switch (self._delimiter) {
+ .whitespace => {
+ var iterator = std.mem.tokenizeAny(u8, self._snapshot.items, WHITESPACE);
+ while (iterator.next()) |item| try self._items.append(frame.arena, item);
+ },
+ // A set of comma-separated tokens: every segment is a token, even
+ // an empty one, and each is trimmed of surrounding whitespace.
+ .comma => {
+ var iterator = std.mem.splitScalar(u8, self._snapshot.items, ',');
+ while (iterator.next()) |part| {
+ try self._items.append(frame.arena, std.mem.trim(u8, part, WHITESPACE));
+ }
+ },
+ }
}
- return items;
+ self._synced = true;
}
+const WHITESPACE = " \t\r\n\x0c";
+
pub const JsApi = struct {
pub const bridge = js.Bridge(StringList);
diff --git a/src/browser/webapi/svg/Transform.zig b/src/browser/webapi/svg/Transform.zig
index d79ab1839..2ebe7dc02 100644
--- a/src/browser/webapi/svg/Transform.zig
+++ b/src/browser/webapi/svg/Transform.zig
@@ -48,6 +48,10 @@ _cy: f64 = 0,
_matrix: *DOMMatrix,
_attachment: ?Attachment = null,
+// Sticky. An animVal item that a later external attribute change detaches from
+// its list must not turn into a writable orphan.
+_read_only: bool = false,
+
pub const State = struct {
typ: u16,
angle: f64,
@@ -59,7 +63,6 @@ pub const State = struct {
pub const Attachment = struct {
owner: *anyopaque,
- read_only: bool,
mutate: *const fn (*anyopaque, *Transform, State) anyerror!void,
};
@@ -78,7 +81,7 @@ pub fn detached(frame: *Frame) !*Transform {
const matrix = try DOMMatrix.create(RO.identity(), true, frame._page);
errdefer matrix._proto.deinit(frame._page);
const self = try frame._factory.create(Transform{ ._matrix = matrix });
- self.attachMatrix(false);
+ self.attachMatrix();
return self;
}
@@ -87,14 +90,13 @@ pub fn fromMatrix(init: ?DOMMatrix2DInit, frame: *Frame) !*Transform {
const matrix = try DOMMatrix.create(parsed.m, true, frame._page);
errdefer matrix._proto.deinit(frame._page);
const self = try frame._factory.create(Transform{ ._matrix = matrix });
- self.attachMatrix(false);
+ self.attachMatrix();
return self;
}
pub fn fromParsed(parsed: RO.ParsedTransform, frame: *Frame) !*Transform {
const typ: u16 = switch (parsed.kind) {
.matrix => 1,
- .matrix3d => 0,
.translate => 2,
.scale => 3,
.rotate => 4,
@@ -111,7 +113,7 @@ pub fn fromParsed(parsed: RO.ParsedTransform, frame: *Frame) !*Transform {
._cy = if (typ == 4 and parsed.count == 3) parsed.values[2] else 0,
._matrix = matrix,
});
- self.attachMatrix(false);
+ self.attachMatrix();
return self;
}
@@ -126,7 +128,7 @@ pub fn clone(self: *const Transform, frame: *Frame) !*Transform {
._cy = current.cy,
._matrix = matrix,
});
- cloned.attachMatrix(false);
+ cloned.attachMatrix();
return cloned;
}
@@ -199,25 +201,22 @@ pub fn applyStateRaw(self: *Transform, state: State) void {
}
fn applyState(self: *Transform, state: State) !void {
+ if (self._read_only) return error.NoModificationAllowed;
try ensureFinite(&state.matrix);
if (self._attachment) |attachment| {
- if (attachment.read_only) return error.NoModificationAllowed;
return attachment.mutate(attachment.owner, self, state);
}
self.applyStateRaw(state);
}
-pub fn attach(self: *Transform, attachment: Attachment) void {
+pub fn attach(self: *Transform, attachment: Attachment, read_only: bool) void {
self._attachment = attachment;
- self.attachMatrix(attachment.read_only);
+ if (read_only) self._read_only = true;
}
pub fn detach(self: *Transform, owner: *anyopaque) void {
const attachment = self._attachment orelse return;
- if (attachment.owner == owner) {
- self._attachment = null;
- self.attachMatrix(false);
- }
+ if (attachment.owner == owner) self._attachment = null;
}
pub fn isAttached(self: *const Transform) bool {
@@ -231,12 +230,6 @@ pub fn isAttachedTo(self: *const Transform, owner: *anyopaque) bool {
pub fn writeState(state: State, writer: anytype) !void {
switch (state.typ) {
- 0 => try writer.print("matrix3d({d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d})", .{
- state.matrix[0], state.matrix[1], state.matrix[2], state.matrix[3],
- state.matrix[4], state.matrix[5], state.matrix[6], state.matrix[7],
- state.matrix[8], state.matrix[9], state.matrix[10], state.matrix[11],
- state.matrix[12], state.matrix[13], state.matrix[14], state.matrix[15],
- }),
1 => try writer.print("matrix({d} {d} {d} {d} {d} {d})", .{
state.matrix[0], state.matrix[1], state.matrix[4], state.matrix[5], state.matrix[12], state.matrix[13],
}),
@@ -252,23 +245,31 @@ pub fn writeState(state: State, writer: anytype) !void {
}
}
-fn attachMatrix(self: *Transform, read_only: bool) void {
+fn attachMatrix(self: *Transform) void {
self._matrix._proto.attach(.{
.owner = self,
- .read_only = read_only,
.mutate = Transform.mutateMatrix,
});
}
+// An SVGTransform is a 2D affine transform, and the `transform` attribute has
+// no syntax for anything else, so a write through the live matrix keeps only
+// the six components the transform can represent.
fn mutateMatrix(context: *anyopaque, _: *RO, state: RO.State) anyerror!void {
const self: *Transform = @ptrCast(@alignCast(context));
+ const m = state.matrix;
try self.applyState(.{
- .typ = if (state.is_2d) 1 else 0,
+ .typ = 1,
.angle = 0,
.cx = 0,
.cy = 0,
- .matrix = state.matrix,
- .is_2d = state.is_2d,
+ .matrix = .{
+ m[0], m[1], 0, 0,
+ m[4], m[5], 0, 0,
+ 0, 0, 1, 0,
+ m[12], m[13], 0, 1,
+ },
+ .is_2d = true,
});
}
diff --git a/src/browser/webapi/svg/TransformList.zig b/src/browser/webapi/svg/TransformList.zig
index 729e33223..8baf90052 100644
--- a/src/browser/webapi/svg/TransformList.zig
+++ b/src/browser/webapi/svg/TransformList.zig
@@ -53,9 +53,8 @@ pub fn getNumberOfItems(self: *TransformList, frame: *Frame) !u32 {
pub fn clear(self: *TransformList, frame: *Frame) !void {
try self.requireMutable();
try self.sync(frame);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
+ try self.retireAll(frame);
try self.setAttribute(&.{}, frame);
- self.retireAllAssumeCapacity();
}
pub fn initialize(self: *TransformList, item: *Transform, frame: *Frame) !*Transform {
@@ -64,10 +63,9 @@ pub fn initialize(self: *TransformList, item: *Transform, frame: *Frame) !*Trans
const prepared = try self.prepareItem(item, frame);
errdefer prepared.releaseRef(frame._page);
+ try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
try self.setAttribute(&.{prepared}, frame);
- self.retireAllAssumeCapacity();
self._items.appendAssumeCapacity(prepared);
self.attach(prepared);
return prepared;
@@ -142,36 +140,28 @@ pub fn consolidate(self: *TransformList, frame: *Frame) !?*Transform {
if (self._items.items.len == 0) return null;
var matrix = DOMMatrixReadOnly.identity();
- var is_2d = true;
- for (self._items.items) |item| {
- const state = item.getState();
- matrix = DOMMatrixReadOnly.multiplyMatrix(matrix, state.matrix);
- is_2d = is_2d and state.is_2d;
- }
+ for (self._items.items) |item| matrix = DOMMatrixReadOnly.multiplyMatrix(matrix, item.getState().matrix);
for (matrix) |value| if (!std.math.isFinite(value)) return error.TypeError;
- var values: [16]f64 = matrix;
- if (is_2d) {
- values[0] = matrix[0];
- values[1] = matrix[1];
- values[2] = matrix[4];
- values[3] = matrix[5];
- values[4] = matrix[12];
- values[5] = matrix[13];
- }
+ var values: [16]f64 = undefined;
+ values[0] = matrix[0];
+ values[1] = matrix[1];
+ values[2] = matrix[4];
+ values[3] = matrix[5];
+ values[4] = matrix[12];
+ values[5] = matrix[13];
const consolidated = try Transform.fromParsed(.{
- .kind = if (is_2d) .matrix else .matrix3d,
+ .kind = .matrix,
.matrix = matrix,
.values = values,
- .count = if (is_2d) 6 else 16,
- .is_2d = is_2d,
+ .count = 6,
+ .is_2d = true,
}, frame);
consolidated.acquireRef();
errdefer consolidated.releaseRef(frame._page);
+ try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
try self.setAttribute(&.{consolidated}, frame);
- self.retireAllAssumeCapacity();
self._items.appendAssumeCapacity(consolidated);
self.attach(consolidated);
return consolidated;
@@ -190,9 +180,8 @@ fn prepareItem(_: *TransformList, item: *Transform, frame: *Frame) !*Transform {
fn attach(self: *TransformList, transform: *Transform) void {
transform.attach(.{
.owner = self,
- .read_only = self._read_only,
.mutate = TransformList.mutateTransform,
- });
+ }, self._read_only);
}
fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform.State) anyerror!void {
@@ -216,20 +205,17 @@ fn sync(self: *TransformList, frame: *Frame) !void {
const raw = self._element.getAttributeSafe(comptime .wrap("transform")) orelse "";
if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) return;
+ self._synced = false;
var parsed = parse(raw, frame) catch |err| switch (err) {
error.SyntaxError => std.ArrayList(*Transform).empty,
else => return err,
};
errdefer for (parsed.items) |transform| transform.releaseRef(frame._page);
- try self._snapshot.ensureTotalCapacity(frame.arena, raw.len);
- try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
- try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
-
- self._synced = false;
self._snapshot.clearRetainingCapacity();
- self._snapshot.appendSliceAssumeCapacity(raw);
- self.retireAllAssumeCapacity();
+ try self._snapshot.appendSlice(frame.arena, raw);
+ try self.retireAll(frame);
+ try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
for (parsed.items) |transform| {
self._items.appendAssumeCapacity(transform);
self.attach(transform);
@@ -267,7 +253,9 @@ fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*Transform) {
return parsed;
}
-fn retireAllAssumeCapacity(self: *TransformList) void {
+fn retireAll(self: *TransformList, frame: *Frame) !void {
+ self._synced = false;
+ try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
for (self._items.items) |transform| {
transform.detach(self);
self._retired.appendAssumeCapacity(transform);
@@ -296,11 +284,10 @@ fn setAttributeWithOverride(self: *TransformList, index: usize, state: Transform
}
fn commitAttribute(self: *TransformList, serialized: []const u8, frame: *Frame) !void {
- try self._snapshot.ensureTotalCapacity(frame.arena, serialized.len);
- try self._element.setAttributeSafe(comptime .wrap("transform"), .wrap(serialized), frame);
self._synced = false;
+ try self._element.setAttributeSafe(comptime .wrap("transform"), .wrap(serialized), frame);
self._snapshot.clearRetainingCapacity();
- self._snapshot.appendSliceAssumeCapacity(serialized);
+ try self._snapshot.appendSlice(frame.arena, serialized);
self._synced = true;
}