From 700dfe5b742417a3f7b6dbe2c7dfbcc665c8decb Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 27 Jul 2026 20:27:51 +0800 Subject: [PATCH] count in utf-16, like a browser --- src/browser/tests/element/svg/text.html | 26 +++++-- src/browser/text_measure.zig | 67 ++++++++++++++----- src/browser/webapi/element/svg/TSpan.zig | 21 +++++- src/browser/webapi/element/svg/Text.zig | 21 +++++- .../webapi/element/svg/TextContent.zig | 24 ++++++- src/browser/webapi/element/svg/TextPath.zig | 22 +++++- .../webapi/element/svg/TextPositioning.zig | 21 +++++- 7 files changed, 167 insertions(+), 35 deletions(-) diff --git a/src/browser/tests/element/svg/text.html b/src/browser/tests/element/svg/text.html index 30aded83c..e52e25569 100644 --- a/src/browser/tests/element/svg/text.html +++ b/src/browser/tests/element/svg/text.html @@ -1,4 +1,5 @@ + @@ -27,19 +28,34 @@ } + @@ -48,7 +64,6 @@ const text = $('#text'); testing.expectEqual(true, text.textLength instanceof SVGAnimatedLength); testing.expectEqual(true, text.textLength === text.textLength); - testing.expectEqual(0, text.textLength.baseVal.value); text.textLength.baseVal.value = 100; testing.expectEqual('100', text.getAttribute('textLength')); testing.expectEqual(100, text.textLength.animVal.value); @@ -56,7 +71,6 @@ testing.expectEqual(true, text.lengthAdjust === text.lengthAdjust); text.lengthAdjust.baseVal = 2; testing.expectEqual('spacingAndGlyphs', text.getAttribute('lengthAdjust')); - testing.expectError('InvalidStateError', () => text.getBBox()); const textPath = $('#textPath'); testing.expectEqual('#guide', textPath.href.baseVal); diff --git a/src/browser/text_measure.zig b/src/browser/text_measure.zig index a106c42f3..461331cbf 100644 --- a/src/browser/text_measure.zig +++ b/src/browser/text_measure.zig @@ -1,13 +1,40 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// SPDX-License-Identifier: AGPL-3.0-or-later +// 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. -pub fn countCodepoints(text: []const u8) u32 { - return @intCast(std.unicode.utf8CountCodepoints(text) catch 0); + +// 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 { @@ -17,21 +44,21 @@ pub fn width(text: []const u8, font_size: f64) f64 { return result; } -pub fn substringWidth(text: []const u8, offset: u32, count: u32, font_size: f64) !f64 { - if (text.len == 0) return error.IndexSizeError; +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 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 unit: u32 = 0; var result: f64 = 0; - var remaining = count; - while (remaining > 0) : (remaining -= 1) { - const codepoint = iterator.nextCodepoint() orelse break; - result += advance(codepoint, font_size); + 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; } @@ -70,10 +97,14 @@ fn isWide(codepoint: u21) bool { (codepoint >= 0x20000 and codepoint <= 0x3fffd); } -test "fallback metrics count codepoints and ignore combining marks" { - try std.testing.expectEqual(@as(u32, 3), countCodepoints("Aé界")); +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/TSpan.zig b/src/browser/webapi/element/svg/TSpan.zig index 3f13a6c22..3e38329f0 100644 --- a/src/browser/webapi/element/svg/TSpan.zig +++ b/src/browser/webapi/element/svg/TSpan.zig @@ -1,9 +1,26 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// SPDX-License-Identifier: AGPL-3.0-or-later +// 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(); diff --git a/src/browser/webapi/element/svg/Text.zig b/src/browser/webapi/element/svg/Text.zig index 8895076c4..0ea469066 100644 --- a/src/browser/webapi/element/svg/Text.zig +++ b/src/browser/webapi/element/svg/Text.zig @@ -1,9 +1,26 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// SPDX-License-Identifier: AGPL-3.0-or-later +// 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(); diff --git a/src/browser/webapi/element/svg/TextContent.zig b/src/browser/webapi/element/svg/TextContent.zig index 83fce5c18..57322d707 100644 --- a/src/browser/webapi/element/svg/TextContent.zig +++ b/src/browser/webapi/element/svg/TextContent.zig @@ -1,14 +1,32 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// SPDX-License-Identifier: AGPL-3.0-or-later +// 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"); @@ -57,7 +75,7 @@ fn getLengthAdjust(self: *TextContent, frame: *Frame) !*AnimatedEnumeration { } pub fn getNumberOfChars(self: *TextContent, frame: *Frame) u32 { - return text_measure.countCodepoints(self.text(frame)); + return text_measure.utf16Length(self.text(frame)); } pub fn getComputedTextLength(self: *TextContent, frame: *Frame) f64 { diff --git a/src/browser/webapi/element/svg/TextPath.zig b/src/browser/webapi/element/svg/TextPath.zig index 87bc52f94..258365ef6 100644 --- a/src/browser/webapi/element/svg/TextPath.zig +++ b/src/browser/webapi/element/svg/TextPath.zig @@ -1,13 +1,31 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// SPDX-License-Identifier: AGPL-3.0-or-later +// 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(); diff --git a/src/browser/webapi/element/svg/TextPositioning.zig b/src/browser/webapi/element/svg/TextPositioning.zig index 59f47ffd5..9209e060f 100644 --- a/src/browser/webapi/element/svg/TextPositioning.zig +++ b/src/browser/webapi/element/svg/TextPositioning.zig @@ -1,9 +1,26 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// SPDX-License-Identifier: AGPL-3.0-or-later +// 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");