diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index a9e7adfa3..55ccb10f0 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -42,9 +42,6 @@ const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const Element = @import("webapi/Element.zig"); const HtmlElement = @import("webapi/element/Html.zig"); -const AnimatedLength = @import("webapi/svg/AnimatedLength.zig"); -const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig"); -const AnimatedString = @import("webapi/svg/AnimatedString.zig"); const Window = @import("webapi/Window.zig"); const Location = @import("webapi/Location.zig"); const Document = @import("webapi/Document.zig"); @@ -65,8 +62,15 @@ const popover = @import("webapi/element/popover.zig"); const slotting = @import("webapi/element/slotting.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; -const HttpClient = @import("../network/HttpClient.zig"); +const PointList = @import("webapi/svg/PointList.zig"); +const StringList = @import("webapi/svg/StringList.zig"); +const AnimatedLength = @import("webapi/svg/AnimatedLength.zig"); +const AnimatedString = @import("webapi/svg/AnimatedString.zig"); +const AnimatedTransformList = @import("webapi/svg/AnimatedTransformList.zig"); +const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig"); + const sys_url = @import("../sys/url.zig"); +const HttpClient = @import("../network/HttpClient.zig"); const timestamp = @import("../datetime.zig").timestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp; @@ -148,6 +152,9 @@ _element_namespace_uris: Element.NamespaceUriLookup = .empty, _svg_animated_lengths: AnimatedLength.Lookup = .empty, _svg_animated_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty, _svg_animated_strings: AnimatedString.Lookup = .empty, +_svg_animated_transform_lists: AnimatedTransformList.Lookup = .empty, +_svg_point_lists: PointList.Lookup = .empty, +_svg_string_lists: StringList.Lookup = .empty, // Same as above, but for Nodes (slot assigments apply to both Element AND // Text nodes) @@ -503,6 +510,16 @@ pub fn deinit(self: *Frame) void { observers.deinit(self, page); + var svg_point_lists = self._svg_point_lists.valueIterator(); + while (svg_point_lists.next()) |list| { + list.*.deinit(page); + } + + var svg_transform_lists = self._svg_animated_transform_lists.valueIterator(); + while (svg_transform_lists.next()) |list| { + list.*.deinit(page); + } + var document = self.window._document; document._selection.releaseRef(page); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index cdb6c021d..021bdb9e3 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -1076,6 +1076,10 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/svg/AnimatedLength.zig"), @import("../webapi/svg/PreserveAspectRatio.zig"), @import("../webapi/svg/AnimatedPreserveAspectRatio.zig"), + @import("../webapi/svg/PointList.zig"), + @import("../webapi/svg/TransformList.zig"), + @import("../webapi/svg/AnimatedTransformList.zig"), + @import("../webapi/svg/StringList.zig"), @import("../webapi/encoding/TextDecoder.zig"), @import("../webapi/encoding/TextEncoder.zig"), @import("../webapi/encoding/TextEncoderStream.zig"), diff --git a/src/browser/tests/element/svg/collections.html b/src/browser/tests/element/svg/collections.html new file mode 100644 index 000000000..715157080 --- /dev/null +++ b/src/browser/tests/element/svg/collections.html @@ -0,0 +1,720 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/worker/api-worker.js b/src/browser/tests/worker/api-worker.js index 1f75d7e1c..803b8ace1 100644 --- a/src/browser/tests/worker/api-worker.js +++ b/src/browser/tests/worker/api-worker.js @@ -59,6 +59,11 @@ const loc_pathname = loc.pathname; const loc_to_string = String(loc); + // DOMPoint mutation (setters must not require a Frame in a worker) + const point = new DOMPoint(1, 2); + point.x = 5; + point.w = 0.5; + postMessage({ ok: true, results: { @@ -90,6 +95,8 @@ loc_protocol, loc_pathname, loc_to_string, + point_x: point.x, + point_w: point.w, }, }); } catch (e) { diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html index cd3512535..2b953aff1 100644 --- a/src/browser/tests/worker/worker.html +++ b/src/browser/tests/worker/worker.html @@ -247,6 +247,10 @@ testing.expectEqual(r.loc_href, r.loc_to_string); testing.expectEqual('http:', r.loc_protocol); testing.expectTrue(r.loc_pathname.endsWith('api-worker.js')); + + // DOMPoint + testing.expectEqual(5, r.point_x); + testing.expectEqual(0.5, r.point_w); }); } diff --git a/src/browser/webapi/DOMMatrix.zig b/src/browser/webapi/DOMMatrix.zig index 132fa52a7..1e375c5f9 100644 --- a/src/browser/webapi/DOMMatrix.zig +++ b/src/browser/webapi/DOMMatrix.zig @@ -84,137 +84,158 @@ pub fn getF(self: *const DOMMatrix) f64 { return self._proto._m[13]; } -pub fn setA(self: *DOMMatrix, v: f64) void { - self._proto._m[0] = v; +pub fn setA(self: *DOMMatrix, v: f64) !void { + try self.setElement(0, v); } -pub fn setB(self: *DOMMatrix, v: f64) void { - self._proto._m[1] = v; +pub fn setB(self: *DOMMatrix, v: f64) !void { + try self.setElement(1, v); } -pub fn setC(self: *DOMMatrix, v: f64) void { - self._proto._m[4] = v; +pub fn setC(self: *DOMMatrix, v: f64) !void { + try self.setElement(4, v); } -pub fn setD(self: *DOMMatrix, v: f64) void { - self._proto._m[5] = v; +pub fn setD(self: *DOMMatrix, v: f64) !void { + try self.setElement(5, v); } -pub fn setE(self: *DOMMatrix, v: f64) void { - self._proto._m[12] = v; +pub fn setE(self: *DOMMatrix, v: f64) !void { + try self.setElement(12, v); } -pub fn setF(self: *DOMMatrix, v: f64) void { - self._proto._m[13] = v; +pub fn setF(self: *DOMMatrix, v: f64) !void { + try self.setElement(13, v); } -pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) *DOMMatrix { +pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) !*DOMMatrix { const tz = tz_ orelse 0; - const p = self._proto; - p._m = RO.multiplyMatrix(p._m, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz)); - if (tz != 0) p._is_2d = false; - return self; + var state = self._proto.getState(); + state.matrix = RO.multiplyMatrix(state.matrix, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz)); + if (tz != 0) state.is_2d = false; + return self.applyState(state); } -pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix { +pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix { const sx = sx_ orelse 1; const sy = sy_ orelse sx; const sz = sz_ orelse 1; const ox = ox_ orelse 0; const oy = oy_ orelse 0; const oz = oz_ orelse 0; - const p = self._proto; - var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz)); + var state = self._proto.getState(); + var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz)); m = RO.multiplyMatrix(m, RO.scaleMatrix(sx, sy, sz)); m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz)); - p._m = m; - if (sz != 1 or oz != 0) p._is_2d = false; - return self; + state.matrix = m; + if (sz != 1 or oz != 0) state.is_2d = false; + return self.applyState(state); } -pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix { +pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix { const s = scale_ orelse 1; const ox = ox_ orelse 0; const oy = oy_ orelse 0; const oz = oz_ orelse 0; - const p = self._proto; - var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz)); + var state = self._proto.getState(); + var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz)); m = RO.multiplyMatrix(m, RO.scaleMatrix(s, s, s)); m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz)); - p._m = m; - if (s != 1) p._is_2d = false; - return self; + state.matrix = m; + if (s != 1) state.is_2d = false; + return self.applyState(state); } -pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) *DOMMatrix { - const p = self._proto; +pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) !*DOMMatrix { + var state = self._proto.getState(); if (ry_ == null and rz_ == null) { - p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg))); + state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg))); } else { - p._m = RO.multiplyMatrix(p._m, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg))); - p._m = RO.multiplyMatrix(p._m, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg))); - p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg))); - p._is_2d = false; + state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg))); + state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg))); + state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg))); + state.is_2d = false; } - return self; + return self.applyState(state); } -pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) *DOMMatrix { +pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) !*DOMMatrix { const x = x_ orelse 0; const y = y_ orelse 0; const rad = if (x == 0 and y == 0) 0 else std.math.atan2(y, x); - const p = self._proto; - p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(rad)); - return self; + var state = self._proto.getState(); + state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(rad)); + return self.applyState(state); } -pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) *DOMMatrix { - const p = self._proto; - p._m = RO.multiplyMatrix(p._m, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg))); - if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) p._is_2d = false; - return self; +pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) !*DOMMatrix { + var state = self._proto.getState(); + state.matrix = RO.multiplyMatrix(state.matrix, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg))); + if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) state.is_2d = false; + return self.applyState(state); } -pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) *DOMMatrix { - const p = self._proto; - p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0)); - return self; +pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) !*DOMMatrix { + var state = self._proto.getState(); + state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0)); + return self.applyState(state); } -pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) *DOMMatrix { - const p = self._proto; - p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg))); - return self; +pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) !*DOMMatrix { + var state = self._proto.getState(); + state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg))); + return self.applyState(state); } pub fn multiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix { - const p = self._proto; const other = try RO.fixupDict(other_ orelse .{}); - p._m = RO.multiplyMatrix(p._m, other.m); - p._is_2d = p._is_2d and other.is_2d; - return self; + var state = self._proto.getState(); + state.matrix = RO.multiplyMatrix(state.matrix, other.m); + state.is_2d = state.is_2d and other.is_2d; + return self.applyState(state); } pub fn preMultiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix { - const p = self._proto; const other = try RO.fixupDict(other_ orelse .{}); - p._m = RO.multiplyMatrix(other.m, p._m); - p._is_2d = p._is_2d and other.is_2d; - return self; + var state = self._proto.getState(); + state.matrix = RO.multiplyMatrix(other.m, state.matrix); + state.is_2d = state.is_2d and other.is_2d; + return self.applyState(state); } -pub fn invertSelf(self: *DOMMatrix) *DOMMatrix { - const p = self._proto; - if (RO.invertMatrix(p._m)) |v| { - p._m = v; +pub fn invertSelf(self: *DOMMatrix) !*DOMMatrix { + var state = self._proto.getState(); + if (RO.invertMatrix(state.matrix)) |v| { + state.matrix = v; } else { - p._m = .{std.math.nan(f64)} ** 16; - p._is_2d = false; + state.matrix = .{std.math.nan(f64)} ** 16; + state.is_2d = false; } - return self; + return self.applyState(state); } pub fn setMatrixValue(self: *DOMMatrix, transform: []const u8) !*DOMMatrix { - var m = RO.identity(); - var is_2d = true; - try RO.parseTransformList(transform, &m, &is_2d); - self._proto._m = m; - self._proto._is_2d = is_2d; + var state: RO.State = .{ .matrix = RO.identity(), .is_2d = true }; + try RO.parseTransformList(transform, &state.matrix, &state.is_2d); + try self._proto.applyState(state); + return self; +} + +fn setElement(self: *DOMMatrix, comptime index: usize, value: f64) !void { + var state = self._proto.getState(); + state.matrix[index] = value; + + // Assigning a z/w element a value other than its identity drops the + // 2D flag. Setting it back does not restore is2D. + switch (index) { + 2, 3, 6, 7, 8, 9, 11, 14 => if (value != 0) { + state.is_2d = false; + }, + 10, 15 => if (value != 1) { + state.is_2d = false; + }, + else => {}, + } + try self._proto.applyState(state); +} + +fn applyState(self: *DOMMatrix, state: RO.State) !*DOMMatrix { + try self._proto.applyState(state); return self; } @@ -281,23 +302,10 @@ pub const JsApi = struct { }.get; } - fn setM(comptime idx: usize) fn (*DOMMatrix, f64) void { + fn setM(comptime idx: usize) fn (*DOMMatrix, f64) anyerror!void { return struct { - fn set(self: *DOMMatrix, v: f64) void { - self._proto._m[idx] = v; - // Assigning a z/w element a value other than its identity drops the - // 2D flag. Setting it back to the identity value (0 for the - // off-diagonal elements, 1 for m33/m44) preserves is2D. Note `-0` - // compares equal to `0`, so it preserves it too, per spec. - switch (idx) { - 2, 3, 6, 7, 8, 9, 11, 14 => if (v != 0) { - self._proto._is_2d = false; - }, - 10, 15 => if (v != 1) { - self._proto._is_2d = false; - }, - else => {}, - } + fn set(self: *DOMMatrix, v: f64) !void { + try self.setElement(idx, v); } }.set; } diff --git a/src/browser/webapi/DOMMatrixReadOnly.zig b/src/browser/webapi/DOMMatrixReadOnly.zig index d0340f942..e70658e25 100644 --- a/src/browser/webapi/DOMMatrixReadOnly.zig +++ b/src/browser/webapi/DOMMatrixReadOnly.zig @@ -44,12 +44,25 @@ _arena: Allocator, // out[row] = sum_col _m[col*4 + row] * in[col] _m: [16]f64, _is_2d: bool, +_attachment: ?Attachment = null, pub const Type = union(enum) { generic, mutable: *DOMMatrix, }; +pub const State = struct { + matrix: [16]f64, + is_2d: bool, +}; + +// The owner decides whether a write is allowed; a matrix that belongs to an +// animVal SVGTransform is rejected by the transform, not here. +pub const Attachment = struct { + owner: *anyopaque, + mutate: *const fn (*anyopaque, *DOMMatrixReadOnly, State) anyerror!void, +}; + pub fn init(init_: ?js.Value, exec: *const js.Execution) !*DOMMatrixReadOnly { const parsed = try Parsed.init(init_, exec); return createBare(parsed.m, parsed.is_2d, exec.page); @@ -82,6 +95,29 @@ pub fn createBare(m: [16]f64, is_2d: bool, page: *Page) !*DOMMatrixReadOnly { return self; } +pub fn getState(self: *const DOMMatrixReadOnly) State { + return .{ + .matrix = self._m, + .is_2d = self._is_2d, + }; +} + +pub fn applyState(self: *DOMMatrixReadOnly, state: State) !void { + if (self._attachment) |attachment| { + return attachment.mutate(attachment.owner, self, state); + } + self.applyStateRaw(state); +} + +pub fn applyStateRaw(self: *DOMMatrixReadOnly, state: State) void { + self._m = state.matrix; + self._is_2d = state.is_2d; +} + +pub fn attach(self: *DOMMatrixReadOnly, attachment: Attachment) void { + self._attachment = attachment; +} + pub const DOMMatrixInit = struct { a: ?f64 = null, b: ?f64 = null, @@ -312,178 +348,274 @@ pub fn invertMatrix(m: [16]f64) ?[16]f64 { return out; } +pub const TransformSyntax = enum { css, svg }; + +pub const TransformKind = enum { + matrix, + matrix3d, + translate, + translate_x, + translate_y, + translate_z, + translate_3d, + scale, + scale_x, + scale_y, + scale_z, + scale_3d, + rotate, + rotate_x, + rotate_y, + rotate_z, + rotate_3d, + skew, + skew_x, + skew_y, + perspective, +}; + +pub const ParsedTransform = struct { + kind: TransformKind, + matrix: [16]f64, + values: [16]f64, + count: usize, + is_2d: bool, +}; + +pub const TransformFunction = struct { + name: []const u8, + arguments: []const u8, +}; + +pub const TransformFunctionIterator = struct { + input: []const u8, + index: usize = 0, + allow_comma: bool = false, + + pub fn next(self: *TransformFunctionIterator) !?TransformFunction { + while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1; + if (self.index == self.input.len) return null; + if (self.input[self.index] == ',') return error.SyntaxError; + + const name_start = self.index; + while (self.index < self.input.len and + (std.ascii.isAlphabetic(self.input[self.index]) or std.ascii.isDigit(self.input[self.index]))) + { + self.index += 1; + } + if (self.index == name_start or self.index >= self.input.len or self.input[self.index] != '(') { + return error.SyntaxError; + } + const name = self.input[name_start..self.index]; + self.index += 1; + + const arguments_start = self.index; + while (self.index < self.input.len and self.input[self.index] != ')') { + if (self.input[self.index] == '(') return error.SyntaxError; + self.index += 1; + } + if (self.index == self.input.len) return error.SyntaxError; + const arguments = self.input[arguments_start..self.index]; + self.index += 1; + + var had_whitespace = false; + while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) { + had_whitespace = true; + self.index += 1; + } + if (self.index < self.input.len and self.input[self.index] == ',') { + if (!self.allow_comma) return error.SyntaxError; + self.index += 1; + while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1; + if (self.index == self.input.len) return error.SyntaxError; + } else if (self.index < self.input.len and !had_whitespace) { + return error.SyntaxError; + } + return .{ .name = name, .arguments = arguments }; + } +}; + // Parses a CSS (e.g. "matrix(1,0,0,1,10,20) scale(2)") and // accumulates it into `m`. "none"/empty leave the matrix as identity. pub fn parseTransformList(input: []const u8, m: *[16]f64, is_2d: *bool) !void { const trimmed = std.mem.trim(u8, input, " \t\r\n"); - if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) { - return; - } + if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return; - var i: usize = 0; - while (i < trimmed.len) { - // skip whitespace and separating commas - while (i < trimmed.len and (std.ascii.isWhitespace(trimmed[i]) or trimmed[i] == ',')) : (i += 1) {} - if (i >= trimmed.len) { - break; - } - - const name_start = i; - while (i < trimmed.len and trimmed[i] != '(') : (i += 1) {} - if (i >= trimmed.len) { - return error.SyntaxError; - } - const name = std.mem.trim(u8, trimmed[name_start..i], " \t\r\n"); - - i += 1; // consume '(' - const args_start = i; - while (i < trimmed.len and trimmed[i] != ')') : (i += 1) {} - if (i >= trimmed.len) { - return error.SyntaxError; - } - const args = trimmed[args_start..i]; - i += 1; // consume ')' - - const func = try parseFunction(name, args, is_2d); - m.* = multiplyMatrix(m.*, func); + var iterator = TransformFunctionIterator{ .input = trimmed }; + while (try iterator.next()) |function| { + const parsed = try parseTransformFunction(function, .css); + m.* = multiplyMatrix(m.*, parsed.matrix); + if (!parsed.is_2d) is_2d.* = false; } } -fn parseFunction(name: []const u8, args: []const u8, is_2d: *bool) ![16]f64 { - var nums: [16]f64 = undefined; +pub fn parseTransformFunction(function: TransformFunction, syntax: TransformSyntax) !ParsedTransform { + var values: [16]f64 = undefined; var units: [16]ParsedValue.Unit = undefined; - var count: usize = 0; - - var it = std.mem.splitScalar(u8, args, ','); - while (it.next()) |raw| { - const tok = std.mem.trim(u8, raw, " \t\r\n"); - if (tok.len == 0) { - continue; - } - if (count >= 16) { - return error.SyntaxError; - } - const parsed = try ParsedValue.parse(tok); - nums[count] = parsed.value; - units[count] = parsed.unit; - count += 1; - } - + const count = try parseArguments(function.arguments, syntax, &values, &units); + const name = function.name; const Eql = std.mem.eql; + if (Eql(u8, name, "matrix")) { - if (count != 6) { - return error.SyntaxError; - } - return .{ - nums[0], nums[1], 0, 0, - nums[2], nums[3], 0, 0, - 0, 0, 1, 0, - nums[4], nums[5], 0, 1, - }; + try requireCount(count, 6, 6); + try requireUnitless(units[0..count]); + return makeParsedTransform(.matrix, .{ + values[0], values[1], 0, 0, + values[2], values[3], 0, 0, + 0, 0, 1, 0, + values[4], values[5], 0, 1, + }, values, count, true); } if (Eql(u8, name, "matrix3d")) { - if (count != 16) { - return error.SyntaxError; - } - is_2d.* = false; - return nums; + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 16, 16); + try requireUnitless(units[0..count]); + return makeParsedTransform(.matrix3d, values, values, count, false); } if (Eql(u8, name, "translate")) { - const tx = nums[0]; - const ty = if (count > 1) nums[1] else 0; - return translationMatrix(tx, ty, 0); + try requireCount(count, 1, 2); + if (syntax == .svg) try requireUnitless(units[0..count]); + const ty = if (count == 2) values[1] else 0; + return makeParsedTransform(.translate, translationMatrix(values[0], ty, 0), values, count, true); } - if (Eql(u8, name, "translateX")) { - return translationMatrix(nums[0], 0, 0); + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 1, 1); + return makeParsedTransform(.translate_x, translationMatrix(values[0], 0, 0), values, count, true); } - if (Eql(u8, name, "translateY")) { - return translationMatrix(0, nums[0], 0); + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 1, 1); + return makeParsedTransform(.translate_y, translationMatrix(0, values[0], 0), values, count, true); } - if (Eql(u8, name, "translateZ")) { - is_2d.* = false; - return translationMatrix(0, 0, nums[0]); + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 1, 1); + return makeParsedTransform(.translate_z, translationMatrix(0, 0, values[0]), values, count, false); } - if (Eql(u8, name, "translate3d")) { - is_2d.* = false; - return translationMatrix(nums[0], nums[1], nums[2]); + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 3, 3); + return makeParsedTransform(.translate_3d, translationMatrix(values[0], values[1], values[2]), values, count, false); } if (Eql(u8, name, "scale")) { - const sx = nums[0]; - const sy = if (count > 1) nums[1] else sx; - return scaleMatrix(sx, sy, 1); + try requireCount(count, 1, 2); + try requireUnitless(units[0..count]); + const sy = if (count == 2) values[1] else values[0]; + return makeParsedTransform(.scale, scaleMatrix(values[0], sy, 1), values, count, true); } - if (Eql(u8, name, "scaleX")) { - return scaleMatrix(nums[0], 1, 1); + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 1, 1); + try requireUnitless(units[0..count]); + return makeParsedTransform(.scale_x, scaleMatrix(values[0], 1, 1), values, count, true); } - if (Eql(u8, name, "scaleY")) { - return scaleMatrix(1, nums[0], 1); + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 1, 1); + try requireUnitless(units[0..count]); + return makeParsedTransform(.scale_y, scaleMatrix(1, values[0], 1), values, count, true); } - if (Eql(u8, name, "scaleZ")) { - is_2d.* = false; - return scaleMatrix(1, 1, nums[0]); + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 1, 1); + try requireUnitless(units[0..count]); + return makeParsedTransform(.scale_z, scaleMatrix(1, 1, values[0]), values, count, false); } - if (Eql(u8, name, "scale3d")) { - is_2d.* = false; - return scaleMatrix(nums[0], nums[1], nums[2]); - } - if (Eql(u8, name, "rotate") or Eql(u8, name, "rotateZ")) { - if (Eql(u8, name, "rotateZ")) is_2d.* = false; - return rotateZMatrix(toRadians(nums[0], units[0])); + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 3, 3); + try requireUnitless(units[0..count]); + return makeParsedTransform(.scale_3d, scaleMatrix(values[0], values[1], values[2]), values, count, false); } - if (Eql(u8, name, "rotateX")) { - is_2d.* = false; - return rotateXMatrix(toRadians(nums[0], units[0])); - } - - if (Eql(u8, name, "rotateY")) { - is_2d.* = false; - return rotateYMatrix(toRadians(nums[0], units[0])); - } - - if (Eql(u8, name, "rotate3d")) { - is_2d.* = false; - if (count != 4) { - return error.SyntaxError; + if (Eql(u8, name, "rotate")) { + try requireCount(count, 1, if (syntax == .svg) 3 else 1); + if (count != 1 and count != 3) return error.SyntaxError; + try requireAngle(units[0]); + if (syntax == .svg and units[0] != .none) return error.SyntaxError; + if (count == 3) try requireUnitless(units[1..3]); + var matrix = rotateZMatrix(toRadians(values[0], units[0])); + if (count == 3) { + matrix = multiplyMatrix(translationMatrix(values[1], values[2], 0), matrix); + matrix = multiplyMatrix(matrix, translationMatrix(-values[1], -values[2], 0)); } - return axisAngleMatrix(nums[0], nums[1], nums[2], toRadians(nums[3], units[3])); + return makeParsedTransform(.rotate, matrix, values, count, true); + } + if (Eql(u8, name, "rotateX") or Eql(u8, name, "rotateY") or Eql(u8, name, "rotateZ")) { + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 1, 1); + try requireAngle(units[0]); + if (Eql(u8, name, "rotateX")) return makeParsedTransform(.rotate_x, rotateXMatrix(toRadians(values[0], units[0])), values, count, false); + if (Eql(u8, name, "rotateY")) return makeParsedTransform(.rotate_y, rotateYMatrix(toRadians(values[0], units[0])), values, count, false); + return makeParsedTransform(.rotate_z, rotateZMatrix(toRadians(values[0], units[0])), values, count, false); + } + if (Eql(u8, name, "rotate3d")) { + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 4, 4); + try requireUnitless(units[0..3]); + try requireAngle(units[3]); + return makeParsedTransform(.rotate_3d, axisAngleMatrix(values[0], values[1], values[2], toRadians(values[3], units[3])), values, count, false); } if (Eql(u8, name, "skew")) { - const ax = toRadians(nums[0], units[0]); - const ay = if (count > 1) toRadians(nums[1], units[1]) else 0; - return skewMatrix(ax, ay); + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 1, 2); + try requireAngle(units[0]); + if (count == 2) try requireAngle(units[1]); + const ay = if (count == 2) toRadians(values[1], units[1]) else 0; + return makeParsedTransform(.skew, skewMatrix(toRadians(values[0], units[0]), ay), values, count, true); } - - if (Eql(u8, name, "skewX")) { - return skewMatrix(toRadians(nums[0], units[0]), 0); - } - - if (Eql(u8, name, "skewY")) { - return skewMatrix(0, toRadians(nums[0], units[0])); + if (Eql(u8, name, "skewX") or Eql(u8, name, "skewY")) { + try requireCount(count, 1, 1); + try requireAngle(units[0]); + if (syntax == .svg and units[0] != .none) return error.SyntaxError; + if (Eql(u8, name, "skewX")) return makeParsedTransform(.skew_x, skewMatrix(toRadians(values[0], units[0]), 0), values, count, true); + return makeParsedTransform(.skew_y, skewMatrix(0, toRadians(values[0], units[0])), values, count, true); } if (Eql(u8, name, "perspective")) { - is_2d.* = false; - var out = identity(); - if (nums[0] != 0) out[11] = -1.0 / nums[0]; - return out; + if (syntax == .svg) return error.SyntaxError; + try requireCount(count, 1, 1); + var matrix = identity(); + if (values[0] != 0) matrix[11] = -1.0 / values[0]; + return makeParsedTransform(.perspective, matrix, values, count, false); } return error.SyntaxError; } +fn makeParsedTransform(kind: TransformKind, matrix: [16]f64, values: [16]f64, count: usize, is_2d: bool) ParsedTransform { + return .{ .kind = kind, .matrix = matrix, .values = values, .count = count, .is_2d = is_2d }; +} + +fn requireCount(count: usize, minimum: usize, maximum: usize) !void { + if (count < minimum or count > maximum) return error.SyntaxError; +} + +fn requireUnitless(units: []const ParsedValue.Unit) !void { + for (units) |unit| if (unit != .none) return error.SyntaxError; +} + +fn requireAngle(unit: ParsedValue.Unit) !void { + if (unit == .other) return error.SyntaxError; +} + +fn parseArguments(arguments: []const u8, syntax: TransformSyntax, values: *[16]f64, units: *[16]ParsedValue.Unit) !usize { + var scanner = ArgumentScanner{ .input = arguments, .syntax = syntax }; + var count: usize = 0; + while (try scanner.next()) |value| { + if (count == values.len) return error.SyntaxError; + values[count] = value.value; + units[count] = value.unit; + count += 1; + } + return count; +} + pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 { return switch (unit) { .rad => value, @@ -491,6 +623,7 @@ pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 { .turn => value * std.math.tau, // bare numbers in rotate()/skew() are interpreted as degrees .deg, .none => value * std.math.pi / 180.0, + .other => value, }; } @@ -725,43 +858,80 @@ const ParsedValue = struct { rad, grad, turn, + other, }; +}; - // Parses a single CSS dimension token: a number with an optional unit suffix. - // Length units are ignored (we don't resolve layout), so the numeric part is - // taken verbatim; angle units are recorded so they can be normalised. - fn parse(tok: []const u8) !ParsedValue { - var end: usize = 0; - while (end < tok.len) : (end += 1) { - const c = tok[end]; - if ((c >= '0' and c <= '9') or c == '.' or c == '+' or c == '-' or c == 'e' or c == 'E') { - // 'e'/'E' is ambiguous with exponents; only treat as exponent when - // followed by a digit/sign. - if ((c == 'e' or c == 'E') and end > 0) { - if (end + 1 >= tok.len) break; - const nx = tok[end + 1]; - if (!((nx >= '0' and nx <= '9') or nx == '+' or nx == '-')) break; - } - continue; +const ArgumentScanner = struct { + input: []const u8, + syntax: TransformSyntax, + index: usize = 0, + first: bool = true, + + fn next(self: *ArgumentScanner) !?ParsedValue { + var had_whitespace = false; + while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) { + had_whitespace = true; + self.index += 1; + } + + if (!self.first and self.index < self.input.len and self.input[self.index] == ',') { + self.index += 1; + while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1; + if (self.index == self.input.len) return error.SyntaxError; + } else if (!self.first and self.index < self.input.len) { + // CSS separates arguments with a comma and nothing else. The SVG + // grammar also takes whitespace, or no separator at all when the + // next number carries its own sign. + if (self.syntax == .css) return error.SyntaxError; + if (!had_whitespace and self.input[self.index] != '+' and self.input[self.index] != '-') { + return error.SyntaxError; } - break; } - if (end == 0) { - return error.SyntaxError; - } - const value = try std.fmt.parseFloat(f64, tok[0..end]); - const suffix = tok[end..]; - var unit: Unit = .none; - if (std.ascii.eqlIgnoreCase(suffix, "deg")) { - unit = .deg; - } else if (std.ascii.eqlIgnoreCase(suffix, "rad")) { - unit = .rad; - } else if (std.ascii.eqlIgnoreCase(suffix, "grad")) { - unit = .grad; - } else if (std.ascii.eqlIgnoreCase(suffix, "turn")) { - unit = .turn; + if (self.index == self.input.len) return null; + const start = self.index; + if (self.input[self.index] == '+' or self.input[self.index] == '-') self.index += 1; + + var digits: usize = 0; + while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1; + if (self.index < self.input.len and self.input[self.index] == '.') { + self.index += 1; + while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1; } + if (digits == 0) return error.SyntaxError; + + if (self.index < self.input.len and (self.input[self.index] == 'e' or self.input[self.index] == 'E')) { + self.index += 1; + if (self.index < self.input.len and (self.input[self.index] == '+' or self.input[self.index] == '-')) self.index += 1; + const exponent_start = self.index; + while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) self.index += 1; + if (self.index == exponent_start) return error.SyntaxError; + } + + const number_end = self.index; + while (self.index < self.input.len and + (std.ascii.isAlphabetic(self.input[self.index]) or self.input[self.index] == '%')) + { + self.index += 1; + } + const suffix = self.input[number_end..self.index]; + const unit: ParsedValue.Unit = if (suffix.len == 0) + .none + else if (std.ascii.eqlIgnoreCase(suffix, "deg")) + .deg + else if (std.ascii.eqlIgnoreCase(suffix, "rad")) + .rad + else if (std.ascii.eqlIgnoreCase(suffix, "grad")) + .grad + else if (std.ascii.eqlIgnoreCase(suffix, "turn")) + .turn + else + .other; + + const value = std.fmt.parseFloat(f64, self.input[start..number_end]) catch return error.SyntaxError; + if (!std.math.isFinite(value)) return error.SyntaxError; + self.first = false; return .{ .value = value, .unit = unit }; } }; diff --git a/src/browser/webapi/DOMPoint.zig b/src/browser/webapi/DOMPoint.zig index 0df456604..d9356d3f2 100644 --- a/src/browser/webapi/DOMPoint.zig +++ b/src/browser/webapi/DOMPoint.zig @@ -77,17 +77,17 @@ pub fn getW(self: *const DOMPoint) f64 { return self._proto._w; } -pub fn setX(self: *DOMPoint, v: f64) void { - self._proto._x = v; +pub fn setX(self: *DOMPoint, v: f64) !void { + try self._proto.setCoordinate(.x, v); } -pub fn setY(self: *DOMPoint, v: f64) void { - self._proto._y = v; +pub fn setY(self: *DOMPoint, v: f64) !void { + try self._proto.setCoordinate(.y, v); } -pub fn setZ(self: *DOMPoint, v: f64) void { - self._proto._z = v; +pub fn setZ(self: *DOMPoint, v: f64) !void { + try self._proto.setCoordinate(.z, v); } -pub fn setW(self: *DOMPoint, v: f64) void { - self._proto._w = v; +pub fn setW(self: *DOMPoint, v: f64) !void { + try self._proto.setCoordinate(.w, v); } pub const JsApi = struct { diff --git a/src/browser/webapi/DOMPointReadOnly.zig b/src/browser/webapi/DOMPointReadOnly.zig index db91e5a3f..a0bf84a2f 100644 --- a/src/browser/webapi/DOMPointReadOnly.zig +++ b/src/browser/webapi/DOMPointReadOnly.zig @@ -38,6 +38,23 @@ _x: f64, _y: f64, _z: f64, _w: f64, +_attachment: ?Attachment = null, + +// Sticky. An animVal item that a later external attribute change detaches from +// its list must not turn into a writable orphan. +_read_only: bool = false, + +// SVGPoint's coordinates are a restricted float: NaN and infinity are a +// TypeError rather than a stored value. Only points that reach an SVG list are +// restricted; a plain DOMPoint takes an unrestricted double. +_restricted: bool = false, + +pub const Coordinate = enum { x, y, z, w }; + +pub const Attachment = struct { + owner: *anyopaque, + mutate: *const fn (*anyopaque, *DOMPointReadOnly, Coordinate, f64) anyerror!void, +}; pub const Type = union(enum) { generic, @@ -132,6 +149,47 @@ pub fn getW(self: *const DOMPointReadOnly) f64 { return self._w; } +pub fn setCoordinate(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) !void { + if (self._read_only) return error.NoModificationAllowed; + if (self._restricted and !std.math.isFinite(value)) return error.TypeError; + if (self._attachment) |attachment| { + return attachment.mutate(attachment.owner, self, coordinate, value); + } + self.setCoordinateRaw(coordinate, value); +} + +pub fn setCoordinateRaw(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) void { + switch (coordinate) { + .x => self._x = value, + .y => self._y = value, + .z => self._z = value, + .w => self._w = value, + } +} + +pub fn restrict(self: *DOMPointReadOnly) void { + self._restricted = true; +} + +pub fn attach(self: *DOMPointReadOnly, attachment: Attachment, read_only: bool) void { + self._attachment = attachment; + if (read_only) self._read_only = true; +} + +pub fn detach(self: *DOMPointReadOnly, owner: *anyopaque) void { + const attachment = self._attachment orelse return; + if (attachment.owner == owner) self._attachment = null; +} + +pub fn isAttached(self: *const DOMPointReadOnly) bool { + return self._attachment != null; +} + +pub fn isAttachedTo(self: *const DOMPointReadOnly, owner: *anyopaque) bool { + const attachment = self._attachment orelse return false; + return attachment.owner == owner; +} + pub fn toJSON(self: *const DOMPointReadOnly) struct { x: f64, y: f64, diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index a81b82b66..665b9402c 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -856,6 +856,10 @@ pub fn removeAttribute(self: *Element, name: String, frame: *Frame) !void { return self._attributes.delete(name, self, frame); } +pub fn removeAttributeSafe(self: *Element, name: String, frame: *Frame) void { + self._attributes.deleteSafe(name, self, frame); +} + pub fn toggleAttribute(self: *Element, name: String, force: ?bool, frame: *Frame) !bool { try Attribute.validateAttributeName(name); const has = try self.hasAttribute(name, frame); diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 983c6aa5f..c2023a1ef 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -315,9 +315,17 @@ pub const List = struct { pub fn delete(self: *List, name: String, element: *Element, frame: *Frame) !void { const result = try self.getEntryAndNormalizedName(name, frame); const entry = result.entry orelse return; + return self._delete(entry, result.normalized, element, frame); + } + pub fn deleteSafe(self: *List, name: String, element: *Element, frame: *Frame) void { + const entry = self.getEntryWithNormalizedName(name) orelse return; + self._delete(entry, name, element, frame); + } + + fn _delete(self: *List, entry: *Entry, normalized: String, element: *Element, frame: *Frame) void { const owner = element.ownerFrame(frame); - const is_id = shouldAddToIdMap(result.normalized, element); + const is_id = shouldAddToIdMap(normalized, element); const old_value = entry.value(); if (is_id) { @@ -333,7 +341,7 @@ pub const List = struct { self._len -= 1; owner.domChanged(); - owner.attributeRemove(element, result.normalized, .wrap(old_value)); + owner.attributeRemove(element, normalized, .wrap(old_value)); } pub fn getNames(self: *const List, allocator: Allocator) ![][]const u8 { diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig index ccb1386b9..93d10c762 100644 --- a/src/browser/webapi/element/svg/Graphics.zig +++ b/src/browser/webapi/element/svg/Graphics.zig @@ -17,9 +17,12 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const SvgElement = @import("../Svg.zig"); +const AnimatedTransformList = @import("../../svg/AnimatedTransformList.zig"); +const StringList = @import("../../svg/StringList.zig"); pub const Svg = @import("Svg.zig"); pub const G = @import("G.zig"); @@ -72,4 +75,20 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const transform = bridge.accessor(Graphics.getTransform, null, .{}); + pub const requiredExtensions = bridge.accessor(Graphics.getRequiredExtensions, null, .{}); + pub const systemLanguage = bridge.accessor(Graphics.getSystemLanguage, null, .{}); }; + +pub fn getTransform(self: *Graphics, frame: *Frame) !*AnimatedTransformList { + return AnimatedTransformList.getOrCreate(self.asElement(), frame); +} + +pub fn getRequiredExtensions(self: *Graphics, frame: *Frame) !*StringList { + return StringList.getOrCreate(self.asElement(), .required_extensions, frame); +} + +pub fn getSystemLanguage(self: *Graphics, frame: *Frame) !*StringList { + return StringList.getOrCreate(self.asElement(), .system_language, frame); +} diff --git a/src/browser/webapi/element/svg/Polygon.zig b/src/browser/webapi/element/svg/Polygon.zig index 752917079..9d55797e4 100644 --- a/src/browser/webapi/element/svg/Polygon.zig +++ b/src/browser/webapi/element/svg/Polygon.zig @@ -17,11 +17,13 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Geometry = @import("Geometry.zig"); +const PointList = @import("../../svg/PointList.zig"); const Polygon = @This(); _proto: *Geometry, @@ -41,4 +43,15 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const points = bridge.accessor(Polygon.getPoints, null, .{}); + pub const animatedPoints = bridge.accessor(Polygon.getAnimatedPoints, null, .{}); }; + +pub fn getPoints(self: *Polygon, frame: *Frame) !*PointList { + return PointList.getOrCreate(self.asElement(), .base, frame); +} + +pub fn getAnimatedPoints(self: *Polygon, frame: *Frame) !*PointList { + return PointList.getOrCreate(self.asElement(), .animated, frame); +} diff --git a/src/browser/webapi/element/svg/Polyline.zig b/src/browser/webapi/element/svg/Polyline.zig index a232e4950..6ac253167 100644 --- a/src/browser/webapi/element/svg/Polyline.zig +++ b/src/browser/webapi/element/svg/Polyline.zig @@ -17,11 +17,13 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Geometry = @import("Geometry.zig"); +const PointList = @import("../../svg/PointList.zig"); const Polyline = @This(); _proto: *Geometry, @@ -41,4 +43,15 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const points = bridge.accessor(Polyline.getPoints, null, .{}); + pub const animatedPoints = bridge.accessor(Polyline.getAnimatedPoints, null, .{}); }; + +pub fn getPoints(self: *Polyline, frame: *Frame) !*PointList { + return PointList.getOrCreate(self.asElement(), .base, frame); +} + +pub fn getAnimatedPoints(self: *Polyline, frame: *Frame) !*PointList { + return PointList.getOrCreate(self.asElement(), .animated, frame); +} diff --git a/src/browser/webapi/element/svg/Svg.zig b/src/browser/webapi/element/svg/Svg.zig index ae6ae220b..42419255d 100644 --- a/src/browser/webapi/element/svg/Svg.zig +++ b/src/browser/webapi/element/svg/Svg.zig @@ -82,7 +82,9 @@ pub fn getPreserveAspectRatio(self: *Svg, frame: *Frame) !*AnimatedPreserveAspec } pub fn createSVGPoint(_: *Svg, frame: *Frame) !*DOMPoint { - return DOMPoint.create(0, 0, 0, 1, frame._page); + const point = try DOMPoint.create(0, 0, 0, 1, frame._page); + point._proto.restrict(); + return point; } pub fn createSVGMatrix(_: *Svg, frame: *Frame) !*DOMMatrix { diff --git a/src/browser/webapi/svg/AnimatedTransformList.zig b/src/browser/webapi/svg/AnimatedTransformList.zig new file mode 100644 index 000000000..0ec5670f6 --- /dev/null +++ b/src/browser/webapi/svg/AnimatedTransformList.zig @@ -0,0 +1,65 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +const std = @import("std"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); +const Page = @import("../../Page.zig"); +const Element = @import("../Element.zig"); +const TransformList = @import("TransformList.zig"); + +const AnimatedTransformList = @This(); + +_base_val: *TransformList, +_anim_val: *TransformList, + +pub const Lookup = std.AutoHashMapUnmanaged(*Element, *AnimatedTransformList); + +pub fn getOrCreate(element: *Element, frame: *Frame) !*AnimatedTransformList { + const gop = try frame._svg_animated_transform_lists.getOrPut(frame.arena, element); + if (!gop.found_existing) { + errdefer _ = frame._svg_animated_transform_lists.remove(element); + gop.value_ptr.* = try create(element, frame); + } + return gop.value_ptr.*; +} + +pub fn create(element: *Element, frame: *Frame) !*AnimatedTransformList { + const base_val = try TransformList.create(element, false, frame); + const anim_val = try TransformList.create(element, true, frame); + return frame._factory.create(AnimatedTransformList{ + ._base_val = base_val, + ._anim_val = anim_val, + }); +} + +pub fn deinit(self: *AnimatedTransformList, page: *Page) void { + self._base_val.deinit(page); + self._anim_val.deinit(page); +} + +pub fn getBaseVal(self: *AnimatedTransformList) *TransformList { + return self._base_val; +} + +pub fn getAnimVal(self: *AnimatedTransformList) *TransformList { + return self._anim_val; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(AnimatedTransformList); + + pub const Meta = struct { + pub const name = "SVGAnimatedTransformList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const baseVal = bridge.accessor(AnimatedTransformList.getBaseVal, null, .{}); + pub const animVal = bridge.accessor(AnimatedTransformList.getAnimVal, null, .{}); +}; diff --git a/src/browser/webapi/svg/PointList.zig b/src/browser/webapi/svg/PointList.zig new file mode 100644 index 000000000..6c9a645a4 --- /dev/null +++ b/src/browser/webapi/svg/PointList.zig @@ -0,0 +1,381 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); +const Page = @import("../../Page.zig"); +const DOMPoint = @import("../DOMPoint.zig"); +const DOMPointReadOnly = @import("../DOMPointReadOnly.zig"); +const Element = @import("../Element.zig"); + +const PointList = @This(); + +_frame: *Frame, +_element: *Element, +_read_only: bool, +_synced: bool = false, +_snapshot: std.ArrayList(u8) = .empty, +_items: std.ArrayList(*DOMPoint) = .empty, +_retired: std.ArrayList(*DOMPoint) = .empty, + +pub const Kind = enum { base, animated }; + +pub const Key = struct { + element: *Element, + kind: Kind, +}; + +pub const Lookup = std.AutoHashMapUnmanaged(Key, *PointList); + +pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*PointList { + const key: Key = .{ + .element = element, + .kind = kind, + }; + const gop = try frame._svg_point_lists.getOrPut(frame.arena, key); + if (!gop.found_existing) { + errdefer _ = frame._svg_point_lists.remove(key); + gop.value_ptr.* = try frame._factory.create(PointList{ + ._frame = frame, + ._element = element, + ._read_only = kind == .animated, + }); + } + return gop.value_ptr.*; +} + +pub fn deinit(self: *PointList, page: *Page) void { + for (self._items.items) |point| { + point._proto.detach(self); + point._proto.releaseRef(page); + } + self._items.clearRetainingCapacity(); + self.releaseRetired(page); +} + +pub fn getLength(self: *PointList, frame: *Frame) !u32 { + try self.sync(frame); + return @intCast(self._items.items.len); +} + +pub fn getNumberOfItems(self: *PointList, frame: *Frame) !u32 { + return self.getLength(frame); +} + +pub fn clear(self: *PointList, frame: *Frame) !void { + try self.requireMutable(); + try self.sync(frame); + try self.retireAll(frame); + try self.setAttribute(&.{}, frame); +} + +pub fn initialize(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint { + try self.requireMutable(); + try self.sync(frame); + + const prepared = try self.prepareItem(item, frame); + errdefer prepared._proto.releaseRef(frame._page); + + try self.retireAll(frame); + try self._items.ensureTotalCapacity(frame.arena, 1); + try self.setAttribute(&.{prepared}, frame); + self._items.appendAssumeCapacity(prepared); + self.attach(prepared); + return prepared; +} + +pub fn getItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint { + try self.sync(frame); + if (index >= self._items.items.len) return error.IndexSizeError; + return self._items.items[index]; +} + +pub fn insertItemBefore(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint { + try self.requireMutable(); + try self.sync(frame); + + const prepared = try self.prepareItem(item, frame); + errdefer prepared._proto.releaseRef(frame._page); + const at = @min(@as(usize, index), self._items.items.len); + const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len + 1); + @memcpy(next[0..at], self._items.items[0..at]); + next[at] = prepared; + @memcpy(next[at + 1 ..], self._items.items[at..]); + + try self._items.ensureUnusedCapacity(frame.arena, 1); + try self.setAttribute(next, frame); + self._items.insertAssumeCapacity(at, prepared); + self.attach(prepared); + return prepared; +} + +pub fn replaceItem(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint { + try self.requireMutable(); + try self.sync(frame); + if (index >= self._items.items.len) return error.IndexSizeError; + + const prepared = try self.prepareItem(item, frame); + errdefer prepared._proto.releaseRef(frame._page); + const next = try frame.local_arena.dupe(*DOMPoint, self._items.items); + next[index] = prepared; + + try self._retired.ensureUnusedCapacity(frame.arena, 1); + try self.setAttribute(next, frame); + const replaced = self._items.items[index]; + replaced._proto.detach(self); + self._retired.appendAssumeCapacity(replaced); + self._items.items[index] = prepared; + self.attach(prepared); + return prepared; +} + +pub fn removeItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint { + try self.requireMutable(); + try self.sync(frame); + if (index >= self._items.items.len) return error.IndexSizeError; + + const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len - 1); + @memcpy(next[0..index], self._items.items[0..index]); + @memcpy(next[index..], self._items.items[index + 1 ..]); + + try self._retired.ensureUnusedCapacity(frame.arena, 1); + try self.setAttribute(next, frame); + const removed = self._items.orderedRemove(index); + removed._proto.detach(self); + self._retired.appendAssumeCapacity(removed); + return removed; +} + +pub fn appendItem(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint { + return self.insertItemBefore(item, std.math.maxInt(u32), frame); +} + +fn requireMutable(self: *const PointList) !void { + if (self._read_only) return error.NoModificationAllowed; +} + +fn prepareItem(_: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint { + if (!std.math.isFinite(item._proto._x) or !std.math.isFinite(item._proto._y)) return error.TypeError; + const prepared = if (item._proto.isAttached()) + try DOMPoint.create(item._proto._x, item._proto._y, item._proto._z, item._proto._w, frame._page) + else + item; + prepared._proto.acquireRef(); + return prepared; +} + +fn attach(self: *PointList, point: *DOMPoint) void { + point._proto.restrict(); + point._proto.attach(.{ + .owner = self, + .mutate = PointList.mutatePoint, + }, self._read_only); +} + +fn mutatePoint( + context: *anyopaque, + point: *DOMPointReadOnly, + coordinate: DOMPointReadOnly.Coordinate, + value: f64, +) anyerror!void { + const self: *PointList = @ptrCast(@alignCast(context)); + const frame = self._frame; + try self.sync(frame); + + // An external attribute mutation detaches the old item during sync. The + // caller still owns that DOMPoint identity, but it no longer mutates the list. + if (!point.isAttachedTo(self)) { + point.setCoordinateRaw(coordinate, value); + return; + } + + if (coordinate == .z or coordinate == .w) { + point.setCoordinateRaw(coordinate, value); + return; + } + if (!std.math.isFinite(value)) return error.TypeError; + + const index = for (self._items.items, 0..) |candidate, i| { + if (candidate._proto == point) break i; + } else unreachable; + + try self.setAttributeWithOverride(index, coordinate, value, frame); + point.setCoordinateRaw(coordinate, value); +} + +fn sync(self: *PointList, frame: *Frame) !void { + self.releaseRetired(frame._page); + + const raw = self._element.getAttributeSafe(comptime .wrap("points")) orelse ""; + if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) { + return; + } + + self._synced = false; + var parsed = parse(raw, frame) catch |err| switch (err) { + error.SyntaxError => std.ArrayList(*DOMPoint).empty, + else => return err, + }; + errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page); + + self._snapshot.clearRetainingCapacity(); + try self._snapshot.appendSlice(frame.arena, raw); + try self.retireAll(frame); + try self._items.ensureTotalCapacity(frame.arena, parsed.items.len); + for (parsed.items) |point| { + self._items.appendAssumeCapacity(point); + self.attach(point); + } + parsed.clearRetainingCapacity(); + self._synced = true; +} + +// A retired item must outlive the operation that retired it: removeItem's +// return value has no JS wrapper until the bridge wraps it after we return. +// By the next operation, anything still reachable holds its own ref. +fn releaseRetired(self: *PointList, page: *Page) void { + for (self._retired.items) |point| { + point._proto.releaseRef(page); + } + self._retired.clearRetainingCapacity(); +} + +fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) { + var scanner = NumberScanner{ .input = raw }; + var parsed: std.ArrayList(*DOMPoint) = .empty; + errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page); + + while (try scanner.next()) |x| { + // A trailing coordinate with no pair truncates the list; only a + // malformed number invalidates the whole attribute. + const y = (try scanner.next()) orelse break; + const point = try DOMPoint.create(x, y, 0, 1, frame._page); + point._proto.acquireRef(); + parsed.append(frame.local_arena, point) catch |err| { + point._proto.releaseRef(frame._page); + return err; + }; + } + return parsed; +} + +fn retireAll(self: *PointList, frame: *Frame) !void { + self._synced = false; + try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len); + for (self._items.items) |point| { + point._proto.detach(self); + self._retired.appendAssumeCapacity(point); + } + self._items.clearRetainingCapacity(); +} + +fn setAttribute(self: *PointList, items: []const *DOMPoint, frame: *Frame) !void { + var serialized: std.Io.Writer.Allocating = .init(frame.local_arena); + const writer = &serialized.writer; + for (items, 0..) |point, i| { + if (i != 0) try writer.writeByte(' '); + try writer.print("{d} {d}", .{ point._proto._x, point._proto._y }); + } + try self.commitAttribute(serialized.written(), frame); +} + +fn setAttributeWithOverride( + self: *PointList, + index: usize, + coordinate: DOMPointReadOnly.Coordinate, + value: f64, + frame: *Frame, +) !void { + var serialized: std.Io.Writer.Allocating = .init(frame.local_arena); + const writer = &serialized.writer; + for (self._items.items, 0..) |point, i| { + if (i != 0) try writer.writeByte(' '); + const x = if (i == index and coordinate == .x) value else point._proto._x; + const y = if (i == index and coordinate == .y) value else point._proto._y; + try writer.print("{d} {d}", .{ x, y }); + } + try self.commitAttribute(serialized.written(), frame); +} + +fn commitAttribute(self: *PointList, serialized: []const u8, frame: *Frame) !void { + self._synced = false; + try self._element.setAttributeSafe(comptime .wrap("points"), .wrap(serialized), frame); + self._snapshot.clearRetainingCapacity(); + try self._snapshot.appendSlice(frame.arena, serialized); + self._synced = true; +} + +const NumberScanner = struct { + input: []const u8, + index: usize = 0, + first: bool = true, + + fn next(self: *NumberScanner) !?f64 { + var had_whitespace = false; + while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) { + had_whitespace = true; + self.index += 1; + } + + if (!self.first and self.index < self.input.len and self.input[self.index] == ',') { + self.index += 1; + while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1; + } else if (!self.first and self.index < self.input.len and !had_whitespace and + self.input[self.index] != '+' and self.input[self.index] != '-') + { + return error.SyntaxError; + } + + if (self.index == self.input.len) return null; + const start = self.index; + if (self.input[self.index] == '+' or self.input[self.index] == '-') self.index += 1; + + var digits: usize = 0; + while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1; + if (self.index < self.input.len and self.input[self.index] == '.') { + self.index += 1; + while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1; + } + if (digits == 0) return error.SyntaxError; + + if (self.index < self.input.len and (self.input[self.index] == 'e' or self.input[self.index] == 'E')) { + self.index += 1; + if (self.index < self.input.len and (self.input[self.index] == '+' or self.input[self.index] == '-')) self.index += 1; + const exponent_start = self.index; + while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) self.index += 1; + if (self.index == exponent_start) return error.SyntaxError; + } + + const value = std.fmt.parseFloat(f64, self.input[start..self.index]) catch return error.SyntaxError; + if (!std.math.isFinite(value)) return error.SyntaxError; + self.first = false; + return value; + } +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(PointList); + + pub const Meta = struct { + pub const name = "SVGPointList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const length = bridge.accessor(PointList.getLength, null, .{}); + pub const numberOfItems = bridge.accessor(PointList.getNumberOfItems, null, .{}); + pub const clear = bridge.function(PointList.clear, .{}); + pub const initialize = bridge.function(PointList.initialize, .{}); + pub const getItem = bridge.function(PointList.getItem, .{}); + pub const insertItemBefore = bridge.function(PointList.insertItemBefore, .{}); + pub const replaceItem = bridge.function(PointList.replaceItem, .{}); + pub const removeItem = bridge.function(PointList.removeItem, .{}); + pub const appendItem = bridge.function(PointList.appendItem, .{}); +}; diff --git a/src/browser/webapi/svg/StringList.zig b/src/browser/webapi/svg/StringList.zig new file mode 100644 index 000000000..ccd217063 --- /dev/null +++ b/src/browser/webapi/svg/StringList.zig @@ -0,0 +1,225 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); +const Element = @import("../Element.zig"); + +const StringList = @This(); + +pub const Delimiter = enum { whitespace, comma }; + +_element: *Element, +_attribute_name: lp.String, +_delimiter: Delimiter, +_synced: bool = false, +// An absent attribute is an empty list, but an empty attribute is not: a +// comma-separated value parses "" as a single empty token. +_present: bool = false, +_snapshot: std.ArrayList(u8) = .empty, +_items: std.ArrayList([]const u8) = .empty, + +pub const Kind = enum { + required_extensions, + system_language, + + fn attributeName(self: Kind) lp.String { + return switch (self) { + .required_extensions => .wrap("requiredExtensions"), + .system_language => .wrap("systemLanguage"), + }; + } + + fn delimiter(self: Kind) Delimiter { + return switch (self) { + .required_extensions => .whitespace, + .system_language => .comma, + }; + } +}; + +pub const Key = struct { + element: *Element, + kind: Kind, +}; + +pub const Lookup = std.AutoHashMapUnmanaged(Key, *StringList); + +pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*StringList { + const key: Key = .{ + .element = element, + .kind = kind, + }; + const gop = try frame._svg_string_lists.getOrPut(frame.arena, key); + if (!gop.found_existing) { + errdefer _ = frame._svg_string_lists.remove(key); + gop.value_ptr.* = try frame._factory.create(StringList{ + ._element = element, + ._attribute_name = kind.attributeName(), + ._delimiter = kind.delimiter(), + }); + } + return gop.value_ptr.*; +} + +pub fn getLength(self: *StringList, frame: *Frame) !u32 { + try self.sync(frame); + return @intCast(self._items.items.len); +} + +pub fn getNumberOfItems(self: *StringList, frame: *Frame) !u32 { + return self.getLength(frame); +} + +pub fn clear(self: *StringList, frame: *Frame) !void { + try self.commit(&.{}, frame); +} + +pub fn initialize(self: *StringList, item: []const u8, frame: *Frame) ![]const u8 { + try self.commit(&.{item}, frame); + return item; +} + +pub fn getItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 { + try self.sync(frame); + if (index >= self._items.items.len) return error.IndexSizeError; + return self._items.items[index]; +} + +pub fn insertItemBefore(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 { + try self.sync(frame); + const at = @min(@as(usize, index), self._items.items.len); + const next = try frame.local_arena.alloc([]const u8, self._items.items.len + 1); + @memcpy(next[0..at], self._items.items[0..at]); + next[at] = item; + @memcpy(next[at + 1 ..], self._items.items[at..]); + try self.commit(next, frame); + return item; +} + +pub fn replaceItem(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 { + try self.sync(frame); + if (index >= self._items.items.len) return error.IndexSizeError; + const next = try frame.local_arena.dupe([]const u8, self._items.items); + next[index] = item; + try self.commit(next, frame); + return item; +} + +pub fn removeItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 { + try self.sync(frame); + if (index >= self._items.items.len) return error.IndexSizeError; + const removed = try frame.local_arena.dupe(u8, self._items.items[index]); + const next = try frame.local_arena.alloc([]const u8, self._items.items.len - 1); + @memcpy(next[0..index], self._items.items[0..index]); + @memcpy(next[index..], self._items.items[index + 1 ..]); + try self.commit(next, frame); + return removed; +} + +pub fn appendItem(self: *StringList, item: []const u8, frame: *Frame) ![]const u8 { + return self.insertItemBefore(item, std.math.maxInt(u32), frame); +} + +fn sync(self: *StringList, frame: *Frame) !void { + const raw = self._element.getAttributeSafe(self._attribute_name); + if (self._synced and self._present == (raw != null) and + std.mem.eql(u8, self._snapshot.items, raw orelse "")) + { + return; + } + return self.rebuild(raw, frame); +} + +// The list is authoritative: an item keeps its identity even when it contains +// the delimiter, so we record where each one landed rather than reparsing our +// own serialization. +fn commit(self: *StringList, items: []const []const u8, frame: *Frame) !void { + if (items.len == 0) { + self._synced = false; + self._element.removeAttributeSafe(self._attribute_name, frame); + self._present = false; + self._snapshot.clearRetainingCapacity(); + self._items.clearRetainingCapacity(); + self._synced = true; + return; + } + + const separator: []const u8 = if (self._delimiter == .comma) "," else " "; + var serialized: std.Io.Writer.Allocating = .init(frame.local_arena); + const writer = &serialized.writer; + const bounds = try frame.local_arena.alloc([2]usize, items.len); + for (items, bounds, 0..) |item, *bound, i| { + if (i != 0) try writer.writeAll(separator); + const start = serialized.written().len; + try writer.writeAll(item); + bound.* = .{ start, serialized.written().len }; + } + const bytes = serialized.written(); + + self._synced = false; + try self._element.setAttributeSafe(self._attribute_name, .wrap(bytes), frame); + self._present = true; + self._snapshot.clearRetainingCapacity(); + try self._snapshot.appendSlice(frame.arena, bytes); + self._items.clearRetainingCapacity(); + for (bounds) |bound| { + try self._items.append(frame.arena, self._snapshot.items[bound[0]..bound[1]]); + } + self._synced = true; +} + +fn rebuild(self: *StringList, raw: ?[]const u8, frame: *Frame) !void { + self._synced = false; + self._present = raw != null; + self._snapshot.clearRetainingCapacity(); + self._items.clearRetainingCapacity(); + + if (raw) |value| { + try self._snapshot.appendSlice(frame.arena, value); + switch (self._delimiter) { + .whitespace => { + var iterator = std.mem.tokenizeAny(u8, self._snapshot.items, WHITESPACE); + while (iterator.next()) |item| try self._items.append(frame.arena, item); + }, + // A set of comma-separated tokens: every segment is a token, even + // an empty one, and each is trimmed of surrounding whitespace. + .comma => { + var iterator = std.mem.splitScalar(u8, self._snapshot.items, ','); + while (iterator.next()) |part| { + try self._items.append(frame.arena, std.mem.trim(u8, part, WHITESPACE)); + } + }, + } + } + self._synced = true; +} + +const WHITESPACE = " \t\r\n\x0c"; + +pub const JsApi = struct { + pub const bridge = js.Bridge(StringList); + + pub const Meta = struct { + pub const name = "SVGStringList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const length = bridge.accessor(StringList.getLength, null, .{}); + pub const numberOfItems = bridge.accessor(StringList.getNumberOfItems, null, .{}); + pub const clear = bridge.function(StringList.clear, .{}); + pub const initialize = bridge.function(StringList.initialize, .{}); + pub const getItem = bridge.function(StringList.getItem, .{}); + pub const insertItemBefore = bridge.function(StringList.insertItemBefore, .{}); + pub const replaceItem = bridge.function(StringList.replaceItem, .{}); + pub const removeItem = bridge.function(StringList.removeItem, .{}); + pub const appendItem = bridge.function(StringList.appendItem, .{}); +}; diff --git a/src/browser/webapi/svg/Transform.zig b/src/browser/webapi/svg/Transform.zig index fe6c09e30..2ebe7dc02 100644 --- a/src/browser/webapi/svg/Transform.zig +++ b/src/browser/webapi/svg/Transform.zig @@ -43,7 +43,28 @@ pub const DOMMatrix2DInit = struct { _type: u16 = 1, _angle: f64 = 0, +_cx: f64 = 0, +_cy: f64 = 0, _matrix: *DOMMatrix, +_attachment: ?Attachment = null, + +// Sticky. An animVal item that a later external attribute change detaches from +// its list must not turn into a writable orphan. +_read_only: bool = false, + +pub const State = struct { + typ: u16, + angle: f64, + cx: f64, + cy: f64, + matrix: [16]f64, + is_2d: bool, +}; + +pub const Attachment = struct { + owner: *anyopaque, + mutate: *const fn (*anyopaque, *Transform, State) anyerror!void, +}; // The transform owns the matrix arena even when no JS wrapper currently // references `matrix`. Forwarding the transform's bridge lifetime keeps the @@ -58,13 +79,57 @@ pub fn releaseRef(self: *Transform, page: *Page) void { pub fn detached(frame: *Frame) !*Transform { const matrix = try DOMMatrix.create(RO.identity(), true, frame._page); - return frame._factory.create(Transform{ ._matrix = matrix }); + errdefer matrix._proto.deinit(frame._page); + const self = try frame._factory.create(Transform{ ._matrix = matrix }); + self.attachMatrix(); + return self; } pub fn fromMatrix(init: ?DOMMatrix2DInit, frame: *Frame) !*Transform { const parsed = try fixup2D(init orelse .{}); const matrix = try DOMMatrix.create(parsed.m, true, frame._page); - return frame._factory.create(Transform{ ._matrix = matrix }); + errdefer matrix._proto.deinit(frame._page); + const self = try frame._factory.create(Transform{ ._matrix = matrix }); + self.attachMatrix(); + return self; +} + +pub fn fromParsed(parsed: RO.ParsedTransform, frame: *Frame) !*Transform { + const typ: u16 = switch (parsed.kind) { + .matrix => 1, + .translate => 2, + .scale => 3, + .rotate => 4, + .skew_x => 5, + .skew_y => 6, + else => return error.SyntaxError, + }; + const matrix = try DOMMatrix.create(parsed.matrix, parsed.is_2d, frame._page); + errdefer matrix._proto.deinit(frame._page); + const self = try frame._factory.create(Transform{ + ._type = typ, + ._angle = if (typ >= 4) parsed.values[0] else 0, + ._cx = if (typ == 4 and parsed.count == 3) parsed.values[1] else 0, + ._cy = if (typ == 4 and parsed.count == 3) parsed.values[2] else 0, + ._matrix = matrix, + }); + self.attachMatrix(); + return self; +} + +pub fn clone(self: *const Transform, frame: *Frame) !*Transform { + const current = self.getState(); + const matrix = try DOMMatrix.create(current.matrix, current.is_2d, frame._page); + errdefer matrix._proto.deinit(frame._page); + const cloned = try frame._factory.create(Transform{ + ._type = current.typ, + ._angle = current.angle, + ._cx = current.cx, + ._cy = current.cy, + ._matrix = matrix, + }); + cloned.attachMatrix(); + return cloned; } pub fn getType(self: *const Transform) u16 { @@ -81,23 +146,17 @@ pub fn getAngle(self: *const Transform) f64 { pub fn setMatrix(self: *Transform, init: ?DOMMatrix2DInit) !void { const parsed = try fixup2D(init orelse .{}); - self.replaceMatrix(parsed.m, true); - self._type = 1; - self._angle = 0; + try self.applyState(.{ .typ = 1, .angle = 0, .cx = 0, .cy = 0, .matrix = parsed.m, .is_2d = true }); } pub fn setTranslate(self: *Transform, tx: f64, ty: f64) !void { try ensureFinite(&.{ tx, ty }); - self.replaceMatrix(RO.translationMatrix(tx, ty, 0), true); - self._type = 2; - self._angle = 0; + try self.applyState(.{ .typ = 2, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.translationMatrix(tx, ty, 0), .is_2d = true }); } pub fn setScale(self: *Transform, sx: f64, sy: f64) !void { try ensureFinite(&.{ sx, sy }); - self.replaceMatrix(RO.scaleMatrix(sx, sy, 1), true); - self._type = 3; - self._angle = 0; + try self.applyState(.{ .typ = 3, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.scaleMatrix(sx, sy, 1), .is_2d = true }); } pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void { @@ -106,32 +165,116 @@ pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void { var matrix = RO.translationMatrix(cx, cy, 0); matrix = RO.multiplyMatrix(matrix, RO.rotateZMatrix(radians)); matrix = RO.multiplyMatrix(matrix, RO.translationMatrix(-cx, -cy, 0)); - self.replaceMatrix(matrix, true); - self._type = 4; - self._angle = angle; + try self.applyState(.{ .typ = 4, .angle = angle, .cx = cx, .cy = cy, .matrix = matrix, .is_2d = true }); } pub fn setSkewX(self: *Transform, angle: f64) !void { try ensureFinite(&.{angle}); - self.replaceMatrix(RO.skewMatrix(angle * std.math.pi / 180.0, 0), true); - self._type = 5; - self._angle = angle; + try self.applyState(.{ .typ = 5, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(angle * std.math.pi / 180.0, 0), .is_2d = true }); } pub fn setSkewY(self: *Transform, angle: f64) !void { try ensureFinite(&.{angle}); - self.replaceMatrix(RO.skewMatrix(0, angle * std.math.pi / 180.0), true); - self._type = 6; - self._angle = angle; + try self.applyState(.{ .typ = 6, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(0, angle * std.math.pi / 180.0), .is_2d = true }); } -fn replaceMatrix(self: *Transform, matrix: [16]f64, is_2d: bool) void { - self._matrix._proto._m = matrix; - self._matrix._proto._is_2d = is_2d; +pub fn getState(self: *const Transform) State { + return .{ + .typ = self._type, + .angle = self._angle, + .cx = self._cx, + .cy = self._cy, + .matrix = self._matrix._proto._m, + .is_2d = self._matrix._proto._is_2d, + }; +} + +pub fn applyStateRaw(self: *Transform, state: State) void { + self._type = state.typ; + self._angle = state.angle; + self._cx = state.cx; + self._cy = state.cy; + self._matrix._proto.applyStateRaw(.{ + .matrix = state.matrix, + .is_2d = state.is_2d, + }); +} + +fn applyState(self: *Transform, state: State) !void { + if (self._read_only) return error.NoModificationAllowed; + try ensureFinite(&state.matrix); + if (self._attachment) |attachment| { + return attachment.mutate(attachment.owner, self, state); + } + self.applyStateRaw(state); +} + +pub fn attach(self: *Transform, attachment: Attachment, read_only: bool) void { + self._attachment = attachment; + if (read_only) self._read_only = true; +} + +pub fn detach(self: *Transform, owner: *anyopaque) void { + const attachment = self._attachment orelse return; + if (attachment.owner == owner) self._attachment = null; +} + +pub fn isAttached(self: *const Transform) bool { + return self._attachment != null; +} + +pub fn isAttachedTo(self: *const Transform, owner: *anyopaque) bool { + const attachment = self._attachment orelse return false; + return attachment.owner == owner; +} + +pub fn writeState(state: State, writer: anytype) !void { + switch (state.typ) { + 1 => try writer.print("matrix({d} {d} {d} {d} {d} {d})", .{ + state.matrix[0], state.matrix[1], state.matrix[4], state.matrix[5], state.matrix[12], state.matrix[13], + }), + 2 => try writer.print("translate({d} {d})", .{ state.matrix[12], state.matrix[13] }), + 3 => try writer.print("scale({d} {d})", .{ state.matrix[0], state.matrix[5] }), + 4 => if (state.cx == 0 and state.cy == 0) + try writer.print("rotate({d})", .{state.angle}) + else + try writer.print("rotate({d} {d} {d})", .{ state.angle, state.cx, state.cy }), + 5 => try writer.print("skewX({d})", .{state.angle}), + 6 => try writer.print("skewY({d})", .{state.angle}), + else => return error.SyntaxError, + } +} + +fn attachMatrix(self: *Transform) void { + self._matrix._proto.attach(.{ + .owner = self, + .mutate = Transform.mutateMatrix, + }); +} + +// An SVGTransform is a 2D affine transform, and the `transform` attribute has +// no syntax for anything else, so a write through the live matrix keeps only +// the six components the transform can represent. +fn mutateMatrix(context: *anyopaque, _: *RO, state: RO.State) anyerror!void { + const self: *Transform = @ptrCast(@alignCast(context)); + const m = state.matrix; + try self.applyState(.{ + .typ = 1, + .angle = 0, + .cx = 0, + .cy = 0, + .matrix = .{ + m[0], m[1], 0, 0, + m[4], m[5], 0, 0, + 0, 0, 1, 0, + m[12], m[13], 0, 1, + }, + .is_2d = true, + }); } fn fixup2D(init: DOMMatrix2DInit) !RO.Parsed { - return RO.fixupDict(.{ + const parsed = try RO.fixupDict(.{ .a = init.a, .b = init.b, .c = init.c, @@ -146,6 +289,8 @@ fn fixup2D(init: DOMMatrix2DInit) !RO.Parsed { .m42 = init.m42, .is2D = true, }); + try ensureFinite(&parsed.m); + return parsed; } fn ensureFinite(values: []const f64) !void { diff --git a/src/browser/webapi/svg/TransformList.zig b/src/browser/webapi/svg/TransformList.zig new file mode 100644 index 000000000..8baf90052 --- /dev/null +++ b/src/browser/webapi/svg/TransformList.zig @@ -0,0 +1,313 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +const std = @import("std"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); +const Page = @import("../../Page.zig"); +const DOMMatrixReadOnly = @import("../DOMMatrixReadOnly.zig"); +const Element = @import("../Element.zig"); +const Transform = @import("Transform.zig"); + +const TransformList = @This(); + +_frame: *Frame, +_read_only: bool, +_element: *Element, +_synced: bool = false, +_snapshot: std.ArrayList(u8) = .empty, +_items: std.ArrayList(*Transform) = .empty, +_retired: std.ArrayList(*Transform) = .empty, + +pub fn create(element: *Element, read_only: bool, frame: *Frame) !*TransformList { + return frame._factory.create(TransformList{ + ._frame = frame, + ._element = element, + ._read_only = read_only, + }); +} + +pub fn deinit(self: *TransformList, page: *Page) void { + for (self._items.items) |transform| { + transform.detach(self); + transform.releaseRef(page); + } + self._items.clearRetainingCapacity(); + self.releaseRetired(page); +} + +pub fn getLength(self: *TransformList, frame: *Frame) !u32 { + try self.sync(frame); + return @intCast(self._items.items.len); +} + +pub fn getNumberOfItems(self: *TransformList, frame: *Frame) !u32 { + return self.getLength(frame); +} + +pub fn clear(self: *TransformList, frame: *Frame) !void { + try self.requireMutable(); + try self.sync(frame); + try self.retireAll(frame); + try self.setAttribute(&.{}, frame); +} + +pub fn initialize(self: *TransformList, item: *Transform, frame: *Frame) !*Transform { + try self.requireMutable(); + try self.sync(frame); + const prepared = try self.prepareItem(item, frame); + errdefer prepared.releaseRef(frame._page); + + try self.retireAll(frame); + try self._items.ensureTotalCapacity(frame.arena, 1); + try self.setAttribute(&.{prepared}, frame); + self._items.appendAssumeCapacity(prepared); + self.attach(prepared); + return prepared; +} + +pub fn getItem(self: *TransformList, index: u32, frame: *Frame) !*Transform { + try self.sync(frame); + if (index >= self._items.items.len) return error.IndexSizeError; + return self._items.items[index]; +} + +pub fn insertItemBefore(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform { + try self.requireMutable(); + try self.sync(frame); + const prepared = try self.prepareItem(item, frame); + errdefer prepared.releaseRef(frame._page); + const at = @min(@as(usize, index), self._items.items.len); + const next = try frame.local_arena.alloc(*Transform, self._items.items.len + 1); + @memcpy(next[0..at], self._items.items[0..at]); + next[at] = prepared; + @memcpy(next[at + 1 ..], self._items.items[at..]); + + try self._items.ensureUnusedCapacity(frame.arena, 1); + try self.setAttribute(next, frame); + self._items.insertAssumeCapacity(at, prepared); + self.attach(prepared); + return prepared; +} + +pub fn replaceItem(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform { + try self.requireMutable(); + try self.sync(frame); + if (index >= self._items.items.len) return error.IndexSizeError; + const prepared = try self.prepareItem(item, frame); + errdefer prepared.releaseRef(frame._page); + const next = try frame.local_arena.dupe(*Transform, self._items.items); + next[index] = prepared; + + try self._retired.ensureUnusedCapacity(frame.arena, 1); + try self.setAttribute(next, frame); + const replaced = self._items.items[index]; + replaced.detach(self); + self._retired.appendAssumeCapacity(replaced); + self._items.items[index] = prepared; + self.attach(prepared); + return prepared; +} + +pub fn removeItem(self: *TransformList, index: u32, frame: *Frame) !*Transform { + try self.requireMutable(); + try self.sync(frame); + if (index >= self._items.items.len) return error.IndexSizeError; + const next = try frame.local_arena.alloc(*Transform, self._items.items.len - 1); + @memcpy(next[0..index], self._items.items[0..index]); + @memcpy(next[index..], self._items.items[index + 1 ..]); + + try self._retired.ensureUnusedCapacity(frame.arena, 1); + try self.setAttribute(next, frame); + const removed = self._items.orderedRemove(index); + removed.detach(self); + self._retired.appendAssumeCapacity(removed); + return removed; +} + +pub fn appendItem(self: *TransformList, item: *Transform, frame: *Frame) !*Transform { + return self.insertItemBefore(item, std.math.maxInt(u32), frame); +} + +pub fn consolidate(self: *TransformList, frame: *Frame) !?*Transform { + try self.requireMutable(); + try self.sync(frame); + if (self._items.items.len == 0) return null; + + var matrix = DOMMatrixReadOnly.identity(); + for (self._items.items) |item| matrix = DOMMatrixReadOnly.multiplyMatrix(matrix, item.getState().matrix); + for (matrix) |value| if (!std.math.isFinite(value)) return error.TypeError; + var values: [16]f64 = undefined; + values[0] = matrix[0]; + values[1] = matrix[1]; + values[2] = matrix[4]; + values[3] = matrix[5]; + values[4] = matrix[12]; + values[5] = matrix[13]; + const consolidated = try Transform.fromParsed(.{ + .kind = .matrix, + .matrix = matrix, + .values = values, + .count = 6, + .is_2d = true, + }, frame); + consolidated.acquireRef(); + errdefer consolidated.releaseRef(frame._page); + + try self.retireAll(frame); + try self._items.ensureTotalCapacity(frame.arena, 1); + try self.setAttribute(&.{consolidated}, frame); + self._items.appendAssumeCapacity(consolidated); + self.attach(consolidated); + return consolidated; +} + +fn requireMutable(self: *const TransformList) !void { + if (self._read_only) return error.NoModificationAllowed; +} + +fn prepareItem(_: *TransformList, item: *Transform, frame: *Frame) !*Transform { + const prepared = if (item.isAttached()) try item.clone(frame) else item; + prepared.acquireRef(); + return prepared; +} + +fn attach(self: *TransformList, transform: *Transform) void { + transform.attach(.{ + .owner = self, + .mutate = TransformList.mutateTransform, + }, self._read_only); +} + +fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform.State) anyerror!void { + const self: *TransformList = @ptrCast(@alignCast(context)); + const frame = self._frame; + try self.sync(frame); + if (!transform.isAttachedTo(self)) { + transform.applyStateRaw(state); + return; + } + const index = for (self._items.items, 0..) |candidate, i| { + if (candidate == transform) break i; + } else unreachable; + try self.setAttributeWithOverride(index, state, frame); + transform.applyStateRaw(state); +} + +fn sync(self: *TransformList, frame: *Frame) !void { + self.releaseRetired(frame._page); + + const raw = self._element.getAttributeSafe(comptime .wrap("transform")) orelse ""; + if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) return; + + self._synced = false; + var parsed = parse(raw, frame) catch |err| switch (err) { + error.SyntaxError => std.ArrayList(*Transform).empty, + else => return err, + }; + errdefer for (parsed.items) |transform| transform.releaseRef(frame._page); + + self._snapshot.clearRetainingCapacity(); + try self._snapshot.appendSlice(frame.arena, raw); + try self.retireAll(frame); + try self._items.ensureTotalCapacity(frame.arena, parsed.items.len); + for (parsed.items) |transform| { + self._items.appendAssumeCapacity(transform); + self.attach(transform); + } + parsed.clearRetainingCapacity(); + self._synced = true; +} + +// A retired item must outlive the operation that retired it: removeItem's +// return value has no JS wrapper until the bridge wraps it after we return. +// By the next operation, anything still reachable holds its own ref. +fn releaseRetired(self: *TransformList, page: *Page) void { + for (self._retired.items) |transform| { + transform.releaseRef(page); + } + self._retired.clearRetainingCapacity(); +} + +fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*Transform) { + var parsed: std.ArrayList(*Transform) = .empty; + errdefer for (parsed.items) |transform| transform.releaseRef(frame._page); + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return parsed; + + var iterator = DOMMatrixReadOnly.TransformFunctionIterator{ .input = trimmed, .allow_comma = true }; + while (try iterator.next()) |function| { + const value = try DOMMatrixReadOnly.parseTransformFunction(function, .svg); + const transform = try Transform.fromParsed(value, frame); + transform.acquireRef(); + parsed.append(frame.local_arena, transform) catch |err| { + transform.releaseRef(frame._page); + return err; + }; + } + return parsed; +} + +fn retireAll(self: *TransformList, frame: *Frame) !void { + self._synced = false; + try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len); + for (self._items.items) |transform| { + transform.detach(self); + self._retired.appendAssumeCapacity(transform); + } + self._items.clearRetainingCapacity(); +} + +fn setAttribute(self: *TransformList, items: []const *Transform, frame: *Frame) !void { + var serialized: std.Io.Writer.Allocating = .init(frame.local_arena); + const writer = &serialized.writer; + for (items, 0..) |transform, i| { + if (i != 0) try writer.writeByte(' '); + try Transform.writeState(transform.getState(), writer); + } + try self.commitAttribute(serialized.written(), frame); +} + +fn setAttributeWithOverride(self: *TransformList, index: usize, state: Transform.State, frame: *Frame) !void { + var serialized: std.Io.Writer.Allocating = .init(frame.local_arena); + const writer = &serialized.writer; + for (self._items.items, 0..) |transform, i| { + if (i != 0) try writer.writeByte(' '); + try Transform.writeState(if (i == index) state else transform.getState(), writer); + } + try self.commitAttribute(serialized.written(), frame); +} + +fn commitAttribute(self: *TransformList, serialized: []const u8, frame: *Frame) !void { + self._synced = false; + try self._element.setAttributeSafe(comptime .wrap("transform"), .wrap(serialized), frame); + self._snapshot.clearRetainingCapacity(); + try self._snapshot.appendSlice(frame.arena, serialized); + self._synced = true; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TransformList); + + pub const Meta = struct { + pub const name = "SVGTransformList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const length = bridge.accessor(TransformList.getLength, null, .{}); + pub const numberOfItems = bridge.accessor(TransformList.getNumberOfItems, null, .{}); + pub const clear = bridge.function(TransformList.clear, .{}); + pub const initialize = bridge.function(TransformList.initialize, .{}); + pub const getItem = bridge.function(TransformList.getItem, .{}); + pub const insertItemBefore = bridge.function(TransformList.insertItemBefore, .{}); + pub const replaceItem = bridge.function(TransformList.replaceItem, .{}); + pub const removeItem = bridge.function(TransformList.removeItem, .{}); + pub const appendItem = bridge.function(TransformList.appendItem, .{}); + pub const consolidate = bridge.function(TransformList.consolidate, .{}); +};