From 5e6395c6ad177b58fa3b1c9400a020671d26b12d Mon Sep 17 00:00:00 2001
From: Madison Steiner <8176115+mh0pe@users.noreply.github.com>
Date: Tue, 21 Jul 2026 23:07:44 -0700
Subject: [PATCH 1/2] webapi: add dependency-free SVG text DOM
---
src/browser/frame/node_factory.zig | 3 +
src/browser/js/bridge.zig | 7 ++
src/browser/tests/element/svg/hierarchy.html | 5 ++
src/browser/tests/element/svg/text.html | 72 +++++++++++++++
src/browser/text_measure.zig | 80 +++++++++++++++++
src/browser/webapi/element/Svg.zig | 7 ++
src/browser/webapi/element/svg/Graphics.zig | 14 ++-
src/browser/webapi/element/svg/TSpan.zig | 26 ++++++
src/browser/webapi/element/svg/Text.zig | 26 ++++++
.../webapi/element/svg/TextContent.zig | 88 +++++++++++++++++++
src/browser/webapi/element/svg/TextPath.zig | 53 +++++++++++
.../webapi/element/svg/TextPositioning.zig | 44 ++++++++++
.../webapi/svg/AnimatedEnumeration.zig | 23 +++++
src/browser/webapi/svg/AnimatedLength.zig | 7 +-
src/browser/webapi/svg/Length.zig | 7 +-
15 files changed, 456 insertions(+), 6 deletions(-)
create mode 100644 src/browser/tests/element/svg/text.html
create mode 100644 src/browser/text_measure.zig
create mode 100644 src/browser/webapi/element/svg/TSpan.zig
create mode 100644 src/browser/webapi/element/svg/Text.zig
create mode 100644 src/browser/webapi/element/svg/TextContent.zig
create mode 100644 src/browser/webapi/element/svg/TextPath.zig
create mode 100644 src/browser/webapi/element/svg/TextPositioning.zig
diff --git a/src/browser/frame/node_factory.zig b/src/browser/frame/node_factory.zig
index 5049a3dbc..3fdc09f45 100644
--- a/src/browser/frame/node_factory.zig
+++ b/src/browser/frame/node_factory.zig
@@ -905,6 +905,7 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons
asUint("mask") => return createSvgElementT(frame, Element.Svg.Mask, name, attribute_iterator, .{ ._proto = undefined }),
asUint("rect") => return createSvgElementT(frame, Geometry.Rect, name, attribute_iterator, .{ ._proto = undefined }),
asUint("stop") => return createSvgElementT(frame, Element.Svg.Stop, name, attribute_iterator, .{ ._proto = undefined }),
+ asUint("text") => return createSvgElementT(frame, Graphics.TextContent.TextPositioning.Text, name, attribute_iterator, .{ ._proto = undefined }),
asUint("line") => return createSvgElementT(frame, Geometry.Line, name, attribute_iterator, .{ ._proto = undefined }),
asUint("path") => return createSvgElementT(frame, Geometry.Path, name, attribute_iterator, .{ ._proto = undefined }),
asUint("view") => return createSvgElementT(frame, Element.Svg.View, name, attribute_iterator, .{ ._proto = undefined }),
@@ -913,6 +914,7 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons
5 => switch (@as(u40, @bitCast(name[0..5].*))) {
asUint("image") => return createSvgElementT(frame, Graphics.Image, name, attribute_iterator, .{ ._proto = undefined }),
asUint("title") => return createSvgElementT(frame, Element.Svg.Title, name, attribute_iterator, .{ ._proto = undefined }),
+ asUint("tspan") => return createSvgElementT(frame, Graphics.TextContent.TextPositioning.TSpan, name, attribute_iterator, .{ ._proto = undefined }),
else => {},
},
6 => switch (@as(u48, @bitCast(name[0..6].*))) {
@@ -932,6 +934,7 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons
asUint("clipPath") => return createSvgElementT(frame, Element.Svg.ClipPath, name, attribute_iterator, .{ ._proto = undefined }),
asUint("metadata") => return createSvgElementT(frame, Element.Svg.Metadata, name, attribute_iterator, .{ ._proto = undefined }),
asUint("polyline") => return createSvgElementT(frame, Geometry.Polyline, name, attribute_iterator, .{ ._proto = undefined }),
+ asUint("textPath") => return createSvgElementT(frame, Graphics.TextContent.TextPath, name, attribute_iterator, .{ ._proto = undefined }),
else => {},
},
13 => switch (@as(u104, @bitCast(name[0..13].*))) {
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 53af562df..9129ed80a 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -812,6 +812,7 @@ fn PrototypeType(comptime T: type) ?type {
}
fn flattenTypes(comptime Types: []const type) [countFlattenedTypes(Types)]type {
+ @setEvalBranchQuota(10_000);
var index: usize = 0;
var flat: [countFlattenedTypes(Types)]type = undefined;
for (Types) |T| {
@@ -829,6 +830,7 @@ fn flattenTypes(comptime Types: []const type) [countFlattenedTypes(Types)]type {
}
fn countFlattenedTypes(comptime Types: []const type) usize {
+ @setEvalBranchQuota(10_000);
var c: usize = 0;
for (Types) |T| {
c += if (@hasDecl(T, "registerTypes")) T.registerTypes().len else 1;
@@ -1064,6 +1066,11 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/element/svg/Symbol.zig"),
@import("../webapi/element/svg/Switch.zig"),
@import("../webapi/element/svg/ForeignObject.zig"),
+ @import("../webapi/element/svg/TextContent.zig"),
+ @import("../webapi/element/svg/TextPositioning.zig"),
+ @import("../webapi/element/svg/Text.zig"),
+ @import("../webapi/element/svg/TSpan.zig"),
+ @import("../webapi/element/svg/TextPath.zig"),
@import("../webapi/element/svg/View.zig"),
@import("../webapi/element/svg/Title.zig"),
@import("../webapi/element/svg/Desc.zig"),
diff --git a/src/browser/tests/element/svg/hierarchy.html b/src/browser/tests/element/svg/hierarchy.html
index 32ab775ca..210823486 100644
--- a/src/browser/tests/element/svg/hierarchy.html
+++ b/src/browser/tests/element/svg/hierarchy.html
@@ -55,6 +55,11 @@
testing.expectEqual('function', typeof SVGMaskElement);
testing.expectEqual('function', typeof SVGPatternElement);
testing.expectEqual('function', typeof SVGStopElement);
+ testing.expectEqual('function', typeof SVGTextContentElement);
+ testing.expectEqual('function', typeof SVGTextPositioningElement);
+ testing.expectEqual('function', typeof SVGTextElement);
+ testing.expectEqual('function', typeof SVGTSpanElement);
+ testing.expectEqual('function', typeof SVGTextPathElement);
}
diff --git a/src/browser/tests/element/svg/text.html b/src/browser/tests/element/svg/text.html
new file mode 100644
index 000000000..30aded83c
--- /dev/null
+++ b/src/browser/tests/element/svg/text.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/text_measure.zig b/src/browser/text_measure.zig
new file mode 100644
index 000000000..a106c42f3
--- /dev/null
+++ b/src/browser/text_measure.zig
@@ -0,0 +1,80 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+const std = @import("std");
+
+// Deterministic DOM fallback only. This intentionally does not claim to shape
+// scripts, apply kerning, or select fonts; a real shaping backend can replace
+// this module without changing SVGTextContentElement.
+pub fn countCodepoints(text: []const u8) u32 {
+ return @intCast(std.unicode.utf8CountCodepoints(text) catch 0);
+}
+
+pub fn width(text: []const u8, font_size: f64) f64 {
+ var iterator = std.unicode.Utf8Iterator{ .bytes = text, .i = 0 };
+ var result: f64 = 0;
+ while (iterator.nextCodepoint()) |codepoint| result += advance(codepoint, font_size);
+ return result;
+}
+
+pub fn substringWidth(text: []const u8, offset: u32, count: u32, font_size: f64) !f64 {
+ if (text.len == 0) return error.IndexSizeError;
+
+ var iterator = std.unicode.Utf8Iterator{ .bytes = text, .i = 0 };
+ var index: u32 = 0;
+ while (index < offset) : (index += 1) {
+ if (iterator.nextCodepoint() == null) return error.IndexSizeError;
+ }
+ if (offset > 0 and iterator.i == text.len) return error.IndexSizeError;
+
+ var result: f64 = 0;
+ var remaining = count;
+ while (remaining > 0) : (remaining -= 1) {
+ const codepoint = iterator.nextCodepoint() orelse break;
+ result += advance(codepoint, font_size);
+ }
+ return result;
+}
+
+fn advance(codepoint: u21, font_size: f64) f64 {
+ if (isZeroWidth(codepoint)) return 0;
+ if (codepoint == '\n' or codepoint == '\r') return 0;
+ if (codepoint == '\t') return font_size * 1.32;
+ if (std.ascii.isWhitespace(@intCast(@min(codepoint, 0x7f)))) return font_size * 0.33;
+ if (isWide(codepoint)) return font_size;
+ return font_size * 0.6;
+}
+
+fn isZeroWidth(codepoint: u21) bool {
+ return codepoint == 0x200c or codepoint == 0x200d or
+ (codepoint >= 0x0300 and codepoint <= 0x036f) or
+ (codepoint >= 0x1ab0 and codepoint <= 0x1aff) or
+ (codepoint >= 0x1dc0 and codepoint <= 0x1dff) or
+ (codepoint >= 0x20d0 and codepoint <= 0x20ff) or
+ (codepoint >= 0xfe00 and codepoint <= 0xfe0f) or
+ (codepoint >= 0xfe20 and codepoint <= 0xfe2f) or
+ (codepoint >= 0xe0100 and codepoint <= 0xe01ef);
+}
+
+fn isWide(codepoint: u21) bool {
+ return (codepoint >= 0x1100 and codepoint <= 0x115f) or
+ codepoint == 0x2329 or codepoint == 0x232a or
+ (codepoint >= 0x2e80 and codepoint <= 0xa4cf) or
+ (codepoint >= 0xac00 and codepoint <= 0xd7a3) or
+ (codepoint >= 0xf900 and codepoint <= 0xfaff) or
+ (codepoint >= 0xfe10 and codepoint <= 0xfe19) or
+ (codepoint >= 0xfe30 and codepoint <= 0xfe6f) or
+ (codepoint >= 0xff00 and codepoint <= 0xff60) or
+ (codepoint >= 0xffe0 and codepoint <= 0xffe6) or
+ (codepoint >= 0x1f000 and codepoint <= 0x1faff) or
+ (codepoint >= 0x20000 and codepoint <= 0x3fffd);
+}
+
+test "fallback metrics count codepoints and ignore combining marks" {
+ try std.testing.expectEqual(@as(u32, 3), countCodepoints("Aé界"));
+ try std.testing.expectApproxEqAbs(@as(f64, 16), width("A\u{0301}界", 10), 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f64, 10), try substringWidth("A界", 1, 1, 10), 0.0001);
+ try std.testing.expectError(error.IndexSizeError, substringWidth("A", 2, 1, 10));
+ try std.testing.expectError(error.IndexSizeError, substringWidth("A", 1, 0, 10));
+ try std.testing.expectError(error.IndexSizeError, substringWidth("", 0, 0, 10));
+}
diff --git a/src/browser/webapi/element/Svg.zig b/src/browser/webapi/element/Svg.zig
index f53a16160..56728991f 100644
--- a/src/browser/webapi/element/Svg.zig
+++ b/src/browser/webapi/element/Svg.zig
@@ -85,6 +85,13 @@ pub fn getTag(self: *const Svg) Element.Tag {
// No dedicated Element.Tag values; tag-name matching falls back
// to _tag_name, like it does for generic SVG elements.
.a, .use, .image, .defs, .symbol, .switch_element, .foreign_object => .unknown,
+ .text_content => |content| switch (content._type) {
+ .positioning => |positioning| switch (positioning._type) {
+ .text => .text,
+ .tspan => .unknown,
+ },
+ .text_path => .unknown,
+ },
.geometry => |geo| switch (geo._type) {
.rect => .rect,
.circle => .circle,
diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig
index cbdf90715..084395078 100644
--- a/src/browser/webapi/element/svg/Graphics.zig
+++ b/src/browser/webapi/element/svg/Graphics.zig
@@ -38,6 +38,7 @@ pub const Defs = @import("Defs.zig");
pub const Symbol = @import("Symbol.zig");
pub const Switch = @import("Switch.zig");
pub const ForeignObject = @import("ForeignObject.zig");
+pub const TextContent = @import("TextContent.zig");
pub const Geometry = @import("Geometry.zig");
const Graphics = @This();
@@ -54,6 +55,7 @@ pub const Type = union(enum) {
symbol: *Symbol,
switch_element: *Switch,
foreign_object: *ForeignObject,
+ text_content: *TextContent,
geometry: *Geometry,
};
@@ -68,6 +70,9 @@ pub fn is(self: *Graphics, comptime T: type) ?*T {
if (self._type == .geometry) {
return self._type.geometry.is(T);
}
+ if (self._type == .text_content) {
+ return self._type.text_content.is(T);
+ }
return null;
}
@@ -106,7 +111,7 @@ pub fn getBBox(self: *Graphics, frame: *Frame) !*DOMRect {
},
.foreign_object => |foreign_object| bounds = try foreign_object.getBounds(frame),
.g, .a, .svg => try accumulateChildren(self, .{}, &bounds, frame),
- .defs, .symbol, .switch_element, .use, .image => {},
+ .defs, .symbol, .switch_element, .use, .image, .text_content => {},
}
if (bounds.isEmpty()) {
return DOMRect.create(.{}, frame._factory);
@@ -173,9 +178,10 @@ fn accumulateChildren(parent: *Graphics, matrix: PathData.Matrix, bounds: *PathD
.matrix = child_matrix,
}),
// and never render. Nested viewports, ,
- //