From 861126f810104354008533cf98a9f73b6721085b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 21 Feb 2026 12:58:35 +0800 Subject: [PATCH 1/4] Add dummy implementation of OffscreenCanvas --- src/browser/js/bridge.zig | 2 + .../tests/canvas/offscreen_canvas.html | 64 ++++++ src/browser/webapi/canvas/OffscreenCanvas.zig | 105 +++++++++ .../OffscreenCanvasRenderingContext2D.zig | 209 ++++++++++++++++++ src/browser/webapi/element/html/Canvas.zig | 10 + src/browser/webapi/storage/Cookie.zig | 2 +- 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 src/browser/tests/canvas/offscreen_canvas.html create mode 100644 src/browser/webapi/canvas/OffscreenCanvas.zig create mode 100644 src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 3a991313..cbab600d 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -865,6 +865,8 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/navigation/NavigationActivation.zig"), @import("../webapi/canvas/CanvasRenderingContext2D.zig"), @import("../webapi/canvas/WebGLRenderingContext.zig"), + @import("../webapi/canvas/OffscreenCanvas.zig"), + @import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"), @import("../webapi/SubtleCrypto.zig"), @import("../webapi/Selection.zig"), @import("../webapi/ImageData.zig"), diff --git a/src/browser/tests/canvas/offscreen_canvas.html b/src/browser/tests/canvas/offscreen_canvas.html new file mode 100644 index 00000000..f162213a --- /dev/null +++ b/src/browser/tests/canvas/offscreen_canvas.html @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + diff --git a/src/browser/webapi/canvas/OffscreenCanvas.zig b/src/browser/webapi/canvas/OffscreenCanvas.zig new file mode 100644 index 00000000..be83c146 --- /dev/null +++ b/src/browser/webapi/canvas/OffscreenCanvas.zig @@ -0,0 +1,105 @@ +// 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"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const Blob = @import("../Blob.zig"); +const OffscreenCanvasRenderingContext2D = @import("OffscreenCanvasRenderingContext2D.zig"); + +/// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas +const OffscreenCanvas = @This(); + +pub const _prototype_root = true; + +_width: u32, +_height: u32, + +/// Since there's no base class rendering contextes inherit from, +/// we're using tagged union. +const DrawingContext = union(enum) { + @"2d": *OffscreenCanvasRenderingContext2D, +}; + +pub fn constructor(width: u32, height: u32, page: *Page) !*OffscreenCanvas { + return page._factory.create(OffscreenCanvas{ + ._width = width, + ._height = height, + }); +} + +pub fn getWidth(self: *const OffscreenCanvas) u32 { + return self._width; +} + +pub fn setWidth(self: *OffscreenCanvas, value: u32) void { + self._width = value; +} + +pub fn getHeight(self: *const OffscreenCanvas) u32 { + return self._height; +} + +pub fn setHeight(self: *OffscreenCanvas, value: u32) void { + self._height = value; +} + +pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, page: *Page) !?DrawingContext { + if (std.mem.eql(u8, context_type, "2d")) { + const ctx = try page._factory.create(OffscreenCanvasRenderingContext2D{}); + return .{ .@"2d" = ctx }; + } + + return null; +} + +/// Returns a Promise that resolves to a Blob containing the image. +/// Since we have no actual rendering, this returns an empty blob. +pub fn convertToBlob(_: *OffscreenCanvas, page: *Page) !js.Promise { + const blob = try Blob.init(null, null, page); + return page.js.local.?.resolvePromise(blob); +} + +/// Returns an ImageBitmap with the rendered content (stub). +pub fn transferToImageBitmap(_: *OffscreenCanvas) ?void { + // ImageBitmap not implemented yet, return null + return null; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(OffscreenCanvas); + + pub const Meta = struct { + pub const name = "OffscreenCanvas"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(OffscreenCanvas.constructor, .{}); + pub const width = bridge.accessor(OffscreenCanvas.getWidth, OffscreenCanvas.setWidth, .{}); + pub const height = bridge.accessor(OffscreenCanvas.getHeight, OffscreenCanvas.setHeight, .{}); + pub const getContext = bridge.function(OffscreenCanvas.getContext, .{}); + pub const convertToBlob = bridge.function(OffscreenCanvas.convertToBlob, .{}); + pub const transferToImageBitmap = bridge.function(OffscreenCanvas.transferToImageBitmap, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: OffscreenCanvas" { + try testing.htmlRunner("canvas/offscreen_canvas.html", .{}); +} diff --git a/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig b/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig new file mode 100644 index 00000000..26ebb8ec --- /dev/null +++ b/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig @@ -0,0 +1,209 @@ +// Copyright (C) 2023-2025 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"); + +const js = @import("../../js/js.zig"); +const color = @import("../../color.zig"); +const Page = @import("../../Page.zig"); + +const ImageData = @import("../ImageData.zig"); + +/// This class doesn't implement a `constructor`. +/// It can be obtained with a call to `OffscreenCanvas#getContext`. +/// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvasRenderingContext2D +const OffscreenCanvasRenderingContext2D = @This(); +/// Fill color. +/// TODO: Add support for `CanvasGradient` and `CanvasPattern`. +_fill_style: color.RGBA = color.RGBA.Named.black, + +pub fn getFillStyle(self: *const OffscreenCanvasRenderingContext2D, page: *Page) ![]const u8 { + var w = std.Io.Writer.Allocating.init(page.call_arena); + try self._fill_style.format(&w.writer); + return w.written(); +} + +pub fn setFillStyle( + self: *OffscreenCanvasRenderingContext2D, + value: []const u8, +) !void { + // Prefer the same fill_style if fails. + self._fill_style = color.RGBA.parse(value) catch self._fill_style; +} + +pub fn getGlobalAlpha(_: *const OffscreenCanvasRenderingContext2D) f64 { + return 1.0; +} + +pub fn getGlobalCompositeOperation(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "source-over"; +} + +pub fn getStrokeStyle(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "#000000"; +} + +pub fn getLineWidth(_: *const OffscreenCanvasRenderingContext2D) f64 { + return 1.0; +} + +pub fn getLineCap(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "butt"; +} + +pub fn getLineJoin(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "miter"; +} + +pub fn getMiterLimit(_: *const OffscreenCanvasRenderingContext2D) f64 { + return 10.0; +} + +pub fn getFont(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "10px sans-serif"; +} + +pub fn getTextAlign(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "start"; +} + +pub fn getTextBaseline(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "alphabetic"; +} + +const WidthOrImageData = union(enum) { + width: u32, + image_data: *ImageData, +}; + +pub fn createImageData( + _: *const OffscreenCanvasRenderingContext2D, + width_or_image_data: WidthOrImageData, + /// If `ImageData` variant preferred, this is null. + maybe_height: ?u32, + /// Can be used if width and height provided. + maybe_settings: ?ImageData.ConstructorSettings, + page: *Page, +) !*ImageData { + switch (width_or_image_data) { + .width => |width| { + const height = maybe_height orelse return error.TypeError; + return ImageData.constructor(width, height, maybe_settings, page); + }, + .image_data => |image_data| { + return ImageData.constructor(image_data._width, image_data._height, null, page); + }, + } +} + +pub fn putImageData(_: *const OffscreenCanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {} + +pub fn save(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn restore(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn scale(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} +pub fn rotate(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} +pub fn translate(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} +pub fn transform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} +pub fn setTransform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} +pub fn resetTransform(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn setGlobalAlpha(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} +pub fn setGlobalCompositeOperation(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setStrokeStyle(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setLineWidth(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} +pub fn setLineCap(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setLineJoin(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setMiterLimit(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} +pub fn clearRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn fillRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn strokeRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn beginPath(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn closePath(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn moveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} +pub fn lineTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} +pub fn quadraticCurveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn bezierCurveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} +pub fn arc(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: ?bool) void {} +pub fn arcTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64) void {} +pub fn rect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn fill(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn stroke(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn clip(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn setFont(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setTextAlign(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setTextBaseline(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn fillText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} +pub fn strokeText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} + +pub const JsApi = struct { + pub const bridge = js.Bridge(OffscreenCanvasRenderingContext2D); + + pub const Meta = struct { + pub const name = "OffscreenCanvasRenderingContext2D"; + + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const createImageData = bridge.function(OffscreenCanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); + pub const putImageData = bridge.function(OffscreenCanvasRenderingContext2D.putImageData, .{}); + + pub const save = bridge.function(OffscreenCanvasRenderingContext2D.save, .{}); + pub const restore = bridge.function(OffscreenCanvasRenderingContext2D.restore, .{}); + + pub const scale = bridge.function(OffscreenCanvasRenderingContext2D.scale, .{}); + pub const rotate = bridge.function(OffscreenCanvasRenderingContext2D.rotate, .{}); + pub const translate = bridge.function(OffscreenCanvasRenderingContext2D.translate, .{}); + pub const transform = bridge.function(OffscreenCanvasRenderingContext2D.transform, .{}); + pub const setTransform = bridge.function(OffscreenCanvasRenderingContext2D.setTransform, .{}); + pub const resetTransform = bridge.function(OffscreenCanvasRenderingContext2D.resetTransform, .{}); + + pub const globalAlpha = bridge.accessor(OffscreenCanvasRenderingContext2D.getGlobalAlpha, OffscreenCanvasRenderingContext2D.setGlobalAlpha, .{}); + pub const globalCompositeOperation = bridge.accessor(OffscreenCanvasRenderingContext2D.getGlobalCompositeOperation, OffscreenCanvasRenderingContext2D.setGlobalCompositeOperation, .{}); + + pub const fillStyle = bridge.accessor(OffscreenCanvasRenderingContext2D.getFillStyle, OffscreenCanvasRenderingContext2D.setFillStyle, .{}); + pub const strokeStyle = bridge.accessor(OffscreenCanvasRenderingContext2D.getStrokeStyle, OffscreenCanvasRenderingContext2D.setStrokeStyle, .{}); + + pub const lineWidth = bridge.accessor(OffscreenCanvasRenderingContext2D.getLineWidth, OffscreenCanvasRenderingContext2D.setLineWidth, .{}); + pub const lineCap = bridge.accessor(OffscreenCanvasRenderingContext2D.getLineCap, OffscreenCanvasRenderingContext2D.setLineCap, .{}); + pub const lineJoin = bridge.accessor(OffscreenCanvasRenderingContext2D.getLineJoin, OffscreenCanvasRenderingContext2D.setLineJoin, .{}); + pub const miterLimit = bridge.accessor(OffscreenCanvasRenderingContext2D.getMiterLimit, OffscreenCanvasRenderingContext2D.setMiterLimit, .{}); + + pub const clearRect = bridge.function(OffscreenCanvasRenderingContext2D.clearRect, .{}); + pub const fillRect = bridge.function(OffscreenCanvasRenderingContext2D.fillRect, .{}); + pub const strokeRect = bridge.function(OffscreenCanvasRenderingContext2D.strokeRect, .{}); + + pub const beginPath = bridge.function(OffscreenCanvasRenderingContext2D.beginPath, .{}); + pub const closePath = bridge.function(OffscreenCanvasRenderingContext2D.closePath, .{}); + pub const moveTo = bridge.function(OffscreenCanvasRenderingContext2D.moveTo, .{}); + pub const lineTo = bridge.function(OffscreenCanvasRenderingContext2D.lineTo, .{}); + pub const quadraticCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.quadraticCurveTo, .{}); + pub const bezierCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.bezierCurveTo, .{}); + pub const arc = bridge.function(OffscreenCanvasRenderingContext2D.arc, .{}); + pub const arcTo = bridge.function(OffscreenCanvasRenderingContext2D.arcTo, .{}); + pub const rect = bridge.function(OffscreenCanvasRenderingContext2D.rect, .{}); + + pub const fill = bridge.function(OffscreenCanvasRenderingContext2D.fill, .{}); + pub const stroke = bridge.function(OffscreenCanvasRenderingContext2D.stroke, .{}); + pub const clip = bridge.function(OffscreenCanvasRenderingContext2D.clip, .{}); + + pub const font = bridge.accessor(OffscreenCanvasRenderingContext2D.getFont, OffscreenCanvasRenderingContext2D.setFont, .{}); + pub const textAlign = bridge.accessor(OffscreenCanvasRenderingContext2D.getTextAlign, OffscreenCanvasRenderingContext2D.setTextAlign, .{}); + pub const textBaseline = bridge.accessor(OffscreenCanvasRenderingContext2D.getTextBaseline, OffscreenCanvasRenderingContext2D.setTextBaseline, .{}); + pub const fillText = bridge.function(OffscreenCanvasRenderingContext2D.fillText, .{}); + pub const strokeText = bridge.function(OffscreenCanvasRenderingContext2D.strokeText, .{}); +}; diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig index 122552b5..a29dce4a 100644 --- a/src/browser/webapi/element/html/Canvas.zig +++ b/src/browser/webapi/element/html/Canvas.zig @@ -25,6 +25,7 @@ const HtmlElement = @import("../Html.zig"); const CanvasRenderingContext2D = @import("../../canvas/CanvasRenderingContext2D.zig"); const WebGLRenderingContext = @import("../../canvas/WebGLRenderingContext.zig"); +const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig"); const Canvas = @This(); _proto: *HtmlElement, @@ -80,6 +81,14 @@ pub fn getContext(_: *Canvas, context_type: []const u8, page: *Page) !?DrawingCo return null; } +/// Transfers control of the canvas to an OffscreenCanvas. +/// Returns an OffscreenCanvas with the same dimensions. +pub fn transferControlToOffscreen(self: *Canvas, page: *Page) !*OffscreenCanvas { + const width = self.getWidth(); + const height = self.getHeight(); + return OffscreenCanvas.constructor(width, height, page); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Canvas); @@ -92,4 +101,5 @@ pub const JsApi = struct { pub const width = bridge.accessor(Canvas.getWidth, Canvas.setWidth, .{}); pub const height = bridge.accessor(Canvas.getHeight, Canvas.setHeight, .{}); pub const getContext = bridge.function(Canvas.getContext, .{}); + pub const transferControlToOffscreen = bridge.function(Canvas.transferControlToOffscreen, .{}); }; diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 5e352bd4..da499efb 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -435,7 +435,7 @@ pub const Jar = struct { pub fn removeExpired(self: *Jar, request_time: ?i64) void { if (self.cookies.items.len == 0) return; const time = request_time orelse std.time.timestamp(); - var i: usize = self.cookies.items.len ; + var i: usize = self.cookies.items.len; while (i > 0) { i -= 1; const cookie = &self.cookies.items[i]; From a90bcde38cc195acc0607a068dafd23f5e7bbfc0 Mon Sep 17 00:00:00 2001 From: egrs Date: Sat, 21 Feb 2026 10:36:04 +0100 Subject: [PATCH 2/4] fix WPT failures: nodeName prefix case, PI validation, willValidate, maxLength - uppercase entire qualified name in tagName (including prefix) - validate PI data for "?>" and use proper XML Name production with Unicode - implement willValidate on HTMLInputElement - throw IndexSizeError DOMException for negative maxLength assignment flips: Node-nodeName, Document-createProcessingInstruction, button, maxlength, input-willvalidate (+6 subtests) --- src/browser/Page.zig | 66 +++++++++++++++++++++-- src/browser/tests/element/html/input.html | 2 +- src/browser/tests/legacy/html/input.html | 2 +- src/browser/webapi/Element.zig | 7 --- src/browser/webapi/element/html/Input.zig | 25 ++++++++- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index c17eae62..50ec0efc 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -2346,13 +2346,16 @@ pub fn createCDATASection(self: *Page, data: []const u8) !*Node { } pub fn createProcessingInstruction(self: *Page, target: []const u8, data: []const u8) !*Node { - // Validate target doesn't contain "?>" + // Validate neither target nor data contain "?>" if (std.mem.indexOf(u8, target, "?>") != null) { return error.InvalidCharacterError; } + if (std.mem.indexOf(u8, data, "?>") != null) { + return error.InvalidCharacterError; + } - // Validate target follows XML name rules (similar to attribute name validation) - try Element.Attribute.validateAttributeName(.wrap(target)); + // Validate target follows XML Name production + try validateXmlName(target); const owned_target = try self.dupeString(target); const owned_data = try self.dupeString(data); @@ -2374,6 +2377,63 @@ pub fn createProcessingInstruction(self: *Page, target: []const u8, data: []cons return cd.asNode(); } +/// Validate a string against the XML Name production. +/// https://www.w3.org/TR/xml/#NT-Name +fn validateXmlName(name: []const u8) !void { + if (name.len == 0) return error.InvalidCharacterError; + + var i: usize = 0; + + // First character must be a NameStartChar. + const first_len = std.unicode.utf8ByteSequenceLength(name[0]) catch + return error.InvalidCharacterError; + if (first_len > name.len) return error.InvalidCharacterError; + const first_cp = std.unicode.utf8Decode(name[0..][0..first_len]) catch + return error.InvalidCharacterError; + if (!isXmlNameStartChar(first_cp)) return error.InvalidCharacterError; + i = first_len; + + // Subsequent characters must be NameChars. + while (i < name.len) { + const cp_len = std.unicode.utf8ByteSequenceLength(name[i]) catch + return error.InvalidCharacterError; + if (i + cp_len > name.len) return error.InvalidCharacterError; + const cp = std.unicode.utf8Decode(name[i..][0..cp_len]) catch + return error.InvalidCharacterError; + if (!isXmlNameChar(cp)) return error.InvalidCharacterError; + i += cp_len; + } +} + +fn isXmlNameStartChar(c: u21) bool { + return c == ':' or + (c >= 'A' and c <= 'Z') or + c == '_' or + (c >= 'a' and c <= 'z') or + (c >= 0xC0 and c <= 0xD6) or + (c >= 0xD8 and c <= 0xF6) or + (c >= 0xF8 and c <= 0x2FF) or + (c >= 0x370 and c <= 0x37D) or + (c >= 0x37F and c <= 0x1FFF) or + (c >= 0x200C and c <= 0x200D) or + (c >= 0x2070 and c <= 0x218F) or + (c >= 0x2C00 and c <= 0x2FEF) or + (c >= 0x3001 and c <= 0xD7FF) or + (c >= 0xF900 and c <= 0xFDCF) or + (c >= 0xFDF0 and c <= 0xFFFD) or + (c >= 0x10000 and c <= 0xEFFFF); +} + +fn isXmlNameChar(c: u21) bool { + return isXmlNameStartChar(c) or + c == '-' or + c == '.' or + (c >= '0' and c <= '9') or + c == 0xB7 or + (c >= 0x300 and c <= 0x36F) or + (c >= 0x203F and c <= 0x2040); +} + pub fn dupeString(self: *Page, value: []const u8) ![]const u8 { if (String.intern(value)) |v| { return v; diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html index 2b071e88..ede10c00 100644 --- a/src/browser/tests/element/html/input.html +++ b/src/browser/tests/element/html/input.html @@ -46,7 +46,7 @@ testing.expectEqual(5, input.maxLength); input.maxLength = 'banana'; testing.expectEqual(0, input.maxLength); - testing.expectError('Error: NegativeValueNotAllowed', () => { input.maxLength = -45;}); + testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { input.maxLength = -45;}); testing.expectEqual(20, input.size); input.size = 5; diff --git a/src/browser/tests/legacy/html/input.html b/src/browser/tests/legacy/html/input.html index 45963d81..0232ddbf 100644 --- a/src/browser/tests/legacy/html/input.html +++ b/src/browser/tests/legacy/html/input.html @@ -43,7 +43,7 @@ testing.expectEqual(5, input.maxLength); input.maxLength = 'banana'; testing.expectEqual(0, input.maxLength); - testing.expectError('Error: NegativeValueNotAllowed', () => { input.maxLength = -45;}); + testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { input.maxLength = -45;}); testing.expectEqual(20, input.size); input.size = 5; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 753d50a7..f37be4da 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1374,13 +1374,6 @@ fn upperTagName(tag_name: *String, buf: []u8) []const u8 { return tag_name.str(); } const tag = tag_name.str(); - // If the tag_name has a prefix, we must uppercase only the suffix part. - // example: te:st should be returned as te:ST. - if (std.mem.indexOfPos(u8, tag, 0, ":")) |pos| { - @memcpy(buf[0 .. pos + 1], tag[0 .. pos + 1]); - _ = std.ascii.upperString(buf[pos..tag.len], tag[pos..tag.len]); - return buf[0..tag.len]; - } return std.ascii.upperString(buf, tag); } diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 84dd727f..5793c169 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -182,6 +182,26 @@ pub fn setDefaultChecked(self: *Input, checked: bool, page: *Page) !void { } } +pub fn getWillValidate(self: *const Input) bool { + // An input element is barred from constraint validation if: + // - type is hidden, button, or reset + // - element is disabled + // - element has a datalist ancestor + return switch (self._input_type) { + .hidden, .button, .reset => false, + else => !self.getDisabled() and !self.hasDatalistAncestor(), + }; +} + +fn hasDatalistAncestor(self: *const Input) bool { + var node = self.asConstElement().asConstNode().parentElement(); + while (node) |parent| { + if (parent.is(HtmlElement.DataList) != null) return true; + node = parent.asConstNode().parentElement(); + } + return false; +} + pub fn getDisabled(self: *const Input) bool { // TODO: Also check for disabled fieldset ancestors // (but not if we're inside a of that fieldset) @@ -227,7 +247,7 @@ pub fn getMaxLength(self: *const Input) i32 { pub fn setMaxLength(self: *Input, max_length: i32, page: *Page) !void { if (max_length < 0) { - return error.NegativeValueNotAllowed; + return error.IndexSizeError; } var buf: [32]u8 = undefined; const value = std.fmt.bufPrint(&buf, "{d}", .{max_length}) catch unreachable; @@ -855,7 +875,7 @@ pub const JsApi = struct { pub const accept = bridge.accessor(Input.getAccept, Input.setAccept, .{}); pub const readOnly = bridge.accessor(Input.getReadonly, Input.setReadonly, .{}); pub const alt = bridge.accessor(Input.getAlt, Input.setAlt, .{}); - pub const maxLength = bridge.accessor(Input.getMaxLength, Input.setMaxLength, .{}); + pub const maxLength = bridge.accessor(Input.getMaxLength, Input.setMaxLength, .{ .dom_exception = true }); pub const size = bridge.accessor(Input.getSize, Input.setSize, .{}); pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{}); pub const form = bridge.accessor(Input.getForm, null, .{}); @@ -866,6 +886,7 @@ pub const JsApi = struct { pub const step = bridge.accessor(Input.getStep, Input.setStep, .{}); pub const multiple = bridge.accessor(Input.getMultiple, Input.setMultiple, .{}); pub const autocomplete = bridge.accessor(Input.getAutocomplete, Input.setAutocomplete, .{}); + pub const willValidate = bridge.accessor(Input.getWillValidate, null, .{}); pub const select = bridge.function(Input.select, .{}); pub const selectionStart = bridge.accessor(Input.getSelectionStart, Input.setSelectionStart, .{}); From 8b952110558e000aaaa1ae697705729dc5af4c71 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sun, 22 Feb 2026 14:59:41 +0100 Subject: [PATCH 3/4] add dummy implementation of font face set --- src/browser/js/bridge.zig | 1 + src/browser/tests/css/font_face_set.html | 58 +++++++++++++++++++++++ src/browser/webapi/Document.zig | 12 +++++ src/browser/webapi/css/FontFaceSet.zig | 59 ++++++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 src/browser/tests/css/font_face_set.html create mode 100644 src/browser/webapi/css/FontFaceSet.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 3a991313..7640d0a1 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -713,6 +713,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/css/CSSStyleRule.zig"), @import("../webapi/css/CSSStyleSheet.zig"), @import("../webapi/css/CSSStyleProperties.zig"), + @import("../webapi/css/FontFaceSet.zig"), @import("../webapi/css/MediaQueryList.zig"), @import("../webapi/css/StyleSheetList.zig"), @import("../webapi/Document.zig"), diff --git a/src/browser/tests/css/font_face_set.html b/src/browser/tests/css/font_face_set.html new file mode 100644 index 00000000..a860669e --- /dev/null +++ b/src/browser/tests/css/font_face_set.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index e92f0dcf..7a7989ab 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -34,6 +34,7 @@ const DOMTreeWalker = @import("DOMTreeWalker.zig"); const DOMNodeIterator = @import("DOMNodeIterator.zig"); const DOMImplementation = @import("DOMImplementation.zig"); const StyleSheetList = @import("css/StyleSheetList.zig"); +const FontFaceSet = @import("css/FontFaceSet.zig"); const Selection = @import("Selection.zig"); pub const XMLDocument = @import("XMLDocument.zig"); @@ -52,6 +53,7 @@ _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, _removed_ids: std.StringHashMapUnmanaged(void) = .empty, _active_element: ?*Element = null, _style_sheets: ?*StyleSheetList = null, +_fonts: ?*FontFaceSet = null, _write_insertion_point: ?*Node = null, _script_created_parser: ?Parser.Streaming = null, _adopted_style_sheets: ?js.Object.Global = null, @@ -422,6 +424,15 @@ pub fn getStyleSheets(self: *Document, page: *Page) !*StyleSheetList { return sheets; } +pub fn getFonts(self: *Document, page: *Page) !*FontFaceSet { + if (self._fonts) |fonts| { + return fonts; + } + const fonts = try FontFaceSet.init(page); + self._fonts = fonts; + return fonts; +} + pub fn adoptNode(_: *const Document, node: *Node, page: *Page) !*Node { if (node._type == .document) { return error.NotSupported; @@ -955,6 +966,7 @@ pub const JsApi = struct { pub const implementation = bridge.accessor(Document.getImplementation, null, .{}); pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{}); pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{}); + pub const fonts = bridge.accessor(Document.getFonts, null, .{}); pub const contentType = bridge.accessor(Document.getContentType, null, .{}); pub const domain = bridge.accessor(Document.getDomain, null, .{}); pub const createElement = bridge.function(Document.createElement, .{ .dom_exception = true }); diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig new file mode 100644 index 00000000..cd3cf2b1 --- /dev/null +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -0,0 +1,59 @@ +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const FontFaceSet = @This(); + +// Padding to avoid zero-size struct, which causes identity_map pointer collisions. +_pad: bool = false, + +pub fn init(page: *Page) !*FontFaceSet { + return page._factory.create(FontFaceSet{}); +} + +// FontFaceSet.ready - returns an already-resolved Promise. +// In a headless browser there is no font loading, so fonts are always ready. +pub fn getReady(_: *FontFaceSet, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise({}); +} + +pub fn getStatus(_: *const FontFaceSet) []const u8 { + return "loaded"; +} + +pub fn getSize(_: *const FontFaceSet) u32 { + return 0; +} + +// check(font, text?) - always true; headless has no real fonts to check. +pub fn check(_: *const FontFaceSet, font: []const u8) bool { + _ = font; + return true; +} + +// load(font, text?) - resolves immediately with an empty array. +pub fn load(_: *FontFaceSet, font: []const u8, page: *Page) !js.Promise { + _ = font; + return page.js.local.?.resolvePromise({}); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(FontFaceSet); + + pub const Meta = struct { + pub const name = "FontFaceSet"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const ready = bridge.accessor(FontFaceSet.getReady, null, .{}); + pub const status = bridge.accessor(FontFaceSet.getStatus, null, .{}); + pub const size = bridge.accessor(FontFaceSet.getSize, null, .{}); + pub const check = bridge.function(FontFaceSet.check, .{}); + pub const load = bridge.function(FontFaceSet.load, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: FontFaceSet" { + try testing.htmlRunner("css/font_face_set.html", .{}); +} From 0e5ec86ca97829d9237756fc174b81fd8e894f93 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sun, 22 Feb 2026 15:03:12 +0100 Subject: [PATCH 4/4] add Option.text setter --- src/browser/tests/element/html/option.html | 6 ++++++ src/browser/webapi/element/html/Option.zig | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/element/html/option.html b/src/browser/tests/element/html/option.html index 6e7f72c8..45983370 100644 --- a/src/browser/tests/element/html/option.html +++ b/src/browser/tests/element/html/option.html @@ -29,6 +29,12 @@ testing.expectEqual('Text 3', $('#opt3').text) + +