Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions src/browser/file/Blob.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <[email protected]>
// Pierre Tachoire <[email protected]>
//
// 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 <https://www.gnu.org/licenses/>.

const std = @import("std");
const Writer = std.Io.Writer;

const builtin = @import("builtin");
const is_windows = builtin.os.tag == .windows;

const Page = @import("../page.zig").Page;
const js = @import("../js/js.zig");

const ReadableStream = @import("../streams/ReadableStream.zig");

/// https://w3c.github.io/FileAPI/#blob-section
/// https://developer.mozilla.org/en-US/docs/Web/API/Blob
const Blob = @This();

/// 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,

const ConstructorOptions = 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 constructor(
maybe_blob_parts: ?[]const []const u8,
maybe_options: ?ConstructorOptions,
page: *Page,
) !Blob {
const options: ConstructorOptions = 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);
};

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);

return .{ .slice = w.written(), .mime = mime };
}

// We don't have `blob_parts`, why would you want a Blob anyway then?
return .{ .slice = "", .mime = mime };
}

/// 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.
// TODO: Vector search.

// 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;
var start = end;
while (end < part.len) {
if (part[end] == '\r') {
_ = try writer.writeVec(&.{ part[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') {
start = end + 2;
} else {
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[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 {
const resolver = page.js.createPromiseResolver(.none);
try resolver.resolve(js.ArrayBuffer{ .values = self.slice });
return resolver.promise();
}

/// Returns a ReadableStream which upon reading returns the data
/// contained within the Blob.
pub fn _stream(self: *const Blob, page: *Page) !*ReadableStream {
const stream = try ReadableStream.constructor(null, null, page);
try stream.queue.append(page.arena, .{
.uint8array = .{ .values = self.slice },
});
return stream;
}

/// 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 {
const resolver = page.js.createPromiseResolver(.none);
try resolver.resolve(self.slice);
return resolver.promise();
}

/// 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 {
const resolver = page.js.createPromiseResolver(.none);
try resolver.resolve(js.TypedArray(u8){ .values = self.slice });
return resolver.promise();
}

/// Returns a new Blob object which contains data
/// from a subset of the blob on which it's called.
pub fn _slice(
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 .{ .slice = slice[start..end], .mime = mime };
}

return .{ .slice = slice, .mime = mime };
}

/// Returns the size of the Blob in bytes.
pub fn get_size(self: *const Blob) usize {
return self.slice.len;
}

/// Returns the type of Blob; likely a MIME type, yet anything can be given.
pub fn get_type(self: *const Blob) []const u8 {
return self.mime;
}

const testing = @import("../../testing.zig");
test "Browser: File.Blob" {
try testing.htmlRunner("file/blob.html");
}
8 changes: 3 additions & 5 deletions src/browser/xhr/File.zig → src/browser/file/File.zig
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,12 @@ const std = @import("std");
// https://w3c.github.io/FileAPI/#file-section
const File = @This();

// Very incomplete. The prototype for this is Blob, which we don't have.
// This minimum "implementation" is added because some JavaScript code just
// checks: if (x instanceof File) throw Error(...)
/// TODO: Implement File API.
pub fn constructor() File {
return .{};
}

const testing = @import("../../testing.zig");
test "Browser: File" {
try testing.htmlRunner("xhr/file.html");
test "Browser: File.File" {
try testing.htmlRunner("file/file.html");
}
7 changes: 7 additions & 0 deletions src/browser/file/root.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! File API.
//! https://developer.mozilla.org/en-US/docs/Web/API/File_API

pub const Interfaces = .{
@import("./Blob.zig"),
@import("./File.zig"),
};
17 changes: 17 additions & 0 deletions src/browser/js/js.zig
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ pub fn TypedArray(comptime T: type) type {
};
}

pub const ArrayBuffer = struct {
values: []const u8,
};

pub const PromiseResolver = struct {
context: *Context,
resolver: v8.PromiseResolver,
Expand Down Expand Up @@ -324,6 +328,19 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo
},
.@"struct" => {
const T = @TypeOf(value);

if (T == ArrayBuffer) {
const values = value.values;
const len = values.len;
var array_buffer: v8.ArrayBuffer = undefined;
const backing_store = v8.BackingStore.init(isolate, len);
const data: [*]u8 = @ptrCast(@alignCast(backing_store.getData()));
@memcpy(data[0..len], @as([]const u8, @ptrCast(values))[0..len]);
array_buffer = v8.ArrayBuffer.initWithBackingStore(isolate, &backing_store.toSharedPtr());

return .{ .handle = array_buffer.handle };
}

if (@hasDecl(T, "_TYPED_ARRAY_ID_KLUDGE")) {
const values = value.values;
const value_type = @typeInfo(@TypeOf(values)).pointer.child;
Expand Down
2 changes: 1 addition & 1 deletion src/browser/js/types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const Interfaces = generate.Tuple(.{
@import("../url/url.zig").Interfaces,
@import("../xhr/xhr.zig").Interfaces,
@import("../navigation/root.zig").Interfaces,
@import("../file/root.zig").Interfaces,
@import("../xhr/form_data.zig").Interfaces,
@import("../xhr/File.zig"),
@import("../xmlserializer/xmlserializer.zig").Interfaces,
@import("../fetch/fetch.zig").Interfaces,
@import("../streams/streams.zig").Interfaces,
Expand Down
89 changes: 89 additions & 0 deletions src/tests/file/blob.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<script src="../testing.js"></script>

<script id=Blob/Blob.text>
{
const parts = ["\r\nthe quick brown\rfo\rx\r", "\njumps over\r\nthe\nlazy\r", "\ndog"];
// "transparent" ending should not modify the final buffer.
const blob = new Blob(parts, { type: "text/html" });

const expected = parts.join("");
testing.expectEqual(expected.length, blob.size);
testing.expectEqual("text/html", blob.type);
testing.async(blob.text(), result => testing.expectEqual(expected, result));
}

{
const parts = ["\rhello\r", "\nwor\r\nld"];
// "native" ending should modify the final buffer.
const blob = new Blob(parts, { endings: "native" });

const expected = "\nhello\n\nwor\nld";
testing.expectEqual(expected.length, blob.size);
testing.async(blob.text(), result => testing.expectEqual(expected, result));

testing.async(blob.arrayBuffer(), result => testing.expectEqual(true, result instanceof ArrayBuffer));
}
</script>

<script id=Blob.stream>
{
const parts = ["may", "thy", "knife", "chip", "and", "shatter"];
const blob = new Blob(parts);
const reader = blob.stream().getReader();

testing.async(reader.read(), ({ done, value }) => {
const expected = new Uint8Array([109, 97, 121, 116, 104, 121, 107, 110,
105, 102, 101, 99, 104, 105, 112, 97,
110, 100, 115, 104, 97, 116, 116, 101,
114]);
testing.expectEqual(false, done);
testing.expectEqual(true, value instanceof Uint8Array);
testing.expectEqual(expected, value);
});
}
</script>

<script id=Blob.arrayBuffer/Blob.slice>
{
const parts = ["la", "symphonie", "des", "éclairs"];
const blob = new Blob(parts);
testing.async(blob.arrayBuffer(), result => testing.expectEqual(true, result instanceof ArrayBuffer));

let temp = blob.slice(0);
testing.expectEqual(blob.size, temp.size);
testing.async(temp.text(), result => {
testing.expectEqual("lasymphoniedeséclairs", result);
});

temp = blob.slice(-4, -2, "custom");
testing.expectEqual(2, temp.size);
testing.expectEqual("custom", temp.type);
testing.async(temp.text(), result => testing.expectEqual("ai", result));

temp = blob.slice(14);
testing.expectEqual(8, temp.size);
testing.async(temp.text(), result => testing.expectEqual("éclairs", result));

temp = blob.slice(6, -10, "text/eclair");
testing.expectEqual(6, temp.size);
testing.expectEqual("text/eclair", temp.type);
testing.async(temp.text(), result => testing.expectEqual("honied", result));
}
</script>

<!-- Firefox and Safari only -->
<script id=Blob.bytes>
{
const parts = ["light ", "panda ", "rocks ", "!"];
const blob = new Blob(parts);

testing.async(blob.bytes(), result => {
const expected = new Uint8Array([108, 105, 103, 104, 116, 32, 112, 97,
110, 100, 97, 32, 114, 111, 99, 107, 115,
32, 33]);
testing.expectEqual(true, result instanceof Uint8Array);
testing.expectEqual(expected, result);
});
}
</script>
Loading