webapi: implement SVG geometry

This commit is contained in:
Madison Steiner
2026-07-21 22:24:46 -07:00
committed by Karl Seguin
parent 8a256f59e0
commit 2e89b805fc
15 changed files with 1408 additions and 5 deletions

View File

@@ -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,

View File

@@ -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"),

View File

@@ -0,0 +1,173 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<svg id=root width="200" height="100">
<rect id=rect x="10" y="20" width="30" height="40" rx="20"></rect>
<circle id=circle cx="50" cy="30" r="10"></circle>
<ellipse id=ellipse cx="70" cy="40" rx="20" ry="5"></ellipse>
<line id=line x1="1" y1="2" x2="4" y2="6"></line>
<polygon id=polygon points="0,0 10,0 10,10"></polygon>
<polyline id=polyline points="-5,-2 3,7 12,1"></polyline>
<path id=cubic d="M0 0 C0 100 100 100 100 0"></path>
<path id=arc d="M0 0 A80 20 45 1 1 100 100"></path>
<path id=partial d="M10 10 L20 20 30"></path>
<path id=moves d="M0 0 10 10 20 0"></path>
<g id=transformed transform="translate(10 20)">
<rect x="0" y="0" width="10" height="5"></rect>
<g transform="scale(2)"><circle cx="10" cy="10" r="2"></circle></g>
</g>
</svg>
<script id=typedShapeProperties>
{
const rect = $('#rect');
testing.expectEqual(true, rect.x instanceof SVGAnimatedLength);
testing.expectEqual(true, rect.x === rect.x);
testing.expectEqual(true, rect.width.baseVal === rect.width.baseVal);
testing.expectEqual(false, rect.width.baseVal === rect.width.animVal);
testing.expectEqual(30, rect.width.baseVal.value);
rect.width.baseVal.value = 35;
testing.expectEqual('35', rect.getAttribute('width'));
testing.expectEqual(35, rect.width.animVal.value);
testing.expectError('NoModificationAllowedError', () => { rect.width.animVal.value = 5; });
testing.expectEqual(true, $('#circle').r instanceof SVGAnimatedLength);
testing.expectEqual(true, $('#ellipse').rx instanceof SVGAnimatedLength);
testing.expectEqual(true, $('#line').x2 instanceof SVGAnimatedLength);
const pathLength = rect.pathLength;
testing.expectEqual(true, pathLength instanceof SVGAnimatedNumber);
testing.expectEqual(true, pathLength === rect.pathLength);
testing.expectEqual('number', typeof pathLength.baseVal);
testing.expectEqual(0, pathLength.baseVal);
pathLength.baseVal = 75;
testing.expectEqual('75', rect.getAttribute('pathLength'));
testing.expectEqual(75, pathLength.animVal);
testing.expectError('TypeError', () => { pathLength.baseVal = 'invalid'; });
testing.expectError('TypeError', () => { pathLength.baseVal = NaN; });
testing.expectError('TypeError', () => { pathLength.baseVal = Infinity; });
testing.expectEqual(75, pathLength.baseVal);
testing.expectEqual('75', rect.getAttribute('pathLength'));
testing.expectEqual(false, Reflect.set(pathLength, 'animVal', 4));
testing.expectEqual(75, pathLength.animVal);
rect.setAttribute('pathLength', 'invalid');
testing.expectEqual(0, pathLength.baseVal);
}
</script>
<script id=prototypePlacementAndShapeBounds>
{
testing.expectEqual(true, Object.prototype.hasOwnProperty.call(SVGGraphicsElement.prototype, 'getBBox'));
testing.expectEqual(false, Object.prototype.hasOwnProperty.call(SVGGeometryElement.prototype, 'getBBox'));
testing.expectEqual(false, Object.prototype.hasOwnProperty.call(SVGRectElement.prototype, 'getBBox'));
let box = $('#rect').getBBox();
testing.expectEqual(10, box.x);
testing.expectEqual(20, box.y);
testing.expectEqual(35, box.width);
testing.expectEqual(40, box.height);
testing.expectEqual(true, box instanceof DOMRect);
box = $('#circle').getBBox();
testing.expectEqual(40, box.x);
testing.expectEqual(20, box.y);
testing.expectEqual(20, box.width);
testing.expectEqual(20, box.height);
box = $('#ellipse').getBBox();
testing.expectEqual(50, box.x);
testing.expectEqual(35, box.y);
testing.expectEqual(40, box.width);
testing.expectEqual(10, box.height);
box = $('#line').getBBox();
testing.expectEqual(1, box.x);
testing.expectEqual(2, box.y);
testing.expectEqual(3, box.width);
testing.expectEqual(4, box.height);
box = $('#polyline').getBBox();
testing.expectEqual(-5, box.x);
testing.expectEqual(-2, box.y);
testing.expectEqual(17, box.width);
testing.expectEqual(9, box.height);
box = $('#circle').getBBox({fill: false});
testing.expectEqual(0, box.width);
testing.expectError('NotSupportedError', () => $('#circle').getBBox({stroke: true}));
$('#circle').setAttribute('r', '-2');
testing.expectEqual(0, $('#circle').getBBox().width);
$('#circle').setAttribute('r', '10');
}
</script>
<script id=pathParsingAndBounds>
{
let box = $('#cubic').getBBox();
testing.expectEqual(0, box.x);
testing.expectEqual(0, box.y);
testing.expectEqual(100, box.width);
testing.expectEqual(75, box.height);
box = $('#partial').getBBox();
testing.expectEqual(10, box.x);
testing.expectEqual(10, box.y);
testing.expectEqual(10, box.width);
testing.expectEqual(10, box.height);
box = $('#moves').getBBox();
testing.expectEqual(0, box.x);
testing.expectEqual(0, box.y);
testing.expectEqual(20, box.width);
testing.expectEqual(10, box.height);
box = $('#arc').getBBox();
testing.expectEqual(true, box.x < 0);
testing.expectEqual(true, box.width > 100);
testing.expectEqual(true, box.height >= 100);
}
</script>
<script id=lengthAndPointAtLength>
{
testing.expectEqual(5, $('#line').getTotalLength());
let point = $('#line').getPointAtLength(2.5);
testing.expectEqual(true, point instanceof DOMPoint);
testing.expectEqual(2.5, point.x);
testing.expectEqual(4, point.y);
point = $('#line').getPointAtLength(-100);
testing.expectEqual(1, point.x);
testing.expectEqual(2, point.y);
point = $('#line').getPointAtLength(100);
testing.expectEqual(4, point.x);
testing.expectEqual(6, point.y);
const cubic = $('#cubic');
testing.expectEqual(true, Math.abs(cubic.getTotalLength() - 200) < 0.01);
point = cubic.getPointAtLength(cubic.getTotalLength() / 2);
testing.expectEqual(true, Math.abs(point.x - 50) < 0.01);
testing.expectEqual(true, Math.abs(point.y - 75) < 0.01);
// pathLength calibrates author distance, not these native geometry methods.
cubic.pathLength.baseVal.value = 10;
testing.expectEqual(true, Math.abs(cubic.getTotalLength() - 200) < 0.01);
}
</script>
<script id=containerTransforms>
{
const box = $('#transformed').getBBox();
// The group's own translate is excluded. Its direct rect occupies 0..10,
// while the scaled child circle occupies 16..24 on both axes.
testing.expectEqual(0, box.x);
testing.expectEqual(0, box.y);
testing.expectEqual(24, box.width);
testing.expectEqual(24, box.height);
const rootBox = $('#root').getBBox();
// From the root, the group's transform is included.
testing.expectEqual(true, rootBox.width >= 100);
testing.expectEqual(true, rootBox.height >= 100);
}
</script>

View File

@@ -38,6 +38,7 @@
testing.expectEqual('function', typeof SVGPolylineElement);
testing.expectEqual('function', typeof SVGAnimatedString);
testing.expectEqual('function', typeof SVGNumber);
testing.expectEqual('function', typeof SVGAnimatedNumber);
}
</script>

View File

@@ -17,11 +17,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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);
}

View File

@@ -17,11 +17,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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);
}

View File

@@ -16,12 +16,19 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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;
}

View File

@@ -16,12 +16,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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);
}

View File

@@ -17,11 +17,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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);
}

View File

@@ -17,9 +17,11 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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);
}

View File

@@ -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 {

View File

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

View File

@@ -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, .{});
};

View File

@@ -16,17 +16,54 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 {

View File

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