diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index 55ccb10f0..a1072e995 100644
--- a/src/browser/Frame.zig
+++ b/src/browser/Frame.zig
@@ -65,6 +65,7 @@ const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const PointList = @import("webapi/svg/PointList.zig");
const StringList = @import("webapi/svg/StringList.zig");
const AnimatedLength = @import("webapi/svg/AnimatedLength.zig");
+const AnimatedNumber = @import("webapi/svg/AnimatedNumber.zig");
const AnimatedString = @import("webapi/svg/AnimatedString.zig");
const AnimatedTransformList = @import("webapi/svg/AnimatedTransformList.zig");
const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig");
@@ -150,6 +151,7 @@ _node_owner_documents: Node.OwnerDocumentLookup = .empty,
_element_scroll_positions: Element.ScrollPositionLookup = .empty,
_element_namespace_uris: Element.NamespaceUriLookup = .empty,
_svg_animated_lengths: AnimatedLength.Lookup = .empty,
+_svg_animated_numbers: AnimatedNumber.Lookup = .empty,
_svg_animated_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty,
_svg_animated_strings: AnimatedString.Lookup = .empty,
_svg_animated_transform_lists: AnimatedTransformList.Lookup = .empty,
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 021bdb9e3..b0c13794e 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -1074,6 +1074,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/svg/Angle.zig"),
@import("../webapi/svg/Transform.zig"),
@import("../webapi/svg/AnimatedLength.zig"),
+ @import("../webapi/svg/AnimatedNumber.zig"),
@import("../webapi/svg/PreserveAspectRatio.zig"),
@import("../webapi/svg/AnimatedPreserveAspectRatio.zig"),
@import("../webapi/svg/PointList.zig"),
diff --git a/src/browser/tests/element/svg/geometry.html b/src/browser/tests/element/svg/geometry.html
new file mode 100644
index 000000000..0e143b375
--- /dev/null
+++ b/src/browser/tests/element/svg/geometry.html
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/svg/hierarchy.html b/src/browser/tests/element/svg/hierarchy.html
index dac451853..441b94b4b 100644
--- a/src/browser/tests/element/svg/hierarchy.html
+++ b/src/browser/tests/element/svg/hierarchy.html
@@ -38,6 +38,7 @@
testing.expectEqual('function', typeof SVGPolylineElement);
testing.expectEqual('function', typeof SVGAnimatedString);
testing.expectEqual('function', typeof SVGNumber);
+ testing.expectEqual('function', typeof SVGAnimatedNumber);
}
diff --git a/src/browser/webapi/element/svg/Circle.zig b/src/browser/webapi/element/svg/Circle.zig
index 067f23904..089e44b06 100644
--- a/src/browser/webapi/element/svg/Circle.zig
+++ b/src/browser/webapi/element/svg/Circle.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 AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Circle = @This();
_proto: *Geometry,
@@ -41,4 +43,18 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
+
+ pub const cx = bridge.accessor(Circle.getCx, null, .{});
+ pub const cy = bridge.accessor(Circle.getCy, null, .{});
+ pub const r = bridge.accessor(Circle.getR, null, .{});
};
+
+pub fn getCx(self: *Circle, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .cx, frame);
+}
+pub fn getCy(self: *Circle, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .cy, frame);
+}
+pub fn getR(self: *Circle, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .r, frame);
+}
diff --git a/src/browser/webapi/element/svg/Ellipse.zig b/src/browser/webapi/element/svg/Ellipse.zig
index 00a1311b5..c42a46a7c 100644
--- a/src/browser/webapi/element/svg/Ellipse.zig
+++ b/src/browser/webapi/element/svg/Ellipse.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 AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Ellipse = @This();
_proto: *Geometry,
@@ -41,4 +43,22 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
+
+ pub const cx = bridge.accessor(Ellipse.getCx, null, .{});
+ pub const cy = bridge.accessor(Ellipse.getCy, null, .{});
+ pub const rx = bridge.accessor(Ellipse.getRx, null, .{});
+ pub const ry = bridge.accessor(Ellipse.getRy, null, .{});
};
+
+pub fn getCx(self: *Ellipse, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .cx, frame);
+}
+pub fn getCy(self: *Ellipse, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .cy, frame);
+}
+pub fn getRx(self: *Ellipse, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .rx, frame);
+}
+pub fn getRy(self: *Ellipse, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .ry, frame);
+}
diff --git a/src/browser/webapi/element/svg/Geometry.zig b/src/browser/webapi/element/svg/Geometry.zig
index 280feb818..e1b4df58a 100644
--- a/src/browser/webapi/element/svg/Geometry.zig
+++ b/src/browser/webapi/element/svg/Geometry.zig
@@ -16,12 +16,19 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
+
const js = @import("../../../js/js.zig");
+const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
+const DOMPoint = @import("../../DOMPoint.zig");
const Graphics = @import("Graphics.zig");
+const AnimatedNumber = @import("../../svg/AnimatedNumber.zig");
+const AnimatedLength = @import("../../svg/AnimatedLength.zig");
+const PathData = @import("../../svg/PathData.zig");
pub const Rect = @import("Rect.zig");
pub const Circle = @import("Circle.zig");
pub const Ellipse = @import("Ellipse.zig");
@@ -70,4 +77,174 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
+
+ pub const pathLength = bridge.accessor(Geometry.getPathLength, null, .{});
+ pub const getTotalLength = bridge.function(Geometry.getTotalLength, .{});
+ pub const getPointAtLength = bridge.function(Geometry.getPointAtLength, .{});
};
+
+pub fn getPathLength(self: *Geometry, frame: *Frame) !*AnimatedNumber {
+ return AnimatedNumber.getOrCreate(self.asElement(), .path_length, frame);
+}
+
+pub fn getTotalLength(self: *Geometry, frame: *Frame) !f64 {
+ var path = try self.buildPath(frame);
+ defer path.deinit(frame.local_arena);
+ return path.totalLength(frame.local_arena);
+}
+
+pub fn getPointAtLength(self: *Geometry, distance: f64, frame: *Frame) !*DOMPoint {
+ if (!std.math.isFinite(distance)) return error.TypeError;
+ var path = try self.buildPath(frame);
+ defer path.deinit(frame.local_arena);
+ const point = try path.pointAtLength(distance, frame.local_arena);
+ return DOMPoint.create(point.x, point.y, 0, 1, frame._page);
+}
+
+pub fn buildPath(self: *Geometry, frame: *Frame) !PathData.Path {
+ return switch (self._type) {
+ .rect => |rect| buildRect(rect, frame),
+ .circle => |circle| buildCircle(circle, frame),
+ .ellipse => |ellipse| buildEllipse(ellipse, frame),
+ .line => |line| buildLine(line, frame),
+ .path => |path| PathData.parse(
+ path.asElement().getAttributeSafe(comptime .wrap("d")) orelse "",
+ frame.local_arena,
+ ),
+ .polygon => |polygon| buildPoints(try polygon.getPoints(frame), true, frame),
+ .polyline => |polyline| buildPoints(try polyline.getPoints(frame), false, frame),
+ };
+}
+
+fn value(length: *AnimatedLength, frame: *Frame) ?f64 {
+ const base = length.getBaseVal();
+ if (base.getUnitType() == 0) return null;
+ const result = base.getValue(frame);
+ return if (std.math.isFinite(result)) result else null;
+}
+
+fn buildRect(rect: *Rect, frame: *Frame) !PathData.Path {
+ var path: PathData.Path = .{};
+ errdefer path.deinit(frame.local_arena);
+
+ const x = value(try rect.getX(frame), frame) orelse 0;
+ const y = value(try rect.getY(frame), frame) orelse 0;
+ const width = value(try rect.getWidth(frame), frame) orelse 0;
+ const height = value(try rect.getHeight(frame), frame) orelse 0;
+ if (width <= 0 or height <= 0) return path;
+
+ const rx_value = optionalRadius(rect.asElement(), "rx", try rect.getRx(frame), frame);
+ const ry_value = optionalRadius(rect.asElement(), "ry", try rect.getRy(frame), frame);
+ var rx = rx_value orelse ry_value orelse 0;
+ var ry = ry_value orelse rx_value orelse 0;
+ rx = @min(rx, width / 2.0);
+ ry = @min(ry, height / 2.0);
+
+ const top_left = PathData.Point{ .x = x, .y = y };
+ if (rx == 0 or ry == 0) {
+ const top_right = PathData.Point{ .x = x + width, .y = y };
+ const bottom_right = PathData.Point{ .x = x + width, .y = y + height };
+ const bottom_left = PathData.Point{ .x = x, .y = y + height };
+ try path.appendLine(top_left, top_right, frame.local_arena);
+ try path.appendLine(top_right, bottom_right, frame.local_arena);
+ try path.appendLine(bottom_right, bottom_left, frame.local_arena);
+ try path.appendLine(bottom_left, top_left, frame.local_arena);
+ return path;
+ }
+
+ const start = PathData.Point{ .x = x + rx, .y = y };
+ const top_end = PathData.Point{ .x = x + width - rx, .y = y };
+ const right_start = PathData.Point{ .x = x + width, .y = y + ry };
+ const right_end = PathData.Point{ .x = x + width, .y = y + height - ry };
+ const bottom_start = PathData.Point{ .x = x + width - rx, .y = y + height };
+ const bottom_end = PathData.Point{ .x = x + rx, .y = y + height };
+ const left_start = PathData.Point{ .x = x, .y = y + height - ry };
+ const left_end = PathData.Point{ .x = x, .y = y + ry };
+
+ try path.appendLine(start, top_end, frame.local_arena);
+ try path.appendArc(top_end, rx, ry, 0, false, true, right_start, frame.local_arena);
+ try path.appendLine(right_start, right_end, frame.local_arena);
+ try path.appendArc(right_end, rx, ry, 0, false, true, bottom_start, frame.local_arena);
+ try path.appendLine(bottom_start, bottom_end, frame.local_arena);
+ try path.appendArc(bottom_end, rx, ry, 0, false, true, left_start, frame.local_arena);
+ try path.appendLine(left_start, left_end, frame.local_arena);
+ try path.appendArc(left_end, rx, ry, 0, false, true, start, frame.local_arena);
+ return path;
+}
+
+fn optionalRadius(element: *Element, comptime name: []const u8, length: *AnimatedLength, frame: *Frame) ?f64 {
+ const raw = element.getAttributeSafe(comptime .wrap(name)) orelse return null;
+ if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, raw, " \t\r\n\x0c"), "auto")) return null;
+ const result = value(length, frame) orelse return null;
+ return if (result < 0) null else result;
+}
+
+fn buildCircle(circle: *Circle, frame: *Frame) !PathData.Path {
+ const center = PathData.Point{
+ .x = value(try circle.getCx(frame), frame) orelse 0,
+ .y = value(try circle.getCy(frame), frame) orelse 0,
+ };
+ const radius = value(try circle.getR(frame), frame) orelse 0;
+ return buildEllipsePath(center, radius, radius, frame);
+}
+
+fn buildEllipse(ellipse: *Ellipse, frame: *Frame) !PathData.Path {
+ const center = PathData.Point{
+ .x = value(try ellipse.getCx(frame), frame) orelse 0,
+ .y = value(try ellipse.getCy(frame), frame) orelse 0,
+ };
+ const rx = optionalRadius(ellipse.asElement(), "rx", try ellipse.getRx(frame), frame);
+ const ry = optionalRadius(ellipse.asElement(), "ry", try ellipse.getRy(frame), frame);
+ return buildEllipsePath(center, rx orelse ry orelse 0, ry orelse rx orelse 0, frame);
+}
+
+fn buildEllipsePath(center: PathData.Point, rx: f64, ry: f64, frame: *Frame) !PathData.Path {
+ var path: PathData.Path = .{};
+ errdefer path.deinit(frame.local_arena);
+ if (rx <= 0 or ry <= 0) return path;
+
+ const right = PathData.Point{ .x = center.x + rx, .y = center.y };
+ const bottom = PathData.Point{ .x = center.x, .y = center.y + ry };
+ const left = PathData.Point{ .x = center.x - rx, .y = center.y };
+ const top = PathData.Point{ .x = center.x, .y = center.y - ry };
+ try path.appendArc(right, rx, ry, 0, false, true, bottom, frame.local_arena);
+ try path.appendArc(bottom, rx, ry, 0, false, true, left, frame.local_arena);
+ try path.appendArc(left, rx, ry, 0, false, true, top, frame.local_arena);
+ try path.appendArc(top, rx, ry, 0, false, true, right, frame.local_arena);
+ return path;
+}
+
+fn buildLine(line: *Line, frame: *Frame) !PathData.Path {
+ var path: PathData.Path = .{};
+ errdefer path.deinit(frame.local_arena);
+ const start = PathData.Point{
+ .x = value(try line.getX1(frame), frame) orelse 0,
+ .y = value(try line.getY1(frame), frame) orelse 0,
+ };
+ const end = PathData.Point{
+ .x = value(try line.getX2(frame), frame) orelse 0,
+ .y = value(try line.getY2(frame), frame) orelse 0,
+ };
+ try path.appendLine(start, end, frame.local_arena);
+ return path;
+}
+
+fn buildPoints(list: anytype, close: bool, frame: *Frame) !PathData.Path {
+ var path: PathData.Path = .{};
+ errdefer path.deinit(frame.local_arena);
+ const count = try list.getNumberOfItems(frame);
+ if (count == 0) return path;
+
+ const first_item = try list.getItem(0, frame);
+ const first = PathData.Point{ .x = first_item.getX(), .y = first_item.getY() };
+ path.first_point = first;
+ var previous = first;
+ for (1..count) |index| {
+ const item = try list.getItem(@intCast(index), frame);
+ const next = PathData.Point{ .x = item.getX(), .y = item.getY() };
+ try path.appendLine(previous, next, frame.local_arena);
+ previous = next;
+ }
+ if (close and count > 1) try path.appendLine(previous, first, frame.local_arena);
+ return path;
+}
diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig
index 93d10c762..70a6e7b27 100644
--- a/src/browser/webapi/element/svg/Graphics.zig
+++ b/src/browser/webapi/element/svg/Graphics.zig
@@ -16,12 +16,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
+
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
+const DOMRect = @import("../../DOMRect.zig");
+const DOMMatrixReadOnly = @import("../../DOMMatrixReadOnly.zig");
const SvgElement = @import("../Svg.zig");
const AnimatedTransformList = @import("../../svg/AnimatedTransformList.zig");
+const PathData = @import("../../svg/PathData.zig");
const StringList = @import("../../svg/StringList.zig");
pub const Svg = @import("Svg.zig");
@@ -79,8 +84,83 @@ pub const JsApi = struct {
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 const getBBox = bridge.function(Graphics.getBBox, .{});
};
+const BoundingBoxOptions = struct {
+ fill: bool = true,
+ stroke: bool = false,
+ markers: bool = false,
+ clipped: bool = false,
+};
+
+pub fn getBBox(self: *Graphics, options_: ?BoundingBoxOptions, frame: *Frame) !*DOMRect {
+ const options = options_ orelse BoundingBoxOptions{};
+ if (options.stroke or options.markers or options.clipped) return error.NotSupported;
+ if (!options.fill) return DOMRect.create(.{}, frame._factory);
+
+ var bounds: PathData.Bounds = .{};
+ switch (self._type) {
+ .geometry => |geometry| {
+ var path = try geometry.buildPath(frame);
+ defer path.deinit(frame.local_arena);
+ bounds = path.bounds(.{});
+ },
+ .g, .a, .svg => try accumulateChildren(self, .{}, &bounds, frame),
+ .defs, .use, .image => return error.InvalidStateError,
+ }
+ if (bounds.isEmpty()) return DOMRect.create(.{}, frame._factory);
+ return DOMRect.create(.{
+ .x = bounds.min_x,
+ .y = bounds.min_y,
+ .width = bounds.width(),
+ .height = bounds.height(),
+ }, frame._factory);
+}
+
+fn accumulateChildren(parent: *Graphics, matrix: PathData.Matrix, bounds: *PathData.Bounds, frame: *Frame) !void {
+ var child = parent.asNode().firstChild();
+ while (child) |node| : (child = node.nextSibling()) {
+ const element = node.is(Element) orelse continue;
+ if (element._namespace != .svg) continue;
+ const svg = element.as(SvgElement);
+ const graphics = svg.is(Graphics) orelse continue;
+ const child_matrix = matrix.multiply(transformMatrix(element));
+
+ switch (graphics._type) {
+ .geometry => |geometry| {
+ var path = try geometry.buildPath(frame);
+ defer path.deinit(frame.local_arena);
+ bounds.merge(path.bounds(child_matrix));
+ },
+ .g, .a => try accumulateChildren(graphics, child_matrix, bounds, frame),
+ .defs => {},
+ .svg, .use, .image => return error.InvalidStateError,
+ }
+ }
+}
+
+fn transformMatrix(element: *Element) PathData.Matrix {
+ const raw = element.getAttributeSafe(comptime .wrap("transform")) orelse return .{};
+ const trimmed = std.mem.trim(u8, raw, " \t\r\n");
+ if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return .{};
+
+ var matrix = DOMMatrixReadOnly.identity();
+ var iterator = DOMMatrixReadOnly.TransformFunctionIterator{ .input = trimmed, .allow_comma = true };
+ while (iterator.next() catch return .{}) |function| {
+ const parsed = DOMMatrixReadOnly.parseTransformFunction(function, .svg) catch return .{};
+ matrix = DOMMatrixReadOnly.multiplyMatrix(matrix, parsed.matrix);
+ }
+ return .{
+ .a = matrix[0],
+ .b = matrix[1],
+ .c = matrix[4],
+ .d = matrix[5],
+ .e = matrix[12],
+ .f = matrix[13],
+ };
+}
+
pub fn getTransform(self: *Graphics, frame: *Frame) !*AnimatedTransformList {
return AnimatedTransformList.getOrCreate(self.asElement(), frame);
}
diff --git a/src/browser/webapi/element/svg/Line.zig b/src/browser/webapi/element/svg/Line.zig
index d40e1e305..139b421f1 100644
--- a/src/browser/webapi/element/svg/Line.zig
+++ b/src/browser/webapi/element/svg/Line.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 AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Line = @This();
_proto: *Geometry,
@@ -41,4 +43,22 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
+
+ pub const x1 = bridge.accessor(Line.getX1, null, .{});
+ pub const y1 = bridge.accessor(Line.getY1, null, .{});
+ pub const x2 = bridge.accessor(Line.getX2, null, .{});
+ pub const y2 = bridge.accessor(Line.getY2, null, .{});
};
+
+pub fn getX1(self: *Line, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .x1, frame);
+}
+pub fn getY1(self: *Line, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .y1, frame);
+}
+pub fn getX2(self: *Line, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .x2, frame);
+}
+pub fn getY2(self: *Line, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .y2, frame);
+}
diff --git a/src/browser/webapi/element/svg/Rect.zig b/src/browser/webapi/element/svg/Rect.zig
index c6dd4f40c..d841e39a0 100644
--- a/src/browser/webapi/element/svg/Rect.zig
+++ b/src/browser/webapi/element/svg/Rect.zig
@@ -17,9 +17,11 @@
// 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 AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Rect = @This();
_proto: *Geometry,
@@ -39,4 +41,30 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
+
+ pub const x = bridge.accessor(Rect.getX, null, .{});
+ pub const y = bridge.accessor(Rect.getY, null, .{});
+ pub const width = bridge.accessor(Rect.getWidth, null, .{});
+ pub const height = bridge.accessor(Rect.getHeight, null, .{});
+ pub const rx = bridge.accessor(Rect.getRx, null, .{});
+ pub const ry = bridge.accessor(Rect.getRy, null, .{});
};
+
+pub fn getX(self: *Rect, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .x, frame);
+}
+pub fn getY(self: *Rect, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .y, frame);
+}
+pub fn getWidth(self: *Rect, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .width, frame);
+}
+pub fn getHeight(self: *Rect, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .height, frame);
+}
+pub fn getRx(self: *Rect, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .rx, frame);
+}
+pub fn getRy(self: *Rect, frame: *Frame) !*AnimatedLength {
+ return AnimatedLength.getOrCreate(self.asElement(), .ry, frame);
+}
diff --git a/src/browser/webapi/element/svg/Svg.zig b/src/browser/webapi/element/svg/Svg.zig
index 42419255d..ab60357f9 100644
--- a/src/browser/webapi/element/svg/Svg.zig
+++ b/src/browser/webapi/element/svg/Svg.zig
@@ -102,7 +102,7 @@ pub fn createSVGRect(_: *Svg, frame: *Frame) !*DOMRect {
}
pub fn createSVGNumber(_: *Svg, frame: *Frame) !*SvgNumber {
- return frame._factory.create(SvgNumber{});
+ return SvgNumber.detached(frame);
}
pub fn createSVGLength(_: *Svg, frame: *Frame) !*SvgLength {
diff --git a/src/browser/webapi/svg/AnimatedLength.zig b/src/browser/webapi/svg/AnimatedLength.zig
index 299b344ea..47fb4927a 100644
--- a/src/browser/webapi/svg/AnimatedLength.zig
+++ b/src/browser/webapi/svg/AnimatedLength.zig
@@ -34,6 +34,15 @@ pub const Kind = enum {
y,
width,
height,
+ cx,
+ cy,
+ r,
+ rx,
+ ry,
+ x1,
+ y1,
+ x2,
+ y2,
fn attributeName(self: Kind) lp.String {
return switch (self) {
@@ -41,13 +50,23 @@ pub const Kind = enum {
.y => comptime .wrap("y"),
.width => comptime .wrap("width"),
.height => comptime .wrap("height"),
+ .cx => comptime .wrap("cx"),
+ .cy => comptime .wrap("cy"),
+ .r => comptime .wrap("r"),
+ .rx => comptime .wrap("rx"),
+ .ry => comptime .wrap("ry"),
+ .x1 => comptime .wrap("x1"),
+ .y1 => comptime .wrap("y1"),
+ .x2 => comptime .wrap("x2"),
+ .y2 => comptime .wrap("y2"),
};
}
fn direction(self: Kind) Length.Direction {
return switch (self) {
- .x, .width => .horizontal,
- .y, .height => .vertical,
+ .x, .width, .cx, .rx, .x1, .x2 => .horizontal,
+ .y, .height, .cy, .ry, .y1, .y2 => .vertical,
+ .r => .unspecified,
};
}
};
diff --git a/src/browser/webapi/svg/AnimatedNumber.zig b/src/browser/webapi/svg/AnimatedNumber.zig
new file mode 100644
index 000000000..7973f50fe
--- /dev/null
+++ b/src/browser/webapi/svg/AnimatedNumber.zig
@@ -0,0 +1,96 @@
+// 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 AnimatedNumber = @This();
+
+_element: *Element,
+_attr_name: lp.String,
+_allow_percentage: bool,
+
+pub const Kind = enum {
+ path_length,
+ offset,
+
+ fn attributeName(self: Kind) lp.String {
+ return switch (self) {
+ .path_length => comptime .wrap("pathLength"),
+ .offset => comptime .wrap("offset"),
+ };
+ }
+
+ fn allowsPercentage(self: Kind) bool {
+ return switch (self) {
+ .path_length => false,
+ .offset => true,
+ };
+ }
+};
+
+pub const Key = struct {
+ element: *Element,
+ kind: Kind,
+};
+
+pub const Lookup = std.AutoHashMapUnmanaged(Key, *AnimatedNumber);
+
+pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*AnimatedNumber {
+ const key: Key = .{ .element = element, .kind = kind };
+ const gop = try frame._svg_animated_numbers.getOrPut(frame.arena, key);
+ if (!gop.found_existing) {
+ errdefer _ = frame._svg_animated_numbers.remove(key);
+ gop.value_ptr.* = try frame._factory.create(AnimatedNumber{
+ ._element = element,
+ ._attr_name = kind.attributeName(),
+ ._allow_percentage = kind.allowsPercentage(),
+ });
+ }
+ return gop.value_ptr.*;
+}
+
+pub fn getBaseVal(self: *const AnimatedNumber) f32 {
+ return self.currentValue();
+}
+
+pub fn setBaseVal(self: *AnimatedNumber, value: f32, frame: *Frame) !void {
+ if (!std.math.isFinite(value)) return error.TypeError;
+ const serialized = try std.fmt.allocPrint(frame.call_arena, "{d}", .{value});
+ try self._element.setAttributeSafe(self._attr_name, lp.String.wrap(serialized), frame);
+}
+
+pub fn getAnimVal(self: *const AnimatedNumber) f32 {
+ return self.currentValue();
+}
+
+fn currentValue(self: *const AnimatedNumber) f32 {
+ const raw = self._element.getAttributeSafe(self._attr_name) orelse return 0;
+ const trimmed = std.mem.trim(u8, raw, " \t\r\n\x0c");
+ const value = if (self._allow_percentage and std.mem.endsWith(u8, trimmed, "%"))
+ (std.fmt.parseFloat(f32, trimmed[0 .. trimmed.len - 1]) catch return 0) / 100
+ else
+ std.fmt.parseFloat(f32, trimmed) catch return 0;
+ return if (std.math.isFinite(value)) value else 0;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(AnimatedNumber);
+
+ pub const Meta = struct {
+ pub const name = "SVGAnimatedNumber";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const baseVal = bridge.accessor(AnimatedNumber.getBaseVal, AnimatedNumber.setBaseVal, .{});
+ pub const animVal = bridge.accessor(AnimatedNumber.getAnimVal, null, .{});
+};
diff --git a/src/browser/webapi/svg/Number.zig b/src/browser/webapi/svg/Number.zig
index d1a29feca..c1c547d26 100644
--- a/src/browser/webapi/svg/Number.zig
+++ b/src/browser/webapi/svg/Number.zig
@@ -16,17 +16,54 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+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 Number = @This();
_value: f32 = 0,
+_element: ?*Element = null,
+_attr_name: lp.String = .empty,
+_read_only: bool = false,
-pub fn getValue(self: *const Number) f32 {
+pub fn detached(frame: *Frame) !*Number {
+ return frame._factory.create(Number{});
+}
+
+pub fn reflected(element: *Element, attr_name: lp.String, read_only: bool, frame: *Frame) !*Number {
+ return frame._factory.create(Number{
+ ._element = element,
+ ._attr_name = attr_name,
+ ._read_only = read_only,
+ });
+}
+
+pub fn getValue(self: *Number) f32 {
+ self.syncFromAttribute();
return self._value;
}
-pub fn setValue(self: *Number, value: f32) void {
+pub fn setValue(self: *Number, value: f32, frame: *Frame) !void {
+ if (self._read_only) return error.NoModificationAllowed;
+ if (!std.math.isFinite(value)) return error.TypeError;
self._value = value;
+ const element = self._element orelse return;
+ const serialized = try std.fmt.allocPrint(frame.call_arena, "{d}", .{value});
+ try element.setAttributeSafe(self._attr_name, lp.String.wrap(serialized), frame);
+}
+
+fn syncFromAttribute(self: *Number) void {
+ const element = self._element orelse return;
+ const raw = element.getAttributeSafe(self._attr_name) orelse {
+ self._value = 0;
+ return;
+ };
+ const trimmed = std.mem.trim(u8, raw, " \t\r\n\x0c");
+ self._value = std.fmt.parseFloat(f32, trimmed) catch 0;
+ if (!std.math.isFinite(self._value)) self._value = 0;
}
pub const JsApi = struct {
diff --git a/src/browser/webapi/svg/PathData.zig b/src/browser/webapi/svg/PathData.zig
new file mode 100644
index 000000000..69d2b0f88
--- /dev/null
+++ b/src/browser/webapi/svg/PathData.zig
@@ -0,0 +1,733 @@
+// 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 Allocator = std.mem.Allocator;
+const tau = 2.0 * std.math.pi;
+
+pub const Point = struct {
+ x: f64,
+ y: f64,
+
+ fn add(a: Point, b: Point) Point {
+ return .{ .x = a.x + b.x, .y = a.y + b.y };
+ }
+
+ fn midpoint(a: Point, b: Point) Point {
+ return .{ .x = (a.x + b.x) / 2.0, .y = (a.y + b.y) / 2.0 };
+ }
+};
+
+pub const Matrix = struct {
+ a: f64 = 1,
+ b: f64 = 0,
+ c: f64 = 0,
+ d: f64 = 1,
+ e: f64 = 0,
+ f: f64 = 0,
+
+ pub fn apply(self: Matrix, point: Point) Point {
+ return .{
+ .x = self.a * point.x + self.c * point.y + self.e,
+ .y = self.b * point.x + self.d * point.y + self.f,
+ };
+ }
+
+ pub fn multiply(self: Matrix, child: Matrix) Matrix {
+ return .{
+ .a = self.a * child.a + self.c * child.b,
+ .b = self.b * child.a + self.d * child.b,
+ .c = self.a * child.c + self.c * child.d,
+ .d = self.b * child.c + self.d * child.d,
+ .e = self.a * child.e + self.c * child.f + self.e,
+ .f = self.b * child.e + self.d * child.f + self.f,
+ };
+ }
+};
+
+pub const Bounds = struct {
+ min_x: f64 = std.math.inf(f64),
+ min_y: f64 = std.math.inf(f64),
+ max_x: f64 = -std.math.inf(f64),
+ max_y: f64 = -std.math.inf(f64),
+
+ pub fn include(self: *Bounds, point: Point) void {
+ self.min_x = @min(self.min_x, point.x);
+ self.min_y = @min(self.min_y, point.y);
+ self.max_x = @max(self.max_x, point.x);
+ self.max_y = @max(self.max_y, point.y);
+ }
+
+ pub fn merge(self: *Bounds, other: Bounds) void {
+ if (other.isEmpty()) return;
+ self.include(.{ .x = other.min_x, .y = other.min_y });
+ self.include(.{ .x = other.max_x, .y = other.max_y });
+ }
+
+ pub fn isEmpty(self: Bounds) bool {
+ return self.min_x > self.max_x or self.min_y > self.max_y;
+ }
+
+ pub fn width(self: Bounds) f64 {
+ return if (self.isEmpty()) 0 else self.max_x - self.min_x;
+ }
+
+ pub fn height(self: Bounds) f64 {
+ return if (self.isEmpty()) 0 else self.max_y - self.min_y;
+ }
+};
+
+pub const Line = struct { start: Point, end: Point };
+pub const Quadratic = struct { start: Point, control: Point, end: Point };
+pub const Cubic = struct { start: Point, control1: Point, control2: Point, end: Point };
+pub const Arc = struct {
+ start: Point,
+ end: Point,
+ center: Point,
+ rx: f64,
+ ry: f64,
+ rotation: f64,
+ theta: f64,
+ delta: f64,
+};
+
+pub const Segment = union(enum) {
+ line: Line,
+ quadratic: Quadratic,
+ cubic: Cubic,
+ arc: Arc,
+
+ fn start(self: Segment) Point {
+ return switch (self) {
+ inline else => |segment| segment.start,
+ };
+ }
+
+ fn end(self: Segment) Point {
+ return switch (self) {
+ inline else => |segment| segment.end,
+ };
+ }
+};
+
+pub const Path = struct {
+ segments: std.ArrayList(Segment) = .empty,
+ first_point: ?Point = null,
+
+ pub fn deinit(self: *Path, allocator: Allocator) void {
+ self.segments.deinit(allocator);
+ }
+
+ pub fn appendLine(self: *Path, start: Point, end: Point, allocator: Allocator) !void {
+ if (self.first_point == null) self.first_point = start;
+ try self.segments.append(allocator, .{ .line = .{ .start = start, .end = end } });
+ }
+
+ pub fn appendQuadratic(self: *Path, segment: Quadratic, allocator: Allocator) !void {
+ if (self.first_point == null) self.first_point = segment.start;
+ try self.segments.append(allocator, .{ .quadratic = segment });
+ }
+
+ pub fn appendCubic(self: *Path, segment: Cubic, allocator: Allocator) !void {
+ if (self.first_point == null) self.first_point = segment.start;
+ try self.segments.append(allocator, .{ .cubic = segment });
+ }
+
+ pub fn appendArc(self: *Path, start: Point, rx: f64, ry: f64, rotation: f64, large: bool, sweep: bool, end: Point, allocator: Allocator) !void {
+ if (self.first_point == null) self.first_point = start;
+ if (pointsEqual(start, end)) return;
+ if (rx == 0 or ry == 0) return self.appendLine(start, end, allocator);
+ const arc = endpointArc(start, rx, ry, rotation, large, sweep, end) orelse
+ return self.appendLine(start, end, allocator);
+ try self.segments.append(allocator, .{ .arc = arc });
+ }
+
+ pub fn bounds(self: *const Path, matrix: Matrix) Bounds {
+ var result: Bounds = .{};
+ for (self.segments.items) |segment| includeSegmentBounds(&result, segment, matrix);
+ return result;
+ }
+
+ pub fn totalLength(self: *const Path, allocator: Allocator) !f64 {
+ var total: f64 = 0;
+ for (self.segments.items) |segment| total += try segmentLength(segment, allocator);
+ return total;
+ }
+
+ pub fn pointAtLength(self: *const Path, requested: f64, allocator: Allocator) !Point {
+ if (self.segments.items.len == 0) return self.first_point orelse .{ .x = 0, .y = 0 };
+
+ var remaining = @max(requested, 0);
+ for (self.segments.items, 0..) |segment, segment_index| {
+ var points: std.ArrayList(Point) = .empty;
+ defer points.deinit(allocator);
+ try flatten(segment, &points, allocator);
+
+ for (points.items[0 .. points.items.len - 1], points.items[1..]) |a, b| {
+ const length = distance(a, b);
+ if (remaining <= length) {
+ if (length == 0) return b;
+ const ratio = remaining / length;
+ return .{
+ .x = a.x + (b.x - a.x) * ratio,
+ .y = a.y + (b.y - a.y) * ratio,
+ };
+ }
+ remaining -= length;
+ }
+
+ if (segment_index + 1 == self.segments.items.len) return segment.end();
+ }
+ unreachable;
+ }
+};
+
+const Previous = enum { other, cubic, quadratic };
+
+pub fn parse(input: []const u8, allocator: Allocator) !Path {
+ var path: Path = .{};
+ errdefer path.deinit(allocator);
+
+ var parser = Parser{ .input = input };
+ var command: ?u8 = null;
+ var command_fresh = false;
+ var current = Point{ .x = 0, .y = 0 };
+ var subpath_start = current;
+ var last_control = current;
+ var previous: Previous = .other;
+ var saw_moveto = false;
+
+ while (true) {
+ parser.skipWhitespace();
+ if (parser.atEnd()) break;
+
+ const byte = parser.peek();
+ if (isCommand(byte)) {
+ parser.index += 1;
+ command = byte;
+ command_fresh = true;
+
+ if (upper(byte) == 'Z') {
+ if (!saw_moveto) break;
+ try path.appendLine(current, subpath_start, allocator);
+ current = subpath_start;
+ previous = .other;
+ command = null;
+ continue;
+ }
+ } else if (std.ascii.isAlphabetic(byte) or command == null) {
+ break;
+ }
+
+ const cmd = command.?;
+ const kind = upper(cmd);
+ if (!saw_moveto and kind != 'M') break;
+ const relative = std.ascii.isLower(cmd);
+ const allow_first_comma = !command_fresh;
+
+ switch (kind) {
+ 'M', 'L' => {
+ const x = parser.number(allow_first_comma) orelse break;
+ const y = parser.number(true) orelse break;
+ const end = absolutePoint(current, x, y, relative);
+ if (kind == 'M') {
+ current = end;
+ subpath_start = end;
+ if (path.first_point == null) path.first_point = end;
+ saw_moveto = true;
+ command = if (relative) 'l' else 'L';
+ } else {
+ try path.appendLine(current, end, allocator);
+ current = end;
+ }
+ previous = .other;
+ },
+ 'H' => {
+ const x = parser.number(allow_first_comma) orelse break;
+ const end = Point{ .x = if (relative) current.x + x else x, .y = current.y };
+ try path.appendLine(current, end, allocator);
+ current = end;
+ previous = .other;
+ },
+ 'V' => {
+ const y = parser.number(allow_first_comma) orelse break;
+ const end = Point{ .x = current.x, .y = if (relative) current.y + y else y };
+ try path.appendLine(current, end, allocator);
+ current = end;
+ previous = .other;
+ },
+ 'C' => {
+ const x1 = parser.number(allow_first_comma) orelse break;
+ const y1 = parser.number(true) orelse break;
+ const x2 = parser.number(true) orelse break;
+ const y2 = parser.number(true) orelse break;
+ const x = parser.number(true) orelse break;
+ const y = parser.number(true) orelse break;
+ const control1 = absolutePoint(current, x1, y1, relative);
+ const control2 = absolutePoint(current, x2, y2, relative);
+ const end = absolutePoint(current, x, y, relative);
+ try path.appendCubic(.{ .start = current, .control1 = control1, .control2 = control2, .end = end }, allocator);
+ current = end;
+ last_control = control2;
+ previous = .cubic;
+ },
+ 'S' => {
+ const x2 = parser.number(allow_first_comma) orelse break;
+ const y2 = parser.number(true) orelse break;
+ const x = parser.number(true) orelse break;
+ const y = parser.number(true) orelse break;
+ const control1 = if (previous == .cubic) reflect(last_control, current) else current;
+ const control2 = absolutePoint(current, x2, y2, relative);
+ const end = absolutePoint(current, x, y, relative);
+ try path.appendCubic(.{ .start = current, .control1 = control1, .control2 = control2, .end = end }, allocator);
+ current = end;
+ last_control = control2;
+ previous = .cubic;
+ },
+ 'Q' => {
+ const x1 = parser.number(allow_first_comma) orelse break;
+ const y1 = parser.number(true) orelse break;
+ const x = parser.number(true) orelse break;
+ const y = parser.number(true) orelse break;
+ const control = absolutePoint(current, x1, y1, relative);
+ const end = absolutePoint(current, x, y, relative);
+ try path.appendQuadratic(.{ .start = current, .control = control, .end = end }, allocator);
+ current = end;
+ last_control = control;
+ previous = .quadratic;
+ },
+ 'T' => {
+ const x = parser.number(allow_first_comma) orelse break;
+ const y = parser.number(true) orelse break;
+ const control = if (previous == .quadratic) reflect(last_control, current) else current;
+ const end = absolutePoint(current, x, y, relative);
+ try path.appendQuadratic(.{ .start = current, .control = control, .end = end }, allocator);
+ current = end;
+ last_control = control;
+ previous = .quadratic;
+ },
+ 'A' => {
+ const rx = parser.number(allow_first_comma) orelse break;
+ const ry = parser.number(true) orelse break;
+ const rotation = parser.number(true) orelse break;
+ const large = parser.flag(true) orelse break;
+ const sweep = parser.flag(true) orelse break;
+ const x = parser.number(true) orelse break;
+ const y = parser.number(true) orelse break;
+ const end = absolutePoint(current, x, y, relative);
+ try path.appendArc(current, rx, ry, rotation, large, sweep, end, allocator);
+ current = end;
+ previous = .other;
+ },
+ else => break,
+ }
+ command_fresh = false;
+ }
+
+ return path;
+}
+
+const Parser = struct {
+ input: []const u8,
+ index: usize = 0,
+
+ fn atEnd(self: Parser) bool {
+ return self.index >= self.input.len;
+ }
+
+ fn peek(self: Parser) u8 {
+ return self.input[self.index];
+ }
+
+ fn skipWhitespace(self: *Parser) void {
+ while (!self.atEnd() and isWhitespace(self.peek())) self.index += 1;
+ }
+
+ fn separator(self: *Parser, allow_comma: bool) bool {
+ self.skipWhitespace();
+ if (!self.atEnd() and self.peek() == ',') {
+ if (!allow_comma) return false;
+ self.index += 1;
+ self.skipWhitespace();
+ if (self.atEnd() or self.peek() == ',') return false;
+ }
+ return true;
+ }
+
+ fn number(self: *Parser, allow_comma: bool) ?f64 {
+ if (!self.separator(allow_comma)) return null;
+ const start = self.index;
+ if (self.atEnd()) return null;
+
+ if (self.peek() == '+' or self.peek() == '-') self.index += 1;
+ var digits: usize = 0;
+ while (!self.atEnd() and std.ascii.isDigit(self.peek())) : (self.index += 1) digits += 1;
+ if (!self.atEnd() and self.peek() == '.') {
+ self.index += 1;
+ while (!self.atEnd() and std.ascii.isDigit(self.peek())) : (self.index += 1) digits += 1;
+ }
+ if (digits == 0) {
+ self.index = start;
+ return null;
+ }
+
+ if (!self.atEnd() and (self.peek() == 'e' or self.peek() == 'E')) {
+ self.index += 1;
+ if (!self.atEnd() and (self.peek() == '+' or self.peek() == '-')) self.index += 1;
+ const exponent_start = self.index;
+ while (!self.atEnd() and std.ascii.isDigit(self.peek())) self.index += 1;
+ if (self.index == exponent_start) {
+ self.index = start;
+ return null;
+ }
+ }
+
+ const value = std.fmt.parseFloat(f64, self.input[start..self.index]) catch {
+ self.index = start;
+ return null;
+ };
+ if (!std.math.isFinite(value)) {
+ self.index = start;
+ return null;
+ }
+ return value;
+ }
+
+ fn flag(self: *Parser, allow_comma: bool) ?bool {
+ if (!self.separator(allow_comma) or self.atEnd()) return null;
+ return switch (self.peek()) {
+ '0' => blk: {
+ self.index += 1;
+ break :blk false;
+ },
+ '1' => blk: {
+ self.index += 1;
+ break :blk true;
+ },
+ else => null,
+ };
+ }
+};
+
+fn isWhitespace(byte: u8) bool {
+ return byte == ' ' or byte == '\t' or byte == '\r' or byte == '\n' or byte == '\x0c';
+}
+
+fn isCommand(byte: u8) bool {
+ return switch (upper(byte)) {
+ 'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A' => true,
+ else => false,
+ };
+}
+
+fn upper(byte: u8) u8 {
+ return if (byte >= 'a' and byte <= 'z') byte - ('a' - 'A') else byte;
+}
+
+fn absolutePoint(origin: Point, x: f64, y: f64, relative: bool) Point {
+ return if (relative) Point.add(origin, .{ .x = x, .y = y }) else .{ .x = x, .y = y };
+}
+
+fn reflect(control: Point, around: Point) Point {
+ return .{ .x = 2.0 * around.x - control.x, .y = 2.0 * around.y - control.y };
+}
+
+fn pointsEqual(a: Point, b: Point) bool {
+ return a.x == b.x and a.y == b.y;
+}
+
+fn distance(a: Point, b: Point) f64 {
+ return std.math.hypot(b.x - a.x, b.y - a.y);
+}
+
+fn endpointArc(start: Point, rx_input: f64, ry_input: f64, rotation_degrees: f64, large: bool, sweep: bool, end: Point) ?Arc {
+ var rx = @abs(rx_input);
+ var ry = @abs(ry_input);
+ if (rx == 0 or ry == 0 or pointsEqual(start, end)) return null;
+
+ const rotation = @mod(rotation_degrees, 360.0) * std.math.pi / 180.0;
+ const cosine = @cos(rotation);
+ const sine = @sin(rotation);
+ const dx = (start.x - end.x) / 2.0;
+ const dy = (start.y - end.y) / 2.0;
+ const x_prime = cosine * dx + sine * dy;
+ const y_prime = -sine * dx + cosine * dy;
+
+ const radii_scale = x_prime * x_prime / (rx * rx) + y_prime * y_prime / (ry * ry);
+ if (radii_scale > 1) {
+ const scale = @sqrt(radii_scale);
+ rx *= scale;
+ ry *= scale;
+ }
+
+ const rx2 = rx * rx;
+ const ry2 = ry * ry;
+ const numerator = @max(0, rx2 * ry2 - rx2 * y_prime * y_prime - ry2 * x_prime * x_prime);
+ const denominator = rx2 * y_prime * y_prime + ry2 * x_prime * x_prime;
+ if (denominator == 0) return null;
+ var coefficient = @sqrt(numerator / denominator);
+ if (large == sweep) coefficient = -coefficient;
+
+ const center_prime = Point{
+ .x = coefficient * rx * y_prime / ry,
+ .y = -coefficient * ry * x_prime / rx,
+ };
+ const center = Point{
+ .x = cosine * center_prime.x - sine * center_prime.y + (start.x + end.x) / 2.0,
+ .y = sine * center_prime.x + cosine * center_prime.y + (start.y + end.y) / 2.0,
+ };
+
+ const first = Point{
+ .x = (x_prime - center_prime.x) / rx,
+ .y = (y_prime - center_prime.y) / ry,
+ };
+ const second = Point{
+ .x = (-x_prime - center_prime.x) / rx,
+ .y = (-y_prime - center_prime.y) / ry,
+ };
+ const theta = std.math.atan2(first.y, first.x);
+ var delta = std.math.atan2(first.x * second.y - first.y * second.x, first.x * second.x + first.y * second.y);
+ if (sweep and delta < 0) delta += tau;
+ if (!sweep and delta > 0) delta -= tau;
+
+ return .{
+ .start = start,
+ .end = end,
+ .center = center,
+ .rx = rx,
+ .ry = ry,
+ .rotation = rotation,
+ .theta = theta,
+ .delta = delta,
+ };
+}
+
+fn includeSegmentBounds(bounds: *Bounds, segment: Segment, matrix: Matrix) void {
+ bounds.include(matrix.apply(segment.start()));
+ bounds.include(matrix.apply(segment.end()));
+
+ switch (segment) {
+ .line => {},
+ .quadratic => |quadratic| {
+ includeQuadraticExtremum(bounds, quadratic.start.x, quadratic.control.x, quadratic.end.x, quadratic, matrix);
+ includeQuadraticExtremum(bounds, quadratic.start.y, quadratic.control.y, quadratic.end.y, quadratic, matrix);
+ includeTransformedQuadraticExtrema(bounds, quadratic, matrix);
+ },
+ .cubic => |cubic| includeTransformedCubicExtrema(bounds, cubic, matrix),
+ .arc => |arc| includeArcExtrema(bounds, arc, matrix),
+ }
+}
+
+// The direct-axis roots help the identity case and are harmless duplicates for
+// transformed paths. The transformed roots below are authoritative.
+fn includeQuadraticExtremum(bounds: *Bounds, p0: f64, p1: f64, p2: f64, quadratic: Quadratic, matrix: Matrix) void {
+ const denominator = p0 - 2.0 * p1 + p2;
+ if (@abs(denominator) < 1e-14) return;
+ const t = (p0 - p1) / denominator;
+ if (t > 0 and t < 1) bounds.include(matrix.apply(evalQuadratic(quadratic, t)));
+}
+
+fn includeTransformedQuadraticExtrema(bounds: *Bounds, quadratic: Quadratic, matrix: Matrix) void {
+ const p0 = matrix.apply(quadratic.start);
+ const p1 = matrix.apply(quadratic.control);
+ const p2 = matrix.apply(quadratic.end);
+ for ([_][3]f64{ .{ p0.x, p1.x, p2.x }, .{ p0.y, p1.y, p2.y } }) |values| {
+ const denominator = values[0] - 2.0 * values[1] + values[2];
+ if (@abs(denominator) < 1e-14) continue;
+ const t = (values[0] - values[1]) / denominator;
+ if (t > 0 and t < 1) bounds.include(matrix.apply(evalQuadratic(quadratic, t)));
+ }
+}
+
+fn includeTransformedCubicExtrema(bounds: *Bounds, cubic: Cubic, matrix: Matrix) void {
+ const p0 = matrix.apply(cubic.start);
+ const p1 = matrix.apply(cubic.control1);
+ const p2 = matrix.apply(cubic.control2);
+ const p3 = matrix.apply(cubic.end);
+ for ([_][4]f64{ .{ p0.x, p1.x, p2.x, p3.x }, .{ p0.y, p1.y, p2.y, p3.y } }) |values| {
+ const a = -values[0] + 3.0 * values[1] - 3.0 * values[2] + values[3];
+ const b = 2.0 * (values[0] - 2.0 * values[1] + values[2]);
+ const c = values[1] - values[0];
+ for (quadraticRoots(a, b, c)) |root| {
+ const t = root orelse continue;
+ if (t > 0 and t < 1) bounds.include(matrix.apply(evalCubic(cubic, t)));
+ }
+ }
+}
+
+fn includeArcExtrema(bounds: *Bounds, arc: Arc, matrix: Matrix) void {
+ const cosine = @cos(arc.rotation);
+ const sine = @sin(arc.rotation);
+
+ const x_cos = arc.rx * cosine;
+ const x_sin = -arc.ry * sine;
+ const y_cos = arc.rx * sine;
+ const y_sin = arc.ry * cosine;
+
+ const transformed = [_][2]f64{
+ .{ matrix.a * x_cos + matrix.c * y_cos, matrix.a * x_sin + matrix.c * y_sin },
+ .{ matrix.b * x_cos + matrix.d * y_cos, matrix.b * x_sin + matrix.d * y_sin },
+ };
+ for (transformed) |coefficients| {
+ const candidate = std.math.atan2(coefficients[1], coefficients[0]);
+ for ([_]f64{ candidate, candidate + std.math.pi }) |angle| {
+ if (angleInSweep(angle, arc.theta, arc.delta)) bounds.include(matrix.apply(evalArc(arc, angle)));
+ }
+ }
+}
+
+fn quadraticRoots(a: f64, b: f64, c: f64) [2]?f64 {
+ if (@abs(a) < 1e-14) {
+ if (@abs(b) < 1e-14) return .{ null, null };
+ return .{ -c / b, null };
+ }
+ const discriminant = b * b - 4.0 * a * c;
+ if (discriminant < 0) return .{ null, null };
+ const root = @sqrt(discriminant);
+ return .{ (-b + root) / (2.0 * a), (-b - root) / (2.0 * a) };
+}
+
+fn angleInSweep(angle: f64, start: f64, delta: f64) bool {
+ if (delta >= 0) return positiveAngle(angle - start) <= delta + 1e-12;
+ return positiveAngle(start - angle) <= -delta + 1e-12;
+}
+
+fn positiveAngle(angle: f64) f64 {
+ var result = @mod(angle, tau);
+ if (result < 0) result += tau;
+ return result;
+}
+
+fn evalQuadratic(segment: Quadratic, t: f64) Point {
+ const inverse = 1.0 - t;
+ return .{
+ .x = inverse * inverse * segment.start.x + 2.0 * inverse * t * segment.control.x + t * t * segment.end.x,
+ .y = inverse * inverse * segment.start.y + 2.0 * inverse * t * segment.control.y + t * t * segment.end.y,
+ };
+}
+
+fn evalCubic(segment: Cubic, t: f64) Point {
+ const inverse = 1.0 - t;
+ return .{
+ .x = inverse * inverse * inverse * segment.start.x + 3.0 * inverse * inverse * t * segment.control1.x + 3.0 * inverse * t * t * segment.control2.x + t * t * t * segment.end.x,
+ .y = inverse * inverse * inverse * segment.start.y + 3.0 * inverse * inverse * t * segment.control1.y + 3.0 * inverse * t * t * segment.control2.y + t * t * t * segment.end.y,
+ };
+}
+
+fn evalArc(arc: Arc, angle: f64) Point {
+ const cosine = @cos(arc.rotation);
+ const sine = @sin(arc.rotation);
+ const x = arc.rx * @cos(angle);
+ const y = arc.ry * @sin(angle);
+ return .{
+ .x = arc.center.x + cosine * x - sine * y,
+ .y = arc.center.y + sine * x + cosine * y,
+ };
+}
+
+fn segmentLength(segment: Segment, allocator: Allocator) !f64 {
+ var points: std.ArrayList(Point) = .empty;
+ defer points.deinit(allocator);
+ try flatten(segment, &points, allocator);
+
+ var total: f64 = 0;
+ for (points.items[0 .. points.items.len - 1], points.items[1..]) |a, b| total += distance(a, b);
+ return total;
+}
+
+const flatten_tolerance = 0.001;
+const flatten_depth = 18;
+
+fn flatten(segment: Segment, points: *std.ArrayList(Point), allocator: Allocator) !void {
+ try points.append(allocator, segment.start());
+ switch (segment) {
+ .line => |line| try points.append(allocator, line.end),
+ .quadratic => |quadratic| try flattenQuadratic(quadratic, points, allocator, 0),
+ .cubic => |cubic| try flattenCubic(cubic, points, allocator, 0),
+ .arc => |arc| try flattenArc(arc, arc.theta, arc.theta + arc.delta, arc.start, arc.end, points, allocator, 0),
+ }
+}
+
+fn flattenQuadratic(segment: Quadratic, points: *std.ArrayList(Point), allocator: Allocator, depth: usize) !void {
+ if (depth >= flatten_depth or pointLineDistance(segment.control, segment.start, segment.end) <= flatten_tolerance) {
+ return points.append(allocator, segment.end);
+ }
+ const a = Point.midpoint(segment.start, segment.control);
+ const b = Point.midpoint(segment.control, segment.end);
+ const middle = Point.midpoint(a, b);
+ try flattenQuadratic(.{ .start = segment.start, .control = a, .end = middle }, points, allocator, depth + 1);
+ try flattenQuadratic(.{ .start = middle, .control = b, .end = segment.end }, points, allocator, depth + 1);
+}
+
+fn flattenCubic(segment: Cubic, points: *std.ArrayList(Point), allocator: Allocator, depth: usize) !void {
+ const flatness = @max(
+ pointLineDistance(segment.control1, segment.start, segment.end),
+ pointLineDistance(segment.control2, segment.start, segment.end),
+ );
+ if (depth >= flatten_depth or flatness <= flatten_tolerance) return points.append(allocator, segment.end);
+
+ const a = Point.midpoint(segment.start, segment.control1);
+ const b = Point.midpoint(segment.control1, segment.control2);
+ const c = Point.midpoint(segment.control2, segment.end);
+ const d = Point.midpoint(a, b);
+ const e = Point.midpoint(b, c);
+ const middle = Point.midpoint(d, e);
+ try flattenCubic(.{ .start = segment.start, .control1 = a, .control2 = d, .end = middle }, points, allocator, depth + 1);
+ try flattenCubic(.{ .start = middle, .control1 = e, .control2 = c, .end = segment.end }, points, allocator, depth + 1);
+}
+
+fn flattenArc(arc: Arc, start_angle: f64, end_angle: f64, start: Point, end: Point, points: *std.ArrayList(Point), allocator: Allocator, depth: usize) !void {
+ const middle_angle = (start_angle + end_angle) / 2.0;
+ const middle = evalArc(arc, middle_angle);
+ if (depth >= flatten_depth or pointLineDistance(middle, start, end) <= flatten_tolerance) {
+ return points.append(allocator, end);
+ }
+ try flattenArc(arc, start_angle, middle_angle, start, middle, points, allocator, depth + 1);
+ try flattenArc(arc, middle_angle, end_angle, middle, end, points, allocator, depth + 1);
+}
+
+fn pointLineDistance(point: Point, start: Point, end: Point) f64 {
+ const dx = end.x - start.x;
+ const dy = end.y - start.y;
+ const denominator = std.math.hypot(dx, dy);
+ if (denominator == 0) return distance(point, start);
+ return @abs(dy * point.x - dx * point.y + end.x * start.y - end.y * start.x) / denominator;
+}
+
+test "path parser preserves complete packs before an error" {
+ var path = try parse("M10 10 L20 20 30", std.testing.allocator);
+ defer path.deinit(std.testing.allocator);
+ try std.testing.expectEqual(@as(usize, 1), path.segments.items.len);
+ const bounds = path.bounds(.{});
+ try std.testing.expectEqual(@as(f64, 10), bounds.min_x);
+ try std.testing.expectEqual(@as(f64, 20), bounds.max_x);
+}
+
+test "moveto coordinate pairs become lines and malformed exponents stop" {
+ var path = try parse("M0 0 10 10 20 1e L99 99", std.testing.allocator);
+ defer path.deinit(std.testing.allocator);
+ try std.testing.expectEqual(@as(usize, 1), path.segments.items.len);
+ try std.testing.expectEqual(Point{ .x = 10, .y = 10 }, path.segments.items[0].end());
+}
+
+test "rotated arc bounds use ellipse derivative extrema" {
+ var path = try parse("M0 0 A80 20 45 1 1 100 100", std.testing.allocator);
+ defer path.deinit(std.testing.allocator);
+ const bounds = path.bounds(.{});
+ try std.testing.expect(bounds.min_x < 0);
+ try std.testing.expect(bounds.max_y >= 100);
+}
+
+test "length and point lookup share adaptive geometry" {
+ var path = try parse("M0 0 C0 100 100 100 100 0", std.testing.allocator);
+ defer path.deinit(std.testing.allocator);
+ const length = try path.totalLength(std.testing.allocator);
+ const middle = try path.pointAtLength(length / 2.0, std.testing.allocator);
+ try std.testing.expectApproxEqAbs(@as(f64, 50), middle.x, 0.01);
+ try std.testing.expectApproxEqAbs(@as(f64, 75), middle.y, 0.01);
+}