diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 7640d0a1..46a7e235 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -866,6 +866,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];