diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig
index a1552440..1cf82bfc 100644
--- a/src/browser/EventManager.zig
+++ b/src/browser/EventManager.zig
@@ -234,6 +234,7 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co
.screen,
.screen_orientation,
.visual_viewport,
+ .file_reader,
.generic,
=> {
const list = self.lookup.get(.{
diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig
index 402ce240..cbc2170d 100644
--- a/src/browser/Factory.zig
+++ b/src/browser/Factory.zig
@@ -59,7 +59,10 @@ pub fn init(arena: Allocator) !*Factory {
// this is a root object
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
- const allocator = self._slab.allocator();
+ return self.eventTargetWithAllocator(self._slab.allocator(), child);
+}
+
+pub fn eventTargetWithAllocator(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ EventTarget, @TypeOf(child) },
).allocate(allocator);
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 30aae516..aaad5cc5 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -857,6 +857,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/IdleDeadline.zig"),
@import("../webapi/Blob.zig"),
@import("../webapi/File.zig"),
+ @import("../webapi/FileReader.zig"),
@import("../webapi/Screen.zig"),
@import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"),
diff --git a/src/browser/tests/file_reader.html b/src/browser/tests/file_reader.html
new file mode 100644
index 00000000..ca0d87c4
--- /dev/null
+++ b/src/browser/tests/file_reader.html
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig
index d322b68d..6da06768 100644
--- a/src/browser/webapi/EventTarget.zig
+++ b/src/browser/webapi/EventTarget.zig
@@ -43,6 +43,7 @@ pub const Type = union(enum) {
screen: *@import("Screen.zig"),
screen_orientation: *@import("Screen.zig").Orientation,
visual_viewport: *@import("VisualViewport.zig"),
+ file_reader: *@import("FileReader.zig"),
};
pub fn init(page: *Page) !*EventTarget {
@@ -152,6 +153,7 @@ pub fn toString(self: *EventTarget) []const u8 {
.screen => return "[object Screen]",
.screen_orientation => return "[object ScreenOrientation]",
.visual_viewport => return "[object VisualViewport]",
+ .file_reader => return "[object FileReader]",
};
}
diff --git a/src/browser/webapi/FileReader.zig b/src/browser/webapi/FileReader.zig
new file mode 100644
index 00000000..3d189089
--- /dev/null
+++ b/src/browser/webapi/FileReader.zig
@@ -0,0 +1,358 @@
+// 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 EventTarget = @import("EventTarget.zig");
+const ProgressEvent = @import("event/ProgressEvent.zig");
+const Blob = @import("Blob.zig");
+
+const Allocator = std.mem.Allocator;
+
+/// https://w3c.github.io/FileAPI/#dfn-filereader
+/// https://developer.mozilla.org/en-US/docs/Web/API/FileReader
+const FileReader = @This();
+
+_page: *Page,
+_proto: *EventTarget,
+_arena: Allocator,
+
+_ready_state: ReadyState = .empty,
+_result: ?Result = null,
+_error: ?[]const u8 = null,
+
+_on_abort: ?js.Function.Temp = null,
+_on_error: ?js.Function.Temp = null,
+_on_load: ?js.Function.Temp = null,
+_on_load_end: ?js.Function.Temp = null,
+_on_load_start: ?js.Function.Temp = null,
+_on_progress: ?js.Function.Temp = null,
+
+_aborted: bool = false,
+
+const ReadyState = enum(u8) {
+ empty = 0,
+ loading = 1,
+ done = 2,
+};
+
+const Result = union(enum) {
+ string: []const u8,
+ arraybuffer: js.ArrayBuffer,
+};
+
+pub fn init(page: *Page) !*FileReader {
+ const arena = try page.getArena(.{ .debug = "FileReader" });
+ errdefer page.releaseArena(arena);
+
+ return page._factory.eventTargetWithAllocator(arena, FileReader{
+ ._page = page,
+ ._arena = arena,
+ ._proto = undefined,
+ });
+}
+
+pub fn deinit(self: *FileReader, _: bool, page: *Page) void {
+ const js_ctx = page.js;
+
+ if (self._on_abort) |func| js_ctx.release(func);
+ if (self._on_error) |func| js_ctx.release(func);
+ if (self._on_load) |func| js_ctx.release(func);
+ if (self._on_load_end) |func| js_ctx.release(func);
+ if (self._on_load_start) |func| js_ctx.release(func);
+ if (self._on_progress) |func| js_ctx.release(func);
+
+ page.releaseArena(self._arena);
+}
+
+fn asEventTarget(self: *FileReader) *EventTarget {
+ return self._proto;
+}
+
+pub fn getOnAbort(self: *const FileReader) ?js.Function.Temp {
+ return self._on_abort;
+}
+
+pub fn setOnAbort(self: *FileReader, cb: ?js.Function.Temp) !void {
+ self._on_abort = cb;
+}
+
+pub fn getOnError(self: *const FileReader) ?js.Function.Temp {
+ return self._on_error;
+}
+
+pub fn setOnError(self: *FileReader, cb: ?js.Function.Temp) !void {
+ self._on_error = cb;
+}
+
+pub fn getOnLoad(self: *const FileReader) ?js.Function.Temp {
+ return self._on_load;
+}
+
+pub fn setOnLoad(self: *FileReader, cb: ?js.Function.Temp) !void {
+ self._on_load = cb;
+}
+
+pub fn getOnLoadEnd(self: *const FileReader) ?js.Function.Temp {
+ return self._on_load_end;
+}
+
+pub fn setOnLoadEnd(self: *FileReader, cb: ?js.Function.Temp) !void {
+ self._on_load_end = cb;
+}
+
+pub fn getOnLoadStart(self: *const FileReader) ?js.Function.Temp {
+ return self._on_load_start;
+}
+
+pub fn setOnLoadStart(self: *FileReader, cb: ?js.Function.Temp) !void {
+ self._on_load_start = cb;
+}
+
+pub fn getOnProgress(self: *const FileReader) ?js.Function.Temp {
+ return self._on_progress;
+}
+
+pub fn setOnProgress(self: *FileReader, cb: ?js.Function.Temp) !void {
+ self._on_progress = cb;
+}
+
+pub fn getReadyState(self: *const FileReader) u8 {
+ return @intFromEnum(self._ready_state);
+}
+
+pub fn getResult(self: *const FileReader) ?Result {
+ return self._result;
+}
+
+pub fn getError(self: *const FileReader) ?[]const u8 {
+ return self._error;
+}
+
+pub fn readAsArrayBuffer(self: *FileReader, blob: *Blob) !void {
+ try self.readInternal(blob, .arraybuffer);
+}
+
+pub fn readAsBinaryString(self: *FileReader, blob: *Blob) !void {
+ try self.readInternal(blob, .binary_string);
+}
+
+pub fn readAsText(self: *FileReader, blob: *Blob, encoding_: ?[]const u8) !void {
+ _ = encoding_; // TODO: Handle encoding properly
+ try self.readInternal(blob, .text);
+}
+
+pub fn readAsDataURL(self: *FileReader, blob: *Blob) !void {
+ try self.readInternal(blob, .data_url);
+}
+
+const ReadType = enum {
+ arraybuffer,
+ binary_string,
+ text,
+ data_url,
+};
+
+fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void {
+ if (self._ready_state == .loading) {
+ return error.InvalidStateError;
+ }
+
+ // Reset state
+ self._ready_state = .loading;
+ self._result = null;
+ self._error = null;
+ self._aborted = false;
+
+ const page = self._page;
+
+ var ls: js.Local.Scope = undefined;
+ page.js.localScope(&ls);
+ defer ls.deinit();
+ const local = &ls.local;
+
+ try self.dispatch(.load_start, .{ .loaded = 0, .total = blob.getSize() }, local, page);
+ if (self._aborted) {
+ return;
+ }
+
+ // Perform the read (synchronous since data is in memory)
+ const data = blob._slice;
+ const size = data.len;
+ try self.dispatch(.progress, .{ .loaded = size, .total = size }, local, page);
+ if (self._aborted) {
+ return;
+ }
+
+ // Process the data based on read type
+ self._result = switch (read_type) {
+ .arraybuffer => .{ .arraybuffer = .{ .values = data } },
+ .binary_string => .{ .string = data },
+ .text => .{ .string = data },
+ .data_url => blk: {
+ // Create data URL with base64 encoding
+ const mime = if (blob._mime.len > 0) blob._mime else "application/octet-stream";
+ const data_url = try encodeDataURL(self._arena, mime, data);
+ break :blk .{ .string = data_url };
+ },
+ };
+
+ self._ready_state = .done;
+
+ try self.dispatch(.load, .{ .loaded = size, .total = size }, local, page);
+ try self.dispatch(.load_end, .{ .loaded = size, .total = size }, local, page);
+}
+
+pub fn abort(self: *FileReader) !void {
+ if (self._ready_state != .loading) {
+ return;
+ }
+
+ self._aborted = true;
+ self._ready_state = .done;
+ self._result = null;
+
+ const page = self._page;
+
+ var ls: js.Local.Scope = undefined;
+ page.js.localScope(&ls);
+ defer ls.deinit();
+ const local = &ls.local;
+
+ try self.dispatch(.abort, null, local, page);
+
+ try self.dispatch(.load_end, null, local, page);
+}
+
+fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Progress, local: *const js.Local, page: *Page) !void {
+ const field, const typ = comptime blk: {
+ break :blk switch (event_type) {
+ .abort => .{ "_on_abort", "abort" },
+ .err => .{ "_on_error", "error" },
+ .load => .{ "_on_load", "load" },
+ .load_end => .{ "_on_load_end", "loadend" },
+ .load_start => .{ "_on_load_start", "loadstart" },
+ .progress => .{ "_on_progress", "progress" },
+ };
+ };
+
+ const progress = progress_ orelse Progress{};
+ const event = (try ProgressEvent.initTrusted(
+ comptime .wrap(typ),
+ .{ .total = progress.total, .loaded = progress.loaded },
+ page,
+ )).asEvent();
+
+ return page._event_manager.dispatchWithFunction(
+ self.asEventTarget(),
+ event,
+ local.toLocal(@field(self, field)),
+ .{ .context = "FileReader " ++ typ },
+ );
+}
+
+const DispatchType = enum {
+ abort,
+ err,
+ load,
+ load_end,
+ load_start,
+ progress,
+};
+
+const Progress = struct {
+ loaded: usize = 0,
+ total: usize = 0,
+};
+
+/// Encodes binary data as a data URL with base64 encoding.
+/// Format: data:[][;base64],
+fn encodeDataURL(arena: Allocator, mime: []const u8, data: []const u8) ![]const u8 {
+ const base64 = std.base64.standard.Encoder;
+
+ // Calculate size needed for base64 encoding
+ const encoded_size = base64.calcSize(data.len);
+
+ // Allocate buffer for the full data URL
+ // Format: "data:" + mime + ";base64," + encoded_data
+ const prefix = "data:";
+ const suffix = ";base64,";
+ const total_size = prefix.len + mime.len + suffix.len + encoded_size;
+
+ var pos: usize = 0;
+ const buf = try arena.alloc(u8, total_size);
+
+ @memcpy(buf[pos..][0..prefix.len], prefix);
+ pos += prefix.len;
+
+ @memcpy(buf[pos..][0..mime.len], mime);
+ pos += mime.len;
+
+ @memcpy(buf[pos..][0..suffix.len], suffix);
+ pos += suffix.len;
+
+ _ = base64.encode(buf[pos..], data);
+
+ return buf;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(FileReader);
+
+ pub const Meta = struct {
+ pub const name = "FileReader";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const weak = true;
+ pub const finalizer = bridge.finalizer(FileReader.deinit);
+ };
+
+ pub const constructor = bridge.constructor(FileReader.init, .{});
+
+ // State constants
+ pub const EMPTY = bridge.property(@intFromEnum(FileReader.ReadyState.empty), .{ .template = true });
+ pub const LOADING = bridge.property(@intFromEnum(FileReader.ReadyState.loading), .{ .template = true });
+ pub const DONE = bridge.property(@intFromEnum(FileReader.ReadyState.done), .{ .template = true });
+
+ // Properties
+ pub const readyState = bridge.accessor(FileReader.getReadyState, null, .{});
+ pub const result = bridge.accessor(FileReader.getResult, null, .{});
+ pub const @"error" = bridge.accessor(FileReader.getError, null, .{});
+
+ // Event handlers
+ pub const onabort = bridge.accessor(FileReader.getOnAbort, FileReader.setOnAbort, .{});
+ pub const onerror = bridge.accessor(FileReader.getOnError, FileReader.setOnError, .{});
+ pub const onload = bridge.accessor(FileReader.getOnLoad, FileReader.setOnLoad, .{});
+ pub const onloadend = bridge.accessor(FileReader.getOnLoadEnd, FileReader.setOnLoadEnd, .{});
+ pub const onloadstart = bridge.accessor(FileReader.getOnLoadStart, FileReader.setOnLoadStart, .{});
+ pub const onprogress = bridge.accessor(FileReader.getOnProgress, FileReader.setOnProgress, .{});
+
+ // Methods
+ pub const readAsArrayBuffer = bridge.function(FileReader.readAsArrayBuffer, .{ .dom_exception = true });
+ pub const readAsBinaryString = bridge.function(FileReader.readAsBinaryString, .{ .dom_exception = true });
+ pub const readAsText = bridge.function(FileReader.readAsText, .{ .dom_exception = true });
+ pub const readAsDataURL = bridge.function(FileReader.readAsDataURL, .{ .dom_exception = true });
+ pub const abort = bridge.function(FileReader.abort, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: FileReader" {
+ try testing.htmlRunner("file_reader.html", .{});
+}
diff --git a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig
index cb7be000..ae268a59 100644
--- a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig
+++ b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig
@@ -75,72 +75,48 @@ pub fn getOnAbort(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp {
return self._on_abort;
}
-pub fn setOnAbort(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void {
- if (cb_) |cb| {
- self._on_abort = try cb.tempWithThis(self);
- } else {
- self._on_abort = null;
- }
+pub fn setOnAbort(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void {
+ self._on_abort = cb;
}
pub fn getOnError(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp {
return self._on_error;
}
-pub fn setOnError(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void {
- if (cb_) |cb| {
- self._on_error = try cb.tempWithThis(self);
- } else {
- self._on_error = null;
- }
+pub fn setOnError(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void {
+ self._on_error = cb;
}
pub fn getOnLoad(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp {
return self._on_load;
}
-pub fn setOnLoad(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void {
- if (cb_) |cb| {
- self._on_load = try cb.tempWithThis(self);
- } else {
- self._on_load = null;
- }
+pub fn setOnLoad(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void {
+ self._on_load = cb;
}
pub fn getOnLoadEnd(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp {
return self._on_load_end;
}
-pub fn setOnLoadEnd(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void {
- if (cb_) |cb| {
- self._on_load_end = try cb.tempWithThis(self);
- } else {
- self._on_load_end = null;
- }
+pub fn setOnLoadEnd(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void {
+ self._on_load_end = cb;
}
pub fn getOnLoadStart(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp {
return self._on_load_start;
}
-pub fn setOnLoadStart(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void {
- if (cb_) |cb| {
- self._on_load_start = try cb.tempWithThis(self);
- } else {
- self._on_load_start = null;
- }
+pub fn setOnLoadStart(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void {
+ self._on_load_start = cb;
}
pub fn getOnProgress(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp {
return self._on_progress;
}
-pub fn setOnProgress(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void {
- if (cb_) |cb| {
- self._on_progress = try cb.tempWithThis(self);
- } else {
- self._on_progress = null;
- }
+pub fn setOnProgress(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void {
+ self._on_progress = cb;
}
pub fn getOnTimeout(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp {