Merge pull request #3030 from mh0pe/codex/svg-03-collections

webapi: add live SVG collections
This commit is contained in:
Karl Seguin
2026-07-27 13:02:50 +08:00
committed by GitHub
20 changed files with 2457 additions and 281 deletions

View File

@@ -42,9 +42,6 @@ const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
const AnimatedLength = @import("webapi/svg/AnimatedLength.zig");
const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig");
const AnimatedString = @import("webapi/svg/AnimatedString.zig");
const Window = @import("webapi/Window.zig");
const Location = @import("webapi/Location.zig");
const Document = @import("webapi/Document.zig");
@@ -65,8 +62,15 @@ const popover = @import("webapi/element/popover.zig");
const slotting = @import("webapi/element/slotting.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const HttpClient = @import("../network/HttpClient.zig");
const PointList = @import("webapi/svg/PointList.zig");
const StringList = @import("webapi/svg/StringList.zig");
const AnimatedLength = @import("webapi/svg/AnimatedLength.zig");
const AnimatedString = @import("webapi/svg/AnimatedString.zig");
const AnimatedTransformList = @import("webapi/svg/AnimatedTransformList.zig");
const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig");
const sys_url = @import("../sys/url.zig");
const HttpClient = @import("../network/HttpClient.zig");
const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
@@ -148,6 +152,9 @@ _element_namespace_uris: Element.NamespaceUriLookup = .empty,
_svg_animated_lengths: AnimatedLength.Lookup = .empty,
_svg_animated_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty,
_svg_animated_strings: AnimatedString.Lookup = .empty,
_svg_animated_transform_lists: AnimatedTransformList.Lookup = .empty,
_svg_point_lists: PointList.Lookup = .empty,
_svg_string_lists: StringList.Lookup = .empty,
// Same as above, but for Nodes (slot assigments apply to both Element AND
// Text nodes)
@@ -503,6 +510,16 @@ pub fn deinit(self: *Frame) void {
observers.deinit(self, page);
var svg_point_lists = self._svg_point_lists.valueIterator();
while (svg_point_lists.next()) |list| {
list.*.deinit(page);
}
var svg_transform_lists = self._svg_animated_transform_lists.valueIterator();
while (svg_transform_lists.next()) |list| {
list.*.deinit(page);
}
var document = self.window._document;
document._selection.releaseRef(page);

View File

@@ -1076,6 +1076,10 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/svg/AnimatedLength.zig"),
@import("../webapi/svg/PreserveAspectRatio.zig"),
@import("../webapi/svg/AnimatedPreserveAspectRatio.zig"),
@import("../webapi/svg/PointList.zig"),
@import("../webapi/svg/TransformList.zig"),
@import("../webapi/svg/AnimatedTransformList.zig"),
@import("../webapi/svg/StringList.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextEncoderStream.zig"),

View File

@@ -0,0 +1,720 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<svg id=root>
<polygon id=polygon points="0,0 10,20"></polygon>
<polyline id=polylineLive points="0,0 10,20"></polyline>
<polyline id=polylineExternal points="0,0 10,20"></polyline>
<polyline id=polylineInvalid points="0,0 10,20"></polyline>
<polyline id=polylineIdentity points="1 2 3 4"></polyline>
<polyline id=polylineMutators points="1 2 3 4"></polyline>
<polyline id=polylineStale points="1 2 3 4"></polyline>
<polyline id=polylineErrors points="1 2 3 4"></polyline>
<polygon id=polygonReadOnly points="1 2 3 4"></polygon>
<polyline id=many></polyline>
<g id=group transform="translate(10 20) rotate(45 1 2)"></g>
<g id=transformLive transform="translate(10 20) rotate(45 1 2)"></g>
<g id=transformExternal transform="translate(10 20)"></g>
<g id=transformIdentity transform="translate(1 2)"></g>
<g id=transformMutators transform="translate(1 2)"></g>
<g id=transformStale transform="translate(10 20)"></g>
<g id=transformConsolidate transform="translate(10 20) scale(2)"></g>
<g id=transformConsolidateCommit transform="rotate(30 5 6) scale(2 3)"></g>
<g id=matrixIs2D transform="translate(1 2)"></g>
<g id=matrixStaleMatrix transform="translate(1 2)"></g>
<g id=transformReadOnly transform="translate(1 2)"></g>
<g id=transformParse transform="translate(1 2) scale(3,4) rotate(30 5 6)"></g>
<g id=matrixGroup transform="translate(1 2)"></g>
<g id=matrixReadOnly transform="translate(1 2)"></g>
<g id=strings requiredExtensions="urn:one urn:two" systemLanguage="en, fr"></g>
<g id=stringMutators requiredExtensions="urn:one urn:two"></g>
<g id=stringReturns requiredExtensions="urn:one urn:two"></g>
<g id=stringClear requiredExtensions="urn:one urn:two"></g>
<g id=stringLanguages systemLanguage="en, fr"></g>
<g id=stringExternal systemLanguage="en, fr"></g>
<g id=stringTokens systemLanguage="en, fr"></g>
<g id=stringNoValidation requiredExtensions="urn:one"></g>
</svg>
<!--
These fail in Chrome, deliberately. SVG 2 builds the list interfaces over a
list of real objects — getItem returns "a reference to that object and not a
copy of it" — so two getItem calls hand back the same object. Firefox does
this; Chrome creates a fresh tear-off per call and per mutator return, and
honours identity only where the IDL says [SameObject] (SVGTransform.matrix,
asserted in transformMatrixLive).
WPT sits this one out: every list test in svg/types/scripted compares .value
rather than the objects. We follow the spec and Firefox. Kept in its own
script so the rest of the file still runs in Chrome.
-->
<script id=pointListIdentity>
{
const root = $('#root');
const polygon = $('#polygon');
const points = polygon.points;
testing.expectTrue(points === polygon.points);
testing.expectTrue(polygon.animatedPoints === polygon.animatedPoints);
testing.expectFalse(points === polygon.animatedPoints);
testing.expectTrue(points.getItem(0) === points.getItem(0));
// "The inserted item is the item itself and not a copy", so every mutator
// hands back the object it was given.
const list = $('#polylineIdentity').points;
const appended = root.createSVGPoint();
testing.expectEqual(appended, list.appendItem(appended));
const inserted = root.createSVGPoint();
testing.expectEqual(inserted, list.insertItemBefore(inserted, 0));
const replacement = root.createSVGPoint();
testing.expectEqual(replacement, list.replaceItem(replacement, 1));
testing.expectEqual(replacement, list.removeItem(1));
const initialized = root.createSVGPoint();
testing.expectEqual(initialized, list.initialize(initialized));
}
</script>
<script id=pointListBasics>
{
const polygon = $('#polygon');
const points = polygon.points;
testing.expectTrue(points instanceof SVGPointList);
testing.expectEqual(2, points.length);
testing.expectEqual(2, points.numberOfItems);
testing.expectEqual(0, points.getItem(0).x);
testing.expectEqual(0, points.getItem(0).y);
testing.expectEqual(10, points.getItem(1).x);
testing.expectEqual(20, points.getItem(1).y);
}
</script>
<script id=pointListLive>
{
const polyline = $('#polylineLive');
const points = polyline.points;
points.getItem(0).x = 5;
testing.expectEqual('5 0 10 20', polyline.getAttribute('points'));
testing.expectEqual(5, polyline.animatedPoints.getItem(0).x);
points.getItem(1).y = -1;
testing.expectEqual('5 0 10 -1', polyline.getAttribute('points'));
}
</script>
<script id=pointListExternalMutation>
{
const polyline = $('#polylineExternal');
const points = polyline.points;
polyline.setAttribute('points', '1 2, 3 4');
testing.expectEqual(2, points.length);
testing.expectEqual(1, points.getItem(0).x);
testing.expectEqual(2, points.getItem(0).y);
testing.expectEqual(3, points.getItem(1).x);
testing.expectEqual('1 2, 3 4', polyline.getAttribute('points'));
}
</script>
<script id=pointListInvalidAttribute>
{
const polyline = $('#polylineInvalid');
const points = polyline.points;
// A malformed number invalidates the whole attribute...
polyline.setAttribute('points', '0,0 100,0 INVALID');
testing.expectEqual(0, points.length);
testing.expectEqual('0,0 100,0 INVALID', polyline.getAttribute('points'));
// ...but a trailing coordinate with no pair only truncates it.
polyline.setAttribute('points', '0,0 100,0 20');
testing.expectEqual(2, points.length);
polyline.setAttribute('points', '0,0 100,0 20,');
testing.expectEqual(2, points.length);
polyline.setAttribute('points', '1,2,3');
testing.expectEqual(1, points.length);
testing.expectEqual('1,2,3', polyline.getAttribute('points'));
}
</script>
<script id=pointListMutators>
{
const root = $('#root');
const polyline = $('#polylineMutators');
const points = polyline.points;
const detached = root.createSVGPoint();
detached.x = 7;
detached.y = 8;
points.appendItem(detached);
testing.expectEqual('1 2 3 4 7 8', polyline.getAttribute('points'));
// Already in a list, so this one gets copied.
points.appendItem(detached);
testing.expectEqual(4, points.length);
detached.x = 9;
testing.expectEqual(9, points.getItem(2).x);
testing.expectEqual(7, points.getItem(3).x);
const removed = points.removeItem(2);
testing.expectEqual(9, removed.x);
testing.expectEqual('1 2 3 4 7 8', polyline.getAttribute('points'));
const inserted = root.createSVGPoint();
inserted.x = 100;
inserted.y = 200;
points.insertItemBefore(inserted, 0);
testing.expectEqual('100 200 1 2 3 4 7 8', polyline.getAttribute('points'));
const replacement = root.createSVGPoint();
replacement.x = 5;
replacement.y = 6;
points.replaceItem(replacement, 1);
testing.expectEqual('100 200 5 6 3 4 7 8', polyline.getAttribute('points'));
const initialized = root.createSVGPoint();
initialized.x = 4;
initialized.y = 5;
points.initialize(initialized);
testing.expectEqual('4 5', polyline.getAttribute('points'));
points.clear();
testing.expectEqual('', polyline.getAttribute('points'));
}
</script>
<script id=pointListErrors>
{
const root = $('#root');
const points = $('#polylineErrors').points;
testing.expectError('IndexSizeError', () => points.getItem(99));
testing.expectError('IndexSizeError', () => points.replaceItem(root.createSVGPoint(), 99));
testing.expectError('IndexSizeError', () => points.removeItem(99));
}
</script>
<!-- SVGPoint's coordinates are a restricted float, unlike DOMPoint's. -->
<script id=pointCoordinatesAreRestricted>
{
const point = $('#root').createSVGPoint();
point.x = 100;
testing.expectError('TypeError', () => { point.x = NaN; });
testing.expectError('TypeError', () => { point.x = Infinity; });
testing.expectError('TypeError', () => { point.x = point; });
testing.expectEqual(100, point.x);
point.y = null;
testing.expectEqual(0, point.y);
}
</script>
<script id=pointListReadOnly>
{
const root = $('#root');
const polygon = $('#polygonReadOnly');
const animated = polygon.animatedPoints;
testing.expectError('NoModificationAllowedError', () => animated.clear());
testing.expectError('NoModificationAllowedError', () => animated.appendItem(root.createSVGPoint()));
testing.expectError('NoModificationAllowedError', () => { animated.getItem(0).x = 4; });
// Read-only is sticky: an external attribute change drops the item from the
// list, but it must not become a writable orphan.
const stale = animated.getItem(0);
polygon.setAttribute('points', '9 9');
testing.expectEqual(1, animated.length);
testing.expectError('NoModificationAllowedError', () => { stale.x = 4; });
}
</script>
<script id=pointListCapacity>
{
const root = $('#root');
const points = $('#many').points;
for (let i = 0; i < 70; i++) {
const point = root.createSVGPoint();
point.x = i;
point.y = -i;
points.appendItem(point);
}
testing.expectEqual(70, points.numberOfItems);
testing.expectEqual(69, points.getItem(69).x);
testing.expectEqual(-69, points.getItem(69).y);
}
</script>
<!-- Fails in Chrome, passes in Firefox; see pointListIdentity. -->
<script id=transformListIdentity>
{
const root = $('#root');
const group = $('#group');
const animated = group.transform;
testing.expectTrue(animated === group.transform);
testing.expectTrue(animated.baseVal === animated.baseVal);
testing.expectTrue(animated.animVal === animated.animVal);
testing.expectFalse(animated.baseVal === animated.animVal);
testing.expectTrue(animated.baseVal.getItem(1) === animated.baseVal.getItem(1));
testing.expectTrue(animated.baseVal.getItem(1).matrix === animated.baseVal.getItem(1).matrix);
const list = $('#transformIdentity').transform.baseVal;
const appended = root.createSVGTransform();
testing.expectEqual(appended, list.appendItem(appended));
const inserted = root.createSVGTransform();
testing.expectEqual(inserted, list.insertItemBefore(inserted, 0));
const replacement = root.createSVGTransform();
testing.expectEqual(replacement, list.replaceItem(replacement, 1));
testing.expectEqual(replacement, list.removeItem(1));
const initialized = root.createSVGTransform();
testing.expectEqual(initialized, list.initialize(initialized));
}
</script>
<script id=transformListBasics>
{
const group = $('#group');
const animated = group.transform;
const base = animated.baseVal;
testing.expectTrue(animated instanceof SVGAnimatedTransformList);
testing.expectTrue(base instanceof SVGTransformList);
testing.expectEqual(2, base.numberOfItems);
testing.expectEqual(2, base.length);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_TRANSLATE, base.getItem(0).type);
testing.expectEqual(10, base.getItem(0).matrix.e);
testing.expectEqual(20, base.getItem(0).matrix.f);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_ROTATE, base.getItem(1).type);
testing.expectEqual(45, base.getItem(1).angle);
}
</script>
<script id=transformListLive>
{
const group = $('#transformLive');
const base = group.transform.baseVal;
base.getItem(0).setTranslate(3, 4);
testing.expectEqual('translate(3 4)', group.getAttribute('transform').slice(0, 14));
testing.expectEqual(3, group.transform.animVal.getItem(0).matrix.e);
base.getItem(1).setSkewX(15);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_SKEWX, base.getItem(1).type);
testing.expectEqual('translate(3 4) skewX(15)', group.getAttribute('transform'));
}
</script>
<script id=transformListExternalMutation>
{
const group = $('#transformExternal');
const base = group.transform.baseVal;
group.setAttribute('transform', 'scale(2)');
testing.expectEqual(1, base.length);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_SCALE, base.getItem(0).type);
testing.expectEqual(2, base.getItem(0).matrix.a);
testing.expectEqual(2, base.getItem(0).matrix.d);
}
</script>
<!--
All three engines differ on writing an item that an external attribute change
has dropped from its list:
us the write lands on the orphan; the list and attribute are untouched
Chrome lands on the orphan, but the list is re-committed anyway, so the
attribute comes back reserialized ("scale(2)" -> "scale(2 2)")
Firefox lands on the *list* at that index, because its tear-off proxies
slot n rather than the object ("scale(2)" -> "translate(8, 9)")
SVG 2 reparses the attribute into a fresh set of objects, so the item that was
removed is no longer in the list and a write to it should not reach it.
-->
<script id=staleItemWrites>
{
const group = $('#transformStale');
const base = group.transform.baseVal;
const stale = base.getItem(0);
group.setAttribute('transform', 'scale(2)');
testing.expectEqual(1, base.length);
stale.setTranslate(8, 9);
testing.expectEqual('scale(2)', group.getAttribute('transform'));
testing.expectEqual(SVGTransform.SVG_TRANSFORM_SCALE, base.getItem(0).type);
testing.expectEqual(2, base.getItem(0).matrix.a);
const polyline = $('#polylineStale');
const points = polyline.points;
const stalePoint = points.getItem(0);
polyline.setAttribute('points', '9 9');
testing.expectEqual(1, points.length);
stalePoint.x = 5;
testing.expectEqual('9 9', polyline.getAttribute('points'));
testing.expectEqual(9, points.getItem(0).x);
// A live matrix orphaned the same way keeps working as a plain matrix.
const matrixGroup = $('#matrixStaleMatrix');
const matrixBase = matrixGroup.transform.baseVal;
const matrix = matrixBase.getItem(0).matrix;
matrixGroup.setAttribute('transform', 'scale(4)');
testing.expectEqual(1, matrixBase.length);
matrix.e = 11;
testing.expectEqual(11, matrix.e);
testing.expectEqual(0, matrixBase.getItem(0).matrix.e);
testing.expectEqual(4, matrixBase.getItem(0).matrix.a);
}
</script>
<script id=transformListMutators>
{
const root = $('#root');
const group = $('#transformMutators');
const base = group.transform.baseVal;
const scale = root.createSVGTransform();
scale.setScale(2, 3);
base.appendItem(scale);
testing.expectEqual('translate(1 2) scale(2 3)', group.getAttribute('transform'));
const removed = base.removeItem(0);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_TRANSLATE, removed.type);
testing.expectEqual('scale(2 3)', group.getAttribute('transform'));
const rotate = root.createSVGTransform();
rotate.setRotate(30, 5, 6);
base.insertItemBefore(rotate, 0);
testing.expectEqual('rotate(30 5 6) scale(2 3)', group.getAttribute('transform'));
const initialized = root.createSVGTransform();
initialized.setTranslate(7, 8);
base.initialize(initialized);
testing.expectEqual('translate(7 8)', group.getAttribute('transform'));
base.clear();
testing.expectEqual('', group.getAttribute('transform'));
}
</script>
<script id=transformListConsolidate>
{
const base = $('#transformConsolidate').transform.baseVal;
testing.expectEqual(2, base.numberOfItems);
const consolidated = base.consolidate();
testing.expectEqual(SVGTransform.SVG_TRANSFORM_MATRIX, consolidated.type);
testing.expectEqual(1, base.numberOfItems);
testing.expectEqual(2, consolidated.matrix.a);
testing.expectEqual(2, consolidated.matrix.d);
testing.expectEqual(10, consolidated.matrix.e);
testing.expectEqual(20, consolidated.matrix.f);
}
</script>
<!--
Chrome updates the list but leaves the attribute alone, so after consolidating
to a single matrix the attribute still reads "rotate(...)" while the DOM says
one SVG_TRANSFORM_MATRIX item. We commit, like every other mutator does: our
list is rebuilt from the attribute on the next read, so not committing would
undo the consolidate on the very next access.
-->
<script id=transformListConsolidateCommits>
{
const group = $('#transformConsolidateCommit');
group.transform.baseVal.consolidate();
testing.expectEqual('matrix(', group.getAttribute('transform').slice(0, 7));
testing.expectEqual(1, group.transform.baseVal.numberOfItems);
}
</script>
<script id=transformListReadOnly>
{
const root = $('#root');
const group = $('#transformReadOnly');
const anim = group.transform.animVal;
testing.expectError('NoModificationAllowedError', () => anim.clear());
testing.expectError('NoModificationAllowedError', () => anim.appendItem(root.createSVGTransform()));
testing.expectError('NoModificationAllowedError', () => anim.getItem(0).setTranslate(1, 1));
// Read-only is sticky, see pointListReadOnly.
const stale = anim.getItem(0);
group.setAttribute('transform', 'scale(3)');
testing.expectEqual(1, anim.length);
testing.expectError('NoModificationAllowedError', () => stale.setTranslate(1, 1));
}
</script>
<script id=transformMatrixLive>
{
const group = $('#matrixGroup');
const base = group.transform.baseVal;
const anim = group.transform.animVal;
const transform = base.getItem(0);
const matrix = transform.matrix;
testing.expectTrue(matrix === transform.matrix);
matrix.e = 5;
testing.expectEqual(SVGTransform.SVG_TRANSFORM_MATRIX, transform.type);
testing.expectEqual(0, transform.angle);
testing.expectEqual('matrix(1 0 0 1 5 2)', group.getAttribute('transform'));
testing.expectEqual(5, anim.getItem(0).matrix.e);
testing.expectEqual(1, base.length);
}
</script>
<script id=transformMatrixReadOnly>
{
const group = $('#matrixReadOnly');
const animMatrix = group.transform.animVal.getItem(0).matrix;
testing.expectError('NoModificationAllowedError', () => { animMatrix.e = 99; });
testing.expectEqual('translate(1 2)', group.getAttribute('transform'));
testing.expectEqual(1, animMatrix.e);
}
</script>
<!--
SVG 2 types SVGTransform.matrix as a DOMMatrix (SVGMatrix is a typedef for it).
Chrome still returns the legacy SVGMatrix, which has none of these members, so
this block and the next one only run here.
-->
<script id=transformMatrixDomMatrixApi>
{
const root = $('#root');
const transform = root.createSVGTransform();
const matrix = transform.matrix;
testing.expectEqual(matrix, matrix.translateSelf(2, 3));
testing.expectEqual(2, matrix.e);
testing.expectEqual(3, matrix.f);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_MATRIX, transform.type);
testing.expectEqual(matrix, matrix.setMatrixValue('matrix(1, 0, 0, 1, 9, 10)'));
testing.expectEqual(9, matrix.e);
testing.expectEqual(10, matrix.f);
}
</script>
<script id=transformMatrixIs2D>
{
const root = $('#root');
const group = $('#matrixIs2D');
const transform = root.createSVGTransform();
const matrix = transform.matrix;
// An SVGTransform is a 2D affine transform and the `transform` attribute has
// no syntax for anything else, so the 3D components are dropped.
matrix.m13 = 2;
testing.expectEqual(SVGTransform.SVG_TRANSFORM_MATRIX, transform.type);
testing.expectTrue(matrix.is2D);
testing.expectEqual(0, matrix.m13);
matrix.setMatrixValue('translate3d(1px, 2px, 3px)');
testing.expectTrue(matrix.is2D);
testing.expectEqual(1, matrix.e);
testing.expectEqual(2, matrix.f);
testing.expectEqual(0, matrix.m43);
group.transform.baseVal.initialize(transform);
testing.expectEqual('matrix(1 0 0 1 1 2)', group.getAttribute('transform'));
}
</script>
<script id=transformParser>
{
const group = $('#transformParse');
const base = group.transform.baseVal;
testing.expectEqual(3, base.length);
testing.expectEqual(2, base.getItem(0).matrix.f);
testing.expectEqual(4, base.getItem(1).matrix.d);
testing.expectEqual(30, base.getItem(2).angle);
group.setAttribute('transform', 'translate()');
testing.expectEqual(0, base.length);
testing.expectEqual('translate()', group.getAttribute('transform'));
// No 3D function is part of the SVG transform grammar.
group.setAttribute('transform', 'matrix3d(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1)');
testing.expectEqual(0, base.length);
}
</script>
<script id=domMatrixTransformString>
{
testing.expectError('SyntaxError', () => new DOMMatrix('translate()'));
testing.expectError('SyntaxError', () => new DOMMatrix('scale()'));
const matrix = new DOMMatrix('translate(3px, 4px) scale(2, 3)');
testing.expectEqual(3, matrix.e);
testing.expectEqual(4, matrix.f);
testing.expectEqual(2, matrix.a);
}
</script>
<!--
The SVG grammar's separators are not CSS syntax: a DOMMatrix string wants
commas between arguments, even though `transform="translate(3 4)"` does not.
-->
<script id=domMatrixRejectsSvgSeparators>
{
testing.expectError('SyntaxError', () => new DOMMatrix('translate(3px 4px) scale(2 3)'));
testing.expectError('SyntaxError', () => new DOMMatrix('translate(3 4)'));
testing.expectError('SyntaxError', () => new DOMMatrix('matrix(1 0 0 1 5 6)'));
testing.expectError('SyntaxError', () => new DOMMatrix('translate(3px-4px)'));
const matrix = new DOMMatrix('translate(3px, 4px) scale(2, 3)');
testing.expectEqual(3, matrix.e);
testing.expectEqual(2, matrix.a);
}
</script>
<script id=stringListBasics>
{
const group = $('#strings');
const required = group.requiredExtensions;
const languages = group.systemLanguage;
testing.expectTrue(required instanceof SVGStringList);
testing.expectTrue(required === group.requiredExtensions);
testing.expectTrue(languages === group.systemLanguage);
testing.expectEqual(2, required.numberOfItems);
testing.expectEqual('urn:one', required.getItem(0));
testing.expectEqual('urn:two', required.getItem(1));
testing.expectEqual(2, languages.length);
testing.expectEqual('en', languages.getItem(0));
testing.expectEqual('fr', languages.getItem(1));
testing.expectError('IndexSizeError', () => required.getItem(99));
}
</script>
<script id=stringListMutators>
{
const group = $('#stringMutators');
const required = group.requiredExtensions;
required.appendItem('urn:three');
testing.expectEqual('urn:one urn:two urn:three', group.getAttribute('requiredExtensions'));
required.removeItem(1);
testing.expectEqual('urn:one urn:three', group.getAttribute('requiredExtensions'));
required.insertItemBefore('urn:zero', 0);
testing.expectEqual('urn:zero urn:one urn:three', group.getAttribute('requiredExtensions'));
required.replaceItem('urn:x', 1);
testing.expectEqual('urn:zero urn:x urn:three', group.getAttribute('requiredExtensions'));
required.initialize('urn:only');
testing.expectEqual('urn:only', group.getAttribute('requiredExtensions'));
required.clear();
testing.expectEqual(0, required.length);
}
</script>
<!-- Emptying a string list removes the attribute in Chrome but blanks it in
Firefox, which blanks it for point and transform lists too. Chrome is the
inconsistent one here; we follow it. -->
<script id=stringListClearRemovesAttribute>
{
const group = $('#stringClear');
group.requiredExtensions.clear();
testing.expectEqual(null, group.getAttribute('requiredExtensions'));
}
</script>
<!-- Firefox returns "" from SVGStringList.removeItem; Chrome and the spec
return the removed item. -->
<script id=stringListMutatorReturns>
{
const required = $('#stringReturns').requiredExtensions;
testing.expectEqual('urn:three', required.appendItem('urn:three'));
testing.expectEqual('urn:two', required.removeItem(1));
testing.expectEqual('urn:zero', required.insertItemBefore('urn:zero', 0));
testing.expectEqual('urn:x', required.replaceItem('urn:x', 1));
testing.expectEqual('urn:only', required.initialize('urn:only'));
}
</script>
<script id=stringListDelimiter>
{
const group = $('#stringLanguages');
const languages = group.systemLanguage;
languages.insertItemBefore('de', 1);
testing.expectEqual('en,de,fr', group.getAttribute('systemLanguage'));
}
</script>
<!--
systemLanguage is a set of comma-separated tokens: every segment is a token,
including an empty one, and each is trimmed. Mirrors the cases in
wpt/svg/struct/systemLanguage-parsing.html, which we can't run yet because
<text> is not an SVGGraphicsElement here.
-->
<script id=stringListCommaTokens>
{
const group = $('#stringTokens');
const languages = group.systemLanguage;
const parsesAs = (input, expected) => {
group.setAttribute('systemLanguage', input);
testing.expectEqual(expected.length, languages.length);
for (let i = 0; i < expected.length; i++) {
testing.expectEqual(expected[i], languages.getItem(i));
}
};
parsesAs('en,fr,de', ['en', 'fr', 'de']);
parsesAs('en , fr , de', ['en', 'fr', 'de']);
parsesAs(' en, fr ', ['en', 'fr']);
parsesAs(' \t\nen, fr\t\n ', ['en', 'fr']);
parsesAs('en', ['en']);
parsesAs('en-US, zh-Hans', ['en-US', 'zh-Hans']);
parsesAs('en,,fr', ['en', '', 'fr']);
parsesAs('', ['']);
parsesAs(',', ['', '']);
parsesAs('not-a-lang, ???', ['not-a-lang', '???']);
// An absent attribute is an empty list, unlike an empty one.
group.removeAttribute('systemLanguage');
testing.expectEqual(0, languages.length);
}
</script>
<script id=stringListExternalMutation>
{
const group = $('#stringExternal');
const languages = group.systemLanguage;
group.setAttribute('systemLanguage', 'ja, ko');
testing.expectEqual(2, languages.length);
testing.expectEqual('ko', languages.getItem(1));
group.setAttribute('systemLanguage', 'it, pt');
testing.expectEqual(2, languages.length);
testing.expectEqual('pt', languages.getItem(1));
}
</script>
<script id=stringListNoValidation>
{
const group = $('#stringNoValidation');
const required = group.requiredExtensions;
// Neither the spec nor Chrome validates the inserted string against the
// list's delimiter, and the list stays authoritative over its own
// serialization, so this remains one item.
required.appendItem('two words');
testing.expectEqual('urn:one two words', group.getAttribute('requiredExtensions'));
testing.expectEqual(2, required.length);
testing.expectEqual('two words', required.getItem(1));
// Until the attribute is written from the outside, which reparses.
group.setAttribute('requiredExtensions', 'urn:one three more words');
testing.expectEqual(4, required.length);
testing.expectEqual('three', required.getItem(1));
}
</script>

View File

@@ -59,6 +59,11 @@
const loc_pathname = loc.pathname;
const loc_to_string = String(loc);
// DOMPoint mutation (setters must not require a Frame in a worker)
const point = new DOMPoint(1, 2);
point.x = 5;
point.w = 0.5;
postMessage({
ok: true,
results: {
@@ -90,6 +95,8 @@
loc_protocol,
loc_pathname,
loc_to_string,
point_x: point.x,
point_w: point.w,
},
});
} catch (e) {

View File

@@ -247,6 +247,10 @@
testing.expectEqual(r.loc_href, r.loc_to_string);
testing.expectEqual('http:', r.loc_protocol);
testing.expectTrue(r.loc_pathname.endsWith('api-worker.js'));
// DOMPoint
testing.expectEqual(5, r.point_x);
testing.expectEqual(0.5, r.point_w);
});
}
</script>

View File

@@ -84,137 +84,158 @@ pub fn getF(self: *const DOMMatrix) f64 {
return self._proto._m[13];
}
pub fn setA(self: *DOMMatrix, v: f64) void {
self._proto._m[0] = v;
pub fn setA(self: *DOMMatrix, v: f64) !void {
try self.setElement(0, v);
}
pub fn setB(self: *DOMMatrix, v: f64) void {
self._proto._m[1] = v;
pub fn setB(self: *DOMMatrix, v: f64) !void {
try self.setElement(1, v);
}
pub fn setC(self: *DOMMatrix, v: f64) void {
self._proto._m[4] = v;
pub fn setC(self: *DOMMatrix, v: f64) !void {
try self.setElement(4, v);
}
pub fn setD(self: *DOMMatrix, v: f64) void {
self._proto._m[5] = v;
pub fn setD(self: *DOMMatrix, v: f64) !void {
try self.setElement(5, v);
}
pub fn setE(self: *DOMMatrix, v: f64) void {
self._proto._m[12] = v;
pub fn setE(self: *DOMMatrix, v: f64) !void {
try self.setElement(12, v);
}
pub fn setF(self: *DOMMatrix, v: f64) void {
self._proto._m[13] = v;
pub fn setF(self: *DOMMatrix, v: f64) !void {
try self.setElement(13, v);
}
pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) *DOMMatrix {
pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) !*DOMMatrix {
const tz = tz_ orelse 0;
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz));
if (tz != 0) p._is_2d = false;
return self;
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz));
if (tz != 0) state.is_2d = false;
return self.applyState(state);
}
pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix {
const sx = sx_ orelse 1;
const sy = sy_ orelse sx;
const sz = sz_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
const p = self._proto;
var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
var state = self._proto.getState();
var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(sx, sy, sz));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
p._m = m;
if (sz != 1 or oz != 0) p._is_2d = false;
return self;
state.matrix = m;
if (sz != 1 or oz != 0) state.is_2d = false;
return self.applyState(state);
}
pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix {
const s = scale_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
const p = self._proto;
var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
var state = self._proto.getState();
var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(s, s, s));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
p._m = m;
if (s != 1) p._is_2d = false;
return self;
state.matrix = m;
if (s != 1) state.is_2d = false;
return self.applyState(state);
}
pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) *DOMMatrix {
const p = self._proto;
pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) !*DOMMatrix {
var state = self._proto.getState();
if (ry_ == null and rz_ == null) {
p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg)));
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg)));
} else {
p._m = RO.multiplyMatrix(p._m, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg)));
p._m = RO.multiplyMatrix(p._m, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg)));
p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg)));
p._is_2d = false;
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg)));
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg)));
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg)));
state.is_2d = false;
}
return self;
return self.applyState(state);
}
pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) *DOMMatrix {
pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) !*DOMMatrix {
const x = x_ orelse 0;
const y = y_ orelse 0;
const rad = if (x == 0 and y == 0) 0 else std.math.atan2(y, x);
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(rad));
return self;
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(rad));
return self.applyState(state);
}
pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) *DOMMatrix {
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg)));
if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) p._is_2d = false;
return self;
pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) !*DOMMatrix {
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg)));
if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) state.is_2d = false;
return self.applyState(state);
}
pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) *DOMMatrix {
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0));
return self;
pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) !*DOMMatrix {
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0));
return self.applyState(state);
}
pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) *DOMMatrix {
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg)));
return self;
pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) !*DOMMatrix {
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg)));
return self.applyState(state);
}
pub fn multiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
p._m = RO.multiplyMatrix(p._m, other.m);
p._is_2d = p._is_2d and other.is_2d;
return self;
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, other.m);
state.is_2d = state.is_2d and other.is_2d;
return self.applyState(state);
}
pub fn preMultiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
p._m = RO.multiplyMatrix(other.m, p._m);
p._is_2d = p._is_2d and other.is_2d;
return self;
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(other.m, state.matrix);
state.is_2d = state.is_2d and other.is_2d;
return self.applyState(state);
}
pub fn invertSelf(self: *DOMMatrix) *DOMMatrix {
const p = self._proto;
if (RO.invertMatrix(p._m)) |v| {
p._m = v;
pub fn invertSelf(self: *DOMMatrix) !*DOMMatrix {
var state = self._proto.getState();
if (RO.invertMatrix(state.matrix)) |v| {
state.matrix = v;
} else {
p._m = .{std.math.nan(f64)} ** 16;
p._is_2d = false;
state.matrix = .{std.math.nan(f64)} ** 16;
state.is_2d = false;
}
return self;
return self.applyState(state);
}
pub fn setMatrixValue(self: *DOMMatrix, transform: []const u8) !*DOMMatrix {
var m = RO.identity();
var is_2d = true;
try RO.parseTransformList(transform, &m, &is_2d);
self._proto._m = m;
self._proto._is_2d = is_2d;
var state: RO.State = .{ .matrix = RO.identity(), .is_2d = true };
try RO.parseTransformList(transform, &state.matrix, &state.is_2d);
try self._proto.applyState(state);
return self;
}
fn setElement(self: *DOMMatrix, comptime index: usize, value: f64) !void {
var state = self._proto.getState();
state.matrix[index] = value;
// Assigning a z/w element a value other than its identity drops the
// 2D flag. Setting it back does not restore is2D.
switch (index) {
2, 3, 6, 7, 8, 9, 11, 14 => if (value != 0) {
state.is_2d = false;
},
10, 15 => if (value != 1) {
state.is_2d = false;
},
else => {},
}
try self._proto.applyState(state);
}
fn applyState(self: *DOMMatrix, state: RO.State) !*DOMMatrix {
try self._proto.applyState(state);
return self;
}
@@ -281,23 +302,10 @@ pub const JsApi = struct {
}.get;
}
fn setM(comptime idx: usize) fn (*DOMMatrix, f64) void {
fn setM(comptime idx: usize) fn (*DOMMatrix, f64) anyerror!void {
return struct {
fn set(self: *DOMMatrix, v: f64) void {
self._proto._m[idx] = v;
// Assigning a z/w element a value other than its identity drops the
// 2D flag. Setting it back to the identity value (0 for the
// off-diagonal elements, 1 for m33/m44) preserves is2D. Note `-0`
// compares equal to `0`, so it preserves it too, per spec.
switch (idx) {
2, 3, 6, 7, 8, 9, 11, 14 => if (v != 0) {
self._proto._is_2d = false;
},
10, 15 => if (v != 1) {
self._proto._is_2d = false;
},
else => {},
}
fn set(self: *DOMMatrix, v: f64) !void {
try self.setElement(idx, v);
}
}.set;
}

View File

@@ -44,12 +44,25 @@ _arena: Allocator,
// out[row] = sum_col _m[col*4 + row] * in[col]
_m: [16]f64,
_is_2d: bool,
_attachment: ?Attachment = null,
pub const Type = union(enum) {
generic,
mutable: *DOMMatrix,
};
pub const State = struct {
matrix: [16]f64,
is_2d: bool,
};
// The owner decides whether a write is allowed; a matrix that belongs to an
// animVal SVGTransform is rejected by the transform, not here.
pub const Attachment = struct {
owner: *anyopaque,
mutate: *const fn (*anyopaque, *DOMMatrixReadOnly, State) anyerror!void,
};
pub fn init(init_: ?js.Value, exec: *const js.Execution) !*DOMMatrixReadOnly {
const parsed = try Parsed.init(init_, exec);
return createBare(parsed.m, parsed.is_2d, exec.page);
@@ -82,6 +95,29 @@ pub fn createBare(m: [16]f64, is_2d: bool, page: *Page) !*DOMMatrixReadOnly {
return self;
}
pub fn getState(self: *const DOMMatrixReadOnly) State {
return .{
.matrix = self._m,
.is_2d = self._is_2d,
};
}
pub fn applyState(self: *DOMMatrixReadOnly, state: State) !void {
if (self._attachment) |attachment| {
return attachment.mutate(attachment.owner, self, state);
}
self.applyStateRaw(state);
}
pub fn applyStateRaw(self: *DOMMatrixReadOnly, state: State) void {
self._m = state.matrix;
self._is_2d = state.is_2d;
}
pub fn attach(self: *DOMMatrixReadOnly, attachment: Attachment) void {
self._attachment = attachment;
}
pub const DOMMatrixInit = struct {
a: ?f64 = null,
b: ?f64 = null,
@@ -312,178 +348,274 @@ pub fn invertMatrix(m: [16]f64) ?[16]f64 {
return out;
}
pub const TransformSyntax = enum { css, svg };
pub const TransformKind = enum {
matrix,
matrix3d,
translate,
translate_x,
translate_y,
translate_z,
translate_3d,
scale,
scale_x,
scale_y,
scale_z,
scale_3d,
rotate,
rotate_x,
rotate_y,
rotate_z,
rotate_3d,
skew,
skew_x,
skew_y,
perspective,
};
pub const ParsedTransform = struct {
kind: TransformKind,
matrix: [16]f64,
values: [16]f64,
count: usize,
is_2d: bool,
};
pub const TransformFunction = struct {
name: []const u8,
arguments: []const u8,
};
pub const TransformFunctionIterator = struct {
input: []const u8,
index: usize = 0,
allow_comma: bool = false,
pub fn next(self: *TransformFunctionIterator) !?TransformFunction {
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
if (self.index == self.input.len) return null;
if (self.input[self.index] == ',') return error.SyntaxError;
const name_start = self.index;
while (self.index < self.input.len and
(std.ascii.isAlphabetic(self.input[self.index]) or std.ascii.isDigit(self.input[self.index])))
{
self.index += 1;
}
if (self.index == name_start or self.index >= self.input.len or self.input[self.index] != '(') {
return error.SyntaxError;
}
const name = self.input[name_start..self.index];
self.index += 1;
const arguments_start = self.index;
while (self.index < self.input.len and self.input[self.index] != ')') {
if (self.input[self.index] == '(') return error.SyntaxError;
self.index += 1;
}
if (self.index == self.input.len) return error.SyntaxError;
const arguments = self.input[arguments_start..self.index];
self.index += 1;
var had_whitespace = false;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
had_whitespace = true;
self.index += 1;
}
if (self.index < self.input.len and self.input[self.index] == ',') {
if (!self.allow_comma) return error.SyntaxError;
self.index += 1;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
if (self.index == self.input.len) return error.SyntaxError;
} else if (self.index < self.input.len and !had_whitespace) {
return error.SyntaxError;
}
return .{ .name = name, .arguments = arguments };
}
};
// Parses a CSS <transform-list> (e.g. "matrix(1,0,0,1,10,20) scale(2)") and
// accumulates it into `m`. "none"/empty leave the matrix as identity.
pub fn parseTransformList(input: []const u8, m: *[16]f64, is_2d: *bool) !void {
const trimmed = std.mem.trim(u8, input, " \t\r\n");
if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) {
return;
}
if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return;
var i: usize = 0;
while (i < trimmed.len) {
// skip whitespace and separating commas
while (i < trimmed.len and (std.ascii.isWhitespace(trimmed[i]) or trimmed[i] == ',')) : (i += 1) {}
if (i >= trimmed.len) {
break;
}
const name_start = i;
while (i < trimmed.len and trimmed[i] != '(') : (i += 1) {}
if (i >= trimmed.len) {
return error.SyntaxError;
}
const name = std.mem.trim(u8, trimmed[name_start..i], " \t\r\n");
i += 1; // consume '('
const args_start = i;
while (i < trimmed.len and trimmed[i] != ')') : (i += 1) {}
if (i >= trimmed.len) {
return error.SyntaxError;
}
const args = trimmed[args_start..i];
i += 1; // consume ')'
const func = try parseFunction(name, args, is_2d);
m.* = multiplyMatrix(m.*, func);
var iterator = TransformFunctionIterator{ .input = trimmed };
while (try iterator.next()) |function| {
const parsed = try parseTransformFunction(function, .css);
m.* = multiplyMatrix(m.*, parsed.matrix);
if (!parsed.is_2d) is_2d.* = false;
}
}
fn parseFunction(name: []const u8, args: []const u8, is_2d: *bool) ![16]f64 {
var nums: [16]f64 = undefined;
pub fn parseTransformFunction(function: TransformFunction, syntax: TransformSyntax) !ParsedTransform {
var values: [16]f64 = undefined;
var units: [16]ParsedValue.Unit = undefined;
var count: usize = 0;
var it = std.mem.splitScalar(u8, args, ',');
while (it.next()) |raw| {
const tok = std.mem.trim(u8, raw, " \t\r\n");
if (tok.len == 0) {
continue;
}
if (count >= 16) {
return error.SyntaxError;
}
const parsed = try ParsedValue.parse(tok);
nums[count] = parsed.value;
units[count] = parsed.unit;
count += 1;
}
const count = try parseArguments(function.arguments, syntax, &values, &units);
const name = function.name;
const Eql = std.mem.eql;
if (Eql(u8, name, "matrix")) {
if (count != 6) {
return error.SyntaxError;
}
return .{
nums[0], nums[1], 0, 0,
nums[2], nums[3], 0, 0,
0, 0, 1, 0,
nums[4], nums[5], 0, 1,
};
try requireCount(count, 6, 6);
try requireUnitless(units[0..count]);
return makeParsedTransform(.matrix, .{
values[0], values[1], 0, 0,
values[2], values[3], 0, 0,
0, 0, 1, 0,
values[4], values[5], 0, 1,
}, values, count, true);
}
if (Eql(u8, name, "matrix3d")) {
if (count != 16) {
return error.SyntaxError;
}
is_2d.* = false;
return nums;
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 16, 16);
try requireUnitless(units[0..count]);
return makeParsedTransform(.matrix3d, values, values, count, false);
}
if (Eql(u8, name, "translate")) {
const tx = nums[0];
const ty = if (count > 1) nums[1] else 0;
return translationMatrix(tx, ty, 0);
try requireCount(count, 1, 2);
if (syntax == .svg) try requireUnitless(units[0..count]);
const ty = if (count == 2) values[1] else 0;
return makeParsedTransform(.translate, translationMatrix(values[0], ty, 0), values, count, true);
}
if (Eql(u8, name, "translateX")) {
return translationMatrix(nums[0], 0, 0);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
return makeParsedTransform(.translate_x, translationMatrix(values[0], 0, 0), values, count, true);
}
if (Eql(u8, name, "translateY")) {
return translationMatrix(0, nums[0], 0);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
return makeParsedTransform(.translate_y, translationMatrix(0, values[0], 0), values, count, true);
}
if (Eql(u8, name, "translateZ")) {
is_2d.* = false;
return translationMatrix(0, 0, nums[0]);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
return makeParsedTransform(.translate_z, translationMatrix(0, 0, values[0]), values, count, false);
}
if (Eql(u8, name, "translate3d")) {
is_2d.* = false;
return translationMatrix(nums[0], nums[1], nums[2]);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 3, 3);
return makeParsedTransform(.translate_3d, translationMatrix(values[0], values[1], values[2]), values, count, false);
}
if (Eql(u8, name, "scale")) {
const sx = nums[0];
const sy = if (count > 1) nums[1] else sx;
return scaleMatrix(sx, sy, 1);
try requireCount(count, 1, 2);
try requireUnitless(units[0..count]);
const sy = if (count == 2) values[1] else values[0];
return makeParsedTransform(.scale, scaleMatrix(values[0], sy, 1), values, count, true);
}
if (Eql(u8, name, "scaleX")) {
return scaleMatrix(nums[0], 1, 1);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
try requireUnitless(units[0..count]);
return makeParsedTransform(.scale_x, scaleMatrix(values[0], 1, 1), values, count, true);
}
if (Eql(u8, name, "scaleY")) {
return scaleMatrix(1, nums[0], 1);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
try requireUnitless(units[0..count]);
return makeParsedTransform(.scale_y, scaleMatrix(1, values[0], 1), values, count, true);
}
if (Eql(u8, name, "scaleZ")) {
is_2d.* = false;
return scaleMatrix(1, 1, nums[0]);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
try requireUnitless(units[0..count]);
return makeParsedTransform(.scale_z, scaleMatrix(1, 1, values[0]), values, count, false);
}
if (Eql(u8, name, "scale3d")) {
is_2d.* = false;
return scaleMatrix(nums[0], nums[1], nums[2]);
}
if (Eql(u8, name, "rotate") or Eql(u8, name, "rotateZ")) {
if (Eql(u8, name, "rotateZ")) is_2d.* = false;
return rotateZMatrix(toRadians(nums[0], units[0]));
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 3, 3);
try requireUnitless(units[0..count]);
return makeParsedTransform(.scale_3d, scaleMatrix(values[0], values[1], values[2]), values, count, false);
}
if (Eql(u8, name, "rotateX")) {
is_2d.* = false;
return rotateXMatrix(toRadians(nums[0], units[0]));
}
if (Eql(u8, name, "rotateY")) {
is_2d.* = false;
return rotateYMatrix(toRadians(nums[0], units[0]));
}
if (Eql(u8, name, "rotate3d")) {
is_2d.* = false;
if (count != 4) {
return error.SyntaxError;
if (Eql(u8, name, "rotate")) {
try requireCount(count, 1, if (syntax == .svg) 3 else 1);
if (count != 1 and count != 3) return error.SyntaxError;
try requireAngle(units[0]);
if (syntax == .svg and units[0] != .none) return error.SyntaxError;
if (count == 3) try requireUnitless(units[1..3]);
var matrix = rotateZMatrix(toRadians(values[0], units[0]));
if (count == 3) {
matrix = multiplyMatrix(translationMatrix(values[1], values[2], 0), matrix);
matrix = multiplyMatrix(matrix, translationMatrix(-values[1], -values[2], 0));
}
return axisAngleMatrix(nums[0], nums[1], nums[2], toRadians(nums[3], units[3]));
return makeParsedTransform(.rotate, matrix, values, count, true);
}
if (Eql(u8, name, "rotateX") or Eql(u8, name, "rotateY") or Eql(u8, name, "rotateZ")) {
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
try requireAngle(units[0]);
if (Eql(u8, name, "rotateX")) return makeParsedTransform(.rotate_x, rotateXMatrix(toRadians(values[0], units[0])), values, count, false);
if (Eql(u8, name, "rotateY")) return makeParsedTransform(.rotate_y, rotateYMatrix(toRadians(values[0], units[0])), values, count, false);
return makeParsedTransform(.rotate_z, rotateZMatrix(toRadians(values[0], units[0])), values, count, false);
}
if (Eql(u8, name, "rotate3d")) {
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 4, 4);
try requireUnitless(units[0..3]);
try requireAngle(units[3]);
return makeParsedTransform(.rotate_3d, axisAngleMatrix(values[0], values[1], values[2], toRadians(values[3], units[3])), values, count, false);
}
if (Eql(u8, name, "skew")) {
const ax = toRadians(nums[0], units[0]);
const ay = if (count > 1) toRadians(nums[1], units[1]) else 0;
return skewMatrix(ax, ay);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 2);
try requireAngle(units[0]);
if (count == 2) try requireAngle(units[1]);
const ay = if (count == 2) toRadians(values[1], units[1]) else 0;
return makeParsedTransform(.skew, skewMatrix(toRadians(values[0], units[0]), ay), values, count, true);
}
if (Eql(u8, name, "skewX")) {
return skewMatrix(toRadians(nums[0], units[0]), 0);
}
if (Eql(u8, name, "skewY")) {
return skewMatrix(0, toRadians(nums[0], units[0]));
if (Eql(u8, name, "skewX") or Eql(u8, name, "skewY")) {
try requireCount(count, 1, 1);
try requireAngle(units[0]);
if (syntax == .svg and units[0] != .none) return error.SyntaxError;
if (Eql(u8, name, "skewX")) return makeParsedTransform(.skew_x, skewMatrix(toRadians(values[0], units[0]), 0), values, count, true);
return makeParsedTransform(.skew_y, skewMatrix(0, toRadians(values[0], units[0])), values, count, true);
}
if (Eql(u8, name, "perspective")) {
is_2d.* = false;
var out = identity();
if (nums[0] != 0) out[11] = -1.0 / nums[0];
return out;
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
var matrix = identity();
if (values[0] != 0) matrix[11] = -1.0 / values[0];
return makeParsedTransform(.perspective, matrix, values, count, false);
}
return error.SyntaxError;
}
fn makeParsedTransform(kind: TransformKind, matrix: [16]f64, values: [16]f64, count: usize, is_2d: bool) ParsedTransform {
return .{ .kind = kind, .matrix = matrix, .values = values, .count = count, .is_2d = is_2d };
}
fn requireCount(count: usize, minimum: usize, maximum: usize) !void {
if (count < minimum or count > maximum) return error.SyntaxError;
}
fn requireUnitless(units: []const ParsedValue.Unit) !void {
for (units) |unit| if (unit != .none) return error.SyntaxError;
}
fn requireAngle(unit: ParsedValue.Unit) !void {
if (unit == .other) return error.SyntaxError;
}
fn parseArguments(arguments: []const u8, syntax: TransformSyntax, values: *[16]f64, units: *[16]ParsedValue.Unit) !usize {
var scanner = ArgumentScanner{ .input = arguments, .syntax = syntax };
var count: usize = 0;
while (try scanner.next()) |value| {
if (count == values.len) return error.SyntaxError;
values[count] = value.value;
units[count] = value.unit;
count += 1;
}
return count;
}
pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 {
return switch (unit) {
.rad => value,
@@ -491,6 +623,7 @@ pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 {
.turn => value * std.math.tau,
// bare numbers in rotate()/skew() are interpreted as degrees
.deg, .none => value * std.math.pi / 180.0,
.other => value,
};
}
@@ -725,43 +858,80 @@ const ParsedValue = struct {
rad,
grad,
turn,
other,
};
};
// Parses a single CSS dimension token: a number with an optional unit suffix.
// Length units are ignored (we don't resolve layout), so the numeric part is
// taken verbatim; angle units are recorded so they can be normalised.
fn parse(tok: []const u8) !ParsedValue {
var end: usize = 0;
while (end < tok.len) : (end += 1) {
const c = tok[end];
if ((c >= '0' and c <= '9') or c == '.' or c == '+' or c == '-' or c == 'e' or c == 'E') {
// 'e'/'E' is ambiguous with exponents; only treat as exponent when
// followed by a digit/sign.
if ((c == 'e' or c == 'E') and end > 0) {
if (end + 1 >= tok.len) break;
const nx = tok[end + 1];
if (!((nx >= '0' and nx <= '9') or nx == '+' or nx == '-')) break;
}
continue;
const ArgumentScanner = struct {
input: []const u8,
syntax: TransformSyntax,
index: usize = 0,
first: bool = true,
fn next(self: *ArgumentScanner) !?ParsedValue {
var had_whitespace = false;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
had_whitespace = true;
self.index += 1;
}
if (!self.first and self.index < self.input.len and self.input[self.index] == ',') {
self.index += 1;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
if (self.index == self.input.len) return error.SyntaxError;
} else if (!self.first and self.index < self.input.len) {
// CSS separates arguments with a comma and nothing else. The SVG
// grammar also takes whitespace, or no separator at all when the
// next number carries its own sign.
if (self.syntax == .css) return error.SyntaxError;
if (!had_whitespace and self.input[self.index] != '+' and self.input[self.index] != '-') {
return error.SyntaxError;
}
break;
}
if (end == 0) {
return error.SyntaxError;
}
const value = try std.fmt.parseFloat(f64, tok[0..end]);
const suffix = tok[end..];
var unit: Unit = .none;
if (std.ascii.eqlIgnoreCase(suffix, "deg")) {
unit = .deg;
} else if (std.ascii.eqlIgnoreCase(suffix, "rad")) {
unit = .rad;
} else if (std.ascii.eqlIgnoreCase(suffix, "grad")) {
unit = .grad;
} else if (std.ascii.eqlIgnoreCase(suffix, "turn")) {
unit = .turn;
if (self.index == self.input.len) return null;
const start = self.index;
if (self.input[self.index] == '+' or self.input[self.index] == '-') self.index += 1;
var digits: usize = 0;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
if (self.index < self.input.len and self.input[self.index] == '.') {
self.index += 1;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
}
if (digits == 0) return error.SyntaxError;
if (self.index < self.input.len and (self.input[self.index] == 'e' or self.input[self.index] == 'E')) {
self.index += 1;
if (self.index < self.input.len and (self.input[self.index] == '+' or self.input[self.index] == '-')) self.index += 1;
const exponent_start = self.index;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) self.index += 1;
if (self.index == exponent_start) return error.SyntaxError;
}
const number_end = self.index;
while (self.index < self.input.len and
(std.ascii.isAlphabetic(self.input[self.index]) or self.input[self.index] == '%'))
{
self.index += 1;
}
const suffix = self.input[number_end..self.index];
const unit: ParsedValue.Unit = if (suffix.len == 0)
.none
else if (std.ascii.eqlIgnoreCase(suffix, "deg"))
.deg
else if (std.ascii.eqlIgnoreCase(suffix, "rad"))
.rad
else if (std.ascii.eqlIgnoreCase(suffix, "grad"))
.grad
else if (std.ascii.eqlIgnoreCase(suffix, "turn"))
.turn
else
.other;
const value = std.fmt.parseFloat(f64, self.input[start..number_end]) catch return error.SyntaxError;
if (!std.math.isFinite(value)) return error.SyntaxError;
self.first = false;
return .{ .value = value, .unit = unit };
}
};

View File

@@ -77,17 +77,17 @@ pub fn getW(self: *const DOMPoint) f64 {
return self._proto._w;
}
pub fn setX(self: *DOMPoint, v: f64) void {
self._proto._x = v;
pub fn setX(self: *DOMPoint, v: f64) !void {
try self._proto.setCoordinate(.x, v);
}
pub fn setY(self: *DOMPoint, v: f64) void {
self._proto._y = v;
pub fn setY(self: *DOMPoint, v: f64) !void {
try self._proto.setCoordinate(.y, v);
}
pub fn setZ(self: *DOMPoint, v: f64) void {
self._proto._z = v;
pub fn setZ(self: *DOMPoint, v: f64) !void {
try self._proto.setCoordinate(.z, v);
}
pub fn setW(self: *DOMPoint, v: f64) void {
self._proto._w = v;
pub fn setW(self: *DOMPoint, v: f64) !void {
try self._proto.setCoordinate(.w, v);
}
pub const JsApi = struct {

View File

@@ -38,6 +38,23 @@ _x: f64,
_y: f64,
_z: f64,
_w: f64,
_attachment: ?Attachment = null,
// Sticky. An animVal item that a later external attribute change detaches from
// its list must not turn into a writable orphan.
_read_only: bool = false,
// SVGPoint's coordinates are a restricted float: NaN and infinity are a
// TypeError rather than a stored value. Only points that reach an SVG list are
// restricted; a plain DOMPoint takes an unrestricted double.
_restricted: bool = false,
pub const Coordinate = enum { x, y, z, w };
pub const Attachment = struct {
owner: *anyopaque,
mutate: *const fn (*anyopaque, *DOMPointReadOnly, Coordinate, f64) anyerror!void,
};
pub const Type = union(enum) {
generic,
@@ -132,6 +149,47 @@ pub fn getW(self: *const DOMPointReadOnly) f64 {
return self._w;
}
pub fn setCoordinate(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) !void {
if (self._read_only) return error.NoModificationAllowed;
if (self._restricted and !std.math.isFinite(value)) return error.TypeError;
if (self._attachment) |attachment| {
return attachment.mutate(attachment.owner, self, coordinate, value);
}
self.setCoordinateRaw(coordinate, value);
}
pub fn setCoordinateRaw(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) void {
switch (coordinate) {
.x => self._x = value,
.y => self._y = value,
.z => self._z = value,
.w => self._w = value,
}
}
pub fn restrict(self: *DOMPointReadOnly) void {
self._restricted = true;
}
pub fn attach(self: *DOMPointReadOnly, attachment: Attachment, read_only: bool) void {
self._attachment = attachment;
if (read_only) self._read_only = true;
}
pub fn detach(self: *DOMPointReadOnly, owner: *anyopaque) void {
const attachment = self._attachment orelse return;
if (attachment.owner == owner) self._attachment = null;
}
pub fn isAttached(self: *const DOMPointReadOnly) bool {
return self._attachment != null;
}
pub fn isAttachedTo(self: *const DOMPointReadOnly, owner: *anyopaque) bool {
const attachment = self._attachment orelse return false;
return attachment.owner == owner;
}
pub fn toJSON(self: *const DOMPointReadOnly) struct {
x: f64,
y: f64,

View File

@@ -856,6 +856,10 @@ pub fn removeAttribute(self: *Element, name: String, frame: *Frame) !void {
return self._attributes.delete(name, self, frame);
}
pub fn removeAttributeSafe(self: *Element, name: String, frame: *Frame) void {
self._attributes.deleteSafe(name, self, frame);
}
pub fn toggleAttribute(self: *Element, name: String, force: ?bool, frame: *Frame) !bool {
try Attribute.validateAttributeName(name);
const has = try self.hasAttribute(name, frame);

View File

@@ -315,9 +315,17 @@ pub const List = struct {
pub fn delete(self: *List, name: String, element: *Element, frame: *Frame) !void {
const result = try self.getEntryAndNormalizedName(name, frame);
const entry = result.entry orelse return;
return self._delete(entry, result.normalized, element, frame);
}
pub fn deleteSafe(self: *List, name: String, element: *Element, frame: *Frame) void {
const entry = self.getEntryWithNormalizedName(name) orelse return;
self._delete(entry, name, element, frame);
}
fn _delete(self: *List, entry: *Entry, normalized: String, element: *Element, frame: *Frame) void {
const owner = element.ownerFrame(frame);
const is_id = shouldAddToIdMap(result.normalized, element);
const is_id = shouldAddToIdMap(normalized, element);
const old_value = entry.value();
if (is_id) {
@@ -333,7 +341,7 @@ pub const List = struct {
self._len -= 1;
owner.domChanged();
owner.attributeRemove(element, result.normalized, .wrap(old_value));
owner.attributeRemove(element, normalized, .wrap(old_value));
}
pub fn getNames(self: *const List, allocator: Allocator) ![][]const u8 {

View File

@@ -17,9 +17,12 @@
// 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 SvgElement = @import("../Svg.zig");
const AnimatedTransformList = @import("../../svg/AnimatedTransformList.zig");
const StringList = @import("../../svg/StringList.zig");
pub const Svg = @import("Svg.zig");
pub const G = @import("G.zig");
@@ -72,4 +75,20 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const transform = bridge.accessor(Graphics.getTransform, null, .{});
pub const requiredExtensions = bridge.accessor(Graphics.getRequiredExtensions, null, .{});
pub const systemLanguage = bridge.accessor(Graphics.getSystemLanguage, null, .{});
};
pub fn getTransform(self: *Graphics, frame: *Frame) !*AnimatedTransformList {
return AnimatedTransformList.getOrCreate(self.asElement(), frame);
}
pub fn getRequiredExtensions(self: *Graphics, frame: *Frame) !*StringList {
return StringList.getOrCreate(self.asElement(), .required_extensions, frame);
}
pub fn getSystemLanguage(self: *Graphics, frame: *Frame) !*StringList {
return StringList.getOrCreate(self.asElement(), .system_language, frame);
}

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 PointList = @import("../../svg/PointList.zig");
const Polygon = @This();
_proto: *Geometry,
@@ -41,4 +43,15 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const points = bridge.accessor(Polygon.getPoints, null, .{});
pub const animatedPoints = bridge.accessor(Polygon.getAnimatedPoints, null, .{});
};
pub fn getPoints(self: *Polygon, frame: *Frame) !*PointList {
return PointList.getOrCreate(self.asElement(), .base, frame);
}
pub fn getAnimatedPoints(self: *Polygon, frame: *Frame) !*PointList {
return PointList.getOrCreate(self.asElement(), .animated, frame);
}

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 PointList = @import("../../svg/PointList.zig");
const Polyline = @This();
_proto: *Geometry,
@@ -41,4 +43,15 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const points = bridge.accessor(Polyline.getPoints, null, .{});
pub const animatedPoints = bridge.accessor(Polyline.getAnimatedPoints, null, .{});
};
pub fn getPoints(self: *Polyline, frame: *Frame) !*PointList {
return PointList.getOrCreate(self.asElement(), .base, frame);
}
pub fn getAnimatedPoints(self: *Polyline, frame: *Frame) !*PointList {
return PointList.getOrCreate(self.asElement(), .animated, frame);
}

View File

@@ -82,7 +82,9 @@ pub fn getPreserveAspectRatio(self: *Svg, frame: *Frame) !*AnimatedPreserveAspec
}
pub fn createSVGPoint(_: *Svg, frame: *Frame) !*DOMPoint {
return DOMPoint.create(0, 0, 0, 1, frame._page);
const point = try DOMPoint.create(0, 0, 0, 1, frame._page);
point._proto.restrict();
return point;
}
pub fn createSVGMatrix(_: *Svg, frame: *Frame) !*DOMMatrix {

View File

@@ -0,0 +1,65 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const std = @import("std");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const Element = @import("../Element.zig");
const TransformList = @import("TransformList.zig");
const AnimatedTransformList = @This();
_base_val: *TransformList,
_anim_val: *TransformList,
pub const Lookup = std.AutoHashMapUnmanaged(*Element, *AnimatedTransformList);
pub fn getOrCreate(element: *Element, frame: *Frame) !*AnimatedTransformList {
const gop = try frame._svg_animated_transform_lists.getOrPut(frame.arena, element);
if (!gop.found_existing) {
errdefer _ = frame._svg_animated_transform_lists.remove(element);
gop.value_ptr.* = try create(element, frame);
}
return gop.value_ptr.*;
}
pub fn create(element: *Element, frame: *Frame) !*AnimatedTransformList {
const base_val = try TransformList.create(element, false, frame);
const anim_val = try TransformList.create(element, true, frame);
return frame._factory.create(AnimatedTransformList{
._base_val = base_val,
._anim_val = anim_val,
});
}
pub fn deinit(self: *AnimatedTransformList, page: *Page) void {
self._base_val.deinit(page);
self._anim_val.deinit(page);
}
pub fn getBaseVal(self: *AnimatedTransformList) *TransformList {
return self._base_val;
}
pub fn getAnimVal(self: *AnimatedTransformList) *TransformList {
return self._anim_val;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(AnimatedTransformList);
pub const Meta = struct {
pub const name = "SVGAnimatedTransformList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const baseVal = bridge.accessor(AnimatedTransformList.getBaseVal, null, .{});
pub const animVal = bridge.accessor(AnimatedTransformList.getAnimVal, null, .{});
};

View File

@@ -0,0 +1,381 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const DOMPoint = @import("../DOMPoint.zig");
const DOMPointReadOnly = @import("../DOMPointReadOnly.zig");
const Element = @import("../Element.zig");
const PointList = @This();
_frame: *Frame,
_element: *Element,
_read_only: bool,
_synced: bool = false,
_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList(*DOMPoint) = .empty,
_retired: std.ArrayList(*DOMPoint) = .empty,
pub const Kind = enum { base, animated };
pub const Key = struct {
element: *Element,
kind: Kind,
};
pub const Lookup = std.AutoHashMapUnmanaged(Key, *PointList);
pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*PointList {
const key: Key = .{
.element = element,
.kind = kind,
};
const gop = try frame._svg_point_lists.getOrPut(frame.arena, key);
if (!gop.found_existing) {
errdefer _ = frame._svg_point_lists.remove(key);
gop.value_ptr.* = try frame._factory.create(PointList{
._frame = frame,
._element = element,
._read_only = kind == .animated,
});
}
return gop.value_ptr.*;
}
pub fn deinit(self: *PointList, page: *Page) void {
for (self._items.items) |point| {
point._proto.detach(self);
point._proto.releaseRef(page);
}
self._items.clearRetainingCapacity();
self.releaseRetired(page);
}
pub fn getLength(self: *PointList, frame: *Frame) !u32 {
try self.sync(frame);
return @intCast(self._items.items.len);
}
pub fn getNumberOfItems(self: *PointList, frame: *Frame) !u32 {
return self.getLength(frame);
}
pub fn clear(self: *PointList, frame: *Frame) !void {
try self.requireMutable();
try self.sync(frame);
try self.retireAll(frame);
try self.setAttribute(&.{}, frame);
}
pub fn initialize(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
try self.requireMutable();
try self.sync(frame);
const prepared = try self.prepareItem(item, frame);
errdefer prepared._proto.releaseRef(frame._page);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
try self.setAttribute(&.{prepared}, frame);
self._items.appendAssumeCapacity(prepared);
self.attach(prepared);
return prepared;
}
pub fn getItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
return self._items.items[index];
}
pub fn insertItemBefore(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint {
try self.requireMutable();
try self.sync(frame);
const prepared = try self.prepareItem(item, frame);
errdefer prepared._proto.releaseRef(frame._page);
const at = @min(@as(usize, index), self._items.items.len);
const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len + 1);
@memcpy(next[0..at], self._items.items[0..at]);
next[at] = prepared;
@memcpy(next[at + 1 ..], self._items.items[at..]);
try self._items.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
self._items.insertAssumeCapacity(at, prepared);
self.attach(prepared);
return prepared;
}
pub fn replaceItem(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint {
try self.requireMutable();
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const prepared = try self.prepareItem(item, frame);
errdefer prepared._proto.releaseRef(frame._page);
const next = try frame.local_arena.dupe(*DOMPoint, self._items.items);
next[index] = prepared;
try self._retired.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
const replaced = self._items.items[index];
replaced._proto.detach(self);
self._retired.appendAssumeCapacity(replaced);
self._items.items[index] = prepared;
self.attach(prepared);
return prepared;
}
pub fn removeItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint {
try self.requireMutable();
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len - 1);
@memcpy(next[0..index], self._items.items[0..index]);
@memcpy(next[index..], self._items.items[index + 1 ..]);
try self._retired.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
const removed = self._items.orderedRemove(index);
removed._proto.detach(self);
self._retired.appendAssumeCapacity(removed);
return removed;
}
pub fn appendItem(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
return self.insertItemBefore(item, std.math.maxInt(u32), frame);
}
fn requireMutable(self: *const PointList) !void {
if (self._read_only) return error.NoModificationAllowed;
}
fn prepareItem(_: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
if (!std.math.isFinite(item._proto._x) or !std.math.isFinite(item._proto._y)) return error.TypeError;
const prepared = if (item._proto.isAttached())
try DOMPoint.create(item._proto._x, item._proto._y, item._proto._z, item._proto._w, frame._page)
else
item;
prepared._proto.acquireRef();
return prepared;
}
fn attach(self: *PointList, point: *DOMPoint) void {
point._proto.restrict();
point._proto.attach(.{
.owner = self,
.mutate = PointList.mutatePoint,
}, self._read_only);
}
fn mutatePoint(
context: *anyopaque,
point: *DOMPointReadOnly,
coordinate: DOMPointReadOnly.Coordinate,
value: f64,
) anyerror!void {
const self: *PointList = @ptrCast(@alignCast(context));
const frame = self._frame;
try self.sync(frame);
// An external attribute mutation detaches the old item during sync. The
// caller still owns that DOMPoint identity, but it no longer mutates the list.
if (!point.isAttachedTo(self)) {
point.setCoordinateRaw(coordinate, value);
return;
}
if (coordinate == .z or coordinate == .w) {
point.setCoordinateRaw(coordinate, value);
return;
}
if (!std.math.isFinite(value)) return error.TypeError;
const index = for (self._items.items, 0..) |candidate, i| {
if (candidate._proto == point) break i;
} else unreachable;
try self.setAttributeWithOverride(index, coordinate, value, frame);
point.setCoordinateRaw(coordinate, value);
}
fn sync(self: *PointList, frame: *Frame) !void {
self.releaseRetired(frame._page);
const raw = self._element.getAttributeSafe(comptime .wrap("points")) orelse "";
if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) {
return;
}
self._synced = false;
var parsed = parse(raw, frame) catch |err| switch (err) {
error.SyntaxError => std.ArrayList(*DOMPoint).empty,
else => return err,
};
errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page);
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, raw);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
for (parsed.items) |point| {
self._items.appendAssumeCapacity(point);
self.attach(point);
}
parsed.clearRetainingCapacity();
self._synced = true;
}
// A retired item must outlive the operation that retired it: removeItem's
// return value has no JS wrapper until the bridge wraps it after we return.
// By the next operation, anything still reachable holds its own ref.
fn releaseRetired(self: *PointList, page: *Page) void {
for (self._retired.items) |point| {
point._proto.releaseRef(page);
}
self._retired.clearRetainingCapacity();
}
fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) {
var scanner = NumberScanner{ .input = raw };
var parsed: std.ArrayList(*DOMPoint) = .empty;
errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page);
while (try scanner.next()) |x| {
// A trailing coordinate with no pair truncates the list; only a
// malformed number invalidates the whole attribute.
const y = (try scanner.next()) orelse break;
const point = try DOMPoint.create(x, y, 0, 1, frame._page);
point._proto.acquireRef();
parsed.append(frame.local_arena, point) catch |err| {
point._proto.releaseRef(frame._page);
return err;
};
}
return parsed;
}
fn retireAll(self: *PointList, frame: *Frame) !void {
self._synced = false;
try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
for (self._items.items) |point| {
point._proto.detach(self);
self._retired.appendAssumeCapacity(point);
}
self._items.clearRetainingCapacity();
}
fn setAttribute(self: *PointList, items: []const *DOMPoint, frame: *Frame) !void {
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
for (items, 0..) |point, i| {
if (i != 0) try writer.writeByte(' ');
try writer.print("{d} {d}", .{ point._proto._x, point._proto._y });
}
try self.commitAttribute(serialized.written(), frame);
}
fn setAttributeWithOverride(
self: *PointList,
index: usize,
coordinate: DOMPointReadOnly.Coordinate,
value: f64,
frame: *Frame,
) !void {
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
for (self._items.items, 0..) |point, i| {
if (i != 0) try writer.writeByte(' ');
const x = if (i == index and coordinate == .x) value else point._proto._x;
const y = if (i == index and coordinate == .y) value else point._proto._y;
try writer.print("{d} {d}", .{ x, y });
}
try self.commitAttribute(serialized.written(), frame);
}
fn commitAttribute(self: *PointList, serialized: []const u8, frame: *Frame) !void {
self._synced = false;
try self._element.setAttributeSafe(comptime .wrap("points"), .wrap(serialized), frame);
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, serialized);
self._synced = true;
}
const NumberScanner = struct {
input: []const u8,
index: usize = 0,
first: bool = true,
fn next(self: *NumberScanner) !?f64 {
var had_whitespace = false;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
had_whitespace = true;
self.index += 1;
}
if (!self.first and self.index < self.input.len and self.input[self.index] == ',') {
self.index += 1;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
} else if (!self.first and self.index < self.input.len and !had_whitespace and
self.input[self.index] != '+' and self.input[self.index] != '-')
{
return error.SyntaxError;
}
if (self.index == self.input.len) return null;
const start = self.index;
if (self.input[self.index] == '+' or self.input[self.index] == '-') self.index += 1;
var digits: usize = 0;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
if (self.index < self.input.len and self.input[self.index] == '.') {
self.index += 1;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
}
if (digits == 0) return error.SyntaxError;
if (self.index < self.input.len and (self.input[self.index] == 'e' or self.input[self.index] == 'E')) {
self.index += 1;
if (self.index < self.input.len and (self.input[self.index] == '+' or self.input[self.index] == '-')) self.index += 1;
const exponent_start = self.index;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) self.index += 1;
if (self.index == exponent_start) return error.SyntaxError;
}
const value = std.fmt.parseFloat(f64, self.input[start..self.index]) catch return error.SyntaxError;
if (!std.math.isFinite(value)) return error.SyntaxError;
self.first = false;
return value;
}
};
pub const JsApi = struct {
pub const bridge = js.Bridge(PointList);
pub const Meta = struct {
pub const name = "SVGPointList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const length = bridge.accessor(PointList.getLength, null, .{});
pub const numberOfItems = bridge.accessor(PointList.getNumberOfItems, null, .{});
pub const clear = bridge.function(PointList.clear, .{});
pub const initialize = bridge.function(PointList.initialize, .{});
pub const getItem = bridge.function(PointList.getItem, .{});
pub const insertItemBefore = bridge.function(PointList.insertItemBefore, .{});
pub const replaceItem = bridge.function(PointList.replaceItem, .{});
pub const removeItem = bridge.function(PointList.removeItem, .{});
pub const appendItem = bridge.function(PointList.appendItem, .{});
};

View File

@@ -0,0 +1,225 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Element = @import("../Element.zig");
const StringList = @This();
pub const Delimiter = enum { whitespace, comma };
_element: *Element,
_attribute_name: lp.String,
_delimiter: Delimiter,
_synced: bool = false,
// An absent attribute is an empty list, but an empty attribute is not: a
// comma-separated value parses "" as a single empty token.
_present: bool = false,
_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList([]const u8) = .empty,
pub const Kind = enum {
required_extensions,
system_language,
fn attributeName(self: Kind) lp.String {
return switch (self) {
.required_extensions => .wrap("requiredExtensions"),
.system_language => .wrap("systemLanguage"),
};
}
fn delimiter(self: Kind) Delimiter {
return switch (self) {
.required_extensions => .whitespace,
.system_language => .comma,
};
}
};
pub const Key = struct {
element: *Element,
kind: Kind,
};
pub const Lookup = std.AutoHashMapUnmanaged(Key, *StringList);
pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*StringList {
const key: Key = .{
.element = element,
.kind = kind,
};
const gop = try frame._svg_string_lists.getOrPut(frame.arena, key);
if (!gop.found_existing) {
errdefer _ = frame._svg_string_lists.remove(key);
gop.value_ptr.* = try frame._factory.create(StringList{
._element = element,
._attribute_name = kind.attributeName(),
._delimiter = kind.delimiter(),
});
}
return gop.value_ptr.*;
}
pub fn getLength(self: *StringList, frame: *Frame) !u32 {
try self.sync(frame);
return @intCast(self._items.items.len);
}
pub fn getNumberOfItems(self: *StringList, frame: *Frame) !u32 {
return self.getLength(frame);
}
pub fn clear(self: *StringList, frame: *Frame) !void {
try self.commit(&.{}, frame);
}
pub fn initialize(self: *StringList, item: []const u8, frame: *Frame) ![]const u8 {
try self.commit(&.{item}, frame);
return item;
}
pub fn getItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
return self._items.items[index];
}
pub fn insertItemBefore(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 {
try self.sync(frame);
const at = @min(@as(usize, index), self._items.items.len);
const next = try frame.local_arena.alloc([]const u8, self._items.items.len + 1);
@memcpy(next[0..at], self._items.items[0..at]);
next[at] = item;
@memcpy(next[at + 1 ..], self._items.items[at..]);
try self.commit(next, frame);
return item;
}
pub fn replaceItem(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const next = try frame.local_arena.dupe([]const u8, self._items.items);
next[index] = item;
try self.commit(next, frame);
return item;
}
pub fn removeItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const removed = try frame.local_arena.dupe(u8, self._items.items[index]);
const next = try frame.local_arena.alloc([]const u8, self._items.items.len - 1);
@memcpy(next[0..index], self._items.items[0..index]);
@memcpy(next[index..], self._items.items[index + 1 ..]);
try self.commit(next, frame);
return removed;
}
pub fn appendItem(self: *StringList, item: []const u8, frame: *Frame) ![]const u8 {
return self.insertItemBefore(item, std.math.maxInt(u32), frame);
}
fn sync(self: *StringList, frame: *Frame) !void {
const raw = self._element.getAttributeSafe(self._attribute_name);
if (self._synced and self._present == (raw != null) and
std.mem.eql(u8, self._snapshot.items, raw orelse ""))
{
return;
}
return self.rebuild(raw, frame);
}
// The list is authoritative: an item keeps its identity even when it contains
// the delimiter, so we record where each one landed rather than reparsing our
// own serialization.
fn commit(self: *StringList, items: []const []const u8, frame: *Frame) !void {
if (items.len == 0) {
self._synced = false;
self._element.removeAttributeSafe(self._attribute_name, frame);
self._present = false;
self._snapshot.clearRetainingCapacity();
self._items.clearRetainingCapacity();
self._synced = true;
return;
}
const separator: []const u8 = if (self._delimiter == .comma) "," else " ";
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
const bounds = try frame.local_arena.alloc([2]usize, items.len);
for (items, bounds, 0..) |item, *bound, i| {
if (i != 0) try writer.writeAll(separator);
const start = serialized.written().len;
try writer.writeAll(item);
bound.* = .{ start, serialized.written().len };
}
const bytes = serialized.written();
self._synced = false;
try self._element.setAttributeSafe(self._attribute_name, .wrap(bytes), frame);
self._present = true;
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, bytes);
self._items.clearRetainingCapacity();
for (bounds) |bound| {
try self._items.append(frame.arena, self._snapshot.items[bound[0]..bound[1]]);
}
self._synced = true;
}
fn rebuild(self: *StringList, raw: ?[]const u8, frame: *Frame) !void {
self._synced = false;
self._present = raw != null;
self._snapshot.clearRetainingCapacity();
self._items.clearRetainingCapacity();
if (raw) |value| {
try self._snapshot.appendSlice(frame.arena, value);
switch (self._delimiter) {
.whitespace => {
var iterator = std.mem.tokenizeAny(u8, self._snapshot.items, WHITESPACE);
while (iterator.next()) |item| try self._items.append(frame.arena, item);
},
// A set of comma-separated tokens: every segment is a token, even
// an empty one, and each is trimmed of surrounding whitespace.
.comma => {
var iterator = std.mem.splitScalar(u8, self._snapshot.items, ',');
while (iterator.next()) |part| {
try self._items.append(frame.arena, std.mem.trim(u8, part, WHITESPACE));
}
},
}
}
self._synced = true;
}
const WHITESPACE = " \t\r\n\x0c";
pub const JsApi = struct {
pub const bridge = js.Bridge(StringList);
pub const Meta = struct {
pub const name = "SVGStringList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const length = bridge.accessor(StringList.getLength, null, .{});
pub const numberOfItems = bridge.accessor(StringList.getNumberOfItems, null, .{});
pub const clear = bridge.function(StringList.clear, .{});
pub const initialize = bridge.function(StringList.initialize, .{});
pub const getItem = bridge.function(StringList.getItem, .{});
pub const insertItemBefore = bridge.function(StringList.insertItemBefore, .{});
pub const replaceItem = bridge.function(StringList.replaceItem, .{});
pub const removeItem = bridge.function(StringList.removeItem, .{});
pub const appendItem = bridge.function(StringList.appendItem, .{});
};

View File

@@ -43,7 +43,28 @@ pub const DOMMatrix2DInit = struct {
_type: u16 = 1,
_angle: f64 = 0,
_cx: f64 = 0,
_cy: f64 = 0,
_matrix: *DOMMatrix,
_attachment: ?Attachment = null,
// Sticky. An animVal item that a later external attribute change detaches from
// its list must not turn into a writable orphan.
_read_only: bool = false,
pub const State = struct {
typ: u16,
angle: f64,
cx: f64,
cy: f64,
matrix: [16]f64,
is_2d: bool,
};
pub const Attachment = struct {
owner: *anyopaque,
mutate: *const fn (*anyopaque, *Transform, State) anyerror!void,
};
// The transform owns the matrix arena even when no JS wrapper currently
// references `matrix`. Forwarding the transform's bridge lifetime keeps the
@@ -58,13 +79,57 @@ pub fn releaseRef(self: *Transform, page: *Page) void {
pub fn detached(frame: *Frame) !*Transform {
const matrix = try DOMMatrix.create(RO.identity(), true, frame._page);
return frame._factory.create(Transform{ ._matrix = matrix });
errdefer matrix._proto.deinit(frame._page);
const self = try frame._factory.create(Transform{ ._matrix = matrix });
self.attachMatrix();
return self;
}
pub fn fromMatrix(init: ?DOMMatrix2DInit, frame: *Frame) !*Transform {
const parsed = try fixup2D(init orelse .{});
const matrix = try DOMMatrix.create(parsed.m, true, frame._page);
return frame._factory.create(Transform{ ._matrix = matrix });
errdefer matrix._proto.deinit(frame._page);
const self = try frame._factory.create(Transform{ ._matrix = matrix });
self.attachMatrix();
return self;
}
pub fn fromParsed(parsed: RO.ParsedTransform, frame: *Frame) !*Transform {
const typ: u16 = switch (parsed.kind) {
.matrix => 1,
.translate => 2,
.scale => 3,
.rotate => 4,
.skew_x => 5,
.skew_y => 6,
else => return error.SyntaxError,
};
const matrix = try DOMMatrix.create(parsed.matrix, parsed.is_2d, frame._page);
errdefer matrix._proto.deinit(frame._page);
const self = try frame._factory.create(Transform{
._type = typ,
._angle = if (typ >= 4) parsed.values[0] else 0,
._cx = if (typ == 4 and parsed.count == 3) parsed.values[1] else 0,
._cy = if (typ == 4 and parsed.count == 3) parsed.values[2] else 0,
._matrix = matrix,
});
self.attachMatrix();
return self;
}
pub fn clone(self: *const Transform, frame: *Frame) !*Transform {
const current = self.getState();
const matrix = try DOMMatrix.create(current.matrix, current.is_2d, frame._page);
errdefer matrix._proto.deinit(frame._page);
const cloned = try frame._factory.create(Transform{
._type = current.typ,
._angle = current.angle,
._cx = current.cx,
._cy = current.cy,
._matrix = matrix,
});
cloned.attachMatrix();
return cloned;
}
pub fn getType(self: *const Transform) u16 {
@@ -81,23 +146,17 @@ pub fn getAngle(self: *const Transform) f64 {
pub fn setMatrix(self: *Transform, init: ?DOMMatrix2DInit) !void {
const parsed = try fixup2D(init orelse .{});
self.replaceMatrix(parsed.m, true);
self._type = 1;
self._angle = 0;
try self.applyState(.{ .typ = 1, .angle = 0, .cx = 0, .cy = 0, .matrix = parsed.m, .is_2d = true });
}
pub fn setTranslate(self: *Transform, tx: f64, ty: f64) !void {
try ensureFinite(&.{ tx, ty });
self.replaceMatrix(RO.translationMatrix(tx, ty, 0), true);
self._type = 2;
self._angle = 0;
try self.applyState(.{ .typ = 2, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.translationMatrix(tx, ty, 0), .is_2d = true });
}
pub fn setScale(self: *Transform, sx: f64, sy: f64) !void {
try ensureFinite(&.{ sx, sy });
self.replaceMatrix(RO.scaleMatrix(sx, sy, 1), true);
self._type = 3;
self._angle = 0;
try self.applyState(.{ .typ = 3, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.scaleMatrix(sx, sy, 1), .is_2d = true });
}
pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void {
@@ -106,32 +165,116 @@ pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void {
var matrix = RO.translationMatrix(cx, cy, 0);
matrix = RO.multiplyMatrix(matrix, RO.rotateZMatrix(radians));
matrix = RO.multiplyMatrix(matrix, RO.translationMatrix(-cx, -cy, 0));
self.replaceMatrix(matrix, true);
self._type = 4;
self._angle = angle;
try self.applyState(.{ .typ = 4, .angle = angle, .cx = cx, .cy = cy, .matrix = matrix, .is_2d = true });
}
pub fn setSkewX(self: *Transform, angle: f64) !void {
try ensureFinite(&.{angle});
self.replaceMatrix(RO.skewMatrix(angle * std.math.pi / 180.0, 0), true);
self._type = 5;
self._angle = angle;
try self.applyState(.{ .typ = 5, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(angle * std.math.pi / 180.0, 0), .is_2d = true });
}
pub fn setSkewY(self: *Transform, angle: f64) !void {
try ensureFinite(&.{angle});
self.replaceMatrix(RO.skewMatrix(0, angle * std.math.pi / 180.0), true);
self._type = 6;
self._angle = angle;
try self.applyState(.{ .typ = 6, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(0, angle * std.math.pi / 180.0), .is_2d = true });
}
fn replaceMatrix(self: *Transform, matrix: [16]f64, is_2d: bool) void {
self._matrix._proto._m = matrix;
self._matrix._proto._is_2d = is_2d;
pub fn getState(self: *const Transform) State {
return .{
.typ = self._type,
.angle = self._angle,
.cx = self._cx,
.cy = self._cy,
.matrix = self._matrix._proto._m,
.is_2d = self._matrix._proto._is_2d,
};
}
pub fn applyStateRaw(self: *Transform, state: State) void {
self._type = state.typ;
self._angle = state.angle;
self._cx = state.cx;
self._cy = state.cy;
self._matrix._proto.applyStateRaw(.{
.matrix = state.matrix,
.is_2d = state.is_2d,
});
}
fn applyState(self: *Transform, state: State) !void {
if (self._read_only) return error.NoModificationAllowed;
try ensureFinite(&state.matrix);
if (self._attachment) |attachment| {
return attachment.mutate(attachment.owner, self, state);
}
self.applyStateRaw(state);
}
pub fn attach(self: *Transform, attachment: Attachment, read_only: bool) void {
self._attachment = attachment;
if (read_only) self._read_only = true;
}
pub fn detach(self: *Transform, owner: *anyopaque) void {
const attachment = self._attachment orelse return;
if (attachment.owner == owner) self._attachment = null;
}
pub fn isAttached(self: *const Transform) bool {
return self._attachment != null;
}
pub fn isAttachedTo(self: *const Transform, owner: *anyopaque) bool {
const attachment = self._attachment orelse return false;
return attachment.owner == owner;
}
pub fn writeState(state: State, writer: anytype) !void {
switch (state.typ) {
1 => try writer.print("matrix({d} {d} {d} {d} {d} {d})", .{
state.matrix[0], state.matrix[1], state.matrix[4], state.matrix[5], state.matrix[12], state.matrix[13],
}),
2 => try writer.print("translate({d} {d})", .{ state.matrix[12], state.matrix[13] }),
3 => try writer.print("scale({d} {d})", .{ state.matrix[0], state.matrix[5] }),
4 => if (state.cx == 0 and state.cy == 0)
try writer.print("rotate({d})", .{state.angle})
else
try writer.print("rotate({d} {d} {d})", .{ state.angle, state.cx, state.cy }),
5 => try writer.print("skewX({d})", .{state.angle}),
6 => try writer.print("skewY({d})", .{state.angle}),
else => return error.SyntaxError,
}
}
fn attachMatrix(self: *Transform) void {
self._matrix._proto.attach(.{
.owner = self,
.mutate = Transform.mutateMatrix,
});
}
// An SVGTransform is a 2D affine transform, and the `transform` attribute has
// no syntax for anything else, so a write through the live matrix keeps only
// the six components the transform can represent.
fn mutateMatrix(context: *anyopaque, _: *RO, state: RO.State) anyerror!void {
const self: *Transform = @ptrCast(@alignCast(context));
const m = state.matrix;
try self.applyState(.{
.typ = 1,
.angle = 0,
.cx = 0,
.cy = 0,
.matrix = .{
m[0], m[1], 0, 0,
m[4], m[5], 0, 0,
0, 0, 1, 0,
m[12], m[13], 0, 1,
},
.is_2d = true,
});
}
fn fixup2D(init: DOMMatrix2DInit) !RO.Parsed {
return RO.fixupDict(.{
const parsed = try RO.fixupDict(.{
.a = init.a,
.b = init.b,
.c = init.c,
@@ -146,6 +289,8 @@ fn fixup2D(init: DOMMatrix2DInit) !RO.Parsed {
.m42 = init.m42,
.is2D = true,
});
try ensureFinite(&parsed.m);
return parsed;
}
fn ensureFinite(values: []const f64) !void {

View File

@@ -0,0 +1,313 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
const std = @import("std");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const DOMMatrixReadOnly = @import("../DOMMatrixReadOnly.zig");
const Element = @import("../Element.zig");
const Transform = @import("Transform.zig");
const TransformList = @This();
_frame: *Frame,
_read_only: bool,
_element: *Element,
_synced: bool = false,
_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList(*Transform) = .empty,
_retired: std.ArrayList(*Transform) = .empty,
pub fn create(element: *Element, read_only: bool, frame: *Frame) !*TransformList {
return frame._factory.create(TransformList{
._frame = frame,
._element = element,
._read_only = read_only,
});
}
pub fn deinit(self: *TransformList, page: *Page) void {
for (self._items.items) |transform| {
transform.detach(self);
transform.releaseRef(page);
}
self._items.clearRetainingCapacity();
self.releaseRetired(page);
}
pub fn getLength(self: *TransformList, frame: *Frame) !u32 {
try self.sync(frame);
return @intCast(self._items.items.len);
}
pub fn getNumberOfItems(self: *TransformList, frame: *Frame) !u32 {
return self.getLength(frame);
}
pub fn clear(self: *TransformList, frame: *Frame) !void {
try self.requireMutable();
try self.sync(frame);
try self.retireAll(frame);
try self.setAttribute(&.{}, frame);
}
pub fn initialize(self: *TransformList, item: *Transform, frame: *Frame) !*Transform {
try self.requireMutable();
try self.sync(frame);
const prepared = try self.prepareItem(item, frame);
errdefer prepared.releaseRef(frame._page);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
try self.setAttribute(&.{prepared}, frame);
self._items.appendAssumeCapacity(prepared);
self.attach(prepared);
return prepared;
}
pub fn getItem(self: *TransformList, index: u32, frame: *Frame) !*Transform {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
return self._items.items[index];
}
pub fn insertItemBefore(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform {
try self.requireMutable();
try self.sync(frame);
const prepared = try self.prepareItem(item, frame);
errdefer prepared.releaseRef(frame._page);
const at = @min(@as(usize, index), self._items.items.len);
const next = try frame.local_arena.alloc(*Transform, self._items.items.len + 1);
@memcpy(next[0..at], self._items.items[0..at]);
next[at] = prepared;
@memcpy(next[at + 1 ..], self._items.items[at..]);
try self._items.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
self._items.insertAssumeCapacity(at, prepared);
self.attach(prepared);
return prepared;
}
pub fn replaceItem(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform {
try self.requireMutable();
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const prepared = try self.prepareItem(item, frame);
errdefer prepared.releaseRef(frame._page);
const next = try frame.local_arena.dupe(*Transform, self._items.items);
next[index] = prepared;
try self._retired.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
const replaced = self._items.items[index];
replaced.detach(self);
self._retired.appendAssumeCapacity(replaced);
self._items.items[index] = prepared;
self.attach(prepared);
return prepared;
}
pub fn removeItem(self: *TransformList, index: u32, frame: *Frame) !*Transform {
try self.requireMutable();
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const next = try frame.local_arena.alloc(*Transform, self._items.items.len - 1);
@memcpy(next[0..index], self._items.items[0..index]);
@memcpy(next[index..], self._items.items[index + 1 ..]);
try self._retired.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
const removed = self._items.orderedRemove(index);
removed.detach(self);
self._retired.appendAssumeCapacity(removed);
return removed;
}
pub fn appendItem(self: *TransformList, item: *Transform, frame: *Frame) !*Transform {
return self.insertItemBefore(item, std.math.maxInt(u32), frame);
}
pub fn consolidate(self: *TransformList, frame: *Frame) !?*Transform {
try self.requireMutable();
try self.sync(frame);
if (self._items.items.len == 0) return null;
var matrix = DOMMatrixReadOnly.identity();
for (self._items.items) |item| matrix = DOMMatrixReadOnly.multiplyMatrix(matrix, item.getState().matrix);
for (matrix) |value| if (!std.math.isFinite(value)) return error.TypeError;
var values: [16]f64 = undefined;
values[0] = matrix[0];
values[1] = matrix[1];
values[2] = matrix[4];
values[3] = matrix[5];
values[4] = matrix[12];
values[5] = matrix[13];
const consolidated = try Transform.fromParsed(.{
.kind = .matrix,
.matrix = matrix,
.values = values,
.count = 6,
.is_2d = true,
}, frame);
consolidated.acquireRef();
errdefer consolidated.releaseRef(frame._page);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
try self.setAttribute(&.{consolidated}, frame);
self._items.appendAssumeCapacity(consolidated);
self.attach(consolidated);
return consolidated;
}
fn requireMutable(self: *const TransformList) !void {
if (self._read_only) return error.NoModificationAllowed;
}
fn prepareItem(_: *TransformList, item: *Transform, frame: *Frame) !*Transform {
const prepared = if (item.isAttached()) try item.clone(frame) else item;
prepared.acquireRef();
return prepared;
}
fn attach(self: *TransformList, transform: *Transform) void {
transform.attach(.{
.owner = self,
.mutate = TransformList.mutateTransform,
}, self._read_only);
}
fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform.State) anyerror!void {
const self: *TransformList = @ptrCast(@alignCast(context));
const frame = self._frame;
try self.sync(frame);
if (!transform.isAttachedTo(self)) {
transform.applyStateRaw(state);
return;
}
const index = for (self._items.items, 0..) |candidate, i| {
if (candidate == transform) break i;
} else unreachable;
try self.setAttributeWithOverride(index, state, frame);
transform.applyStateRaw(state);
}
fn sync(self: *TransformList, frame: *Frame) !void {
self.releaseRetired(frame._page);
const raw = self._element.getAttributeSafe(comptime .wrap("transform")) orelse "";
if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) return;
self._synced = false;
var parsed = parse(raw, frame) catch |err| switch (err) {
error.SyntaxError => std.ArrayList(*Transform).empty,
else => return err,
};
errdefer for (parsed.items) |transform| transform.releaseRef(frame._page);
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, raw);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
for (parsed.items) |transform| {
self._items.appendAssumeCapacity(transform);
self.attach(transform);
}
parsed.clearRetainingCapacity();
self._synced = true;
}
// A retired item must outlive the operation that retired it: removeItem's
// return value has no JS wrapper until the bridge wraps it after we return.
// By the next operation, anything still reachable holds its own ref.
fn releaseRetired(self: *TransformList, page: *Page) void {
for (self._retired.items) |transform| {
transform.releaseRef(page);
}
self._retired.clearRetainingCapacity();
}
fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*Transform) {
var parsed: std.ArrayList(*Transform) = .empty;
errdefer for (parsed.items) |transform| transform.releaseRef(frame._page);
const trimmed = std.mem.trim(u8, raw, " \t\r\n");
if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return parsed;
var iterator = DOMMatrixReadOnly.TransformFunctionIterator{ .input = trimmed, .allow_comma = true };
while (try iterator.next()) |function| {
const value = try DOMMatrixReadOnly.parseTransformFunction(function, .svg);
const transform = try Transform.fromParsed(value, frame);
transform.acquireRef();
parsed.append(frame.local_arena, transform) catch |err| {
transform.releaseRef(frame._page);
return err;
};
}
return parsed;
}
fn retireAll(self: *TransformList, frame: *Frame) !void {
self._synced = false;
try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
for (self._items.items) |transform| {
transform.detach(self);
self._retired.appendAssumeCapacity(transform);
}
self._items.clearRetainingCapacity();
}
fn setAttribute(self: *TransformList, items: []const *Transform, frame: *Frame) !void {
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
for (items, 0..) |transform, i| {
if (i != 0) try writer.writeByte(' ');
try Transform.writeState(transform.getState(), writer);
}
try self.commitAttribute(serialized.written(), frame);
}
fn setAttributeWithOverride(self: *TransformList, index: usize, state: Transform.State, frame: *Frame) !void {
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
for (self._items.items, 0..) |transform, i| {
if (i != 0) try writer.writeByte(' ');
try Transform.writeState(if (i == index) state else transform.getState(), writer);
}
try self.commitAttribute(serialized.written(), frame);
}
fn commitAttribute(self: *TransformList, serialized: []const u8, frame: *Frame) !void {
self._synced = false;
try self._element.setAttributeSafe(comptime .wrap("transform"), .wrap(serialized), frame);
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, serialized);
self._synced = true;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(TransformList);
pub const Meta = struct {
pub const name = "SVGTransformList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const length = bridge.accessor(TransformList.getLength, null, .{});
pub const numberOfItems = bridge.accessor(TransformList.getNumberOfItems, null, .{});
pub const clear = bridge.function(TransformList.clear, .{});
pub const initialize = bridge.function(TransformList.initialize, .{});
pub const getItem = bridge.function(TransformList.getItem, .{});
pub const insertItemBefore = bridge.function(TransformList.insertItemBefore, .{});
pub const replaceItem = bridge.function(TransformList.replaceItem, .{});
pub const removeItem = bridge.function(TransformList.removeItem, .{});
pub const appendItem = bridge.function(TransformList.appendItem, .{});
pub const consolidate = bridge.function(TransformList.consolidate, .{});
};