diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig
index 1f3e45bad..696ae98c3 100644
--- a/src/browser/Factory.zig
+++ b/src/browser/Factory.zig
@@ -31,6 +31,7 @@ const Element = @import("webapi/Element.zig");
const Document = @import("webapi/Document.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig");
+const Blob = @import("webapi/Blob.zig");
const MemoryPoolAligned = std.heap.MemoryPoolAligned;
@@ -224,6 +225,20 @@ pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
return child_ptr;
}
+pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const child_ptr = try self.createT(@TypeOf(child));
+ child_ptr.* = child;
+
+ const b = try self.createT(Blob);
+ child_ptr._proto = b;
+ b.* = .{
+ ._type = unionInit(Blob.Type, child_ptr),
+ .slice = "",
+ .mime = "",
+ };
+ return child_ptr;
+}
+
pub fn create(self: *Factory, value: anytype) !*@TypeOf(value) {
const ptr = try self.createT(@TypeOf(value));
ptr.* = value;
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 4319df251..f9ba64f6e 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -564,4 +564,6 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/IntersectionObserver.zig"),
@import("../webapi/CustomElementRegistry.zig"),
@import("../webapi/ResizeObserver.zig"),
+ @import("../webapi/Blob.zig"),
+ @import("../webapi/File.zig"),
});
diff --git a/src/browser/tests/blob.html b/src/browser/tests/blob.html
new file mode 100644
index 000000000..693095c1f
--- /dev/null
+++ b/src/browser/tests/blob.html
@@ -0,0 +1,107 @@
+
+
+ Test Document Title
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/file.html b/src/browser/tests/file.html
new file mode 100644
index 000000000..3db5fdfee
--- /dev/null
+++ b/src/browser/tests/file.html
@@ -0,0 +1,12 @@
+
+
+ Test Document Title
+
+
+
+
diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig
new file mode 100644
index 000000000..9abe6f295
--- /dev/null
+++ b/src/browser/webapi/Blob.zig
@@ -0,0 +1,312 @@
+// 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 Writer = std.Io.Writer;
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+/// https://w3c.github.io/FileAPI/#blob-section
+/// https://developer.mozilla.org/en-US/docs/Web/API/Blob
+const Blob = @This();
+
+_type: Type,
+/// Immutable slice of blob.
+/// Note that another blob may hold a pointer/slice to this,
+/// so its better to leave the deallocation of it to arena allocator.
+slice: []const u8,
+/// MIME attached to blob. Can be an empty string.
+mime: []const u8,
+
+pub const Type = union(enum) {
+ generic,
+ file: *@import("File.zig"),
+};
+
+const InitOptions = struct {
+ /// MIME type.
+ type: []const u8 = "",
+ /// How to handle line endings (CR and LF).
+ /// `transparent` means do nothing, `native` expects CRLF (\r\n) on Windows.
+ endings: []const u8 = "transparent",
+};
+
+/// Creates a new Blob.
+pub fn init(
+ maybe_blob_parts: ?[]const []const u8,
+ maybe_options: ?InitOptions,
+ page: *Page,
+) !*Blob {
+ const options: InitOptions = maybe_options orelse .{};
+ // Setup MIME; This can be any string according to my observations.
+ const mime: []const u8 = blk: {
+ const t = options.type;
+ if (t.len == 0) {
+ break :blk "";
+ }
+
+ break :blk try page.arena.dupe(u8, t);
+ };
+
+ const slice = blk: {
+ if (maybe_blob_parts) |blob_parts| {
+ var w: Writer.Allocating = .init(page.arena);
+ const use_native_endings = std.mem.eql(u8, options.endings, "native");
+ try writeBlobParts(&w.writer, blob_parts, use_native_endings);
+
+ break :blk w.written();
+ }
+
+ break :blk "";
+ };
+
+ return page._factory.create(Blob{
+ ._type = .generic,
+ .slice = slice,
+ .mime = mime,
+ });
+}
+
+const largest_vector = @max(std.simd.suggestVectorLength(u8) orelse 1, 8);
+/// Array of possible vector sizes for the current arch in decrementing order.
+/// We may move this to some file for SIMD helpers in the future.
+const vector_sizes = blk: {
+ // Required for length calculation.
+ var n: usize = largest_vector;
+ var total: usize = 0;
+ while (n != 2) : (n /= 2) total += 1;
+ // Populate an array with vector sizes.
+ n = largest_vector;
+ var i: usize = 0;
+ var items: [total]usize = undefined;
+ while (n != 2) : (n /= 2) {
+ defer i += 1;
+ items[i] = n;
+ }
+
+ break :blk items;
+};
+
+/// Writes blob parts to given `Writer` with desired endings.
+fn writeBlobParts(
+ writer: *Writer,
+ blob_parts: []const []const u8,
+ use_native_endings: bool,
+) !void {
+ // Transparent.
+ if (!use_native_endings) {
+ for (blob_parts) |part| {
+ try writer.writeAll(part);
+ }
+
+ return;
+ }
+
+ // TODO: Windows support.
+
+ // Linux & Unix.
+ // Both Firefox and Chrome implement it as such:
+ // CRLF => LF
+ // CR => LF
+ // So even though CR is not followed by LF, it gets replaced.
+ //
+ // I believe this is because such scenario is possible:
+ // ```
+ // let parts = [ "the quick\r", "\nbrown fox" ];
+ // ```
+ // In the example, one should have to check the part before in order to
+ // understand that CRLF is being presented in the final buffer.
+ // So they took a simpler approach, here's what given blob parts produce:
+ // ```
+ // "the quick\n\nbrown fox"
+ // ```
+ scan_parts: for (blob_parts) |part| {
+ var end: usize = 0;
+
+ inline for (vector_sizes) |vector_len| {
+ const Vec = @Vector(vector_len, u8);
+
+ while (end + vector_len <= part.len) : (end += vector_len) {
+ const cr: Vec = @splat('\r');
+ // Load chunk as vectors.
+ const slice = part[end..][0..vector_len];
+ const chunk: Vec = slice.*;
+ // Look for CR.
+ const match = chunk == cr;
+
+ // Create a bitset out of match vector.
+ const bitset = std.bit_set.IntegerBitSet(vector_len){
+ .mask = @bitCast(@intFromBool(match)),
+ };
+
+ var iter = bitset.iterator(.{});
+ var relative_start: usize = 0;
+ while (iter.next()) |index| {
+ _ = try writer.writeVec(&.{ slice[relative_start..index], "\n" });
+
+ if (index + 1 != slice.len and slice[index + 1] == '\n') {
+ relative_start = index + 2;
+ } else {
+ relative_start = index + 1;
+ }
+ }
+
+ _ = try writer.writeVec(&.{slice[relative_start..]});
+ }
+ }
+
+ // Scalar scan fallback.
+ var relative_start: usize = end;
+ while (end < part.len) {
+ if (part[end] == '\r') {
+ _ = try writer.writeVec(&.{ part[relative_start..end], "\n" });
+
+ // Part ends with CR. We can continue to next part.
+ if (end + 1 == part.len) {
+ continue :scan_parts;
+ }
+
+ // If next char is LF, skip it too.
+ if (part[end + 1] == '\n') {
+ relative_start = end + 2;
+ } else {
+ relative_start = end + 1;
+ }
+ }
+
+ end += 1;
+ }
+
+ // Write the remaining. We get this in such situations:
+ // `the quick brown\rfox`
+ // `the quick brown\r\nfox`
+ try writer.writeAll(part[relative_start..end]);
+ }
+}
+
+/// Returns a Promise that resolves with the contents of the blob
+/// as binary data contained in an ArrayBuffer.
+//pub fn arrayBuffer(self: *const Blob, page: *Page) !js.Promise {
+// return page.js.resolvePromise(js.ArrayBuffer{ .values = self.slice });
+//}
+
+// TODO: Implement `stream`; requires `ReadableStream`.
+
+/// Returns a Promise that resolves with a string containing
+/// the contents of the blob, interpreted as UTF-8.
+pub fn text(self: *const Blob, page: *Page) !js.Promise {
+ return page.js.resolvePromise(self.slice);
+}
+
+/// Extension to Blob; works on Firefox and Safari.
+/// https://developer.mozilla.org/en-US/docs/Web/API/Blob/bytes
+/// Returns a Promise that resolves with a Uint8Array containing
+/// the contents of the blob as an array of bytes.
+pub fn bytes(self: *const Blob, page: *Page) !js.Promise {
+ return page.js.resolvePromise(js.TypedArray(u8){ .values = self.slice });
+}
+
+/// Returns a new Blob object which contains data
+/// from a subset of the blob on which it's called.
+pub fn getSlice(
+ self: *const Blob,
+ maybe_start: ?i32,
+ maybe_end: ?i32,
+ maybe_content_type: ?[]const u8,
+ page: *Page,
+) !*Blob {
+ const mime: []const u8 = blk: {
+ if (maybe_content_type) |content_type| {
+ if (content_type.len == 0) {
+ break :blk "";
+ }
+
+ break :blk try page.arena.dupe(u8, content_type);
+ }
+
+ break :blk "";
+ };
+
+ const slice = self.slice;
+ if (maybe_start) |_start| {
+ const start = blk: {
+ if (_start < 0) {
+ break :blk slice.len -| @abs(_start);
+ }
+
+ break :blk @min(slice.len, @as(u31, @intCast(_start)));
+ };
+
+ const end: usize = blk: {
+ if (maybe_end) |_end| {
+ if (_end < 0) {
+ break :blk @max(start, slice.len -| @abs(_end));
+ }
+
+ break :blk @min(slice.len, @max(start, @as(u31, @intCast(_end))));
+ }
+
+ break :blk slice.len;
+ };
+
+ return page._factory.create(Blob{
+ ._type = .generic,
+ .slice = slice[start..end],
+ .mime = mime,
+ });
+ }
+
+ return page._factory.create(Blob{
+ ._type = .generic,
+ .slice = slice,
+ .mime = mime,
+ });
+}
+
+/// Returns the size of the Blob in bytes.
+pub fn getSize(self: *const Blob) usize {
+ return self.slice.len;
+}
+
+/// Returns the type of Blob; likely a MIME type, yet anything can be given.
+pub fn getType(self: *const Blob) []const u8 {
+ return self.mime;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Blob);
+
+ pub const Meta = struct {
+ pub const name = "Blob";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(Blob.init, .{});
+ pub const text = bridge.function(Blob.text, .{});
+ pub const bytes = bridge.function(Blob.bytes, .{});
+ pub const slice = bridge.function(Blob.getSlice, .{});
+ pub const size = bridge.accessor(Blob.getSize, null, .{});
+ pub const @"type" = bridge.accessor(Blob.getType, null, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Blob" {
+ try testing.htmlRunner("blob.html", .{});
+}
diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig
new file mode 100644
index 000000000..a67a8a6f4
--- /dev/null
+++ b/src/browser/webapi/File.zig
@@ -0,0 +1,50 @@
+// 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 Page = @import("../Page.zig");
+const Blob = @import("Blob.zig");
+const js = @import("../js/js.zig");
+
+const File = @This();
+
+/// `File` inherits `Blob`.
+_proto: *Blob,
+
+// TODO: Implement File API.
+pub fn init(page: *Page) !*File {
+ return page._factory.blob(File{ ._proto = undefined });
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(File);
+
+ pub const Meta = struct {
+ pub const name = "File";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(File.init, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: File" {
+ try testing.htmlRunner("file.html", .{});
+}