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; }