diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index a9e7adfa3..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;
@@ -148,6 +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_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)
@@ -503,6 +510,16 @@ pub fn deinit(self: *Frame) void {
observers.deinit(self, 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);
+ }
+
var document = self.window._document;
document._selection.releaseRef(page);
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..715157080
--- /dev/null
+++ b/src/browser/tests/element/svg/collections.html
@@ -0,0 +1,720 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/DOMMatrix.zig b/src/browser/webapi/DOMMatrix.zig
index 132fa52a7..1e375c5f9 100644
--- a/src/browser/webapi/DOMMatrix.zig
+++ b/src/browser/webapi/DOMMatrix.zig
@@ -84,137 +84,158 @@ pub fn getF(self: *const DOMMatrix) f64 {
return self._proto._m[13];
}
-pub fn setA(self: *DOMMatrix, v: f64) void {
- self._proto._m[0] = v;
+pub fn setA(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(0, v);
}
-pub fn setB(self: *DOMMatrix, v: f64) void {
- self._proto._m[1] = v;
+pub fn setB(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(1, v);
}
-pub fn setC(self: *DOMMatrix, v: f64) void {
- self._proto._m[4] = v;
+pub fn setC(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(4, v);
}
-pub fn setD(self: *DOMMatrix, v: f64) void {
- self._proto._m[5] = v;
+pub fn setD(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(5, v);
}
-pub fn setE(self: *DOMMatrix, v: f64) void {
- self._proto._m[12] = v;
+pub fn setE(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(12, v);
}
-pub fn setF(self: *DOMMatrix, v: f64) void {
- self._proto._m[13] = v;
+pub fn setF(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(13, v);
}
-pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) *DOMMatrix {
+pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) !*DOMMatrix {
const tz = tz_ orelse 0;
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz));
- if (tz != 0) p._is_2d = false;
- return self;
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz));
+ if (tz != 0) state.is_2d = false;
+ return self.applyState(state);
}
-pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
+pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix {
const sx = sx_ orelse 1;
const sy = sy_ orelse sx;
const sz = sz_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
- const p = self._proto;
- var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
+ var state = self._proto.getState();
+ var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(sx, sy, sz));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
- p._m = m;
- if (sz != 1 or oz != 0) p._is_2d = false;
- return self;
+ state.matrix = m;
+ if (sz != 1 or oz != 0) state.is_2d = false;
+ return self.applyState(state);
}
-pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
+pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix {
const s = scale_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
- const p = self._proto;
- var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
+ var state = self._proto.getState();
+ var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(s, s, s));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
- p._m = m;
- if (s != 1) p._is_2d = false;
- return self;
+ state.matrix = m;
+ if (s != 1) state.is_2d = false;
+ return self.applyState(state);
}
-pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) *DOMMatrix {
- const p = self._proto;
+pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) !*DOMMatrix {
+ var state = self._proto.getState();
if (ry_ == null and rz_ == null) {
- p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg)));
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg)));
} else {
- p._m = RO.multiplyMatrix(p._m, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg)));
- p._m = RO.multiplyMatrix(p._m, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg)));
- p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg)));
- p._is_2d = false;
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg)));
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg)));
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg)));
+ state.is_2d = false;
}
- return self;
+ return self.applyState(state);
}
-pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) *DOMMatrix {
+pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) !*DOMMatrix {
const x = x_ orelse 0;
const y = y_ orelse 0;
const rad = if (x == 0 and y == 0) 0 else std.math.atan2(y, x);
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(rad));
- return self;
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(rad));
+ return self.applyState(state);
}
-pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) *DOMMatrix {
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg)));
- if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) p._is_2d = false;
- return self;
+pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) !*DOMMatrix {
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg)));
+ if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) state.is_2d = false;
+ return self.applyState(state);
}
-pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) *DOMMatrix {
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0));
- return self;
+pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) !*DOMMatrix {
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0));
+ return self.applyState(state);
}
-pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) *DOMMatrix {
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg)));
- return self;
+pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) !*DOMMatrix {
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg)));
+ return self.applyState(state);
}
pub fn multiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
- const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
- p._m = RO.multiplyMatrix(p._m, other.m);
- p._is_2d = p._is_2d and other.is_2d;
- return self;
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, other.m);
+ state.is_2d = state.is_2d and other.is_2d;
+ return self.applyState(state);
}
pub fn preMultiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
- const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
- p._m = RO.multiplyMatrix(other.m, p._m);
- p._is_2d = p._is_2d and other.is_2d;
- return self;
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(other.m, state.matrix);
+ state.is_2d = state.is_2d and other.is_2d;
+ return self.applyState(state);
}
-pub fn invertSelf(self: *DOMMatrix) *DOMMatrix {
- const p = self._proto;
- if (RO.invertMatrix(p._m)) |v| {
- p._m = v;
+pub fn invertSelf(self: *DOMMatrix) !*DOMMatrix {
+ var state = self._proto.getState();
+ if (RO.invertMatrix(state.matrix)) |v| {
+ state.matrix = v;
} else {
- p._m = .{std.math.nan(f64)} ** 16;
- p._is_2d = false;
+ state.matrix = .{std.math.nan(f64)} ** 16;
+ state.is_2d = false;
}
- return self;
+ return self.applyState(state);
}
pub fn setMatrixValue(self: *DOMMatrix, transform: []const u8) !*DOMMatrix {
- var m = RO.identity();
- var is_2d = true;
- try RO.parseTransformList(transform, &m, &is_2d);
- self._proto._m = m;
- self._proto._is_2d = is_2d;
+ var state: RO.State = .{ .matrix = RO.identity(), .is_2d = true };
+ try RO.parseTransformList(transform, &state.matrix, &state.is_2d);
+ try self._proto.applyState(state);
+ return self;
+}
+
+fn setElement(self: *DOMMatrix, comptime index: usize, value: f64) !void {
+ var state = self._proto.getState();
+ state.matrix[index] = value;
+
+ // Assigning a z/w element a value other than its identity drops the
+ // 2D flag. Setting it back does not restore is2D.
+ switch (index) {
+ 2, 3, 6, 7, 8, 9, 11, 14 => if (value != 0) {
+ state.is_2d = false;
+ },
+ 10, 15 => if (value != 1) {
+ state.is_2d = false;
+ },
+ else => {},
+ }
+ try self._proto.applyState(state);
+}
+
+fn applyState(self: *DOMMatrix, state: RO.State) !*DOMMatrix {
+ try self._proto.applyState(state);
return self;
}
@@ -281,23 +302,10 @@ pub const JsApi = struct {
}.get;
}
- fn setM(comptime idx: usize) fn (*DOMMatrix, f64) void {
+ fn setM(comptime idx: usize) fn (*DOMMatrix, f64) anyerror!void {
return struct {
- fn set(self: *DOMMatrix, v: f64) void {
- self._proto._m[idx] = v;
- // Assigning a z/w element a value other than its identity drops the
- // 2D flag. Setting it back to the identity value (0 for the
- // off-diagonal elements, 1 for m33/m44) preserves is2D. Note `-0`
- // compares equal to `0`, so it preserves it too, per spec.
- switch (idx) {
- 2, 3, 6, 7, 8, 9, 11, 14 => if (v != 0) {
- self._proto._is_2d = false;
- },
- 10, 15 => if (v != 1) {
- self._proto._is_2d = false;
- },
- else => {},
- }
+ fn set(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(idx, v);
}
}.set;
}
diff --git a/src/browser/webapi/DOMMatrixReadOnly.zig b/src/browser/webapi/DOMMatrixReadOnly.zig
index d0340f942..e70658e25 100644
--- a/src/browser/webapi/DOMMatrixReadOnly.zig
+++ b/src/browser/webapi/DOMMatrixReadOnly.zig
@@ -44,12 +44,25 @@ _arena: Allocator,
// out[row] = sum_col _m[col*4 + row] * in[col]
_m: [16]f64,
_is_2d: bool,
+_attachment: ?Attachment = null,
pub const Type = union(enum) {
generic,
mutable: *DOMMatrix,
};
+pub const State = struct {
+ matrix: [16]f64,
+ 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,
+ mutate: *const fn (*anyopaque, *DOMMatrixReadOnly, State) anyerror!void,
+};
+
pub fn init(init_: ?js.Value, exec: *const js.Execution) !*DOMMatrixReadOnly {
const parsed = try Parsed.init(init_, exec);
return createBare(parsed.m, parsed.is_2d, exec.page);
@@ -82,6 +95,29 @@ pub fn createBare(m: [16]f64, is_2d: bool, page: *Page) !*DOMMatrixReadOnly {
return self;
}
+pub fn getState(self: *const DOMMatrixReadOnly) State {
+ return .{
+ .matrix = self._m,
+ .is_2d = self._is_2d,
+ };
+}
+
+pub fn applyState(self: *DOMMatrixReadOnly, state: State) !void {
+ if (self._attachment) |attachment| {
+ return attachment.mutate(attachment.owner, self, state);
+ }
+ self.applyStateRaw(state);
+}
+
+pub fn applyStateRaw(self: *DOMMatrixReadOnly, state: State) void {
+ self._m = state.matrix;
+ self._is_2d = state.is_2d;
+}
+
+pub fn attach(self: *DOMMatrixReadOnly, attachment: Attachment) void {
+ self._attachment = attachment;
+}
+
pub const DOMMatrixInit = struct {
a: ?f64 = null,
b: ?f64 = null,
@@ -312,178 +348,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, syntax, &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, 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;
+ 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 +623,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 +858,80 @@ 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;
+const ArgumentScanner = struct {
+ input: []const u8,
+ syntax: TransformSyntax,
+ 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 (!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) {
+ // 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;
}
- break;
}
- if (end == 0) {
- 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..d9356d3f2 100644
--- a/src/browser/webapi/DOMPoint.zig
+++ b/src/browser/webapi/DOMPoint.zig
@@ -77,17 +77,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) !void {
+ try self._proto.setCoordinate(.x, v);
}
-pub fn setY(self: *DOMPoint, v: f64) void {
- self._proto._y = v;
+pub fn setY(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.y, v);
}
-pub fn setZ(self: *DOMPoint, v: f64) void {
- self._proto._z = v;
+pub fn setZ(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.z, v);
}
-pub fn setW(self: *DOMPoint, v: f64) void {
- self._proto._w = v;
+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 db91e5a3f..a0bf84a2f 100644
--- a/src/browser/webapi/DOMPointReadOnly.zig
+++ b/src/browser/webapi/DOMPointReadOnly.zig
@@ -38,6 +38,23 @@ _x: f64,
_y: f64,
_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,
+ mutate: *const fn (*anyopaque, *DOMPointReadOnly, Coordinate, f64) anyerror!void,
+};
pub const Type = union(enum) {
generic,
@@ -132,6 +149,47 @@ pub fn getW(self: *const DOMPointReadOnly) f64 {
return self._w;
}
+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| {
+ return attachment.mutate(attachment.owner, self, coordinate, value);
+ }
+ 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 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 {
+ 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.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/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig
index ccb1386b9..93d10c762 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");
@@ -72,4 +75,20 @@ 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 {
+ return AnimatedTransformList.getOrCreate(self.asElement(), frame);
+}
+
+pub fn getRequiredExtensions(self: *Graphics, frame: *Frame) !*StringList {
+ return StringList.getOrCreate(self.asElement(), .required_extensions, frame);
+}
+
+pub fn getSystemLanguage(self: *Graphics, frame: *Frame) !*StringList {
+ 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 752917079..9d55797e4 100644
--- a/src/browser/webapi/element/svg/Polygon.zig
+++ b/src/browser/webapi/element/svg/Polygon.zig
@@ -17,11 +17,13 @@
// 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,
@@ -41,4 +43,15 @@ 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 {
+ return PointList.getOrCreate(self.asElement(), .base, frame);
+}
+
+pub fn getAnimatedPoints(self: *Polygon, frame: *Frame) !*PointList {
+ 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 a232e4950..6ac253167 100644
--- a/src/browser/webapi/element/svg/Polyline.zig
+++ b/src/browser/webapi/element/svg/Polyline.zig
@@ -17,11 +17,13 @@
// 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,
@@ -41,4 +43,15 @@ 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 {
+ return PointList.getOrCreate(self.asElement(), .base, frame);
+}
+
+pub fn getAnimatedPoints(self: *Polyline, frame: *Frame) !*PointList {
+ return PointList.getOrCreate(self.asElement(), .animated, frame);
+}
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/AnimatedTransformList.zig b/src/browser/webapi/svg/AnimatedTransformList.zig
new file mode 100644
index 000000000..0ec5670f6
--- /dev/null
+++ b/src/browser/webapi/svg/AnimatedTransformList.zig
@@ -0,0 +1,65 @@
+// 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 Element = @import("../Element.zig");
+const TransformList = @import("TransformList.zig");
+
+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);
+ return frame._factory.create(AnimatedTransformList{
+ ._base_val = base_val,
+ ._anim_val = anim_val,
+ });
+}
+
+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;
+}
+
+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..6c9a645a4
--- /dev/null
+++ b/src/browser/webapi/svg/PointList.zig
@@ -0,0 +1,381 @@
+// 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();
+
+_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,
+
+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.*;
+}
+
+pub fn deinit(self: *PointList, page: *Page) void {
+ for (self._items.items) |point| {
+ point._proto.detach(self);
+ point._proto.releaseRef(page);
+ }
+ self._items.clearRetainingCapacity();
+ self.releaseRetired(page);
+}
+
+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.retireAll(frame);
+ try self.setAttribute(&.{}, frame);
+}
+
+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.retireAll(frame);
+ try self._items.ensureTotalCapacity(frame.arena, 1);
+ try self.setAttribute(&.{prepared}, frame);
+ 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.restrict();
+ point._proto.attach(.{
+ .owner = self,
+ .mutate = PointList.mutatePoint,
+ }, self._read_only);
+}
+
+fn mutatePoint(
+ context: *anyopaque,
+ point: *DOMPointReadOnly,
+ coordinate: DOMPointReadOnly.Coordinate,
+ value: f64,
+) 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
+ // 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 {
+ self.releaseRetired(frame._page);
+
+ const raw = self._element.getAttributeSafe(comptime .wrap("points")) 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(*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);
+ for (parsed.items) |point| {
+ self._items.appendAssumeCapacity(point);
+ self.attach(point);
+ }
+ parsed.clearRetainingCapacity();
+ 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) {
+ 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| {
+ // 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| {
+ point._proto.releaseRef(frame._page);
+ return err;
+ };
+ }
+ return parsed;
+}
+
+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);
+ }
+ 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 {
+ self._synced = false;
+ try self._element.setAttributeSafe(comptime .wrap("points"), .wrap(serialized), frame);
+ self._snapshot.clearRetainingCapacity();
+ try self._snapshot.appendSlice(frame.arena, serialized);
+ self._synced = true;
+}
+
+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;
+ } 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..ccd217063
--- /dev/null
+++ b/src/browser/webapi/svg/StringList.zig
@@ -0,0 +1,225 @@
+// 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,
+_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,
+
+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 {
+ 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.commit(&.{item}, frame);
+ return item;
+}
+
+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.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 item;
+}
+
+pub fn replaceItem(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 {
+ 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 item;
+}
+
+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 sync(self: *StringList, frame: *Frame) !void {
+ 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;
+ 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();
+
+ self._synced = false;
+ 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 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));
+ }
+ },
+ }
+ }
+ self._synced = true;
+}
+
+const WHITESPACE = " \t\r\n\x0c";
+
+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..2ebe7dc02 100644
--- a/src/browser/webapi/svg/Transform.zig
+++ b/src/browser/webapi/svg/Transform.zig
@@ -43,7 +43,28 @@ pub const DOMMatrix2DInit = struct {
_type: u16 = 1,
_angle: f64 = 0,
+_cx: f64 = 0,
+_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,
+ cx: f64,
+ cy: f64,
+ matrix: [16]f64,
+ is_2d: bool,
+};
+
+pub const Attachment = struct {
+ owner: *anyopaque,
+ mutate: *const fn (*anyopaque, *Transform, State) 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,13 +79,57 @@ pub fn releaseRef(self: *Transform, page: *Page) void {
pub fn detached(frame: *Frame) !*Transform {
const matrix = try DOMMatrix.create(RO.identity(), true, frame._page);
- return frame._factory.create(Transform{ ._matrix = matrix });
+ errdefer matrix._proto.deinit(frame._page);
+ const self = try frame._factory.create(Transform{ ._matrix = matrix });
+ self.attachMatrix();
+ return self;
}
pub fn fromMatrix(init: ?DOMMatrix2DInit, frame: *Frame) !*Transform {
const parsed = try fixup2D(init orelse .{});
const matrix = try DOMMatrix.create(parsed.m, true, frame._page);
- return frame._factory.create(Transform{ ._matrix = matrix });
+ errdefer matrix._proto.deinit(frame._page);
+ const self = try frame._factory.create(Transform{ ._matrix = matrix });
+ self.attachMatrix();
+ return self;
+}
+
+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);
+ const self = try 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,
+ });
+ self.attachMatrix();
+ return self;
+}
+
+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);
+ const cloned = try frame._factory.create(Transform{
+ ._type = current.typ,
+ ._angle = current.angle,
+ ._cx = current.cx,
+ ._cy = current.cy,
+ ._matrix = matrix,
+ });
+ cloned.attachMatrix();
+ return cloned;
}
pub fn getType(self: *const Transform) u16 {
@@ -81,23 +146,17 @@ pub fn getAngle(self: *const Transform) f64 {
pub fn setMatrix(self: *Transform, init: ?DOMMatrix2DInit) !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 });
}
pub fn setTranslate(self: *Transform, tx: f64, ty: f64) !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 });
}
pub fn setScale(self: *Transform, sx: f64, sy: f64) !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 });
}
pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void {
@@ -106,32 +165,116 @@ pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void {
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 });
}
pub fn setSkewX(self: *Transform, angle: f64) !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 });
}
pub fn setSkewY(self: *Transform, angle: f64) !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 });
}
-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.applyStateRaw(.{
+ .matrix = state.matrix,
+ .is_2d = state.is_2d,
+ });
+}
+
+fn applyState(self: *Transform, state: State) !void {
+ if (self._read_only) return error.NoModificationAllowed;
+ try ensureFinite(&state.matrix);
+ if (self._attachment) |attachment| {
+ return attachment.mutate(attachment.owner, self, state);
+ }
+ self.applyStateRaw(state);
+}
+
+pub fn attach(self: *Transform, attachment: Attachment, read_only: bool) void {
+ self._attachment = attachment;
+ 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;
+}
+
+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 attachMatrix(self: *Transform) void {
+ self._matrix._proto.attach(.{
+ .owner = self,
+ .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 = 1,
+ .angle = 0,
+ .cx = 0,
+ .cy = 0,
+ .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,
+ });
}
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 +289,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..8baf90052
--- /dev/null
+++ b/src/browser/webapi/svg/TransformList.zig
@@ -0,0 +1,313 @@
+// 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();
+
+_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,
+
+pub fn create(element: *Element, read_only: bool, frame: *Frame) !*TransformList {
+ return frame._factory.create(TransformList{
+ ._frame = frame,
+ ._element = element,
+ ._read_only = read_only,
+ });
+}
+
+pub fn deinit(self: *TransformList, page: *Page) void {
+ for (self._items.items) |transform| {
+ transform.detach(self);
+ transform.releaseRef(page);
+ }
+ self._items.clearRetainingCapacity();
+ self.releaseRetired(page);
+}
+
+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.retireAll(frame);
+ try self.setAttribute(&.{}, frame);
+}
+
+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.retireAll(frame);
+ try self._items.ensureTotalCapacity(frame.arena, 1);
+ try self.setAttribute(&.{prepared}, frame);
+ 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.retireAll(frame);
+ try self._items.ensureTotalCapacity(frame.arena, 1);
+ try self.setAttribute(&.{consolidated}, frame);
+ 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,
+ .mutate = TransformList.mutateTransform,
+ }, self._read_only);
+}
+
+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);
+ 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 {
+ self.releaseRetired(frame._page);
+
+ 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);
+
+ self._snapshot.clearRetainingCapacity();
+ 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);
+ }
+ parsed.clearRetainingCapacity();
+ 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) {
+ 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 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);
+ }
+ 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 {
+ self._synced = false;
+ try self._element.setAttributeSafe(comptime .wrap("transform"), .wrap(serialized), frame);
+ self._snapshot.clearRetainingCapacity();
+ try self._snapshot.appendSlice(frame.arena, serialized);
+ self._synced = true;
+}
+
+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, .{});
+};