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..e52e25569 --- /dev/null +++ b/src/browser/tests/element/svg/text.html @@ -0,0 +1,86 @@ + + + + + + Hello🌍 + A + + abc + + + + + + + + diff --git a/src/browser/text_measure.zig b/src/browser/text_measure.zig new file mode 100644 index 000000000..461331cbf --- /dev/null +++ b/src/browser/text_measure.zig @@ -0,0 +1,111 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +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. + +// Character counts and indices are UTF-16 code units, to match DOM string +// semantics: an astral codepoint is two addressable characters in browsers. +pub fn utf16Length(text: []const u8) u32 { + var iterator = std.unicode.Utf8Iterator{ .bytes = text, .i = 0 }; + var result: u32 = 0; + while (iterator.nextCodepoint()) |codepoint| { + result += unitLength(codepoint); + } + return result; +} + +fn unitLength(codepoint: u21) u32 { + return if (codepoint >= 0x10000) 2 else 1; +} + +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, charnum: u32, nchars: u32, font_size: f64) !f64 { + if (charnum >= utf16Length(text)) return error.IndexSizeError; + const end = charnum +| nchars; + + // A glyph is measured whenever its unit range intersects the request, so + // addressing either half of a surrogate pair yields the full glyph, like + // Chrome does. + var iterator = std.unicode.Utf8Iterator{ .bytes = text, .i = 0 }; + var unit: u32 = 0; + var result: f64 = 0; + while (iterator.nextCodepoint()) |codepoint| { + const units = unitLength(codepoint); + if (unit >= end) break; + if (unit + units > charnum) result += advance(codepoint, font_size); + unit += units; + } + 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 utf-16 units and ignore combining marks" { + try std.testing.expectEqual(@as(u32, 3), utf16Length("Aé界")); + try std.testing.expectEqual(@as(u32, 2), utf16Length("🌍")); + 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.expectApproxEqAbs(@as(f64, 10), try substringWidth("🌍", 0, 1, 10), 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 10), try substringWidth("🌍", 1, 1, 10), 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 0), try substringWidth("A界", 1, 0, 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, , - // and have geometry we cannot resolve yet, so they - // contribute nothing rather than making the whole box unavailable. - .defs, .symbol, .svg, .switch_element, .use, .image => {}, + // , and text have geometry we cannot resolve yet, so + // they contribute nothing rather than making the whole box + // unavailable. + .defs, .symbol, .svg, .switch_element, .use, .image, .text_content => {}, } } } diff --git a/src/browser/webapi/element/svg/TSpan.zig b/src/browser/webapi/element/svg/TSpan.zig new file mode 100644 index 000000000..3e38329f0 --- /dev/null +++ b/src/browser/webapi/element/svg/TSpan.zig @@ -0,0 +1,43 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const TextPositioning = @import("TextPositioning.zig"); + +const TSpan = @This(); +_proto: *TextPositioning, + +pub fn asElement(self: *TSpan) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *TSpan) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TSpan); + pub const Meta = struct { + pub const name = "SVGTSpanElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Text.zig b/src/browser/webapi/element/svg/Text.zig new file mode 100644 index 000000000..0ea469066 --- /dev/null +++ b/src/browser/webapi/element/svg/Text.zig @@ -0,0 +1,43 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const TextPositioning = @import("TextPositioning.zig"); + +const Text = @This(); +_proto: *TextPositioning, + +pub fn asElement(self: *Text) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Text) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Text); + pub const Meta = struct { + pub const name = "SVGTextElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/TextContent.zig b/src/browser/webapi/element/svg/TextContent.zig new file mode 100644 index 000000000..57322d707 --- /dev/null +++ b/src/browser/webapi/element/svg/TextContent.zig @@ -0,0 +1,106 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); +const text_measure = @import("../../../text_measure.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const AnimatedEnumeration = @import("../../svg/AnimatedEnumeration.zig"); +const AnimatedLength = @import("../../svg/AnimatedLength.zig"); +const Length = @import("../../svg/Length.zig"); + +const Graphics = @import("Graphics.zig"); + +pub const TextPositioning = @import("TextPositioning.zig"); +pub const TextPath = @import("TextPath.zig"); + +const TextContent = @This(); +_proto: *Graphics, +_type: Type, + +pub const Type = union(enum) { + positioning: *TextPositioning, + text_path: *TextPath, +}; + +pub fn is(self: *TextContent, comptime T: type) ?*T { + inline for (@typeInfo(Type).@"union".fields) |field| { + if (@field(Type, field.name) == self._type) { + if (field.type == *T) return @field(self._type, field.name); + } + } + if (self._type == .positioning) return self._type.positioning.is(T); + return null; +} + +pub fn asElement(self: *TextContent) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *TextContent) *Node { + return self.asElement().asNode(); +} + +fn text(self: *TextContent, frame: *Frame) []const u8 { + return self.asNode().getTextContentAlloc(frame.local_arena) catch ""; +} + +fn fontSize(self: *TextContent, frame: *Frame) f64 { + return Length.fontSizeForElement(self.asElement(), frame); +} + +fn getTextLength(self: *TextContent, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .text_length, frame); +} + +fn getLengthAdjust(self: *TextContent, frame: *Frame) !*AnimatedEnumeration { + return AnimatedEnumeration.getOrCreate(self.asElement(), .length_adjust, frame); +} + +pub fn getNumberOfChars(self: *TextContent, frame: *Frame) u32 { + return text_measure.utf16Length(self.text(frame)); +} + +pub fn getComputedTextLength(self: *TextContent, frame: *Frame) f64 { + return text_measure.width(self.text(frame), self.fontSize(frame)); +} + +pub fn getSubStringLength(self: *TextContent, charnum: u32, nchars: u32, frame: *Frame) !f64 { + return text_measure.substringWidth(self.text(frame), charnum, nchars, self.fontSize(frame)); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TextContent); + pub const Meta = struct { + pub const name = "SVGTextContentElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const LENGTHADJUST_UNKNOWN = bridge.property(0, .{ .template = true }); + pub const LENGTHADJUST_SPACING = bridge.property(1, .{ .template = true }); + pub const LENGTHADJUST_SPACINGANDGLYPHS = bridge.property(2, .{ .template = true }); + + pub const textLength = bridge.accessor(TextContent.getTextLength, null, .{}); + pub const lengthAdjust = bridge.accessor(TextContent.getLengthAdjust, null, .{}); + pub const getNumberOfChars = bridge.function(TextContent.getNumberOfChars, .{}); + pub const getComputedTextLength = bridge.function(TextContent.getComputedTextLength, .{}); + pub const getSubStringLength = bridge.function(TextContent.getSubStringLength, .{}); +}; diff --git a/src/browser/webapi/element/svg/TextPath.zig b/src/browser/webapi/element/svg/TextPath.zig new file mode 100644 index 000000000..258365ef6 --- /dev/null +++ b/src/browser/webapi/element/svg/TextPath.zig @@ -0,0 +1,71 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const AnimatedEnumeration = @import("../../svg/AnimatedEnumeration.zig"); +const AnimatedLength = @import("../../svg/AnimatedLength.zig"); +const AnimatedString = @import("../../svg/AnimatedString.zig"); + +const TextContent = @import("TextContent.zig"); + +const TextPath = @This(); +_proto: *TextContent, + +pub fn asElement(self: *TextPath) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *TextPath) *Node { + return self.asElement().asNode(); +} + +fn getStartOffset(self: *TextPath, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .text_path_start_offset, frame); +} +fn getHref(self: *TextPath, frame: *Frame) !*AnimatedString { + return AnimatedString.getOrCreate(self.asElement(), .href, frame); +} +fn getMethod(self: *TextPath, frame: *Frame) !*AnimatedEnumeration { + return AnimatedEnumeration.getOrCreate(self.asElement(), .text_path_method, frame); +} +fn getSpacing(self: *TextPath, frame: *Frame) !*AnimatedEnumeration { + return AnimatedEnumeration.getOrCreate(self.asElement(), .text_path_spacing, frame); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TextPath); + pub const Meta = struct { + pub const name = "SVGTextPathElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + pub const TEXTPATH_METHODTYPE_UNKNOWN = bridge.property(0, .{ .template = true }); + pub const TEXTPATH_METHODTYPE_ALIGN = bridge.property(1, .{ .template = true }); + pub const TEXTPATH_METHODTYPE_STRETCH = bridge.property(2, .{ .template = true }); + pub const TEXTPATH_SPACINGTYPE_UNKNOWN = bridge.property(0, .{ .template = true }); + pub const TEXTPATH_SPACINGTYPE_AUTO = bridge.property(1, .{ .template = true }); + pub const TEXTPATH_SPACINGTYPE_EXACT = bridge.property(2, .{ .template = true }); + pub const startOffset = bridge.accessor(TextPath.getStartOffset, null, .{}); + pub const method = bridge.accessor(TextPath.getMethod, null, .{}); + pub const spacing = bridge.accessor(TextPath.getSpacing, null, .{}); + pub const href = bridge.accessor(TextPath.getHref, null, .{}); +}; diff --git a/src/browser/webapi/element/svg/TextPositioning.zig b/src/browser/webapi/element/svg/TextPositioning.zig new file mode 100644 index 000000000..9209e060f --- /dev/null +++ b/src/browser/webapi/element/svg/TextPositioning.zig @@ -0,0 +1,61 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const TextContent = @import("TextContent.zig"); + +pub const Text = @import("Text.zig"); +pub const TSpan = @import("TSpan.zig"); + +const TextPositioning = @This(); +_proto: *TextContent, +_type: Type, + +pub const Type = union(enum) { + text: *Text, + tspan: *TSpan, +}; + +pub fn is(self: *TextPositioning, comptime T: type) ?*T { + inline for (@typeInfo(Type).@"union".fields) |field| { + if (@field(Type, field.name) == self._type) { + if (field.type == *T) return @field(self._type, field.name); + } + } + return null; +} + +pub fn asElement(self: *TextPositioning) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *TextPositioning) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TextPositioning); + pub const Meta = struct { + pub const name = "SVGTextPositioningElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/svg/AnimatedEnumeration.zig b/src/browser/webapi/svg/AnimatedEnumeration.zig index ee2334861..98c486006 100644 --- a/src/browser/webapi/svg/AnimatedEnumeration.zig +++ b/src/browser/webapi/svg/AnimatedEnumeration.zig @@ -48,6 +48,18 @@ const spread_entries = [_]Entry{ .{ .keyword = "reflect", .value = 2 }, .{ .keyword = "repeat", .value = 3 }, }; +const length_adjust_entries = [_]Entry{ + .{ .keyword = "spacing", .value = 1 }, + .{ .keyword = "spacingAndGlyphs", .value = 2 }, +}; +const text_path_method_entries = [_]Entry{ + .{ .keyword = "align", .value = 1 }, + .{ .keyword = "stretch", .value = 2 }, +}; +const text_path_spacing_entries = [_]Entry{ + .{ .keyword = "auto", .value = 1 }, + .{ .keyword = "exact", .value = 2 }, +}; pub const Kind = enum { clip_path_units, @@ -58,6 +70,9 @@ pub const Kind = enum { mask_content_units, pattern_units, pattern_content_units, + length_adjust, + text_path_method, + text_path_spacing, fn attributeName(self: Kind) lp.String { return switch (self) { @@ -69,6 +84,9 @@ pub const Kind = enum { .mask_content_units => .wrap("maskContentUnits"), .pattern_units => .wrap("patternUnits"), .pattern_content_units => .wrap("patternContentUnits"), + .length_adjust => .wrap("lengthAdjust"), + .text_path_method => .wrap("method"), + .text_path_spacing => .wrap("spacing"), }; } @@ -76,6 +94,9 @@ pub const Kind = enum { return switch (self) { .marker_units => &marker_unit_entries, .spread_method => &spread_entries, + .length_adjust => &length_adjust_entries, + .text_path_method => &text_path_method_entries, + .text_path_spacing => &text_path_spacing_entries, else => &unit_entries, }; } @@ -86,6 +107,8 @@ pub const Kind = enum { .mask_content_units, .pattern_content_units, .spread_method, + .length_adjust, + .text_path_method, => 1, else => 2, }; diff --git a/src/browser/webapi/svg/AnimatedLength.zig b/src/browser/webapi/svg/AnimatedLength.zig index 68cc11fd9..8d327ba07 100644 --- a/src/browser/webapi/svg/AnimatedLength.zig +++ b/src/browser/webapi/svg/AnimatedLength.zig @@ -67,6 +67,8 @@ pub const Kind = enum { pattern_y, pattern_width, pattern_height, + text_length, + text_path_start_offset, fn attributeName(self: Kind) lp.String { return switch (self) { @@ -90,6 +92,8 @@ pub const Kind = enum { .marker_ref_y => comptime .wrap("refY"), .marker_width => comptime .wrap("markerWidth"), .marker_height => comptime .wrap("markerHeight"), + .text_length => comptime .wrap("textLength"), + .text_path_start_offset => comptime .wrap("startOffset"), }; } @@ -112,6 +116,7 @@ pub const Kind = enum { .mask_width, .pattern_x, .pattern_width, + .text_length, => .horizontal, .y, .height, @@ -131,7 +136,7 @@ pub const Kind = enum { .pattern_y, .pattern_height, => .vertical, - .r, .radial_gradient_r, .radial_gradient_fr => .unspecified, + .r, .radial_gradient_r, .radial_gradient_fr, .text_path_start_offset => .unspecified, }; } diff --git a/src/browser/webapi/svg/Length.zig b/src/browser/webapi/svg/Length.zig index b33d2acad..f3cc825f8 100644 --- a/src/browser/webapi/svg/Length.zig +++ b/src/browser/webapi/svg/Length.zig @@ -313,6 +313,10 @@ fn resolvedFontSizeAt(element: ?*Element, frame: *Frame, depth: u8) f64 { return resolvedFontSizeAt(parent, frame, depth + 1); } +pub fn fontSizeForElement(element: *Element, frame: *Frame) f64 { + return resolvedFontSize(element, frame); +} + fn parseFontSize(raw: []const u8, parent: ?*Element, frame: *Frame, depth: u8) ?f64 { const value = std.mem.trim(u8, raw, " \t\r\n\x0c"); if (std.ascii.eqlIgnoreCase(value, "inherit") or std.ascii.eqlIgnoreCase(value, "unset")) { @@ -331,7 +335,8 @@ fn parseFontSize(raw: []const u8, parent: ?*Element, frame: *Frame, depth: u8) ? .ex => parent_size / 2.0, else => unreachable, }; - return parsed.value * factor; + const size = parsed.value * factor; + return if (size >= 0 and std.math.isFinite(size)) size else null; } fn absoluteUnitFactor(unit: Unit) ?f64 {