Merge branch 'main' into python-bindings-v1

This commit is contained in:
Adrià Arrufat
2026-07-27 12:38:21 +02:00
92 changed files with 5940 additions and 612 deletions

View File

@@ -38,11 +38,18 @@ endif
# Building V8 from source takes 10+ minutes. `make download-v8` fetches the
# matching prebuilt archive from the zig-v8-fork releases instead. The versions
# are read from the install action so they can't drift from CI.
#
# The cache path is keyed on ZIG_V8_TAG as well as the archive name: a
# zig-v8-fork release keeps the same asset filename across tags (the name
# encodes only the V8 version), so a tag bump that leaves V8_VERSION alone
# still ships different bytes. Keying the cache on the filename alone made
# download-v8's `test -f` guard skip that refresh and leave a stale archive
# in place, which then fails at link time on undefined v8__* symbols.
V8_ACTION := .github/actions/install/action.yml
V8_VERSION := $(shell awk -F\' '/^ v8:/{f=1} f&&/default:/{print $$2; exit}' $(V8_ACTION))
ZIG_V8_TAG := $(shell awk -F\' '/^ zig-v8:/{f=1} f&&/default:/{print $$2; exit}' $(V8_ACTION))
V8_ARCHIVE := libc_v8_$(V8_VERSION)_$(OS)_$(ARCH).a
V8_CACHE := .lp-cache/prebuilt-v8/$(V8_ARCHIVE)
V8_CACHE := .lp-cache/prebuilt-v8/$(ZIG_V8_TAG)/$(V8_ARCHIVE)
# If the prebuilt archive is in place and the caller hasn't set ZIGFLAGS, point
# the build at it rather than building V8 from source.

View File

@@ -36,8 +36,8 @@
.hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz",
},
.zenai = .{
.url = "git+https://github.com/lightpanda-io/zenai.git#00c793fdd2797ae5fc9700c189d8617f167ae0dc",
.hash = "zenai-0.0.0-iOY_VGOnBQDaWE_Gn8cwhh0XXE8RZ8ESE7QtcITF-P73",
.url = "git+https://github.com/lightpanda-io/zenai.git#cab4242b2c498d18e5637a97045f8a51a74e1c32",
.hash = "zenai-0.0.0-iOY_VBbNBQCo_e6zGGSUX5yyB2UYmx0dy3XuVpgO6h2n",
},
.isocline = .{
.url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47",

View File

@@ -392,8 +392,20 @@ pub fn deinit(self: *Notification) void {
}
pub fn register(self: *Notification, comptime event: EventType, receiver: anytype, func: EventFunc(event)) !void {
var list = &@field(self.event_listeners, @tagName(event));
const allocator = self.allocator;
const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver));
if (gop.found_existing) {
for (gop.value_ptr.items) |existing| {
if (existing.event == event) {
lp.assert(@as(*const anyopaque, @ptrCast(func)) == existing.func, "different notification callbacks per receiver", .{ .event = event });
return;
}
}
} else {
gop.value_ptr.* = .empty;
}
var list = &@field(self.event_listeners, @tagName(event));
var listener = try self.mem_pool.create();
errdefer self.mem_pool.destroy(listener);
@@ -405,12 +417,6 @@ pub fn register(self: *Notification, comptime event: EventType, receiver: anytyp
.func = @ptrCast(func),
.struct_name = @typeName(@typeInfo(@TypeOf(receiver)).pointer.child),
};
const allocator = self.allocator;
const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver));
if (gop.found_existing == false) {
gop.value_ptr.* = .empty;
}
try gop.value_ptr.append(allocator, listener);
// we don't add this until we've successfully added the entry to

View File

@@ -296,10 +296,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null);
const verbosity = settings.resolveVerbosity(opts, remembered);
const stream_enabled = settings.resolveStream(remembered);
browser_tools.search_engine = settings.resolveSearchEngine(remembered);
if (resolved) |r| {
if (r.source == .picked) {
settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled }) catch {};
settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {};
}
// provider/model now live in the status bar; just space before the help
std.debug.print("\n", .{});
@@ -353,7 +354,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
try self.startSession();
self.ai_client = if (llm) |l| try zenai.provider.Client.init(allocator, lp.io, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null;
self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null;
errdefer if (self.ai_client) |c| c.deinit(allocator);
if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt);
@@ -721,6 +722,7 @@ fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand.
.load => self.handleLoad(rest),
.model => self.handleModel(arena, rest),
.provider => self.handleProvider(arena, rest),
.searchEngine => self.handleSearchEngine(rest),
}
return false;
}
@@ -760,6 +762,21 @@ fn handleStream(self: *Agent, rest: []const u8) void {
self.reportSaved("stream", if (on) "on" else "off");
}
/// `/searchEngine`: bare prints the current engine; an argument sets and
/// persists it, warning when the chosen engine's API key is unset.
fn handleSearchEngine(self: *Agent, rest: []const u8) void {
self.setEnumOption("searchEngine", &browser_tools.search_engine, rest);
const selected = std.meta.stringToEnum(browser_tools.SearchEngine, rest) orelse return;
const env_var: [:0]const u8 = switch (selected) {
.tavily => "TAVILY_API_KEY",
.brave => "BRAVE_API_KEY",
.auto, .duckduckgo => return,
};
if (std.c.getenv(env_var) == null) {
self.terminal.printWarning("{s} is not set; the search tool will fail until you export it", .{env_var});
}
}
/// Print cumulative session token usage, broken down so the cache's effect is
/// visible — the REPL otherwise never surfaces the `$usage` line `--task`
/// prints. Reads `total_usage` (accumulated per turn by `processUserMessage`);
@@ -870,7 +887,7 @@ fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void {
self.terminal.printInfo("{s}: {s}", .{ label, value });
return;
}
if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled })) {
if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled, .search_engine = browser_tools.search_engine })) {
self.terminal.printInfo("{s}: {s} (saved to {s})", .{ label, value, settings.remembered_path });
} else |_| {
self.terminal.printInfo("{s}: {s}", .{ label, value });
@@ -968,7 +985,7 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 {
/// `owned_key` transfers ownership of an allocated `credentials.key` (Vertex
/// gcloud token) on success; on error the caller still owns it.
fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) !void {
const new_client = try zenai.provider.Client.init(self.allocator, lp.io, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() });
const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() });
errdefer new_client.deinit(self.allocator);
// A same-provider re-select (vertex token refresh) must not reset the model.
@@ -1361,6 +1378,10 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo
"/provider [name|null] — change the provider, or 'null' to disable the LLM (persisted, so the next launch starts in basic mode); Tab completes detected providers, bare /provider shows the current one",
.{},
),
.searchEngine => self.terminal.printInfo(
"/searchEngine " ++ Config.tagHint(browser_tools.SearchEngine) ++ " — set the web search engine behind the search tool (currently: {s}); saved to {s}. 'auto' tries Brave then Tavily (when their API keys are set) and falls back to the DuckDuckGo scrape; an explicit engine is used alone. Bare /searchEngine prints the engine.",
.{ @tagName(browser_tools.search_engine), settings.remembered_path },
),
}
return;
}
@@ -1845,7 +1866,7 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void {
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), llm.provider, llm.key, opts.base_url, lp.environ()) catch |err| {
const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| {
if (llm.provider == .vertex and !settings.vertexProjectMode()) {
std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{});
}
@@ -1902,13 +1923,12 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u
_ = self.model_completion_arena.reset(.retain_capacity);
const ids = zenai.provider.listChatModelIds(
self.allocator,
lp.io,
self.allocator,
self.model_completion_arena.allocator(),
llm.provider,
llm.key,
self.model_base_url,
lp.environ(),
.{ .base_url = self.model_base_url, .environ = lp.environ() },
) catch &.{};
self.model_completions = .{ .provider = llm.provider, .ids = ids };
return ids;

View File

@@ -17,7 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! REPL-only meta slash commands (`/help`, `/quit`, `/verbosity`, `/effort`,
//! `/stream`, `/usage`, `/model`, `/provider`). Not tool slash commands — handled by
//! `/stream`, `/usage`, `/model`, `/provider`, `/searchEngine`). Not tool slash commands — handled by
//! `Agent.handleMeta`, never reaching the recorder. Tool slash-command schema
//! primitives live in `lp.Schema`; import that directly.
@@ -46,7 +46,7 @@ pub const MetaCommand = struct {
/// Dispatched by `Agent.handleMeta` via an exhaustive switch, so a new meta
/// command is a compile error until it's wired up there too.
const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider };
const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider, searchEngine };
};
const tagNames = Config.tagNames;
@@ -65,6 +65,7 @@ pub const meta_commands = [_]MetaCommand{
.{ .tag = .load, .name = "load", .hint = "<path>", .values = &.{}, .description = "Load and run a script from disk" },
.{ .tag = .model, .name = "model", .hint = "[name]", .values = &.{}, .description = "Change the model" },
.{ .tag = .provider, .name = "provider", .hint = "[name|null]", .values = &.{}, .description = "Change the provider, or 'null' to disable the LLM" },
.{ .tag = .searchEngine, .name = "searchEngine", .hint = tagHint(browser_tools.SearchEngine), .values = tagNames(browser_tools.SearchEngine), .description = "Change the web search engine" },
};
/// Derived from `Command.LlmCommand` — name and description both come from the

View File

@@ -50,7 +50,7 @@ pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider,
const key = zenai.provider.envApiKey(lp.environ(), tag) orelse return null;
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), tag, key, base_url, lp.environ()) catch return null;
const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), tag, key, .{ .base_url = base_url, .environ = lp.environ() }) catch return null;
if (ids.len == 0) return null;
return .{ .provider = tag, .key = key };
}
@@ -198,6 +198,7 @@ pub const Remembered = struct {
effort: ?Config.Effort = null,
verbosity: ?Config.AgentVerbosity = null,
stream: ?bool = null,
search_engine: ?lp.tools.SearchEngine = null,
};
pub fn loadRemembered(allocator: std.mem.Allocator) ?Remembered {
@@ -282,6 +283,13 @@ pub fn resolveStream(remembered: ?Remembered) bool {
return true;
}
/// Precedence: remembered `.lp-agent.zon` value > default (auto). No CLI
/// flag — the REPL `/searchEngine` command sets and persists it.
pub fn resolveSearchEngine(remembered: ?Remembered) lp.tools.SearchEngine {
if (remembered) |r| if (r.search_engine) |e| return e;
return .auto;
}
pub const ReconciledModel = union(enum) {
/// Owned by the allocator passed to reconcileModel.
use: []u8,
@@ -302,7 +310,7 @@ pub fn reconcileModel(
) !ReconciledModel {
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const ids: []const []const u8 = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), llm.provider, llm.key, base_url, lp.environ()) catch &.{};
const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = base_url, .environ = lp.environ() }) catch &.{};
if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) };
if (!explicit) {
@@ -352,6 +360,18 @@ test "parseRemembered: stream field round-trips" {
try testing.expect(remembered.stream == false);
}
test "parseRemembered: search_engine field round-trips" {
const remembered = parseRemembered(testing.allocator, ".{ .model = \"m\", .search_engine = .brave }").?;
defer std.zon.parse.free(testing.allocator, remembered);
try testing.expect(remembered.search_engine == .brave);
}
test "resolveSearchEngine: default auto, remembered wins" {
try testing.expect(resolveSearchEngine(null) == .auto);
try testing.expect(resolveSearchEngine(.{ .model = "m", .search_engine = null }) == .auto);
try testing.expect(resolveSearchEngine(.{ .model = "m", .search_engine = .duckduckgo }) == .duckduckgo);
}
test "resolveStream: default on, remembered wins" {
try testing.expect(resolveStream(null));
try testing.expect(resolveStream(.{ .model = "m", .stream = null }));

View File

@@ -42,9 +42,6 @@ const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
const AnimatedLength = @import("webapi/svg/AnimatedLength.zig");
const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig");
const AnimatedString = @import("webapi/svg/AnimatedString.zig");
const Window = @import("webapi/Window.zig");
const Location = @import("webapi/Location.zig");
const Document = @import("webapi/Document.zig");
@@ -65,14 +62,23 @@ const popover = @import("webapi/element/popover.zig");
const slotting = @import("webapi/element/slotting.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const HttpClient = @import("../network/HttpClient.zig");
const PointList = @import("webapi/svg/PointList.zig");
const StringList = @import("webapi/svg/StringList.zig");
const AnimatedLength = @import("webapi/svg/AnimatedLength.zig");
const AnimatedNumber = @import("webapi/svg/AnimatedNumber.zig");
const AnimatedString = @import("webapi/svg/AnimatedString.zig");
const AnimatedTransformList = @import("webapi/svg/AnimatedTransformList.zig");
const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig");
const sys_url = @import("../sys/url.zig");
const HttpClient = @import("../network/HttpClient.zig");
const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
pub const parse = @import("frame/parse.zig");
pub const preload = @import("frame/preload.zig");
pub const observers = @import("frame/observers.zig");
pub const user_input = @import("frame/user_input.zig");
@@ -145,8 +151,12 @@ _node_owner_documents: Node.OwnerDocumentLookup = .empty,
_element_scroll_positions: Element.ScrollPositionLookup = .empty,
_element_namespace_uris: Element.NamespaceUriLookup = .empty,
_svg_animated_lengths: AnimatedLength.Lookup = .empty,
_svg_animated_numbers: AnimatedNumber.Lookup = .empty,
_svg_animated_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty,
_svg_animated_strings: AnimatedString.Lookup = .empty,
_svg_animated_transform_lists: AnimatedTransformList.Lookup = .empty,
_svg_point_lists: PointList.Lookup = .empty,
_svg_string_lists: StringList.Lookup = .empty,
// Same as above, but for Nodes (slot assigments apply to both Element AND
// Text nodes)
@@ -502,6 +512,16 @@ pub fn deinit(self: *Frame) void {
observers.deinit(self, page);
var svg_point_lists = self._svg_point_lists.valueIterator();
while (svg_point_lists.next()) |list| {
list.*.deinit(page);
}
var svg_transform_lists = self._svg_animated_transform_lists.valueIterator();
while (svg_transform_lists.next()) |list| {
list.*.deinit(page);
}
var document = self.window._document;
document._selection.releaseRef(page);
@@ -1024,7 +1044,9 @@ pub fn makeRequest(self: *Frame, req: HttpClient.Request) !void {
// Two-phase variant; see HttpClient.newRequest for the ownership contract.
pub fn newRequest(self: *Frame, req: HttpClient.Request) !*HttpClient.Transfer {
return self._session.browser.http_client.newRequest(req, &self._http_owner);
var r = req;
r.document_frame_id = self._frame_id;
return self._session.browser.http_client.newRequest(r, &self._http_owner);
}
// Synchronously abort every transfer and WebSocket owned by this frame
@@ -1960,7 +1982,7 @@ pub fn domChanged(self: *Frame) void {
// A DOM change is our "rendering opportunity": re-evaluate the layout
// observers. Both are no-ops unless something they track actually changed.
observers.scheduleIntersectionChecks(self);
observers.scheduleResizeDelivery(self);
observers.scheduleResizeChecks(self);
}
const ElementIdMaps = struct { lookup: *std.StringHashMapUnmanaged(*Element), removed_ids: *std.StringHashMapUnmanaged(void) };
@@ -2867,74 +2889,6 @@ pub fn updateRangesForNodeRemoval(self: *Frame, parent: *Node, child: *Node, chi
}
}
// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '')
pub fn parseHtmlAsChildren(self: *Frame, node: *Node, html: []const u8) !void {
return self.parseHtmlAsChildrenInner(node, html, .{});
}
// setHTMLUnsafe variant: parse a fragment that may contain declarative shadow node
pub fn parseHtmlUnsafeAsChildren(self: *Frame, node: *Node, html: []const u8) !void {
return self.parseHtmlAsChildrenInner(node, html, .{ .allow_declarative_shadow = true });
}
// Range.createContextualFragment variant: unlike innerHTML et al., its scripts
// are run when the fragment is inserted into a document.
pub fn parseContextualFragment(self: *Frame, node: *Node, html: []const u8) !void {
return self.parseHtmlAsChildrenInner(node, html, .{ .scripts_runnable = true });
}
const FragmentParseOpts = struct {
scripts_runnable: bool = false,
allow_declarative_shadow: bool = false,
};
fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: FragmentParseOpts) !void {
const previous_parse_mode = self._parse_mode;
self._parse_mode = .fragment;
defer self._parse_mode = previous_parse_mode;
// The html5ever wrapper-unwrap below rebinds children without going
// through the insertion path, so recompute slot assignments for any
// shadow tree this fragment landed in (idempotent; signals only on diff).
defer if (self._element_shadow_roots.count() != 0) {
const root = node.getRootNode(.{});
if (root.is(ShadowRoot) != null) {
slotting.assignSlottablesForTree(root, self);
}
if (node.is(Element)) |el| {
if (self._element_shadow_roots.get(el)) |shadow_root| {
slotting.assignSlottablesForTree(shadow_root.asNode(), self);
}
}
};
const previous_scripts_runnable = self._fragment_scripts_runnable;
self._fragment_scripts_runnable = opts.scripts_runnable;
defer self._fragment_scripts_runnable = previous_scripts_runnable;
var parser = Parser.init(self.call_arena, node, self, .{ .allow_declarative_shadow = opts.allow_declarative_shadow });
parser.parseFragment(html);
// html5ever wraps fragment output in an <html> element; unwrap so its
// children land directly on `node`. See https://github.com/servo/html5ever/issues/583.
// Because of custom element callbacks, the structure might not be what
// we expect, and nodes might be altogether removed. We deal with this in a
// few different places, but always the same way: leave it as-is.
const children = node._children orelse return;
const first = Node.linkToNode(children.first.?);
if (first.is(Element.Html.Html) == null) {
return;
}
node._children = first._children;
// No mutation records for the unwrapped children either; see the comment
// about fragment parses in _insertNodeRelative.
var it = node.childrenIterator();
while (it.next()) |child| {
child._parent = node;
}
}
// Runs the "ready" work for an inserted node and, when it's an element with
// children, for its descendants in tree order: appending a subtree
// containing scripts must execute them all, after the whole insertion.
@@ -3379,9 +3333,6 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
form_data.acquireRef();
defer form_data.releaseRef(self._page);
const arena = try self._session.getArena(.medium, "submitForm");
errdefer self._session.releaseArena(arena);
// Per HTML spec form-submission algorithm, when the submitter is a submit
// button, its formaction/formmethod/formenctype attributes override the
// form's corresponding attributes (matching how formtarget is honored above).
@@ -3399,8 +3350,37 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
break :blk form_element.getAttributeSafe(comptime .wrap("method"));
};
const method = Element.Html.Form.normalizeMethod(method_attr, "get");
// Per the HTML form-submission algorithm, the dialog method closes the
// form's nearest ancestor dialog with the submitter's value and performs no
// navigation. Falling through here would submit the form as a GET, which
// both reloads the page and leaves the dialog open forever.
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm
if (std.mem.eql(u8, method, "dialog")) {
// A submit button always has a value (empty when unset); with no
// submitter at all the dialog's existing returnValue is left untouched.
const result: ?[]const u8 = if (submit_button) |s| blk: {
if (s.is(Element.Html.Form.Input)) |input| {
break :blk input.getValue();
}
// if not an Input, it has to be a Button (checked above by isSubmitButton)
break :blk s.as(Element.Html.Form.Button).getValue();
} else null;
var ancestor: ?*Element = form_element;
while (ancestor) |el| : (ancestor = el.parentElement()) {
const dialog = el.is(Element.Html.Dialog) orelse continue;
try dialog.close(result, self);
break;
}
return;
}
const is_post = std.mem.eql(u8, method, "post");
const arena = try self._session.getArena(.medium, "submitForm");
errdefer self._session.releaseArena(arena);
// Get charset from accept-charset attribute or fall back to document charset
const charset: []const u8 = blk: {
if (form_element.getAttributeSafe(.wrap("accept-charset"))) |ac| {

View File

@@ -49,6 +49,7 @@ pub const ContentTypeEnum = enum {
application_json,
unknown,
other,
other_xml,
};
pub const ContentType = union(ContentTypeEnum) {
@@ -68,6 +69,7 @@ pub const ContentType = union(ContentTypeEnum) {
// A valid but unrecognized type/subtype. Keeping it would require some
// memory management of the input. Nothing needs it right now, so why bother.
other: void,
other_xml: void, // i.e. application/xml or */*+xml
};
pub fn contentTypeString(mime: *const Mime) []const u8 {
@@ -346,6 +348,13 @@ pub fn isHTML(self: *const Mime) bool {
return self.content_type == .text_html;
}
pub fn isXML(self: *const Mime) bool {
return switch (self.content_type) {
.text_xml, .other_xml => true,
else => false,
};
}
pub fn isText(mime: *const Mime) bool {
return switch (mime.content_type) {
.text_xml, .text_html, .text_javascript, .text_plain, .text_css, .text_markdown => true,
@@ -378,9 +387,11 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
@"image/webp",
@"application/json",
@"application/xml",
}, type_name)) |known_type| {
const ct: ContentType = switch (known_type) {
.@"text/xml" => .{ .text_xml = {} },
.@"application/xml" => .{ .other_xml = {} },
.@"text/html" => .{ .text_html = {} },
.@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} },
.@"text/plain" => .{ .text_plain = {} },
@@ -408,6 +419,10 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
return error.Invalid;
}
if (std.mem.endsWith(u8, type_name, "+xml")) {
return .{ .{ .other_xml = {} }, attribute_start };
}
return .{ .{ .other = {} }, attribute_start };
}
@@ -897,6 +912,28 @@ test "Mime: isHTML" {
try assert(false, "over/9000");
}
test "Mime: isXML" {
defer testing.reset();
const assert = struct {
fn assert(expected: bool, input: []const u8) !void {
const mutable_input = try testing.arena_allocator.dupe(u8, input);
var mime = try Mime.parse(mutable_input);
try testing.expectEqual(expected, mime.isXML());
}
}.assert;
try assert(true, "text/xml");
try assert(true, "TEXT/XML; charset=utf-8");
try assert(true, "application/xml");
try assert(true, "image/svg+xml");
try assert(true, "application/xhtml+xml");
try assert(true, "application/rss+xml;charset=utf-8");
try assert(false, "text/html");
try assert(false, "application/json");
try assert(false, "application/xmlfoo");
try assert(false, "text/xm");
}
test "Mime: sniff" {
try testing.expectEqual(null, Mime.sniff(""));
try testing.expectEqual(null, Mime.sniff("<htm"));

View File

@@ -217,7 +217,9 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
return .{ .ok = 0 };
}
if (hasRunnablePage(session)) {
const has_runnable_page = hasRunnablePage(session);
if (has_runnable_page) {
try browser.runMacrotasks();
}
@@ -295,12 +297,18 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
}
if ((comptime is_cdp) or want_http_tick) {
var ms_to_wait = @min(timeout_ms, browser.msToNextTask() orelse 200);
if (browser.hasBackgroundTasks()) {
// background work will queue more to do soon — don't block long
// for a client message; loop back and run macrotasks instead.
ms_to_wait = @min(ms_to_wait, 10);
}
const ms_to_next_task = blk: {
if (has_runnable_page == false) {
break :blk 200;
}
if (browser.hasBackgroundTasks()) {
// msToNextTask could be less than this, but 10ms drift is ok
break :blk 10;
}
break :blk browser.msToNextTask() orelse 200;
};
const ms_to_wait = @min(timeout_ms, ms_to_next_task);
const waited = try http_client.tick(@intCast(ms_to_wait));
// If the HttpClient didn't wait/poll then it has nothing to do, and we

View File

@@ -511,10 +511,12 @@ fn addRawRule(self: *StyleManager, build_arena: Allocator, selector_text: []cons
pub fn sheetRemoved(self: *StyleManager) void {
self.dirty = true;
Frame.observers.scheduleResizeDelivery(self.frame);
}
pub fn sheetModified(self: *StyleManager) void {
self.dirty = true;
Frame.observers.scheduleResizeDelivery(self.frame);
}
/// Rebuilds the rule list from all document stylesheets.

View File

@@ -282,7 +282,7 @@ fn testForms(html: []const u8) ![]FormInfo {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
return collectForms(frame.call_arena, div.asNode(), frame);
}

View File

@@ -16,10 +16,11 @@
// 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/>.
// Per-frame MutationObserver and IntersectionObserver bookkeeping: registration,
// the scheduling of the microtask deliveries, and broadcasting DOM mutations to
// the registered observers. The state lives on the Frame (frame._mutation /
// frame._intersection); these functions operate on it.
// Per-frame MutationObserver, IntersectionObserver and ResizeObserver
// bookkeeping: registration, the scheduling of the microtask deliveries, and
// broadcasting DOM mutations to the registered observers. The state lives on
// the Frame (frame._mutation / frame._intersection / frame._resize); these
// functions operate on it.
const std = @import("std");
const lp = @import("lightpanda");
@@ -57,6 +58,7 @@ pub const Intersection = struct {
pub const Resize = struct {
// List of active ResizeObservers (i.e. those with >= 1 observation)
observers: std.ArrayList(*ResizeObserver) = .empty,
check_scheduled: bool = false,
delivery_scheduled: bool = false,
delivery_depth: u32 = 0,
};
@@ -171,6 +173,81 @@ pub fn scheduleResizeDelivery(frame: *Frame) void {
};
}
// Called on every DOM change, a full delivery is too expensive to call here.
// What we can do is schedule a check.
pub fn scheduleResizeChecks(frame: *Frame) void {
if (frame._resize.observers.items.len == 0) {
return;
}
if (frame._resize.check_scheduled or frame._resize.delivery_scheduled) {
// check is already scheduled OR delivery is already scheduled
return;
}
frame._resize.check_scheduled = true;
frame.js.queueResizeChecks() catch |err| {
frame._resize.check_scheduled = false;
log.err(.frame, "frame.scheduleResizeChecks", .{ .err = err, .type = frame._type, .url = frame.url });
};
}
pub fn performScheduledResizeChecks(frame: *Frame) void {
if (!frame._resize.check_scheduled) {
return;
}
frame._resize.check_scheduled = false;
if (frame._resize.delivery_scheduled) {
return;
}
// Check if we should schedule a delivery. If you're wondering why we're
// scheduling within a schedule, it's because this can be expensive and we
// want to coalesce as many of these into a single call as possible.
for (frame._resize.observers.items) |observer| {
if (observer.connectivityChanged()) {
scheduleResizeDelivery(frame);
return;
}
}
}
// Only these attributes can change an element's size or visibility in our
// styling model (StyleManager.isHidden + Element.getElementDimensions), and
// only for the element itself and its descendants — so a delivery is only
// scheduled when an observed element is in the changed element's subtree.
fn resizeAttributeChanged(frame: *Frame, element: *Element, name: String) void {
if (frame._resize.observers.items.len == 0) {
return;
}
if (frame._resize.delivery_scheduled) {
return;
}
// This has proven to be on the hot path
switch (name.len) {
2 => if (!name.eqlWithSameLen(comptime .wrap("id"))) {
return;
},
4 => if (!name.eqlWithSameLen(comptime .wrap("type")) and !name.eqlWithSameLen(comptime .wrap("open"))) {
return;
},
5 => if (!name.eqlWithSameLen(comptime .wrap("style")) and !name.eqlWithSameLen(comptime .wrap("class")) and !name.eqlWithSameLen(comptime .wrap("width"))) {
return;
},
6 => if (!name.eqlWithSameLen(comptime .wrap("hidden")) and !name.eqlWithSameLen(comptime .wrap("height"))) {
return;
},
else => return,
}
for (frame._resize.observers.items) |observer| {
if (observer.observesWithin(element)) {
scheduleResizeDelivery(frame);
return;
}
}
}
pub fn deliverResizes(frame: *Frame) void {
if (!frame._resize.delivery_scheduled) {
return;
@@ -183,7 +260,7 @@ pub fn deliverResizes(frame: *Frame) void {
defer if (!frame._resize.delivery_scheduled) {
frame._resize.delivery_depth = 0;
};
if (frame._resize.delivery_depth > 50) {
if (frame._resize.delivery_depth > 16) {
log.warn(.frame, "frame.ResizeLimit", .{ .type = frame._type, .url = frame.url });
frame._resize.delivery_depth = 0;
return;
@@ -242,7 +319,7 @@ pub fn deliverMutations(frame: *Frame) void {
frame._mutation.delivery_depth = 0;
};
if (frame._mutation.delivery_depth > 50) {
if (frame._mutation.delivery_depth > 16) {
log.err(.frame, "frame.MutationLimit", .{ .type = frame._type, .url = frame.url });
frame._mutation.delivery_depth = 0;
return;
@@ -292,9 +369,10 @@ pub fn deliverMutations(frame: *Frame) void {
}
}
// Broadcast an attribute change to every registered MutationObserver. The
// caller (Frame.attributeChange / attributeRemove) handles the non-observer
// side effects (build hooks, custom-element callbacks, slot/popover updates).
// Broadcast an attribute change to every registered MutationObserver and to
// the resize bookkeeping. The caller (Frame.attributeChange / attributeRemove)
// handles the non-observer side effects (build hooks, custom-element
// callbacks, slot/popover updates).
pub fn notifyAttributeChange(frame: *Frame, element: *Element, name: String, old_value: ?String) void {
var it: ?*std.DoublyLinkedList.Node = frame._mutation.observers.first;
while (it) |node| : (it = node.next) {
@@ -303,6 +381,7 @@ pub fn notifyAttributeChange(frame: *Frame, element: *Element, name: String, old
log.err(.frame, "attributeChange.notifyObserver", .{ .err = err, .type = frame._type, .url = frame.url });
};
}
resizeAttributeChanged(frame, element, name);
}
pub fn notifyCharacterDataChange(frame: *Frame, target: *Node, old_value: String) void {

129
src/browser/frame/parse.zig Normal file
View File

@@ -0,0 +1,129 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 Frame = @import("../Frame.zig");
const Parser = @import("../parser/Parser.zig");
const Node = @import("../webapi/Node.zig");
const Element = @import("../webapi/Element.zig");
const Document = @import("../webapi/Document.zig");
const ShadowRoot = @import("../webapi/ShadowRoot.zig");
const slotting = @import("../webapi/element/slotting.zig");
pub fn htmlAsChildren(frame: *Frame, node: *Node, html: []const u8) !void {
return htmlAsChildrenInner(frame, node, html, .{});
}
// setHTMLUnsafe variant: parse a fragment that may contain declarative shadow node
pub fn htmlUnsafeAsChildren(frame: *Frame, node: *Node, html: []const u8) !void {
return htmlAsChildrenInner(frame, node, html, .{ .allow_declarative_shadow = true });
}
// Range.createContextualFragment variant: unlike innerHTML et al., its scripts
// are run when the fragment is inserted into a document.
pub fn contextualFragment(frame: *Frame, node: *Node, html: []const u8) !void {
return htmlAsChildrenInner(frame, node, html, .{ .scripts_runnable = true });
}
const FragmentParseOpts = struct {
scripts_runnable: bool = false,
allow_declarative_shadow: bool = false,
};
fn htmlAsChildrenInner(frame: *Frame, node: *Node, html: []const u8, opts: FragmentParseOpts) !void {
const previous_parse_mode = frame._parse_mode;
frame._parse_mode = .fragment;
defer frame._parse_mode = previous_parse_mode;
// The html5ever wrapper-unwrap below rebinds children without going
// through the insertion path, so recompute slot assignments for any
// shadow tree this fragment landed in (idempotent; signals only on diff).
defer if (frame._element_shadow_roots.count() != 0) {
const root = node.getRootNode(.{});
if (root.is(ShadowRoot) != null) {
slotting.assignSlottablesForTree(root, frame);
}
if (node.is(Element)) |el| {
if (frame._element_shadow_roots.get(el)) |shadow_root| {
slotting.assignSlottablesForTree(shadow_root.asNode(), frame);
}
}
};
const previous_scripts_runnable = frame._fragment_scripts_runnable;
frame._fragment_scripts_runnable = opts.scripts_runnable;
defer frame._fragment_scripts_runnable = previous_scripts_runnable;
var parser = Parser.init(frame.call_arena, node, frame, .{ .allow_declarative_shadow = opts.allow_declarative_shadow });
parser.parseFragment(html);
if (parser.terminated) {
return error.ExecutionTerminated;
}
// html5ever wraps fragment output in an <html> element; unwrap so its
// children land directly on `node`. See https://github.com/servo/html5ever/issues/583.
// Because of custom element callbacks, the structure might not be what
// we expect, and nodes might be altogether removed. We deal with this in a
// few different places, but always the same way: leave it as-is.
const children = node._children orelse return;
const first = Node.linkToNode(children.first.?);
if (first.is(Element.Html.Html) == null) {
return;
}
node._children = first._children;
// No mutation records for the unwrapped children either; see the comment
// about fragment parses in _insertNodeRelative.
var it = node.childrenIterator();
while (it.next()) |child| {
child._parent = node;
}
}
// Build a detached XMLDocument from `xml` (DOMParser.parseFromString and
// XMLHttpRequest.responseXML). Returns null when the input isn't well-formed
// XML.
pub fn xmlDocument(frame: *Frame, xml: []const u8) !?*Document.XMLDocument {
const arena = try frame.getArena(.medium, "parse.xmlDocument");
defer frame.releaseArena(arena);
const previous_parse_mode = frame._parse_mode;
frame._parse_mode = .fragment;
defer frame._parse_mode = previous_parse_mode;
const doc = try frame._factory.document(Document.XMLDocument{ ._proto = undefined });
const doc_node = doc.asNode();
var parser = Parser.init(arena, doc_node, frame, .{});
parser.parseXML(xml);
if (parser.terminated) {
return error.ExecutionTerminated;
}
if (parser.err != null or doc_node.firstChild() == null) {
return null;
}
// If first node is a `ProcessingInstruction` (e.g. the <?xml?>
// declaration), skip it.
const first_child = doc_node.firstChild().?;
if (first_child.getNodeType() == 7) {
_ = try doc_node.removeChild(first_child, frame);
}
return doc;
}

View File

@@ -501,7 +501,7 @@ fn testInteractive(html: []const u8) ![]InteractiveElement {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
return collectInteractiveElements(div.asNode(), frame.call_arena, frame);
}

View File

@@ -1093,6 +1093,17 @@ pub fn queueIntersectionDelivery(self: *Context) !void {
}.run);
}
pub fn queueResizeChecks(self: *Context) !void {
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {
switch (ctx.global) {
.frame => |frame| Frame.observers.performScheduledResizeChecks(frame),
.worker => unreachable,
}
}
}.run);
}
pub fn queueResizeDelivery(self: *Context) !void {
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {

View File

@@ -1074,8 +1074,13 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/svg/Angle.zig"),
@import("../webapi/svg/Transform.zig"),
@import("../webapi/svg/AnimatedLength.zig"),
@import("../webapi/svg/AnimatedNumber.zig"),
@import("../webapi/svg/PreserveAspectRatio.zig"),
@import("../webapi/svg/AnimatedPreserveAspectRatio.zig"),
@import("../webapi/svg/PointList.zig"),
@import("../webapi/svg/TransformList.zig"),
@import("../webapi/svg/AnimatedTransformList.zig"),
@import("../webapi/svg/StringList.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextEncoderStream.zig"),

View File

@@ -94,7 +94,7 @@ fn testLinks(html: []const u8) ![]Link {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
return collectLinks(frame.call_arena, div.asNode(), frame);
}

View File

@@ -599,7 +599,7 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
@@ -800,7 +800,7 @@ test "browser.markdown: resolve links" {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(),
try Frame.parse.htmlAsChildren(frame, div.asNode(),
\\<a href="b">Link</a>
\\<img src="../c.png" alt="Img">
\\<a href="/my page">Space</a>
@@ -839,7 +839,7 @@ test "browser.markdown: max_bytes leaves output untouched when under cap" {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), "<p>Short</p>");
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<p>Short</p>");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
@@ -855,7 +855,7 @@ test "browser.markdown: max_bytes truncates with marker" {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), "<p>" ++ ("AAAA " ** 100) ++ "</p>");
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<p>" ++ ("AAAA " ** 100) ++ "</p>");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
@@ -878,11 +878,11 @@ fn testMarkdownShadow(light: []const u8, shadow: []const u8, expected: []const u
const host = try doc.createElement("div", null, frame);
if (light.len > 0) {
try frame.parseHtmlAsChildren(host.asNode(), light);
try Frame.parse.htmlAsChildren(frame, host.asNode(), light);
}
const sr = try host.attachShadow(.{ .mode = .open }, frame);
try frame.parseHtmlAsChildren(sr.asNode(), shadow);
try Frame.parse.htmlAsChildren(frame, sr.asNode(), shadow);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();

View File

@@ -28,6 +28,7 @@ const CData = @import("../webapi/CData.zig");
pub const AttributeIterator = h5e.AttributeIterator;
const Allocator = std.mem.Allocator;
const TERMINATE_CHECK_INTERVAL = 1024;
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const ParsedNode = struct {
@@ -76,6 +77,9 @@ buf: std.ArrayList(u8),
// innerHTML and DOMParser (per spec). Set from Options at init.
allow_declarative_shadow: bool = false,
terminated: bool = false,
appends_until_terminate_check: u16 = TERMINATE_CHECK_INTERVAL,
pub const Options = struct {
allow_declarative_shadow: bool = false,
};
@@ -428,6 +432,9 @@ fn parseErrorCallback(ctx: *anyopaque, err: h5e.StringSlice) callconv(.c) void {
fn popCallback(ctx: *anyopaque, node_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.terminated) {
return;
}
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._popCallback(getNode(node_ref)) catch |err| {
@@ -535,6 +542,9 @@ fn _createProcessingInstruction(self: *Parser, target: []const u8, data: []const
fn appendDoctypeToDocument(ctx: *anyopaque, name: h5e.StringSlice, public_id: h5e.StringSlice, system_id: h5e.StringSlice) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.terminated) {
return;
}
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendDoctypeToDocument(name.slice(), public_id.slice(), system_id.slice()) catch |err| {
@@ -559,6 +569,9 @@ fn _appendDoctypeToDocument(self: *Parser, name: []const u8, public_id: []const
fn addAttrsIfMissingCallback(ctx: *anyopaque, target_ref: *anyopaque, attributes: h5e.AttributeIterator) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.terminated) {
return;
}
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._addAttrsIfMissingCallback(getNode(target_ref), attributes) catch |err| {
@@ -607,6 +620,7 @@ fn _getTemplateContentsCallback(self: *Parser, node: *Node) !*anyopaque {
// fall back to inserting the template as a normal light-DOM element.
fn attachDeclarativeShadowCallback(ctx: *anyopaque, host_ref: *anyopaque, template_ref: *anyopaque, mode_is_open: u8) callconv(.c) u8 {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.terminated) return 0;
return self._attachDeclarativeShadowCallback(getNode(host_ref), getNode(template_ref), mode_is_open != 0) catch |err| {
self.err = .{ .err = err, .source = .attach_declarative_shadow };
return 0;
@@ -643,6 +657,10 @@ fn getDataCallback(ctx: *anyopaque) callconv(.c) *anyopaque {
fn appendCallback(ctx: *anyopaque, parent_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.pollTerminate()) {
return;
}
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendCallback(getNode(parent_ref), node_or_text) catch |err| {
@@ -677,6 +695,9 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
fn removeFromParentCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.terminated) {
return;
}
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._removeFromParentCallback(getNode(target_ref)) catch |err| {
@@ -695,6 +716,9 @@ fn _removeFromParentCallback(self: *Parser, node: *Node) !void {
fn reparentChildrenCallback(ctx: *anyopaque, node_ref: *anyopaque, new_parent_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.terminated) {
return;
}
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._reparentChildrenCallback(getNode(node_ref), getNode(new_parent_ref)) catch |err| {
@@ -711,6 +735,10 @@ fn _reparentChildrenCallback(self: *Parser, node: *Node, new_parent: *Node) !voi
fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.pollTerminate()) {
return;
}
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendBeforeSiblingCallback(getNode(sibling_ref), node_or_text) catch |err| {
@@ -741,6 +769,10 @@ fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e
fn appendBasedOnParentNodeCallback(ctx: *anyopaque, element_ref: *anyopaque, prev_element_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
if (self.pollTerminate()) {
return;
}
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendBasedOnParentNodeCallback(getNode(element_ref), getNode(prev_element_ref), node_or_text) catch |err| {
@@ -772,3 +804,23 @@ fn asUint(comptime string: anytype) std.meta.Int(
return @bitCast(@as(*const [byteLength]u8, string).*);
}
// v8's terminate isn't pre-emptive. A parse of unbounded input
// (this.innerHTML += this.innerHTML) has to poll the terminate flag itself.
// We'll poll this (atomic) variable every TERMINATE_CHECK_INTERVAL append.
fn pollTerminate(self: *Parser) bool {
if (self.terminated) {
return true;
}
const next_check = self.appends_until_terminate_check - 1;
if (next_check > 0) {
self.appends_until_terminate_check = next_check;
return false;
}
self.appends_until_terminate_check = TERMINATE_CHECK_INTERVAL;
const terminated = self.frame.js.env.terminatePending();
self.terminated = terminated;
return terminated;
}

View File

@@ -494,7 +494,7 @@ fn testStructuredData(html: []const u8) !StructuredData {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
return collectStructuredData(div.asNode(), frame.call_arena, frame);
}
@@ -714,7 +714,7 @@ test "structured_data: link headers from response" {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), "<title>Example</title>");
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<title>Example</title>");
const data = try collectStructuredData(div.asNode(), frame.call_arena, frame);

View File

@@ -0,0 +1,4 @@
<!doctype html>
<script>
new Worker("./worker_network.js");
</script>

View File

@@ -0,0 +1 @@
fetch("/echo_method");

View File

@@ -2,7 +2,7 @@
<body>
<script src="../testing.js"></script>
<!-- Frame.parseHtmlAsChildren extracts children from the temporary
<!-- Frame.parse.htmlAsChildren extracts children from the temporary
<html> element the fragment parser leaves at the top of the parse
result. A custom element's connectedCallback fires synchronously
during that parse, so it can reach two levels up (this.parentNode is

View File

@@ -198,3 +198,99 @@
testing.expectEqual(0, fired)
}
</script>
<script id="dialog_method_closes_with_submitter_value">
{
document.body.insertAdjacentHTML('beforeend',
'<dialog id="d_submit"><form method="dialog">' +
'<button type="submit" value="confirm" id="b_submit">ok</button>' +
'</form></dialog>')
const dialog = $('#d_submit')
dialog.showModal()
let fired = 0
dialog.addEventListener('close', () => fired++)
$('#b_submit').click()
testing.expectEqual(1, fired)
testing.expectEqual(false, dialog.open)
testing.expectEqual('confirm', dialog.returnValue)
}
</script>
<script id="dialog_method_uses_empty_value_when_submitter_has_none">
{
document.body.insertAdjacentHTML('beforeend',
'<dialog id="d_novalue"><form method="dialog">' +
'<button type="submit" id="b_novalue">ok</button>' +
'</form></dialog>')
const dialog = $('#d_novalue')
dialog.returnValue = 'preset'
dialog.showModal()
$('#b_novalue').click()
testing.expectEqual(false, dialog.open)
testing.expectEqual('', dialog.returnValue)
}
</script>
<script id="dialog_method_closes_nearest_ancestor_dialog">
{
document.body.insertAdjacentHTML('beforeend',
'<dialog id="d_outer"><dialog id="d_inner"><form method="dialog">' +
'<button type="submit" value="inner" id="b_nested">ok</button>' +
'</form></dialog></dialog>')
const outer = $('#d_outer')
const inner = $('#d_inner')
outer.show()
inner.show()
$('#b_nested').click()
testing.expectEqual(false, inner.open)
testing.expectEqual('inner', inner.returnValue)
testing.expectEqual(true, outer.open)
}
</script>
<script id="dialog_method_is_inert_without_ancestor_dialog">
{
// No ancestor dialog: the submission is dropped. It must not fall through
// to a GET submission either, so the form's own submit handler is the only
// thing that runs.
document.body.insertAdjacentHTML('beforeend',
'<form method="dialog" id="f_orphan">' +
'<button type="submit" value="x" id="b_orphan">ok</button>' +
'</form>')
let submits = 0
$('#f_orphan').addEventListener('submit', () => submits++)
$('#b_orphan').click()
testing.expectEqual(1, submits)
}
</script>
<script id="dialog_method_honors_formmethod_on_submitter">
{
// formmethod on the submit button overrides the form's method, so a GET
// form can still close its dialog.
document.body.insertAdjacentHTML('beforeend',
'<dialog id="d_formmethod"><form method="get">' +
'<button type="submit" formmethod="dialog" value="via-formmethod" id="b_formmethod">ok</button>' +
'</form></dialog>')
const dialog = $('#d_formmethod')
dialog.showModal()
$('#b_formmethod').click()
testing.expectEqual(false, dialog.open)
testing.expectEqual('via-formmethod', dialog.returnValue)
}
</script>

View File

@@ -0,0 +1,208 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!--
A select's list of options is its option children PLUS the option children of
its optgroup children:
https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element
-->
<form id="grouped_form">
<select id="grouped" name="s">
<option value="">placeholder</option>
<optgroup label="first">
<option value="a">A</option>
<option value="b">B</option>
</optgroup>
<optgroup label="second">
<option value="c">C</option>
</optgroup>
</select>
</form>
<script id="grouped_options_collection">
{
const select = $('#grouped')
// Options inside optgroups are part of the list, in tree order.
testing.expectEqual(4, select.options.length)
testing.expectEqual(4, select.length)
testing.expectEqual('', select.options[0].value)
testing.expectEqual('a', select.options[1].value)
testing.expectEqual('b', select.options[2].value)
testing.expectEqual('c', select.options[3].value)
testing.expectEqual(undefined, select.options[4])
}
</script>
<script id="grouped_value_setter">
{
const select = $('#grouped')
select.value = 'b'
testing.expectEqual('b', select.value)
testing.expectEqual(2, select.selectedIndex)
testing.expectEqual(1, select.selectedOptions.length)
testing.expectEqual('b', select.selectedOptions[0].value)
// Setting the value deselects every other option, grouped or not.
testing.expectFalse(select.options[0].selected)
testing.expectTrue(select.options[2].selected)
testing.expectFalse(select.options[3].selected)
}
</script>
<script id="grouped_option_selected_property">
{
const select = $('#grouped')
// Selecting through the option's own property, which the select has to
// see through the optgroup as well.
select.options[2].selected = false
select.options[3].selected = true
testing.expectEqual('c', select.value)
testing.expectEqual(3, select.selectedIndex)
}
</script>
<script id="grouped_selected_index_setter">
{
const select = $('#grouped')
select.selectedIndex = 1
testing.expectEqual('a', select.value)
testing.expectEqual('a', select.selectedOptions[0].value)
select.selectedIndex = -1
testing.expectEqual('', select.value)
testing.expectEqual(-1, select.selectedIndex)
testing.expectEqual(0, select.selectedOptions.length)
}
</script>
<script id="grouped_form_submission">
{
const select = $('#grouped')
select.value = 'c'
testing.expectEqual('c', new FormData($('#grouped_form')).get('s'))
}
</script>
<script id="grouped_iteration">
{
const values = []
for (const option of $('#grouped').options) {
values.push(option.value)
}
testing.expectEqual(['', 'a', 'b', 'c'], values)
}
</script>
<select id="grouped_selected_attribute">
<option value="x">X</option>
<optgroup label="g">
<option value="y" selected>Y</option>
</optgroup>
</select>
<script id="grouped_selected_attribute_wins">
{
const select = $('#grouped_selected_attribute')
// The explicitly selected option wins over the implicit first one, even
// from inside an optgroup.
testing.expectEqual('y', select.value)
testing.expectEqual(1, select.selectedIndex)
}
</script>
<select id="grouped_disabled">
<optgroup label="off" disabled>
<option value="skipped">skipped</option>
</optgroup>
<optgroup label="on">
<option value="taken">taken</option>
</optgroup>
</select>
<script id="grouped_disabled_optgroup_skipped">
{
// With nothing explicitly selected, the fallback is the first option that
// is not disabled — an option in a disabled optgroup is itself disabled
// (HTML "concept-option-disabled").
testing.expectEqual('taken', $('#grouped_disabled').value)
}
</script>
<select id="grouped_add">
<option value="one">one</option>
<optgroup label="g">
<option value="two">two</option>
</optgroup>
</select>
<script id="grouped_add_before_index">
{
const select = $('#grouped_add')
const option = document.createElement('option')
option.value = 'inserted'
// Index 1 is inside the optgroup, so that is where the option lands.
select.add(option, 1)
testing.expectEqual(3, select.options.length)
testing.expectEqual('inserted', select.options[1].value)
testing.expectEqual('two', select.options[2].value)
testing.expectEqual('OPTGROUP', option.parentElement.tagName)
}
</script>
<select id="nested_too_deep">
<option value="direct">direct</option>
<div>
<option value="buried">buried</option>
</div>
</select>
<script id="options_are_not_arbitrary_descendants">
{
// Only children and optgroup grandchildren count — not every descendant.
const select = $('#nested_too_deep')
testing.expectEqual(1, select.options.length)
testing.expectEqual('direct', select.options[0].value)
}
</script>
<script id="options_stop_at_one_optgroup">
{
// The parser never nests optgroups, so build the tree by hand: an optgroup
// inside an optgroup is not walked into, its options are out of the list.
const select = document.createElement('select')
const outer = document.createElement('optgroup')
const inner = document.createElement('optgroup')
const option = (value) => {
const o = document.createElement('option')
o.value = value
return o
}
inner.appendChild(option('buried'))
outer.appendChild(option('grouped'))
outer.appendChild(inner)
outer.appendChild(option('after'))
select.appendChild(option('before'))
select.appendChild(outer)
select.appendChild(option('last'))
document.body.appendChild(select)
testing.expectEqual(4, select.length)
testing.expectEqual(4, select.options.length)
testing.expectEqual('before', select.options[0].value)
testing.expectEqual('grouped', select.options[1].value)
testing.expectEqual('after', select.options[2].value)
testing.expectEqual('last', select.options[3].value)
// The iterator resumes after the optgroup, so indexes stay in tree order.
select.selectedIndex = 3
testing.expectEqual('last', select.value)
select.value = 'after'
testing.expectEqual(2, select.selectedIndex)
}
</script>

View File

@@ -24,12 +24,206 @@
{
const test1 = $('#test1');
// In dummy layout, scroll dimensions equal client dimensions (no overflow)
// Both scroll dimensions are approximated from the content, so neither falls
// below the element's own box but either may exceed it.
testing.expectTrue(test1.scrollWidth >= test1.clientWidth);
testing.expectTrue(test1.scrollHeight >= test1.clientHeight);
// test1 holds only a text node, and text is not measured on either axis, so
// both still match the element's own box.
testing.expectEqual(test1.clientWidth, test1.scrollWidth);
testing.expectEqual(test1.clientHeight, test1.scrollHeight);
}
</script>
<script id="scrollWidthFromContent">
{
// An empty element has no content to overflow its box.
const empty = document.createElement('div');
document.body.appendChild(empty);
testing.expectEqual(empty.clientWidth, empty.scrollWidth);
// Each added child strictly increases scrollWidth, and enough of them push
// it past the element's own box.
const box = document.createElement('div');
document.body.appendChild(box);
box.appendChild(document.createElement('span'));
const oneChild = box.scrollWidth;
testing.expectTrue(oneChild >= box.clientWidth);
box.appendChild(document.createElement('span'));
testing.expectTrue(box.scrollWidth > oneChild);
testing.expectTrue(box.scrollWidth > box.clientWidth);
// Text contributes nothing, however long. Estimating a run from its length
// would need a per-character advance that tracks font-size, and would report
// overflow for practically every element containing text.
const short = document.createElement('div');
const long = document.createElement('div');
short.textContent = 'ab';
long.textContent = 'abcdefghijklmnop';
document.body.appendChild(short);
document.body.appendChild(long);
testing.expectEqual(short.clientWidth, short.scrollWidth);
testing.expectEqual(long.clientWidth, long.scrollWidth);
testing.expectEqual(short.scrollWidth, long.scrollWidth);
// Comments are not rendered, so they contribute nothing.
const commented = document.createElement('div');
commented.innerHTML = '<!-- a very long comment that renders as nothing -->';
document.body.appendChild(commented);
testing.expectEqual(commented.clientWidth, commented.scrollWidth);
// A display:none child takes up no space.
const withHidden = document.createElement('div');
const hiddenChild = document.createElement('span');
hiddenChild.style.display = 'none';
withHidden.appendChild(hiddenChild);
document.body.appendChild(withHidden);
testing.expectEqual(withHidden.clientWidth, withHidden.scrollWidth);
// A hidden element reports 0, matching clientWidth/offsetWidth.
const hidden = document.createElement('div');
hidden.style.display = 'none';
hidden.appendChild(document.createElement('span'));
document.body.appendChild(hidden);
testing.expectEqual(0, hidden.scrollWidth);
// A visible width:0 box (an off-screen measurement container) is not
// hidden: its content still overflows it.
const zeroWidth = document.createElement('div');
zeroWidth.style.width = '0';
zeroWidth.appendChild(document.createElement('span'));
document.body.appendChild(zeroWidth);
testing.expectEqual(0, zeroWidth.clientWidth);
testing.expectTrue(zeroWidth.scrollWidth > 0);
// The root containers keep their synthetic size rather than summing
// children, so horizontal page-overflow checks stay stable.
testing.expectEqual(document.body.clientWidth, document.body.scrollWidth);
testing.expectEqual(
document.documentElement.clientWidth,
document.documentElement.scrollWidth,
);
}
</script>
<script id="scrollWidthMarqueeLoop">
{
// Regression: the infinite-marquee idiom duplicates content until it is
// twice the width of its container. scrollWidth has to respond to the
// insertion or the loop never terminates.
const track = document.createElement('div');
const lane = document.createElement('div');
lane.innerHTML = '<span>item</span>';
track.appendChild(lane);
document.body.appendChild(track);
// Doubling the markup doubles the measured content width.
const before = lane.scrollWidth;
lane.innerHTML += lane.innerHTML;
testing.expectEqual(before * 2, lane.scrollWidth);
let guard = 0;
while (lane.scrollWidth < track.offsetWidth * 2 && guard < 50) {
lane.innerHTML += lane.innerHTML;
guard++;
}
testing.expectTrue(guard < 50);
}
</script>
<script id="scrollHeightFromContent">
{
// An empty element has no content to overflow its box.
const empty = document.createElement('div');
document.body.appendChild(empty);
testing.expectEqual(empty.clientHeight, empty.scrollHeight);
// Children stack, so each one strictly increases scrollHeight.
const box = document.createElement('div');
document.body.appendChild(box);
box.appendChild(document.createElement('div'));
const oneChild = box.scrollHeight;
testing.expectTrue(oneChild >= box.clientHeight);
box.appendChild(document.createElement('div'));
testing.expectTrue(box.scrollHeight > oneChild);
testing.expectTrue(box.scrollHeight > box.clientHeight);
// Text contributes no height, as it contributes no width: a text child's
// line box is what the element's own size already stands in for.
const short = document.createElement('div');
const long = document.createElement('div');
short.textContent = 'ab';
long.textContent = 'abcdefghijklmnop';
document.body.appendChild(short);
document.body.appendChild(long);
testing.expectEqual(short.clientHeight, short.scrollHeight);
testing.expectEqual(long.clientHeight, long.scrollHeight);
testing.expectEqual(short.scrollHeight, long.scrollHeight);
// A display:none child takes up no space.
const withHidden = document.createElement('div');
const hiddenChild = document.createElement('div');
hiddenChild.style.display = 'none';
withHidden.appendChild(hiddenChild);
document.body.appendChild(withHidden);
testing.expectEqual(withHidden.clientHeight, withHidden.scrollHeight);
// A hidden element reports 0, matching clientHeight/offsetHeight.
const hidden = document.createElement('div');
hidden.style.display = 'none';
hidden.appendChild(document.createElement('div'));
document.body.appendChild(hidden);
testing.expectEqual(0, hidden.scrollHeight);
// The collapse/expand idiom: scrollHeight is read while the panel is
// collapsed at height:0 to know how far to open it. A zero-height box is
// not hidden; its content is still measured.
const panel = document.createElement('div');
panel.style.height = '0';
panel.appendChild(document.createElement('div'));
panel.appendChild(document.createElement('div'));
document.body.appendChild(panel);
testing.expectEqual(0, panel.clientHeight);
testing.expectTrue(panel.scrollHeight > 0);
// The root containers keep their synthetic size rather than summing
// children. This is what keeps infinite-scroll triggers reading
// `scrollTop + clientHeight >= scrollHeight` on body/documentElement
// behaving as they do today.
testing.expectEqual(document.body.clientHeight, document.body.scrollHeight);
testing.expectEqual(
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
);
}
</script>
<script id="scrollHeightMarqueeLoop">
{
// The vertical counterpart of scrollWidthMarqueeLoop: a ticker that grows
// its lane until it is twice the height of its container.
const track = document.createElement('div');
const lane = document.createElement('div');
lane.innerHTML = '<div>item</div>';
track.appendChild(lane);
document.body.appendChild(track);
// Doubling the markup doubles the measured content height.
const before = lane.scrollHeight;
lane.innerHTML += lane.innerHTML;
testing.expectEqual(before * 2, lane.scrollHeight);
let guard = 0;
while (lane.scrollHeight < track.offsetHeight * 2 && guard < 50) {
lane.innerHTML += lane.innerHTML;
guard++;
}
testing.expectTrue(guard < 50);
}
</script>
<script id="scrollPosition">
{
const test1 = $('#test1');

View File

@@ -0,0 +1,720 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<svg id=root>
<polygon id=polygon points="0,0 10,20"></polygon>
<polyline id=polylineLive points="0,0 10,20"></polyline>
<polyline id=polylineExternal points="0,0 10,20"></polyline>
<polyline id=polylineInvalid points="0,0 10,20"></polyline>
<polyline id=polylineIdentity points="1 2 3 4"></polyline>
<polyline id=polylineMutators points="1 2 3 4"></polyline>
<polyline id=polylineStale points="1 2 3 4"></polyline>
<polyline id=polylineErrors points="1 2 3 4"></polyline>
<polygon id=polygonReadOnly points="1 2 3 4"></polygon>
<polyline id=many></polyline>
<g id=group transform="translate(10 20) rotate(45 1 2)"></g>
<g id=transformLive transform="translate(10 20) rotate(45 1 2)"></g>
<g id=transformExternal transform="translate(10 20)"></g>
<g id=transformIdentity transform="translate(1 2)"></g>
<g id=transformMutators transform="translate(1 2)"></g>
<g id=transformStale transform="translate(10 20)"></g>
<g id=transformConsolidate transform="translate(10 20) scale(2)"></g>
<g id=transformConsolidateCommit transform="rotate(30 5 6) scale(2 3)"></g>
<g id=matrixIs2D transform="translate(1 2)"></g>
<g id=matrixStaleMatrix transform="translate(1 2)"></g>
<g id=transformReadOnly transform="translate(1 2)"></g>
<g id=transformParse transform="translate(1 2) scale(3,4) rotate(30 5 6)"></g>
<g id=matrixGroup transform="translate(1 2)"></g>
<g id=matrixReadOnly transform="translate(1 2)"></g>
<g id=strings requiredExtensions="urn:one urn:two" systemLanguage="en, fr"></g>
<g id=stringMutators requiredExtensions="urn:one urn:two"></g>
<g id=stringReturns requiredExtensions="urn:one urn:two"></g>
<g id=stringClear requiredExtensions="urn:one urn:two"></g>
<g id=stringLanguages systemLanguage="en, fr"></g>
<g id=stringExternal systemLanguage="en, fr"></g>
<g id=stringTokens systemLanguage="en, fr"></g>
<g id=stringNoValidation requiredExtensions="urn:one"></g>
</svg>
<!--
These fail in Chrome, deliberately. SVG 2 builds the list interfaces over a
list of real objects — getItem returns "a reference to that object and not a
copy of it" — so two getItem calls hand back the same object. Firefox does
this; Chrome creates a fresh tear-off per call and per mutator return, and
honours identity only where the IDL says [SameObject] (SVGTransform.matrix,
asserted in transformMatrixLive).
WPT sits this one out: every list test in svg/types/scripted compares .value
rather than the objects. We follow the spec and Firefox. Kept in its own
script so the rest of the file still runs in Chrome.
-->
<script id=pointListIdentity>
{
const root = $('#root');
const polygon = $('#polygon');
const points = polygon.points;
testing.expectTrue(points === polygon.points);
testing.expectTrue(polygon.animatedPoints === polygon.animatedPoints);
testing.expectFalse(points === polygon.animatedPoints);
testing.expectTrue(points.getItem(0) === points.getItem(0));
// "The inserted item is the item itself and not a copy", so every mutator
// hands back the object it was given.
const list = $('#polylineIdentity').points;
const appended = root.createSVGPoint();
testing.expectEqual(appended, list.appendItem(appended));
const inserted = root.createSVGPoint();
testing.expectEqual(inserted, list.insertItemBefore(inserted, 0));
const replacement = root.createSVGPoint();
testing.expectEqual(replacement, list.replaceItem(replacement, 1));
testing.expectEqual(replacement, list.removeItem(1));
const initialized = root.createSVGPoint();
testing.expectEqual(initialized, list.initialize(initialized));
}
</script>
<script id=pointListBasics>
{
const polygon = $('#polygon');
const points = polygon.points;
testing.expectTrue(points instanceof SVGPointList);
testing.expectEqual(2, points.length);
testing.expectEqual(2, points.numberOfItems);
testing.expectEqual(0, points.getItem(0).x);
testing.expectEqual(0, points.getItem(0).y);
testing.expectEqual(10, points.getItem(1).x);
testing.expectEqual(20, points.getItem(1).y);
}
</script>
<script id=pointListLive>
{
const polyline = $('#polylineLive');
const points = polyline.points;
points.getItem(0).x = 5;
testing.expectEqual('5 0 10 20', polyline.getAttribute('points'));
testing.expectEqual(5, polyline.animatedPoints.getItem(0).x);
points.getItem(1).y = -1;
testing.expectEqual('5 0 10 -1', polyline.getAttribute('points'));
}
</script>
<script id=pointListExternalMutation>
{
const polyline = $('#polylineExternal');
const points = polyline.points;
polyline.setAttribute('points', '1 2, 3 4');
testing.expectEqual(2, points.length);
testing.expectEqual(1, points.getItem(0).x);
testing.expectEqual(2, points.getItem(0).y);
testing.expectEqual(3, points.getItem(1).x);
testing.expectEqual('1 2, 3 4', polyline.getAttribute('points'));
}
</script>
<script id=pointListInvalidAttribute>
{
const polyline = $('#polylineInvalid');
const points = polyline.points;
// A malformed number invalidates the whole attribute...
polyline.setAttribute('points', '0,0 100,0 INVALID');
testing.expectEqual(0, points.length);
testing.expectEqual('0,0 100,0 INVALID', polyline.getAttribute('points'));
// ...but a trailing coordinate with no pair only truncates it.
polyline.setAttribute('points', '0,0 100,0 20');
testing.expectEqual(2, points.length);
polyline.setAttribute('points', '0,0 100,0 20,');
testing.expectEqual(2, points.length);
polyline.setAttribute('points', '1,2,3');
testing.expectEqual(1, points.length);
testing.expectEqual('1,2,3', polyline.getAttribute('points'));
}
</script>
<script id=pointListMutators>
{
const root = $('#root');
const polyline = $('#polylineMutators');
const points = polyline.points;
const detached = root.createSVGPoint();
detached.x = 7;
detached.y = 8;
points.appendItem(detached);
testing.expectEqual('1 2 3 4 7 8', polyline.getAttribute('points'));
// Already in a list, so this one gets copied.
points.appendItem(detached);
testing.expectEqual(4, points.length);
detached.x = 9;
testing.expectEqual(9, points.getItem(2).x);
testing.expectEqual(7, points.getItem(3).x);
const removed = points.removeItem(2);
testing.expectEqual(9, removed.x);
testing.expectEqual('1 2 3 4 7 8', polyline.getAttribute('points'));
const inserted = root.createSVGPoint();
inserted.x = 100;
inserted.y = 200;
points.insertItemBefore(inserted, 0);
testing.expectEqual('100 200 1 2 3 4 7 8', polyline.getAttribute('points'));
const replacement = root.createSVGPoint();
replacement.x = 5;
replacement.y = 6;
points.replaceItem(replacement, 1);
testing.expectEqual('100 200 5 6 3 4 7 8', polyline.getAttribute('points'));
const initialized = root.createSVGPoint();
initialized.x = 4;
initialized.y = 5;
points.initialize(initialized);
testing.expectEqual('4 5', polyline.getAttribute('points'));
points.clear();
testing.expectEqual('', polyline.getAttribute('points'));
}
</script>
<script id=pointListErrors>
{
const root = $('#root');
const points = $('#polylineErrors').points;
testing.expectError('IndexSizeError', () => points.getItem(99));
testing.expectError('IndexSizeError', () => points.replaceItem(root.createSVGPoint(), 99));
testing.expectError('IndexSizeError', () => points.removeItem(99));
}
</script>
<!-- SVGPoint's coordinates are a restricted float, unlike DOMPoint's. -->
<script id=pointCoordinatesAreRestricted>
{
const point = $('#root').createSVGPoint();
point.x = 100;
testing.expectError('TypeError', () => { point.x = NaN; });
testing.expectError('TypeError', () => { point.x = Infinity; });
testing.expectError('TypeError', () => { point.x = point; });
testing.expectEqual(100, point.x);
point.y = null;
testing.expectEqual(0, point.y);
}
</script>
<script id=pointListReadOnly>
{
const root = $('#root');
const polygon = $('#polygonReadOnly');
const animated = polygon.animatedPoints;
testing.expectError('NoModificationAllowedError', () => animated.clear());
testing.expectError('NoModificationAllowedError', () => animated.appendItem(root.createSVGPoint()));
testing.expectError('NoModificationAllowedError', () => { animated.getItem(0).x = 4; });
// Read-only is sticky: an external attribute change drops the item from the
// list, but it must not become a writable orphan.
const stale = animated.getItem(0);
polygon.setAttribute('points', '9 9');
testing.expectEqual(1, animated.length);
testing.expectError('NoModificationAllowedError', () => { stale.x = 4; });
}
</script>
<script id=pointListCapacity>
{
const root = $('#root');
const points = $('#many').points;
for (let i = 0; i < 70; i++) {
const point = root.createSVGPoint();
point.x = i;
point.y = -i;
points.appendItem(point);
}
testing.expectEqual(70, points.numberOfItems);
testing.expectEqual(69, points.getItem(69).x);
testing.expectEqual(-69, points.getItem(69).y);
}
</script>
<!-- Fails in Chrome, passes in Firefox; see pointListIdentity. -->
<script id=transformListIdentity>
{
const root = $('#root');
const group = $('#group');
const animated = group.transform;
testing.expectTrue(animated === group.transform);
testing.expectTrue(animated.baseVal === animated.baseVal);
testing.expectTrue(animated.animVal === animated.animVal);
testing.expectFalse(animated.baseVal === animated.animVal);
testing.expectTrue(animated.baseVal.getItem(1) === animated.baseVal.getItem(1));
testing.expectTrue(animated.baseVal.getItem(1).matrix === animated.baseVal.getItem(1).matrix);
const list = $('#transformIdentity').transform.baseVal;
const appended = root.createSVGTransform();
testing.expectEqual(appended, list.appendItem(appended));
const inserted = root.createSVGTransform();
testing.expectEqual(inserted, list.insertItemBefore(inserted, 0));
const replacement = root.createSVGTransform();
testing.expectEqual(replacement, list.replaceItem(replacement, 1));
testing.expectEqual(replacement, list.removeItem(1));
const initialized = root.createSVGTransform();
testing.expectEqual(initialized, list.initialize(initialized));
}
</script>
<script id=transformListBasics>
{
const group = $('#group');
const animated = group.transform;
const base = animated.baseVal;
testing.expectTrue(animated instanceof SVGAnimatedTransformList);
testing.expectTrue(base instanceof SVGTransformList);
testing.expectEqual(2, base.numberOfItems);
testing.expectEqual(2, base.length);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_TRANSLATE, base.getItem(0).type);
testing.expectEqual(10, base.getItem(0).matrix.e);
testing.expectEqual(20, base.getItem(0).matrix.f);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_ROTATE, base.getItem(1).type);
testing.expectEqual(45, base.getItem(1).angle);
}
</script>
<script id=transformListLive>
{
const group = $('#transformLive');
const base = group.transform.baseVal;
base.getItem(0).setTranslate(3, 4);
testing.expectEqual('translate(3 4)', group.getAttribute('transform').slice(0, 14));
testing.expectEqual(3, group.transform.animVal.getItem(0).matrix.e);
base.getItem(1).setSkewX(15);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_SKEWX, base.getItem(1).type);
testing.expectEqual('translate(3 4) skewX(15)', group.getAttribute('transform'));
}
</script>
<script id=transformListExternalMutation>
{
const group = $('#transformExternal');
const base = group.transform.baseVal;
group.setAttribute('transform', 'scale(2)');
testing.expectEqual(1, base.length);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_SCALE, base.getItem(0).type);
testing.expectEqual(2, base.getItem(0).matrix.a);
testing.expectEqual(2, base.getItem(0).matrix.d);
}
</script>
<!--
All three engines differ on writing an item that an external attribute change
has dropped from its list:
us the write lands on the orphan; the list and attribute are untouched
Chrome lands on the orphan, but the list is re-committed anyway, so the
attribute comes back reserialized ("scale(2)" -> "scale(2 2)")
Firefox lands on the *list* at that index, because its tear-off proxies
slot n rather than the object ("scale(2)" -> "translate(8, 9)")
SVG 2 reparses the attribute into a fresh set of objects, so the item that was
removed is no longer in the list and a write to it should not reach it.
-->
<script id=staleItemWrites>
{
const group = $('#transformStale');
const base = group.transform.baseVal;
const stale = base.getItem(0);
group.setAttribute('transform', 'scale(2)');
testing.expectEqual(1, base.length);
stale.setTranslate(8, 9);
testing.expectEqual('scale(2)', group.getAttribute('transform'));
testing.expectEqual(SVGTransform.SVG_TRANSFORM_SCALE, base.getItem(0).type);
testing.expectEqual(2, base.getItem(0).matrix.a);
const polyline = $('#polylineStale');
const points = polyline.points;
const stalePoint = points.getItem(0);
polyline.setAttribute('points', '9 9');
testing.expectEqual(1, points.length);
stalePoint.x = 5;
testing.expectEqual('9 9', polyline.getAttribute('points'));
testing.expectEqual(9, points.getItem(0).x);
// A live matrix orphaned the same way keeps working as a plain matrix.
const matrixGroup = $('#matrixStaleMatrix');
const matrixBase = matrixGroup.transform.baseVal;
const matrix = matrixBase.getItem(0).matrix;
matrixGroup.setAttribute('transform', 'scale(4)');
testing.expectEqual(1, matrixBase.length);
matrix.e = 11;
testing.expectEqual(11, matrix.e);
testing.expectEqual(0, matrixBase.getItem(0).matrix.e);
testing.expectEqual(4, matrixBase.getItem(0).matrix.a);
}
</script>
<script id=transformListMutators>
{
const root = $('#root');
const group = $('#transformMutators');
const base = group.transform.baseVal;
const scale = root.createSVGTransform();
scale.setScale(2, 3);
base.appendItem(scale);
testing.expectEqual('translate(1 2) scale(2 3)', group.getAttribute('transform'));
const removed = base.removeItem(0);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_TRANSLATE, removed.type);
testing.expectEqual('scale(2 3)', group.getAttribute('transform'));
const rotate = root.createSVGTransform();
rotate.setRotate(30, 5, 6);
base.insertItemBefore(rotate, 0);
testing.expectEqual('rotate(30 5 6) scale(2 3)', group.getAttribute('transform'));
const initialized = root.createSVGTransform();
initialized.setTranslate(7, 8);
base.initialize(initialized);
testing.expectEqual('translate(7 8)', group.getAttribute('transform'));
base.clear();
testing.expectEqual('', group.getAttribute('transform'));
}
</script>
<script id=transformListConsolidate>
{
const base = $('#transformConsolidate').transform.baseVal;
testing.expectEqual(2, base.numberOfItems);
const consolidated = base.consolidate();
testing.expectEqual(SVGTransform.SVG_TRANSFORM_MATRIX, consolidated.type);
testing.expectEqual(1, base.numberOfItems);
testing.expectEqual(2, consolidated.matrix.a);
testing.expectEqual(2, consolidated.matrix.d);
testing.expectEqual(10, consolidated.matrix.e);
testing.expectEqual(20, consolidated.matrix.f);
}
</script>
<!--
Chrome updates the list but leaves the attribute alone, so after consolidating
to a single matrix the attribute still reads "rotate(...)" while the DOM says
one SVG_TRANSFORM_MATRIX item. We commit, like every other mutator does: our
list is rebuilt from the attribute on the next read, so not committing would
undo the consolidate on the very next access.
-->
<script id=transformListConsolidateCommits>
{
const group = $('#transformConsolidateCommit');
group.transform.baseVal.consolidate();
testing.expectEqual('matrix(', group.getAttribute('transform').slice(0, 7));
testing.expectEqual(1, group.transform.baseVal.numberOfItems);
}
</script>
<script id=transformListReadOnly>
{
const root = $('#root');
const group = $('#transformReadOnly');
const anim = group.transform.animVal;
testing.expectError('NoModificationAllowedError', () => anim.clear());
testing.expectError('NoModificationAllowedError', () => anim.appendItem(root.createSVGTransform()));
testing.expectError('NoModificationAllowedError', () => anim.getItem(0).setTranslate(1, 1));
// Read-only is sticky, see pointListReadOnly.
const stale = anim.getItem(0);
group.setAttribute('transform', 'scale(3)');
testing.expectEqual(1, anim.length);
testing.expectError('NoModificationAllowedError', () => stale.setTranslate(1, 1));
}
</script>
<script id=transformMatrixLive>
{
const group = $('#matrixGroup');
const base = group.transform.baseVal;
const anim = group.transform.animVal;
const transform = base.getItem(0);
const matrix = transform.matrix;
testing.expectTrue(matrix === transform.matrix);
matrix.e = 5;
testing.expectEqual(SVGTransform.SVG_TRANSFORM_MATRIX, transform.type);
testing.expectEqual(0, transform.angle);
testing.expectEqual('matrix(1 0 0 1 5 2)', group.getAttribute('transform'));
testing.expectEqual(5, anim.getItem(0).matrix.e);
testing.expectEqual(1, base.length);
}
</script>
<script id=transformMatrixReadOnly>
{
const group = $('#matrixReadOnly');
const animMatrix = group.transform.animVal.getItem(0).matrix;
testing.expectError('NoModificationAllowedError', () => { animMatrix.e = 99; });
testing.expectEqual('translate(1 2)', group.getAttribute('transform'));
testing.expectEqual(1, animMatrix.e);
}
</script>
<!--
SVG 2 types SVGTransform.matrix as a DOMMatrix (SVGMatrix is a typedef for it).
Chrome still returns the legacy SVGMatrix, which has none of these members, so
this block and the next one only run here.
-->
<script id=transformMatrixDomMatrixApi>
{
const root = $('#root');
const transform = root.createSVGTransform();
const matrix = transform.matrix;
testing.expectEqual(matrix, matrix.translateSelf(2, 3));
testing.expectEqual(2, matrix.e);
testing.expectEqual(3, matrix.f);
testing.expectEqual(SVGTransform.SVG_TRANSFORM_MATRIX, transform.type);
testing.expectEqual(matrix, matrix.setMatrixValue('matrix(1, 0, 0, 1, 9, 10)'));
testing.expectEqual(9, matrix.e);
testing.expectEqual(10, matrix.f);
}
</script>
<script id=transformMatrixIs2D>
{
const root = $('#root');
const group = $('#matrixIs2D');
const transform = root.createSVGTransform();
const matrix = transform.matrix;
// An SVGTransform is a 2D affine transform and the `transform` attribute has
// no syntax for anything else, so the 3D components are dropped.
matrix.m13 = 2;
testing.expectEqual(SVGTransform.SVG_TRANSFORM_MATRIX, transform.type);
testing.expectTrue(matrix.is2D);
testing.expectEqual(0, matrix.m13);
matrix.setMatrixValue('translate3d(1px, 2px, 3px)');
testing.expectTrue(matrix.is2D);
testing.expectEqual(1, matrix.e);
testing.expectEqual(2, matrix.f);
testing.expectEqual(0, matrix.m43);
group.transform.baseVal.initialize(transform);
testing.expectEqual('matrix(1 0 0 1 1 2)', group.getAttribute('transform'));
}
</script>
<script id=transformParser>
{
const group = $('#transformParse');
const base = group.transform.baseVal;
testing.expectEqual(3, base.length);
testing.expectEqual(2, base.getItem(0).matrix.f);
testing.expectEqual(4, base.getItem(1).matrix.d);
testing.expectEqual(30, base.getItem(2).angle);
group.setAttribute('transform', 'translate()');
testing.expectEqual(0, base.length);
testing.expectEqual('translate()', group.getAttribute('transform'));
// No 3D function is part of the SVG transform grammar.
group.setAttribute('transform', 'matrix3d(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1)');
testing.expectEqual(0, base.length);
}
</script>
<script id=domMatrixTransformString>
{
testing.expectError('SyntaxError', () => new DOMMatrix('translate()'));
testing.expectError('SyntaxError', () => new DOMMatrix('scale()'));
const matrix = new DOMMatrix('translate(3px, 4px) scale(2, 3)');
testing.expectEqual(3, matrix.e);
testing.expectEqual(4, matrix.f);
testing.expectEqual(2, matrix.a);
}
</script>
<!--
The SVG grammar's separators are not CSS syntax: a DOMMatrix string wants
commas between arguments, even though `transform="translate(3 4)"` does not.
-->
<script id=domMatrixRejectsSvgSeparators>
{
testing.expectError('SyntaxError', () => new DOMMatrix('translate(3px 4px) scale(2 3)'));
testing.expectError('SyntaxError', () => new DOMMatrix('translate(3 4)'));
testing.expectError('SyntaxError', () => new DOMMatrix('matrix(1 0 0 1 5 6)'));
testing.expectError('SyntaxError', () => new DOMMatrix('translate(3px-4px)'));
const matrix = new DOMMatrix('translate(3px, 4px) scale(2, 3)');
testing.expectEqual(3, matrix.e);
testing.expectEqual(2, matrix.a);
}
</script>
<script id=stringListBasics>
{
const group = $('#strings');
const required = group.requiredExtensions;
const languages = group.systemLanguage;
testing.expectTrue(required instanceof SVGStringList);
testing.expectTrue(required === group.requiredExtensions);
testing.expectTrue(languages === group.systemLanguage);
testing.expectEqual(2, required.numberOfItems);
testing.expectEqual('urn:one', required.getItem(0));
testing.expectEqual('urn:two', required.getItem(1));
testing.expectEqual(2, languages.length);
testing.expectEqual('en', languages.getItem(0));
testing.expectEqual('fr', languages.getItem(1));
testing.expectError('IndexSizeError', () => required.getItem(99));
}
</script>
<script id=stringListMutators>
{
const group = $('#stringMutators');
const required = group.requiredExtensions;
required.appendItem('urn:three');
testing.expectEqual('urn:one urn:two urn:three', group.getAttribute('requiredExtensions'));
required.removeItem(1);
testing.expectEqual('urn:one urn:three', group.getAttribute('requiredExtensions'));
required.insertItemBefore('urn:zero', 0);
testing.expectEqual('urn:zero urn:one urn:three', group.getAttribute('requiredExtensions'));
required.replaceItem('urn:x', 1);
testing.expectEqual('urn:zero urn:x urn:three', group.getAttribute('requiredExtensions'));
required.initialize('urn:only');
testing.expectEqual('urn:only', group.getAttribute('requiredExtensions'));
required.clear();
testing.expectEqual(0, required.length);
}
</script>
<!-- Emptying a string list removes the attribute in Chrome but blanks it in
Firefox, which blanks it for point and transform lists too. Chrome is the
inconsistent one here; we follow it. -->
<script id=stringListClearRemovesAttribute>
{
const group = $('#stringClear');
group.requiredExtensions.clear();
testing.expectEqual(null, group.getAttribute('requiredExtensions'));
}
</script>
<!-- Firefox returns "" from SVGStringList.removeItem; Chrome and the spec
return the removed item. -->
<script id=stringListMutatorReturns>
{
const required = $('#stringReturns').requiredExtensions;
testing.expectEqual('urn:three', required.appendItem('urn:three'));
testing.expectEqual('urn:two', required.removeItem(1));
testing.expectEqual('urn:zero', required.insertItemBefore('urn:zero', 0));
testing.expectEqual('urn:x', required.replaceItem('urn:x', 1));
testing.expectEqual('urn:only', required.initialize('urn:only'));
}
</script>
<script id=stringListDelimiter>
{
const group = $('#stringLanguages');
const languages = group.systemLanguage;
languages.insertItemBefore('de', 1);
testing.expectEqual('en,de,fr', group.getAttribute('systemLanguage'));
}
</script>
<!--
systemLanguage is a set of comma-separated tokens: every segment is a token,
including an empty one, and each is trimmed. Mirrors the cases in
wpt/svg/struct/systemLanguage-parsing.html, which we can't run yet because
<text> is not an SVGGraphicsElement here.
-->
<script id=stringListCommaTokens>
{
const group = $('#stringTokens');
const languages = group.systemLanguage;
const parsesAs = (input, expected) => {
group.setAttribute('systemLanguage', input);
testing.expectEqual(expected.length, languages.length);
for (let i = 0; i < expected.length; i++) {
testing.expectEqual(expected[i], languages.getItem(i));
}
};
parsesAs('en,fr,de', ['en', 'fr', 'de']);
parsesAs('en , fr , de', ['en', 'fr', 'de']);
parsesAs(' en, fr ', ['en', 'fr']);
parsesAs(' \t\nen, fr\t\n ', ['en', 'fr']);
parsesAs('en', ['en']);
parsesAs('en-US, zh-Hans', ['en-US', 'zh-Hans']);
parsesAs('en,,fr', ['en', '', 'fr']);
parsesAs('', ['']);
parsesAs(',', ['', '']);
parsesAs('not-a-lang, ???', ['not-a-lang', '???']);
// An absent attribute is an empty list, unlike an empty one.
group.removeAttribute('systemLanguage');
testing.expectEqual(0, languages.length);
}
</script>
<script id=stringListExternalMutation>
{
const group = $('#stringExternal');
const languages = group.systemLanguage;
group.setAttribute('systemLanguage', 'ja, ko');
testing.expectEqual(2, languages.length);
testing.expectEqual('ko', languages.getItem(1));
group.setAttribute('systemLanguage', 'it, pt');
testing.expectEqual(2, languages.length);
testing.expectEqual('pt', languages.getItem(1));
}
</script>
<script id=stringListNoValidation>
{
const group = $('#stringNoValidation');
const required = group.requiredExtensions;
// Neither the spec nor Chrome validates the inserted string against the
// list's delimiter, and the list stays authoritative over its own
// serialization, so this remains one item.
required.appendItem('two words');
testing.expectEqual('urn:one two words', group.getAttribute('requiredExtensions'));
testing.expectEqual(2, required.length);
testing.expectEqual('two words', required.getItem(1));
// Until the attribute is written from the outside, which reparses.
group.setAttribute('requiredExtensions', 'urn:one three more words');
testing.expectEqual(4, required.length);
testing.expectEqual('three', required.getItem(1));
}
</script>

View File

@@ -0,0 +1,212 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<svg id=root width="200" height="100">
<rect id=rect x="10" y="20" width="30" height="40" rx="20"></rect>
<circle id=circle cx="50" cy="30" r="10"></circle>
<ellipse id=ellipse cx="70" cy="40" rx="20" ry="5"></ellipse>
<line id=line x1="1" y1="2" x2="4" y2="6"></line>
<polygon id=polygon points="0,0 10,0 10,10"></polygon>
<polyline id=polyline points="-5,-2 3,7 12,1"></polyline>
<path id=cubic d="M0 0 C0 100 100 100 100 0"></path>
<path id=arc d="M0 0 A80 20 45 1 1 100 100"></path>
<path id=partial d="M10 10 L20 20 30"></path>
<path id=moves d="M0 0 10 10 20 0"></path>
<path id=overshoot d="M0 0 C0 -50 0 150 0 100"></path>
<g id=unresolved>
<rect x="0" y="0" width="10" height="10"></rect>
<use href="#circle"></use>
<image x="40" y="40" width="10" height="10"></image>
<svg x="0" y="0" width="10" height="10"><rect width="5" height="5"></rect></svg>
</g>
<g id=transformed transform="translate(10 20)">
<rect x="0" y="0" width="10" height="5"></rect>
<g transform="scale(2)"><circle cx="10" cy="10" r="2"></circle></g>
</g>
</svg>
<script id=typedShapeProperties>
{
const rect = $('#rect');
testing.expectEqual(true, rect.x instanceof SVGAnimatedLength);
testing.expectEqual(true, rect.x === rect.x);
testing.expectEqual(true, rect.width.baseVal === rect.width.baseVal);
testing.expectEqual(false, rect.width.baseVal === rect.width.animVal);
testing.expectEqual(30, rect.width.baseVal.value);
rect.width.baseVal.value = 35;
testing.expectEqual('35', rect.getAttribute('width'));
testing.expectEqual(35, rect.width.animVal.value);
testing.expectError('NoModificationAllowedError', () => { rect.width.animVal.value = 5; });
testing.expectEqual(true, $('#circle').r instanceof SVGAnimatedLength);
testing.expectEqual(true, $('#ellipse').rx instanceof SVGAnimatedLength);
testing.expectEqual(true, $('#line').x2 instanceof SVGAnimatedLength);
const pathLength = rect.pathLength;
testing.expectEqual(true, pathLength instanceof SVGAnimatedNumber);
testing.expectEqual(true, pathLength === rect.pathLength);
testing.expectEqual('number', typeof pathLength.baseVal);
testing.expectEqual(0, pathLength.baseVal);
pathLength.baseVal = 75;
testing.expectEqual('75', rect.getAttribute('pathLength'));
testing.expectEqual(75, pathLength.animVal);
testing.expectError('TypeError', () => { pathLength.baseVal = 'invalid'; });
testing.expectError('TypeError', () => { pathLength.baseVal = NaN; });
testing.expectError('TypeError', () => { pathLength.baseVal = Infinity; });
testing.expectEqual(75, pathLength.baseVal);
testing.expectEqual('75', rect.getAttribute('pathLength'));
testing.expectEqual(false, Reflect.set(pathLength, 'animVal', 4));
testing.expectEqual(75, pathLength.animVal);
rect.setAttribute('pathLength', 'invalid');
testing.expectEqual(0, pathLength.baseVal);
}
</script>
<script id=prototypePlacementAndShapeBounds>
{
testing.expectEqual(true, Object.prototype.hasOwnProperty.call(SVGGraphicsElement.prototype, 'getBBox'));
testing.expectEqual(false, Object.prototype.hasOwnProperty.call(SVGGeometryElement.prototype, 'getBBox'));
testing.expectEqual(false, Object.prototype.hasOwnProperty.call(SVGRectElement.prototype, 'getBBox'));
let box = $('#rect').getBBox();
testing.expectEqual(10, box.x);
testing.expectEqual(20, box.y);
testing.expectEqual(35, box.width);
testing.expectEqual(40, box.height);
// SVG2 and Chrome return a DOMRect. Firefox returns its own legacy SVGRect,
// which does not inherit from DOMRect.
testing.expectEqual(true, box instanceof (window.SVGRect ?? DOMRect));
box = $('#circle').getBBox();
testing.expectEqual(40, box.x);
testing.expectEqual(20, box.y);
testing.expectEqual(20, box.width);
testing.expectEqual(20, box.height);
box = $('#ellipse').getBBox();
testing.expectEqual(50, box.x);
testing.expectEqual(35, box.y);
testing.expectEqual(40, box.width);
testing.expectEqual(10, box.height);
box = $('#line').getBBox();
testing.expectEqual(1, box.x);
testing.expectEqual(2, box.y);
testing.expectEqual(3, box.width);
testing.expectEqual(4, box.height);
box = $('#polyline').getBBox();
testing.expectEqual(-5, box.x);
testing.expectEqual(-2, box.y);
testing.expectEqual(17, box.width);
testing.expectEqual(9, box.height);
// Chrome declares no parameter at all and Firefox's flags degenerate to the
// fill geometry, so the options are inert in every engine.
testing.expectEqual(20, $('#circle').getBBox({fill: false}).width);
testing.expectEqual(20, $('#circle').getBBox({stroke: true}).width);
$('#circle').setAttribute('r', '-2');
testing.expectEqual(0, $('#circle').getBBox().width);
$('#circle').setAttribute('r', '10');
// Nested viewports, <use> and <image> contribute no geometry here, but they
// must not make the whole box unavailable.
box = $('#unresolved').getBBox();
testing.expectEqual(true, box.width >= 10);
testing.expectEqual(true, box.height >= 10);
}
</script>
<script id=pathParsingAndBounds>
{
let box = $('#cubic').getBBox();
testing.expectEqual(0, box.x);
testing.expectEqual(0, box.y);
testing.expectEqual(100, box.width);
testing.expectEqual(75, box.height);
box = $('#partial').getBBox();
testing.expectEqual(10, box.x);
testing.expectEqual(10, box.y);
testing.expectEqual(10, box.width);
testing.expectEqual(10, box.height);
box = $('#moves').getBBox();
testing.expectEqual(0, box.x);
testing.expectEqual(0, box.y);
testing.expectEqual(20, box.width);
testing.expectEqual(10, box.height);
box = $('#arc').getBBox();
testing.expectEqual(true, box.x < 0);
testing.expectEqual(true, box.width > 100);
testing.expectEqual(true, box.height >= 100);
// Both control points are collinear with the chord, so the curve overshoots
// the endpoints along it.
box = $('#overshoot').getBBox();
testing.expectEqual(0, box.width);
testing.expectEqual('-8.1', box.y.toFixed(1));
}
</script>
<script id=lengthAndPointAtLength>
{
testing.expectEqual(5, $('#line').getTotalLength());
let point = $('#line').getPointAtLength(2.5);
// As with getBBox, Firefox returns its legacy SVGPoint instead of a DOMPoint.
testing.expectEqual(true, point instanceof (window.SVGPoint ?? DOMPoint));
testing.expectEqual(2.5, point.x);
testing.expectEqual(4, point.y);
point = $('#line').getPointAtLength(-100);
testing.expectEqual(1, point.x);
testing.expectEqual(2, point.y);
point = $('#line').getPointAtLength(100);
testing.expectEqual(4, point.x);
testing.expectEqual(6, point.y);
const cubic = $('#cubic');
testing.expectEqual(true, Math.abs(cubic.getTotalLength() - 200) < 0.01);
point = cubic.getPointAtLength(cubic.getTotalLength() / 2);
testing.expectEqual(true, Math.abs(point.x - 50) < 0.01);
testing.expectEqual(true, Math.abs(point.y - 75) < 0.01);
// pathLength calibrates author distance, not these native geometry methods.
cubic.pathLength.baseVal = 10;
testing.expectEqual(true, Math.abs(cubic.getTotalLength() - 200) < 0.01);
}
</script>
<script id=overshootLengthFirefoxDiverges>
{
// #overshoot's control points are collinear with its chord, so flattening on
// their distance to the chord *line* sees a flat curve and measures the 100
// unit chord. Firefox does exactly that; Chrome measures the curve, and
// 132.38 is the true arc length, so we follow Chrome. Its own bounding box
// above is analytic in every engine, which is why only length disagrees.
const overshoot = $('#overshoot');
testing.expectEqual('132', overshoot.getTotalLength().toFixed(0));
// The curve leaves the start heading away from the chord, so four units along
// it sits four units below y=0. Firefox reports +4, on the chord.
const point = overshoot.getPointAtLength(4);
testing.expectEqual('-4.0', point.y.toFixed(1));
}
</script>
<script id=containerTransforms>
{
const box = $('#transformed').getBBox();
// The group's own translate is excluded. Its direct rect occupies 0..10,
// while the scaled child circle occupies 16..24 on both axes.
testing.expectEqual(0, box.x);
testing.expectEqual(0, box.y);
testing.expectEqual(24, box.width);
testing.expectEqual(24, box.height);
const rootBox = $('#root').getBBox();
// From the root, the group's transform is included.
testing.expectEqual(true, rootBox.width >= 100);
testing.expectEqual(true, rootBox.height >= 100);
}
</script>

View File

@@ -38,6 +38,7 @@
testing.expectEqual('function', typeof SVGPolylineElement);
testing.expectEqual('function', typeof SVGAnimatedString);
testing.expectEqual('function', typeof SVGNumber);
testing.expectEqual('function', typeof SVGAnimatedNumber);
}
</script>

View File

@@ -348,6 +348,93 @@
}
</script>
<script id=fetch_readablestream_body type=module>
{
const state = await testing.async();
const payload = "name=Streamed&count=9";
const enc = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(enc.encode("name=Streamed&"));
controller.enqueue(enc.encode("count=9"));
controller.close();
},
});
const response = await fetch("http://127.0.0.1:9582/echo_body", {
method: "POST",
body: stream,
duplex: "half",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
const text = await response.text();
state.resolve();
await state.done(() => {
testing.expectEqual(200, response.status);
testing.expectEqual(payload, text);
});
}
</script>
<script id=fetch_readablestream_body_reject type=module>
{
const state = await testing.async();
const url = "http://127.0.0.1:9582/echo_body";
const opts = { method: "POST", duplex: "half" };
let openRejected = false;
try {
const open = new ReadableStream({
start(c) { c.enqueue(new TextEncoder().encode("x")); },
});
await fetch(url, { ...opts, body: open });
} catch (e) {
openRejected = e instanceof TypeError;
}
let lockedRejected = false;
try {
const locked = new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode("x"));
c.close();
},
});
locked.getReader();
await fetch(url, { ...opts, body: locked });
} catch (e) {
lockedRejected = e instanceof TypeError;
}
// A stream is single-use: draining it for one body must not leave it
// looking like a valid (empty) body for the next request.
const once = new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode("x"));
c.close();
},
});
const first = await fetch(url, { ...opts, body: once });
const firstText = await first.text();
let reuseRejected = false;
try {
await fetch(url, { ...opts, body: once });
} catch (e) {
reuseRejected = e instanceof TypeError;
}
state.resolve();
await state.done(() => {
testing.expectEqual(true, openRejected);
testing.expectEqual(true, lockedRejected);
testing.expectEqual("x", firstText);
testing.expectEqual(true, reuseRejected);
});
}
</script>
<script id=fetch_blob_url_in_worker type=module>
{
const state = await testing.async();

View File

@@ -898,3 +898,33 @@
testing.expectEqual('by-listener', fd.get('added'));
}
</script>
<script id=appendBlobAndFileValues>
{
// https://xhr.spec.whatwg.org/#create-an-entry
// A Blob value becomes a File named "blob"; a File keeps its own name; an
// explicit filename overrides both. Before this was implemented the value
// was coerced with toString() and every entry read back "[object Blob]".
const fd = new FormData();
fd.append('plain', 'still-a-string');
fd.append('fromBlob', new Blob(['blob-bytes'], { type: 'text/plain' }));
fd.append('fromFile', new File(['file-bytes'], 'notes.txt', { type: 'text/plain' }));
fd.append('renamed', new Blob(['renamed-bytes'], { type: 'text/plain' }), 'given.txt');
testing.expectEqual('still-a-string', fd.get('plain'));
// A file entry read through get() collapses to its name, per WHATWG.
testing.expectEqual('blob', fd.get('fromBlob'));
testing.expectEqual('notes.txt', fd.get('fromFile'));
testing.expectEqual('given.txt', fd.get('renamed'));
// set() goes through the same path.
fd.set('fromFile', new File(['replaced'], 'replaced.txt', { type: 'text/plain' }));
testing.expectEqual('replaced.txt', fd.get('fromFile'));
// Overwriting a file entry with a string still yields a string.
fd.set('fromBlob', 'back-to-string');
testing.expectEqual('back-to-string', fd.get('fromBlob'));
}
</script>

View File

@@ -372,9 +372,30 @@
<script id=xhr_override_mime_responsexml type=module>
{
// End-to-end: server returns text/html, but overrideMimeType("text/xml")
// makes responseXML lazily parse the body as a Document, while
// responseText is unaffected (responseType is still the default "").
// End-to-end: server returns text/plain, but overrideMimeType("text/xml")
// makes responseXML lazily parse the body as XML, while responseText is
// unaffected (responseType is still the default "").
const state = await testing.async();
const req = new XMLHttpRequest();
req.overrideMimeType('text/xml');
req.open('GET', 'http://127.0.0.1:9582/xhr/xml_as_text');
req.onload = () => state.resolve();
req.send();
await state.done(() => {
testing.expectEqual(200, req.status);
testing.expectEqual(true, req.responseText.startsWith('<?xml'));
testing.expectEqual(true, req.responseXML instanceof XMLDocument);
testing.expectEqual('catalog', req.responseXML.documentElement.tagName);
// Cached across calls.
testing.expectEqual(req.responseXML, req.responseXML);
});
}
</script>
<script id=xhr_override_mime_malformed_xml type=module>
{
// The overridden type selects the XML parser; a body that isn't
// well-formed XML yields responseXML === null.
const state = await testing.async();
const req = new XMLHttpRequest();
req.overrideMimeType('text/xml');
@@ -382,11 +403,45 @@
req.onload = () => state.resolve();
req.send();
await state.done(() => {
testing.expectEqual(200, req.status);
testing.expectEqual(100, req.responseText.length);
testing.expectEqual(true, req.responseXML instanceof Document);
// Cached across calls.
testing.expectEqual(req.responseXML, req.responseXML);
testing.expectEqual(null, req.responseXML);
});
}
</script>
<script id=xhr_responsexml_xml_mime type=module>
{
// An application/xml response parses with the XML parser: XMLDocument,
// tag case preserved, self-closing tags don't swallow their siblings.
const state = await testing.async();
const req = new XMLHttpRequest();
req.open('GET', 'http://127.0.0.1:9582/xhr/xml');
req.onload = () => state.resolve();
req.send();
await state.done(() => {
const doc = req.responseXML;
testing.expectEqual(true, doc instanceof XMLDocument);
testing.expectEqual('catalog', doc.documentElement.tagName);
testing.expectEqual(3, doc.documentElement.children.length);
testing.expectEqual(2, doc.getElementsByTagName('item').length);
testing.expectEqual(1, doc.getElementsByTagName('pubDate').length);
});
}
</script>
<script id=xhr_document_xml type=module>
{
// responseType=document with an XML MIME type also uses the XML parser.
const state = await testing.async();
const req = new XMLHttpRequest();
req.open('GET', 'http://127.0.0.1:9582/xhr/xml');
req.responseType = 'document';
req.onload = () => state.resolve();
req.send();
await state.done(() => {
testing.expectEqual(true, req.response instanceof XMLDocument);
testing.expectEqual('catalog', req.response.documentElement.tagName);
testing.expectEqual(req.response, req.responseXML);
});
}
</script>

View File

@@ -59,6 +59,11 @@
const loc_pathname = loc.pathname;
const loc_to_string = String(loc);
// DOMPoint mutation (setters must not require a Frame in a worker)
const point = new DOMPoint(1, 2);
point.x = 5;
point.w = 0.5;
postMessage({
ok: true,
results: {
@@ -90,6 +95,8 @@
loc_protocol,
loc_pathname,
loc_to_string,
point_x: point.x,
point_w: point.w,
},
});
} catch (e) {

View File

@@ -247,6 +247,10 @@
testing.expectEqual(r.loc_href, r.loc_to_string);
testing.expectEqual('http:', r.loc_protocol);
testing.expectTrue(r.loc_pathname.endsWith('api-worker.js'));
// DOMPoint
testing.expectEqual(5, r.point_x);
testing.expectEqual(0.5, r.point_w);
});
}
</script>

View File

@@ -22,6 +22,7 @@ const zenai = @import("zenai");
const log = lp.log;
const tavily = zenai.search.tavily;
const brave = zenai.search.brave;
const DOMNode = @import("webapi/Node.zig");
const CDPNode = @import("../cdp/Node.zig");
@@ -335,7 +336,7 @@ pub const Tool = enum {
),
},
.search => .{
.description = "Run a web search and return results as markdown. When TAVILY_API_KEY is set, queries the Tavily Search API and returns a numbered list of {title, url, snippet}. Otherwise (or on Tavily failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page.",
.description = "Run a web search and return results as markdown. When BRAVE_API_KEY or TAVILY_API_KEY is set, queries that search API (Brave preferred) and returns a numbered list of {title, url, snippet}. Otherwise (or on API failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page.",
.summary = "Web search, results as markdown",
.input_schema = minify(
\\{
@@ -814,7 +815,7 @@ fn dispatch(
) ToolError!ToolResult {
return switch (tool) {
.goto => .{ .text = try execGoto(arena, session, registry, substituted) },
.search => .{ .text = try execSearch(arena, session, registry, substituted) },
.search => execSearch(arena, session, registry, substituted),
.markdown => .{ .text = try execMarkdown(arena, session, registry, substituted) },
.html => .{ .text = try execHtml(arena, session, registry, substituted) },
.links => .{ .text = try execLinks(arena, session, registry, substituted) },
@@ -946,20 +947,38 @@ pub const SearchParams = struct {
timeout: ?u32 = null,
};
fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 {
/// Search backend for `execSearch`. `.auto` tries Brave then Tavily (each
/// only when its API key is set), then the DuckDuckGo scrape; an explicit
/// engine is used alone. Only the agent REPL's `/searchEngine` mutates this.
pub const SearchEngine = enum { auto, tavily, brave, duckduckgo };
pub var search_engine: SearchEngine = .auto;
fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError!ToolResult {
const args = try parseArgs(SearchParams, arena, arguments);
if (args.query.len == 0) return ToolError.InvalidParams;
// Tavily path: only when TAVILY_API_KEY is set in the process env. On any
// failure (network, non-2xx, parse) fall through to the DuckDuckGo scrape
// so a single Tavily outage doesn't kill a whole benchmark run.
if (std.c.getenv("TAVILY_API_KEY")) |api_key_z| {
const api_key = std.mem.span(api_key_z);
if (tavilySearch(arena, api_key, args.query)) |markdown_| {
return markdown_;
} else |err| {
log.warn(.browser, "tavily fallback", .{ .err = err });
}
switch (search_engine) {
// Any failure (network, non-2xx, parse) falls through to the next
// engine so a single outage doesn't kill a whole benchmark run.
.auto => {
if (std.c.getenv("BRAVE_API_KEY")) |api_key_z| {
if (braveSearch(arena, std.mem.span(api_key_z), args.query)) |markdown_| {
return .{ .text = markdown_ };
} else |err| {
log.warn(.browser, "brave fallback", .{ .err = err });
}
}
if (std.c.getenv("TAVILY_API_KEY")) |api_key_z| {
if (tavilySearch(arena, std.mem.span(api_key_z), args.query)) |markdown_| {
return .{ .text = markdown_ };
} else |err| {
log.warn(.browser, "tavily fallback", .{ .err = err });
}
}
},
.tavily => return searchExplicit(arena, "tavily", "TAVILY_API_KEY", tavilySearch, args.query),
.brave => return searchExplicit(arena, "brave", "BRAVE_API_KEY", braveSearch, args.query),
.duckduckgo => {},
}
const encoded = lp.URL.percentEncodeSegment(arena, args.query, .component) catch return ToolError.OutOfMemory;
@@ -971,7 +990,28 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode
) catch return ToolError.OutOfMemory;
_ = try performGoto(session, registry, ddg_url, args.timeout);
const ddg_frame = try requireFrame(session);
return renderFrameMarkdown(arena, ddg_frame);
return .{ .text = try renderFrameMarkdown(arena, ddg_frame) };
}
/// Search with an explicitly selected engine: a missing key or a failed call
/// comes back as an error result — no DDG fallback.
fn searchExplicit(
arena: std.mem.Allocator,
comptime label: []const u8,
comptime env_var: [:0]const u8,
searchFn: anytype,
query: []const u8,
) ToolError!ToolResult {
const api_key_z = std.c.getenv(env_var) orelse return .{
.text = "web search engine is set to " ++ label ++ " but " ++ env_var ++ " is not set in the environment",
.is_error = true,
};
const markdown_ = searchFn(arena, std.mem.span(api_key_z), query) catch |err| {
const msg = std.fmt.allocPrint(arena, label ++ " search failed: {s}", .{@errorName(err)}) catch
return ToolError.OutOfMemory;
return .{ .text = msg, .is_error = true };
};
return .{ .text = markdown_ };
}
/// Thin wrapper over `zenai.search.tavily.Client` that handles client
@@ -982,40 +1022,97 @@ fn tavilySearch(
api_key: []const u8,
query: []const u8,
) ![]const u8 {
var client: tavily.Client = .init(arena, lp.io, api_key, .{});
var client: tavily.Client = .init(lp.io, arena, api_key, .{});
defer client.deinit();
var response = client.search(query, .{ .max_results = 10 }) catch |err| {
if (client.last_error_status) |status| {
log.warn(.browser, "tavily non-2xx", .{
.status = status,
.body = client.last_error_body orelse "",
.body = client.last_error_body,
});
}
return err;
};
defer response.deinit();
return formatTavilyMarkdown(arena, response.value);
var aw: std.Io.Writer.Allocating = .init(arena);
try formatTavilyMarkdown(&aw.writer, response.value);
return aw.written();
}
fn formatTavilyMarkdown(arena: std.mem.Allocator, resp: tavily.types.SearchResponse) ![]const u8 {
var aw: std.Io.Writer.Allocating = .init(arena);
const w = &aw.writer;
if (resp.answer) |a| {
if (a.len > 0) {
try w.print("**Answer:** {s}\n\n", .{a});
}
fn formatTavilyMarkdown(w: *std.Io.Writer, resp: tavily.types.SearchResponse) !void {
const answer = resp.answer orelse "";
if (answer.len == 0 and resp.results.len == 0) {
return w.writeAll("No results.");
}
if (answer.len > 0) {
try w.print("**Answer:** {s}\n\n", .{answer});
}
for (resp.results, 0..) |r, i| {
try w.print("{d}. **{s}** — {s}\n {s}\n\n", .{ i + 1, r.title, r.url, r.content });
}
if (resp.results.len == 0 and (resp.answer == null or resp.answer.?.len == 0)) {
try w.writeAll("No results.");
try writeResultItem(w, i, r.title, r.url, r.content);
}
}
/// Thin wrapper over `zenai.search.brave.Client` that handles client
/// lifetime and renders the structured response as markdown for the agent.
/// `arena` owns the returned slice. `api_key` is the value of BRAVE_API_KEY.
fn braveSearch(
arena: std.mem.Allocator,
api_key: []const u8,
query: []const u8,
) ![]const u8 {
var client: brave.Client = .init(lp.io, arena, api_key, .{});
defer client.deinit();
// text_decorations=false: no <strong> markup in model-read snippets.
var response = client.search(query, .{ .count = 10, .text_decorations = false }) catch |err| {
if (client.last_error_status) |status| {
log.warn(.browser, "brave non-2xx", .{
.status = status,
.body = client.last_error_body,
});
}
return err;
};
defer response.deinit();
var aw: std.Io.Writer.Allocating = .init(arena);
try formatBraveMarkdown(&aw.writer, response.value);
return aw.written();
}
fn formatBraveMarkdown(w: *std.Io.Writer, resp: brave.types.SearchResponse) !void {
const results: []const brave.types.Result = if (resp.web) |web| web.results else &.{};
if (results.len == 0) {
return w.writeAll("No results.");
}
for (results, 0..) |r, i| {
try writeResultItem(w, i, r.title, r.url, r.description);
}
}
fn writeResultItem(w: *std.Io.Writer, i: usize, title: []const u8, url: []const u8, snippet: []const u8) !void {
try w.print("{d}. **", .{i + 1});
try writeSingleLine(w, title);
try w.print("** — {s}\n ", .{url});
try writeSingleLine(w, snippet);
try w.writeAll("\n\n");
}
/// Newlines in API-provided text would break the numbered-list markdown.
fn writeSingleLine(w: *std.Io.Writer, text: []const u8) !void {
var it = std.mem.tokenizeAny(u8, text, "\r\n");
var first = true;
while (it.next()) |chunk| {
if (!first) {
try w.writeByte(' ');
}
first = false;
try w.writeAll(chunk);
}
}
fn renderFrameMarkdown(arena: std.mem.Allocator, frame: *lp.Frame) ToolError![]const u8 {
var aw: std.Io.Writer.Allocating = .init(arena);
lp.markdown.dump(frame.document.asNode(), .{}, &aw.writer, frame) catch
@@ -2220,7 +2317,9 @@ test "formatTavilyMarkdown renders answer and results" {
},
};
const md = try formatTavilyMarkdown(aa, resp);
var aw: std.Io.Writer.Allocating = .init(aa);
try formatTavilyMarkdown(&aw.writer, resp);
const md = aw.written();
try std.testing.expect(std.mem.indexOf(u8, md, "**Answer:** Paris") != null);
try std.testing.expect(std.mem.indexOf(u8, md, "1. **Paris - Wikipedia**") != null);
try std.testing.expect(std.mem.indexOf(u8, md, "https://en.wikipedia.org/wiki/Paris") != null);
@@ -2233,9 +2332,64 @@ test "formatTavilyMarkdown handles empty results" {
defer arena.deinit();
const aa = arena.allocator();
const resp: tavily.types.SearchResponse = .{};
const md = try formatTavilyMarkdown(aa, resp);
try std.testing.expectEqualStrings("No results.", md);
var aw: std.Io.Writer.Allocating = .init(aa);
try formatTavilyMarkdown(&aw.writer, .{});
try std.testing.expectEqualStrings("No results.", aw.written());
}
test "formatBraveMarkdown renders web results" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
const resp: brave.types.SearchResponse = .{
.web = .{
.results = &.{
.{ .title = "Paris - Wikipedia", .url = "https://en.wikipedia.org/wiki/Paris", .description = "Paris is the capital of France." },
.{ .title = "France", .url = "https://example.org/fr", .description = "Country in Western Europe." },
},
},
};
var aw: std.Io.Writer.Allocating = .init(aa);
try formatBraveMarkdown(&aw.writer, resp);
const md = aw.written();
try std.testing.expect(std.mem.indexOf(u8, md, "1. **Paris - Wikipedia**") != null);
try std.testing.expect(std.mem.indexOf(u8, md, "https://en.wikipedia.org/wiki/Paris") != null);
try std.testing.expect(std.mem.indexOf(u8, md, "Paris is the capital of France.") != null);
try std.testing.expect(std.mem.indexOf(u8, md, "2. **France**") != null);
}
test "formatBraveMarkdown handles empty results" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var no_web: std.Io.Writer.Allocating = .init(aa);
try formatBraveMarkdown(&no_web.writer, .{});
try std.testing.expectEqualStrings("No results.", no_web.written());
var empty_web: std.Io.Writer.Allocating = .init(aa);
try formatBraveMarkdown(&empty_web.writer, .{ .web = .{} });
try std.testing.expectEqualStrings("No results.", empty_web.written());
}
test "formatBraveMarkdown flattens newlines in titles and descriptions" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
const resp: brave.types.SearchResponse = .{
.web = .{
.results = &.{
.{ .title = "Multi\nline title", .url = "https://example.org", .description = "line one\r\n\r\nline two" },
},
},
};
var aw: std.Io.Writer.Allocating = .init(aa);
try formatBraveMarkdown(&aw.writer, resp);
try std.testing.expectEqualStrings("1. **Multi line title** — https://example.org\n line one line two\n\n", aw.written());
}
test "isPathSafe: relative paths without traversal are accepted" {

View File

@@ -84,137 +84,158 @@ pub fn getF(self: *const DOMMatrix) f64 {
return self._proto._m[13];
}
pub fn setA(self: *DOMMatrix, v: f64) void {
self._proto._m[0] = v;
pub fn setA(self: *DOMMatrix, v: f64) !void {
try self.setElement(0, v);
}
pub fn setB(self: *DOMMatrix, v: f64) void {
self._proto._m[1] = v;
pub fn setB(self: *DOMMatrix, v: f64) !void {
try self.setElement(1, v);
}
pub fn setC(self: *DOMMatrix, v: f64) void {
self._proto._m[4] = v;
pub fn setC(self: *DOMMatrix, v: f64) !void {
try self.setElement(4, v);
}
pub fn setD(self: *DOMMatrix, v: f64) void {
self._proto._m[5] = v;
pub fn setD(self: *DOMMatrix, v: f64) !void {
try self.setElement(5, v);
}
pub fn setE(self: *DOMMatrix, v: f64) void {
self._proto._m[12] = v;
pub fn setE(self: *DOMMatrix, v: f64) !void {
try self.setElement(12, v);
}
pub fn setF(self: *DOMMatrix, v: f64) void {
self._proto._m[13] = v;
pub fn setF(self: *DOMMatrix, v: f64) !void {
try self.setElement(13, v);
}
pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) *DOMMatrix {
pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) !*DOMMatrix {
const tz = tz_ orelse 0;
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz));
if (tz != 0) p._is_2d = false;
return self;
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz));
if (tz != 0) state.is_2d = false;
return self.applyState(state);
}
pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix {
const sx = sx_ orelse 1;
const sy = sy_ orelse sx;
const sz = sz_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
const p = self._proto;
var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
var state = self._proto.getState();
var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(sx, sy, sz));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
p._m = m;
if (sz != 1 or oz != 0) p._is_2d = false;
return self;
state.matrix = m;
if (sz != 1 or oz != 0) state.is_2d = false;
return self.applyState(state);
}
pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix {
const s = scale_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
const p = self._proto;
var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
var state = self._proto.getState();
var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(s, s, s));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
p._m = m;
if (s != 1) p._is_2d = false;
return self;
state.matrix = m;
if (s != 1) state.is_2d = false;
return self.applyState(state);
}
pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) *DOMMatrix {
const p = self._proto;
pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) !*DOMMatrix {
var state = self._proto.getState();
if (ry_ == null and rz_ == null) {
p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg)));
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg)));
} else {
p._m = RO.multiplyMatrix(p._m, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg)));
p._m = RO.multiplyMatrix(p._m, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg)));
p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg)));
p._is_2d = false;
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg)));
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg)));
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg)));
state.is_2d = false;
}
return self;
return self.applyState(state);
}
pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) *DOMMatrix {
pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) !*DOMMatrix {
const x = x_ orelse 0;
const y = y_ orelse 0;
const rad = if (x == 0 and y == 0) 0 else std.math.atan2(y, x);
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(rad));
return self;
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(rad));
return self.applyState(state);
}
pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) *DOMMatrix {
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg)));
if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) p._is_2d = false;
return self;
pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) !*DOMMatrix {
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg)));
if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) state.is_2d = false;
return self.applyState(state);
}
pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) *DOMMatrix {
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0));
return self;
pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) !*DOMMatrix {
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0));
return self.applyState(state);
}
pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) *DOMMatrix {
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg)));
return self;
pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) !*DOMMatrix {
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg)));
return self.applyState(state);
}
pub fn multiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
p._m = RO.multiplyMatrix(p._m, other.m);
p._is_2d = p._is_2d and other.is_2d;
return self;
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(state.matrix, other.m);
state.is_2d = state.is_2d and other.is_2d;
return self.applyState(state);
}
pub fn preMultiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
p._m = RO.multiplyMatrix(other.m, p._m);
p._is_2d = p._is_2d and other.is_2d;
return self;
var state = self._proto.getState();
state.matrix = RO.multiplyMatrix(other.m, state.matrix);
state.is_2d = state.is_2d and other.is_2d;
return self.applyState(state);
}
pub fn invertSelf(self: *DOMMatrix) *DOMMatrix {
const p = self._proto;
if (RO.invertMatrix(p._m)) |v| {
p._m = v;
pub fn invertSelf(self: *DOMMatrix) !*DOMMatrix {
var state = self._proto.getState();
if (RO.invertMatrix(state.matrix)) |v| {
state.matrix = v;
} else {
p._m = .{std.math.nan(f64)} ** 16;
p._is_2d = false;
state.matrix = .{std.math.nan(f64)} ** 16;
state.is_2d = false;
}
return self;
return self.applyState(state);
}
pub fn setMatrixValue(self: *DOMMatrix, transform: []const u8) !*DOMMatrix {
var m = RO.identity();
var is_2d = true;
try RO.parseTransformList(transform, &m, &is_2d);
self._proto._m = m;
self._proto._is_2d = is_2d;
var state: RO.State = .{ .matrix = RO.identity(), .is_2d = true };
try RO.parseTransformList(transform, &state.matrix, &state.is_2d);
try self._proto.applyState(state);
return self;
}
fn setElement(self: *DOMMatrix, comptime index: usize, value: f64) !void {
var state = self._proto.getState();
state.matrix[index] = value;
// Assigning a z/w element a value other than its identity drops the
// 2D flag. Setting it back does not restore is2D.
switch (index) {
2, 3, 6, 7, 8, 9, 11, 14 => if (value != 0) {
state.is_2d = false;
},
10, 15 => if (value != 1) {
state.is_2d = false;
},
else => {},
}
try self._proto.applyState(state);
}
fn applyState(self: *DOMMatrix, state: RO.State) !*DOMMatrix {
try self._proto.applyState(state);
return self;
}
@@ -281,23 +302,10 @@ pub const JsApi = struct {
}.get;
}
fn setM(comptime idx: usize) fn (*DOMMatrix, f64) void {
fn setM(comptime idx: usize) fn (*DOMMatrix, f64) anyerror!void {
return struct {
fn set(self: *DOMMatrix, v: f64) void {
self._proto._m[idx] = v;
// Assigning a z/w element a value other than its identity drops the
// 2D flag. Setting it back to the identity value (0 for the
// off-diagonal elements, 1 for m33/m44) preserves is2D. Note `-0`
// compares equal to `0`, so it preserves it too, per spec.
switch (idx) {
2, 3, 6, 7, 8, 9, 11, 14 => if (v != 0) {
self._proto._is_2d = false;
},
10, 15 => if (v != 1) {
self._proto._is_2d = false;
},
else => {},
}
fn set(self: *DOMMatrix, v: f64) !void {
try self.setElement(idx, v);
}
}.set;
}

View File

@@ -44,12 +44,25 @@ _arena: Allocator,
// out[row] = sum_col _m[col*4 + row] * in[col]
_m: [16]f64,
_is_2d: bool,
_attachment: ?Attachment = null,
pub const Type = union(enum) {
generic,
mutable: *DOMMatrix,
};
pub const State = struct {
matrix: [16]f64,
is_2d: bool,
};
// The owner decides whether a write is allowed; a matrix that belongs to an
// animVal SVGTransform is rejected by the transform, not here.
pub const Attachment = struct {
owner: *anyopaque,
mutate: *const fn (*anyopaque, *DOMMatrixReadOnly, State) anyerror!void,
};
pub fn init(init_: ?js.Value, exec: *const js.Execution) !*DOMMatrixReadOnly {
const parsed = try Parsed.init(init_, exec);
return createBare(parsed.m, parsed.is_2d, exec.page);
@@ -82,6 +95,29 @@ pub fn createBare(m: [16]f64, is_2d: bool, page: *Page) !*DOMMatrixReadOnly {
return self;
}
pub fn getState(self: *const DOMMatrixReadOnly) State {
return .{
.matrix = self._m,
.is_2d = self._is_2d,
};
}
pub fn applyState(self: *DOMMatrixReadOnly, state: State) !void {
if (self._attachment) |attachment| {
return attachment.mutate(attachment.owner, self, state);
}
self.applyStateRaw(state);
}
pub fn applyStateRaw(self: *DOMMatrixReadOnly, state: State) void {
self._m = state.matrix;
self._is_2d = state.is_2d;
}
pub fn attach(self: *DOMMatrixReadOnly, attachment: Attachment) void {
self._attachment = attachment;
}
pub const DOMMatrixInit = struct {
a: ?f64 = null,
b: ?f64 = null,
@@ -312,178 +348,274 @@ pub fn invertMatrix(m: [16]f64) ?[16]f64 {
return out;
}
pub const TransformSyntax = enum { css, svg };
pub const TransformKind = enum {
matrix,
matrix3d,
translate,
translate_x,
translate_y,
translate_z,
translate_3d,
scale,
scale_x,
scale_y,
scale_z,
scale_3d,
rotate,
rotate_x,
rotate_y,
rotate_z,
rotate_3d,
skew,
skew_x,
skew_y,
perspective,
};
pub const ParsedTransform = struct {
kind: TransformKind,
matrix: [16]f64,
values: [16]f64,
count: usize,
is_2d: bool,
};
pub const TransformFunction = struct {
name: []const u8,
arguments: []const u8,
};
pub const TransformFunctionIterator = struct {
input: []const u8,
index: usize = 0,
allow_comma: bool = false,
pub fn next(self: *TransformFunctionIterator) !?TransformFunction {
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
if (self.index == self.input.len) return null;
if (self.input[self.index] == ',') return error.SyntaxError;
const name_start = self.index;
while (self.index < self.input.len and
(std.ascii.isAlphabetic(self.input[self.index]) or std.ascii.isDigit(self.input[self.index])))
{
self.index += 1;
}
if (self.index == name_start or self.index >= self.input.len or self.input[self.index] != '(') {
return error.SyntaxError;
}
const name = self.input[name_start..self.index];
self.index += 1;
const arguments_start = self.index;
while (self.index < self.input.len and self.input[self.index] != ')') {
if (self.input[self.index] == '(') return error.SyntaxError;
self.index += 1;
}
if (self.index == self.input.len) return error.SyntaxError;
const arguments = self.input[arguments_start..self.index];
self.index += 1;
var had_whitespace = false;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
had_whitespace = true;
self.index += 1;
}
if (self.index < self.input.len and self.input[self.index] == ',') {
if (!self.allow_comma) return error.SyntaxError;
self.index += 1;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
if (self.index == self.input.len) return error.SyntaxError;
} else if (self.index < self.input.len and !had_whitespace) {
return error.SyntaxError;
}
return .{ .name = name, .arguments = arguments };
}
};
// Parses a CSS <transform-list> (e.g. "matrix(1,0,0,1,10,20) scale(2)") and
// accumulates it into `m`. "none"/empty leave the matrix as identity.
pub fn parseTransformList(input: []const u8, m: *[16]f64, is_2d: *bool) !void {
const trimmed = std.mem.trim(u8, input, " \t\r\n");
if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) {
return;
}
if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return;
var i: usize = 0;
while (i < trimmed.len) {
// skip whitespace and separating commas
while (i < trimmed.len and (std.ascii.isWhitespace(trimmed[i]) or trimmed[i] == ',')) : (i += 1) {}
if (i >= trimmed.len) {
break;
}
const name_start = i;
while (i < trimmed.len and trimmed[i] != '(') : (i += 1) {}
if (i >= trimmed.len) {
return error.SyntaxError;
}
const name = std.mem.trim(u8, trimmed[name_start..i], " \t\r\n");
i += 1; // consume '('
const args_start = i;
while (i < trimmed.len and trimmed[i] != ')') : (i += 1) {}
if (i >= trimmed.len) {
return error.SyntaxError;
}
const args = trimmed[args_start..i];
i += 1; // consume ')'
const func = try parseFunction(name, args, is_2d);
m.* = multiplyMatrix(m.*, func);
var iterator = TransformFunctionIterator{ .input = trimmed };
while (try iterator.next()) |function| {
const parsed = try parseTransformFunction(function, .css);
m.* = multiplyMatrix(m.*, parsed.matrix);
if (!parsed.is_2d) is_2d.* = false;
}
}
fn parseFunction(name: []const u8, args: []const u8, is_2d: *bool) ![16]f64 {
var nums: [16]f64 = undefined;
pub fn parseTransformFunction(function: TransformFunction, syntax: TransformSyntax) !ParsedTransform {
var values: [16]f64 = undefined;
var units: [16]ParsedValue.Unit = undefined;
var count: usize = 0;
var it = std.mem.splitScalar(u8, args, ',');
while (it.next()) |raw| {
const tok = std.mem.trim(u8, raw, " \t\r\n");
if (tok.len == 0) {
continue;
}
if (count >= 16) {
return error.SyntaxError;
}
const parsed = try ParsedValue.parse(tok);
nums[count] = parsed.value;
units[count] = parsed.unit;
count += 1;
}
const count = try parseArguments(function.arguments, syntax, &values, &units);
const name = function.name;
const Eql = std.mem.eql;
if (Eql(u8, name, "matrix")) {
if (count != 6) {
return error.SyntaxError;
}
return .{
nums[0], nums[1], 0, 0,
nums[2], nums[3], 0, 0,
0, 0, 1, 0,
nums[4], nums[5], 0, 1,
};
try requireCount(count, 6, 6);
try requireUnitless(units[0..count]);
return makeParsedTransform(.matrix, .{
values[0], values[1], 0, 0,
values[2], values[3], 0, 0,
0, 0, 1, 0,
values[4], values[5], 0, 1,
}, values, count, true);
}
if (Eql(u8, name, "matrix3d")) {
if (count != 16) {
return error.SyntaxError;
}
is_2d.* = false;
return nums;
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 16, 16);
try requireUnitless(units[0..count]);
return makeParsedTransform(.matrix3d, values, values, count, false);
}
if (Eql(u8, name, "translate")) {
const tx = nums[0];
const ty = if (count > 1) nums[1] else 0;
return translationMatrix(tx, ty, 0);
try requireCount(count, 1, 2);
if (syntax == .svg) try requireUnitless(units[0..count]);
const ty = if (count == 2) values[1] else 0;
return makeParsedTransform(.translate, translationMatrix(values[0], ty, 0), values, count, true);
}
if (Eql(u8, name, "translateX")) {
return translationMatrix(nums[0], 0, 0);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
return makeParsedTransform(.translate_x, translationMatrix(values[0], 0, 0), values, count, true);
}
if (Eql(u8, name, "translateY")) {
return translationMatrix(0, nums[0], 0);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
return makeParsedTransform(.translate_y, translationMatrix(0, values[0], 0), values, count, true);
}
if (Eql(u8, name, "translateZ")) {
is_2d.* = false;
return translationMatrix(0, 0, nums[0]);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
return makeParsedTransform(.translate_z, translationMatrix(0, 0, values[0]), values, count, false);
}
if (Eql(u8, name, "translate3d")) {
is_2d.* = false;
return translationMatrix(nums[0], nums[1], nums[2]);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 3, 3);
return makeParsedTransform(.translate_3d, translationMatrix(values[0], values[1], values[2]), values, count, false);
}
if (Eql(u8, name, "scale")) {
const sx = nums[0];
const sy = if (count > 1) nums[1] else sx;
return scaleMatrix(sx, sy, 1);
try requireCount(count, 1, 2);
try requireUnitless(units[0..count]);
const sy = if (count == 2) values[1] else values[0];
return makeParsedTransform(.scale, scaleMatrix(values[0], sy, 1), values, count, true);
}
if (Eql(u8, name, "scaleX")) {
return scaleMatrix(nums[0], 1, 1);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
try requireUnitless(units[0..count]);
return makeParsedTransform(.scale_x, scaleMatrix(values[0], 1, 1), values, count, true);
}
if (Eql(u8, name, "scaleY")) {
return scaleMatrix(1, nums[0], 1);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
try requireUnitless(units[0..count]);
return makeParsedTransform(.scale_y, scaleMatrix(1, values[0], 1), values, count, true);
}
if (Eql(u8, name, "scaleZ")) {
is_2d.* = false;
return scaleMatrix(1, 1, nums[0]);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
try requireUnitless(units[0..count]);
return makeParsedTransform(.scale_z, scaleMatrix(1, 1, values[0]), values, count, false);
}
if (Eql(u8, name, "scale3d")) {
is_2d.* = false;
return scaleMatrix(nums[0], nums[1], nums[2]);
}
if (Eql(u8, name, "rotate") or Eql(u8, name, "rotateZ")) {
if (Eql(u8, name, "rotateZ")) is_2d.* = false;
return rotateZMatrix(toRadians(nums[0], units[0]));
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 3, 3);
try requireUnitless(units[0..count]);
return makeParsedTransform(.scale_3d, scaleMatrix(values[0], values[1], values[2]), values, count, false);
}
if (Eql(u8, name, "rotateX")) {
is_2d.* = false;
return rotateXMatrix(toRadians(nums[0], units[0]));
}
if (Eql(u8, name, "rotateY")) {
is_2d.* = false;
return rotateYMatrix(toRadians(nums[0], units[0]));
}
if (Eql(u8, name, "rotate3d")) {
is_2d.* = false;
if (count != 4) {
return error.SyntaxError;
if (Eql(u8, name, "rotate")) {
try requireCount(count, 1, if (syntax == .svg) 3 else 1);
if (count != 1 and count != 3) return error.SyntaxError;
try requireAngle(units[0]);
if (syntax == .svg and units[0] != .none) return error.SyntaxError;
if (count == 3) try requireUnitless(units[1..3]);
var matrix = rotateZMatrix(toRadians(values[0], units[0]));
if (count == 3) {
matrix = multiplyMatrix(translationMatrix(values[1], values[2], 0), matrix);
matrix = multiplyMatrix(matrix, translationMatrix(-values[1], -values[2], 0));
}
return axisAngleMatrix(nums[0], nums[1], nums[2], toRadians(nums[3], units[3]));
return makeParsedTransform(.rotate, matrix, values, count, true);
}
if (Eql(u8, name, "rotateX") or Eql(u8, name, "rotateY") or Eql(u8, name, "rotateZ")) {
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
try requireAngle(units[0]);
if (Eql(u8, name, "rotateX")) return makeParsedTransform(.rotate_x, rotateXMatrix(toRadians(values[0], units[0])), values, count, false);
if (Eql(u8, name, "rotateY")) return makeParsedTransform(.rotate_y, rotateYMatrix(toRadians(values[0], units[0])), values, count, false);
return makeParsedTransform(.rotate_z, rotateZMatrix(toRadians(values[0], units[0])), values, count, false);
}
if (Eql(u8, name, "rotate3d")) {
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 4, 4);
try requireUnitless(units[0..3]);
try requireAngle(units[3]);
return makeParsedTransform(.rotate_3d, axisAngleMatrix(values[0], values[1], values[2], toRadians(values[3], units[3])), values, count, false);
}
if (Eql(u8, name, "skew")) {
const ax = toRadians(nums[0], units[0]);
const ay = if (count > 1) toRadians(nums[1], units[1]) else 0;
return skewMatrix(ax, ay);
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 2);
try requireAngle(units[0]);
if (count == 2) try requireAngle(units[1]);
const ay = if (count == 2) toRadians(values[1], units[1]) else 0;
return makeParsedTransform(.skew, skewMatrix(toRadians(values[0], units[0]), ay), values, count, true);
}
if (Eql(u8, name, "skewX")) {
return skewMatrix(toRadians(nums[0], units[0]), 0);
}
if (Eql(u8, name, "skewY")) {
return skewMatrix(0, toRadians(nums[0], units[0]));
if (Eql(u8, name, "skewX") or Eql(u8, name, "skewY")) {
try requireCount(count, 1, 1);
try requireAngle(units[0]);
if (syntax == .svg and units[0] != .none) return error.SyntaxError;
if (Eql(u8, name, "skewX")) return makeParsedTransform(.skew_x, skewMatrix(toRadians(values[0], units[0]), 0), values, count, true);
return makeParsedTransform(.skew_y, skewMatrix(0, toRadians(values[0], units[0])), values, count, true);
}
if (Eql(u8, name, "perspective")) {
is_2d.* = false;
var out = identity();
if (nums[0] != 0) out[11] = -1.0 / nums[0];
return out;
if (syntax == .svg) return error.SyntaxError;
try requireCount(count, 1, 1);
var matrix = identity();
if (values[0] != 0) matrix[11] = -1.0 / values[0];
return makeParsedTransform(.perspective, matrix, values, count, false);
}
return error.SyntaxError;
}
fn makeParsedTransform(kind: TransformKind, matrix: [16]f64, values: [16]f64, count: usize, is_2d: bool) ParsedTransform {
return .{ .kind = kind, .matrix = matrix, .values = values, .count = count, .is_2d = is_2d };
}
fn requireCount(count: usize, minimum: usize, maximum: usize) !void {
if (count < minimum or count > maximum) return error.SyntaxError;
}
fn requireUnitless(units: []const ParsedValue.Unit) !void {
for (units) |unit| if (unit != .none) return error.SyntaxError;
}
fn requireAngle(unit: ParsedValue.Unit) !void {
if (unit == .other) return error.SyntaxError;
}
fn parseArguments(arguments: []const u8, syntax: TransformSyntax, values: *[16]f64, units: *[16]ParsedValue.Unit) !usize {
var scanner = ArgumentScanner{ .input = arguments, .syntax = syntax };
var count: usize = 0;
while (try scanner.next()) |value| {
if (count == values.len) return error.SyntaxError;
values[count] = value.value;
units[count] = value.unit;
count += 1;
}
return count;
}
pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 {
return switch (unit) {
.rad => value,
@@ -491,6 +623,7 @@ pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 {
.turn => value * std.math.tau,
// bare numbers in rotate()/skew() are interpreted as degrees
.deg, .none => value * std.math.pi / 180.0,
.other => value,
};
}
@@ -725,43 +858,80 @@ const ParsedValue = struct {
rad,
grad,
turn,
other,
};
};
// Parses a single CSS dimension token: a number with an optional unit suffix.
// Length units are ignored (we don't resolve layout), so the numeric part is
// taken verbatim; angle units are recorded so they can be normalised.
fn parse(tok: []const u8) !ParsedValue {
var end: usize = 0;
while (end < tok.len) : (end += 1) {
const c = tok[end];
if ((c >= '0' and c <= '9') or c == '.' or c == '+' or c == '-' or c == 'e' or c == 'E') {
// 'e'/'E' is ambiguous with exponents; only treat as exponent when
// followed by a digit/sign.
if ((c == 'e' or c == 'E') and end > 0) {
if (end + 1 >= tok.len) break;
const nx = tok[end + 1];
if (!((nx >= '0' and nx <= '9') or nx == '+' or nx == '-')) break;
}
continue;
const ArgumentScanner = struct {
input: []const u8,
syntax: TransformSyntax,
index: usize = 0,
first: bool = true,
fn next(self: *ArgumentScanner) !?ParsedValue {
var had_whitespace = false;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
had_whitespace = true;
self.index += 1;
}
if (!self.first and self.index < self.input.len and self.input[self.index] == ',') {
self.index += 1;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
if (self.index == self.input.len) return error.SyntaxError;
} else if (!self.first and self.index < self.input.len) {
// CSS separates arguments with a comma and nothing else. The SVG
// grammar also takes whitespace, or no separator at all when the
// next number carries its own sign.
if (self.syntax == .css) return error.SyntaxError;
if (!had_whitespace and self.input[self.index] != '+' and self.input[self.index] != '-') {
return error.SyntaxError;
}
break;
}
if (end == 0) {
return error.SyntaxError;
}
const value = try std.fmt.parseFloat(f64, tok[0..end]);
const suffix = tok[end..];
var unit: Unit = .none;
if (std.ascii.eqlIgnoreCase(suffix, "deg")) {
unit = .deg;
} else if (std.ascii.eqlIgnoreCase(suffix, "rad")) {
unit = .rad;
} else if (std.ascii.eqlIgnoreCase(suffix, "grad")) {
unit = .grad;
} else if (std.ascii.eqlIgnoreCase(suffix, "turn")) {
unit = .turn;
if (self.index == self.input.len) return null;
const start = self.index;
if (self.input[self.index] == '+' or self.input[self.index] == '-') self.index += 1;
var digits: usize = 0;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
if (self.index < self.input.len and self.input[self.index] == '.') {
self.index += 1;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
}
if (digits == 0) return error.SyntaxError;
if (self.index < self.input.len and (self.input[self.index] == 'e' or self.input[self.index] == 'E')) {
self.index += 1;
if (self.index < self.input.len and (self.input[self.index] == '+' or self.input[self.index] == '-')) self.index += 1;
const exponent_start = self.index;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) self.index += 1;
if (self.index == exponent_start) return error.SyntaxError;
}
const number_end = self.index;
while (self.index < self.input.len and
(std.ascii.isAlphabetic(self.input[self.index]) or self.input[self.index] == '%'))
{
self.index += 1;
}
const suffix = self.input[number_end..self.index];
const unit: ParsedValue.Unit = if (suffix.len == 0)
.none
else if (std.ascii.eqlIgnoreCase(suffix, "deg"))
.deg
else if (std.ascii.eqlIgnoreCase(suffix, "rad"))
.rad
else if (std.ascii.eqlIgnoreCase(suffix, "grad"))
.grad
else if (std.ascii.eqlIgnoreCase(suffix, "turn"))
.turn
else
.other;
const value = std.fmt.parseFloat(f64, self.input[start..number_end]) catch return error.SyntaxError;
if (!std.math.isFinite(value)) return error.SyntaxError;
self.first = false;
return .{ .value = value, .unit = unit };
}
};

View File

@@ -24,7 +24,6 @@ const Frame = @import("../Frame.zig");
const Parser = @import("../parser/Parser.zig");
const HTMLDocument = @import("HTMLDocument.zig");
const XMLDocument = @import("XMLDocument.zig");
const Document = @import("Document.zig");
const DOMParser = @This();
@@ -50,22 +49,22 @@ pub fn parseFromString(
@"image/svg+xml",
}, mime_type) orelse return error.NotSupported;
const arena = try frame.getArena(.medium, "DOMParser.parseFromString");
defer frame.releaseArena(arena);
// DOMParser builds a detached Document. Borrow the same fragment
// parse-mode that `parseHtmlAsChildren` uses so frame-side hooks
// triggered from `Build.created` / `nodeIsReady` (external stylesheet
// fetches, script execution, mutation-observer fan-out, default-script
// injection) treat the parsed nodes as detached and skip
// side effects on the live document. The frame's `_parse_mode` is
// restored on exit.
const previous_parse_mode = frame._parse_mode;
frame._parse_mode = .fragment;
defer frame._parse_mode = previous_parse_mode;
return switch (target_mime) {
.@"text/html" => {
const arena = try frame.getArena(.medium, "DOMParser.parseFromString");
defer frame.releaseArena(arena);
// DOMParser builds a detached Document. Borrow the same fragment
// parse-mode that `Frame.parse` uses so frame-side hooks
// triggered from `Build.created` / `nodeIsReady` (external
// stylesheet fetches, script execution, mutation-observer fan-out,
// default-script injection) treat the parsed nodes as detached and
// skip side effects on the live document. The frame's
// `_parse_mode` is restored on exit.
const previous_parse_mode = frame._parse_mode;
frame._parse_mode = .fragment;
defer frame._parse_mode = previous_parse_mode;
// Create a new HTMLDocument
const doc = try frame._factory.document(HTMLDocument{
._proto = undefined,
@@ -79,6 +78,9 @@ pub fn parseFromString(
// Parse HTML into the document
var parser = Parser.init(arena, doc.asNode(), frame, .{});
parser.parse(normalized);
if (parser.terminated) {
return error.ExecutionTerminated;
}
if (parser.err) |pe| {
return pe.err;
@@ -87,32 +89,10 @@ pub fn parseFromString(
return doc.asDocument();
},
else => {
// Create a new XMLDocument.
const doc = try frame._factory.document(XMLDocument{
._proto = undefined,
});
// Parse XML into XMLDocument.
const doc_node = doc.asNode();
var parser = Parser.init(arena, doc_node, frame, .{});
parser.parseXML(html);
if (parser.err != null or doc_node.firstChild() == null) {
const doc = (try Frame.parse.xmlDocument(frame, html)) orelse blk: {
// Return a document with a <parsererror> element per spec.
const err_doc = try frame._factory.document(XMLDocument{ ._proto = undefined });
var err_parser = Parser.init(arena, err_doc.asNode(), frame, .{});
err_parser.parseXML("<parsererror xmlns=\"http://www.mozilla.org/newlayout/xml/parsererror.xml\">error</parsererror>");
return err_doc.asDocument();
}
const first_child = doc_node.firstChild().?;
// If first node is a `ProcessingInstruction`, skip it.
if (first_child.getNodeType() == 7) {
// We're sure that firstChild exist, this cannot fail.
_ = try doc_node.removeChild(first_child, frame);
}
break :blk (try Frame.parse.xmlDocument(frame, "<parsererror xmlns=\"http://www.mozilla.org/newlayout/xml/parsererror.xml\">error</parsererror>")).?;
};
return doc.asDocument();
},
};

View File

@@ -77,17 +77,17 @@ pub fn getW(self: *const DOMPoint) f64 {
return self._proto._w;
}
pub fn setX(self: *DOMPoint, v: f64) void {
self._proto._x = v;
pub fn setX(self: *DOMPoint, v: f64) !void {
try self._proto.setCoordinate(.x, v);
}
pub fn setY(self: *DOMPoint, v: f64) void {
self._proto._y = v;
pub fn setY(self: *DOMPoint, v: f64) !void {
try self._proto.setCoordinate(.y, v);
}
pub fn setZ(self: *DOMPoint, v: f64) void {
self._proto._z = v;
pub fn setZ(self: *DOMPoint, v: f64) !void {
try self._proto.setCoordinate(.z, v);
}
pub fn setW(self: *DOMPoint, v: f64) void {
self._proto._w = v;
pub fn setW(self: *DOMPoint, v: f64) !void {
try self._proto.setCoordinate(.w, v);
}
pub const JsApi = struct {

View File

@@ -38,6 +38,23 @@ _x: f64,
_y: f64,
_z: f64,
_w: f64,
_attachment: ?Attachment = null,
// Sticky. An animVal item that a later external attribute change detaches from
// its list must not turn into a writable orphan.
_read_only: bool = false,
// SVGPoint's coordinates are a restricted float: NaN and infinity are a
// TypeError rather than a stored value. Only points that reach an SVG list are
// restricted; a plain DOMPoint takes an unrestricted double.
_restricted: bool = false,
pub const Coordinate = enum { x, y, z, w };
pub const Attachment = struct {
owner: *anyopaque,
mutate: *const fn (*anyopaque, *DOMPointReadOnly, Coordinate, f64) anyerror!void,
};
pub const Type = union(enum) {
generic,
@@ -132,6 +149,47 @@ pub fn getW(self: *const DOMPointReadOnly) f64 {
return self._w;
}
pub fn setCoordinate(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) !void {
if (self._read_only) return error.NoModificationAllowed;
if (self._restricted and !std.math.isFinite(value)) return error.TypeError;
if (self._attachment) |attachment| {
return attachment.mutate(attachment.owner, self, coordinate, value);
}
self.setCoordinateRaw(coordinate, value);
}
pub fn setCoordinateRaw(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) void {
switch (coordinate) {
.x => self._x = value,
.y => self._y = value,
.z => self._z = value,
.w => self._w = value,
}
}
pub fn restrict(self: *DOMPointReadOnly) void {
self._restricted = true;
}
pub fn attach(self: *DOMPointReadOnly, attachment: Attachment, read_only: bool) void {
self._attachment = attachment;
if (read_only) self._read_only = true;
}
pub fn detach(self: *DOMPointReadOnly, owner: *anyopaque) void {
const attachment = self._attachment orelse return;
if (attachment.owner == owner) self._attachment = null;
}
pub fn isAttached(self: *const DOMPointReadOnly) bool {
return self._attachment != null;
}
pub fn isAttachedTo(self: *const DOMPointReadOnly, owner: *anyopaque) bool {
const attachment = self._attachment orelse return false;
return attachment.owner == owner;
}
pub fn toJSON(self: *const DOMPointReadOnly) struct {
x: f64,
y: f64,

View File

@@ -484,7 +484,7 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void {
var fragment: ?*Node = null;
if (html.len > 0) {
const frag = (try Node.DocumentFragment.init(frame)).asNode();
try frame.parseHtmlAsChildren(frag, html);
try Frame.parse.htmlAsChildren(frame, frag, html);
fragment = frag;
}
@@ -856,6 +856,10 @@ pub fn removeAttribute(self: *Element, name: String, frame: *Frame) !void {
return self._attributes.delete(name, self, frame);
}
pub fn removeAttributeSafe(self: *Element, name: String, frame: *Frame) void {
self._attributes.deleteSafe(name, self, frame);
}
pub fn toggleAttribute(self: *Element, name: String, force: ?bool, frame: *Frame) !bool {
try Attribute.validateAttributeName(name);
const has = try self.hasAttribute(name, frame);
@@ -1377,13 +1381,106 @@ pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void {
}
pub fn getScrollHeight(self: *Element, frame: *Frame) f64 {
// In our dummy layout engine, content doesn't overflow
return self.getClientHeight(frame);
var visibility_cache: VisibilityCache = .{};
if (!self.checkVisibilityCached(&visibility_cache, frame)) {
return 0.0;
}
const height = self.getElementDimensions(frame).height;
const tag = self.getTag();
// As in getScrollWidth: the root containers carry artificial giant
// defaults, and page-level overflow checks read them.
if (tag == .html or tag == .body) {
return height;
}
return @max(height, self.contentHeight(frame, &visibility_cache));
}
// The height of the direct child elements stacked vertically, the counterpart
// of contentWidth.
//
// Note that the two assume contradictory arrangements — contentWidth lays the
// children out in a row, this stacks them. That is deliberate. We can't detect
// the real layout mode (see contentWidth), so each axis independently assumes
// the arrangement that produces overflow. Together they bound the content
// extent per axis rather than describing one coherent layout: reporting no
// overflow when there is some is what wedges measure-then-mutate loops,
// while the reverse merely over-reports.
fn contentHeight(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
var total: f64 = 0;
var child = self.asNode().firstChild();
while (child) |node| : (child = node.nextSibling()) {
if (node.is(Element)) |el| {
if (el.checkVisibilityCached(visibility_cache, frame)) {
total += el.getElementDimensions(frame).height;
}
}
}
return total;
}
pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
// In our dummy layout engine, content doesn't overflow
return self.getClientWidth(frame);
var visibility_cache: VisibilityCache = .{};
if (!self.checkVisibilityCached(&visibility_cache, frame)) {
return 0.0;
}
const width = self.getElementDimensions(frame).width;
const tag = self.getTag();
// The root containers carry artificial giant defaults (1920 and
// 100_000_000, see getElementDimensions). Stacking their children on
// top would inflate a value sites read to detect page overflow.
if (tag == .html or tag == .body) {
return width;
}
return @max(width, self.contentWidth(frame, &visibility_cache));
}
// The width of the direct child elements laid end to end on a single row.
//
// The dummy layout engine has no line-breaking, and an element only overflows
// horizontally when its children don't wrap (white-space:nowrap, a flex row, an
// inline-block strip), so the single-row assumption covers the case that
// matters. We can't detect the layout mode to do better: getStyle() sees only
// the inline `style=` attribute, and the computed cascade resolves stylesheet
// rules for `display:none` and `visibility` alone.
//
// Only direct children are measured, never the whole subtree. This runs on
// every scrollWidth read, and recursing would make an element's cost O(subtree)
// rather than O(fan-out).
//
// Growing with the child count is the point: JS that appends content until
// `scrollWidth` passes a threshold (the infinite-marquee idiom) never
// terminates when the metric ignores what it just inserted.
//
// Text children are not measured, matching contentHeight. Estimating a text run
// from its length would need a per-character advance, which in turn has to track
// font-size or "shrink the font until it fits" loops stop converging — and it
// would report overflow for practically every element containing text, since a
// few words already exceed the default box. Element children are what content
// grown by script actually consists of.
fn contentWidth(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
var total: f64 = 0;
// The cache arrives seeded by the caller's own visibility walk, and
// siblings share that ancestor chain, so the loop costs one own-element
// check per child rather than N ancestor walks.
var child = self.asNode().firstChild();
while (child) |node| : (child = node.nextSibling()) {
if (node.is(Element)) |el| {
if (el.checkVisibilityCached(visibility_cache, frame)) {
total += el.getElementDimensions(frame).width;
}
}
}
return total;
}
pub fn getOffsetHeight(self: *Element, frame: *Frame) f64 {

View File

@@ -437,7 +437,7 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget {
const visible_path_len = if (path_len > visible_start_index) path_len - visible_start_index else 0;
// Allocate and return the visible path using call_arena (short-lived)
const path = try exec.call_arena.alloc(*EventTarget, visible_path_len);
const path = try exec.local_arena.alloc(*EventTarget, visible_path_len);
@memcpy(path, path_buffer[visible_start_index..path_len]);
return path;
}

View File

@@ -71,7 +71,7 @@ pub fn setBody(self: *HTMLDocument, html: []const u8, frame: *Frame) !void {
// parsing strips any <html>/<body>/<head> wrappers the author included.
const new_body_node = try Frame.node_factory.createElementNS(frame, .html, "body", null);
if (html.len > 0) {
try frame.parseHtmlAsChildren(new_body_node, html);
try Frame.parse.htmlAsChildren(frame, new_body_node, html);
}
const document_node = document_element.asNode();

View File

@@ -1557,9 +1557,9 @@ pub fn setHTML(self: *Node, html: []const u8, allow_declarative_shadow: bool, fr
if (html.len > 0) {
if (allow_declarative_shadow) {
try frame.parseHtmlUnsafeAsChildren(self, html);
try Frame.parse.htmlUnsafeAsChildren(frame, self, html);
} else {
try frame.parseHtmlAsChildren(self, html);
try Frame.parse.htmlAsChildren(frame, self, html);
}
}

View File

@@ -703,7 +703,7 @@ pub fn createContextualFragment(self: *const Range, html: []const u8, frame: *Fr
else
try Frame.node_factory.createElementNS(frame, .html, "div", null);
try frame.parseContextualFragment(temp_node, html);
try Frame.parse.contextualFragment(frame, temp_node, html);
// Move all parsed children to the fragment
// Keep removing first child until temp element is empty

View File

@@ -52,6 +52,7 @@ _observations: std.ArrayList(Observation) = .empty,
const Observation = struct {
target: *Element,
connected: bool = false,
last_width: f64 = 0,
last_height: f64 = 0,
};
@@ -123,18 +124,50 @@ pub fn disconnect(self: *ResizeObserver, frame: *Frame) void {
Frame.observers.unregisterResizeObserver(frame, self);
}
// True if any observed element's connectedness changed since the last
// delivery. Runs on every DOM change (via Frame.observers), so it must stay
// pointer walks only — no style lookups.
pub fn connectivityChanged(self: *const ResizeObserver) bool {
for (self._observations.items) |obs| {
if (obs.target.asNode().isConnected() != obs.connected) {
return true;
}
}
return false;
}
// True if any observed element is `ancestor` or one of its descendants. Walks
// the same parentElement chain StyleManager.isHidden resolves visibility
// through.
pub fn observesWithin(self: *const ResizeObserver, ancestor: *Element) bool {
for (self._observations.items) |obs| {
var current: ?*Element = obs.target;
while (current) |el| : (current = el.parentElement()) {
if (el == ancestor) {
return true;
}
}
}
return false;
}
// Gather the observations whose size changed since the last delivery and, if
// any, invoke the callback.
pub fn deliverEntries(self: *ResizeObserver, frame: *Frame) !void {
var entries: std.ArrayList(*ResizeObserverEntry) = .empty;
// Observed elements share ancestors, so one cache serves the whole pass.
var visibility_cache: Element.VisibilityCache = .empty;
for (self._observations.items) |*obs| {
const target = obs.target;
const connected = target.asNode().isConnected();
obs.connected = connected;
const width, const height = blk: {
if (obs.target.asNode().isConnected() == false) {
if (!connected or !target.checkVisibilityCached(&visibility_cache, frame)) {
break :blk .{ 0, 0 };
}
break :blk .{ target.getClientWidth(frame), target.getClientHeight(frame) };
const dims = target.getElementDimensions(frame);
break :blk .{ dims.width, dims.height };
};
if (width == obs.last_width and height == obs.last_height) {

View File

@@ -262,7 +262,9 @@ pub fn makeRequest(self: *WorkerGlobalScope, req: HttpClient.Request) !void {
// Two-phase variant; see HttpClient.newRequest for the ownership contract.
pub fn newRequest(self: *WorkerGlobalScope, req: HttpClient.Request) !*HttpClient.Transfer {
return self._session.browser.http_client.newRequest(req, &self._http_owner);
var r = req;
r.document_frame_id = self._frame._frame_id;
return self._session.browser.http_client.newRequest(r, &self._http_owner);
}
pub fn getSelf(self: *WorkerGlobalScope) *WorkerGlobalScope {
@@ -330,11 +332,11 @@ pub fn setOnUnhandledRejection(self: *WorkerGlobalScope, setter: ?FunctionSetter
const base64 = @import("encoding/base64.zig");
pub fn btoa(_: *const WorkerGlobalScope, input: base64.BinInput, exec: *JS.Execution) ![]const u8 {
return base64.encode(exec.call_arena, input);
return base64.encode(exec.local_arena, input);
}
pub fn atob(_: *const WorkerGlobalScope, input: base64.BinInput, exec: *JS.Execution) !JS.String.OneByte {
const bytes = try base64.decode(exec.call_arena, input);
const bytes = try base64.decode(exec.local_arena, input);
return .{ .bytes = bytes };
}
@@ -399,6 +401,7 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) !
.url = resolved_url,
.method = .GET,
.frame_id = self._frame_id,
.document_frame_id = self._frame._frame_id,
.loader_id = self._loader_id,
.headers = headers,
.cookie_jar = &session.cookie_jar,

View File

@@ -54,7 +54,7 @@ pub fn getHash(self: *const WorkerLocation) []const u8 {
}
pub fn getOrigin(self: *const WorkerLocation, exec: *const js.Execution) ![]const u8 {
return (try U.getOrigin(exec.call_arena, self._url)) orelse "null";
return (try U.getOrigin(exec.local_arena, self._url)) orelse "null";
}
pub fn toString(self: *const WorkerLocation) [:0]const u8 {

View File

@@ -43,7 +43,7 @@ pub fn getCanvas(self: *const CanvasRenderingContext2D) *Canvas {
}
pub fn getFillStyle(self: *const CanvasRenderingContext2D, exec: *Execution) ![]const u8 {
var w = std.Io.Writer.Allocating.init(exec.call_arena);
var w = std.Io.Writer.Allocating.init(exec.local_arena);
try self._fill_style.format(&w.writer);
return w.written();
}

View File

@@ -36,6 +36,7 @@ const Mode = enum {
child_elements,
child_tag,
cells,
select_options,
selected_options,
links,
anchors,
@@ -54,6 +55,7 @@ _data: union(Mode) {
child_elements: NodeLive(.child_elements),
child_tag: NodeLive(.child_tag),
cells: NodeLive(.cells),
select_options: NodeLive(.select_options),
selected_options: NodeLive(.selected_options),
links: NodeLive(.links),
anchors: NodeLive(.anchors),
@@ -111,6 +113,7 @@ pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator {
.child_elements => |*impl| .{ .child_elements = impl._tw.clone() },
.child_tag => |*impl| .{ .child_tag = impl._tw.clone() },
.cells => |*impl| .{ .cells = impl._tw.clone() },
.select_options => |*impl| .{ .select_options = impl._tw.clone() },
.selected_options => |*impl| .{ .selected_options = impl._tw.clone() },
.links => |*impl| .{ .links = impl._tw.clone() },
.anchors => |*impl| .{ .anchors = impl._tw.clone() },
@@ -132,7 +135,8 @@ pub const Iterator = GenericIterator(struct {
child_elements: TreeWalker.Children,
child_tag: TreeWalker.Children,
cells: TreeWalker.Children,
selected_options: TreeWalker.Children,
select_options: TreeWalker.FullExcludeSelf,
selected_options: TreeWalker.FullExcludeSelf,
links: TreeWalker.FullExcludeSelf,
anchors: TreeWalker.FullExcludeSelf,
form: TreeWalker.FullExcludeSelf,
@@ -157,6 +161,7 @@ pub const Iterator = GenericIterator(struct {
.child_elements => |*impl| impl.nextTw(&self.tw.child_elements),
.child_tag => |*impl| impl.nextTw(&self.tw.child_tag),
.cells => |*impl| impl.nextTw(&self.tw.cells),
.select_options => |*impl| impl.nextTw(&self.tw.select_options),
.selected_options => |*impl| impl.nextTw(&self.tw.selected_options),
.links => |*impl| impl.nextTw(&self.tw.links),
.anchors => |*impl| impl.nextTw(&self.tw.anchors),

View File

@@ -40,6 +40,7 @@ const Mode = enum {
child_elements,
child_tag,
cells,
select_options,
selected_options,
links,
anchors,
@@ -68,6 +69,7 @@ const Filters = union(Mode) {
child_elements,
child_tag: Element.Tag,
cells,
select_options,
selected_options,
links,
anchors,
@@ -100,7 +102,10 @@ pub fn NodeLive(comptime mode: Mode) type {
const Filter = Filters.TypeOf(mode);
const TW = switch (mode) {
.tag, .tag_name, .tag_name_ns, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf,
.child_elements, .child_tag, .cells, .selected_options => TreeWalker.Children,
.child_elements, .child_tag, .cells => TreeWalker.Children,
// A select's options can sit one level down, inside an <optgroup>, so
// these two walk the subtree and filter on the parent instead.
.select_options, .selected_options => TreeWalker.FullExcludeSelf,
};
return struct {
_tw: TW,
@@ -299,11 +304,26 @@ pub fn NodeLive(comptime mode: Mode) type {
const el = node.is(Element) orelse return false;
return el.is(Element.Html.TableCell) != null;
},
.selected_options => {
const el = node.is(Element) orelse return false;
const Option = Element.Html.Option;
const opt = el.is(Option) orelse return false;
return opt.getSelected();
.select_options, .selected_options => {
const opt = node.is(Element.Html.Option) orelse return false;
// we have an option, but it's only a match IF
// 1 - this is a direct child of the root (i.e. the <select>)
// OR
// 2 - this is a direct child of an <optgroup> which, itself
// is a direct child of the root (i.e. the <select>)
const parent = node.parentNode() orelse return false;
if (parent == self._tw._root) {
// case 1: it _is_ a direct child of the root
} else {
if (parent.is(Element.Html.OptGroup) == null or parent.parentNode() != self._tw._root) {
return false;
}
// the parent is an optgroup and its parent is the root
}
return if (comptime mode == .selected_options) opt.getSelected() else true;
},
.links => {
// Links are <a> elements with href attribute (TODO: also <area> when implemented)
@@ -390,6 +410,7 @@ pub fn NodeLive(comptime mode: Mode) type {
.child_elements => HTMLCollection{ ._data = .{ .child_elements = self } },
.child_tag => HTMLCollection{ ._data = .{ .child_tag = self } },
.cells => HTMLCollection{ ._data = .{ .cells = self } },
.select_options => HTMLCollection{ ._data = .{ .select_options = self } },
.selected_options => HTMLCollection{ ._data = .{ .selected_options = self } },
.links => HTMLCollection{ ._data = .{ .links = self } },
.anchors => HTMLCollection{ ._data = .{ .anchors = self } },

View File

@@ -315,9 +315,17 @@ pub const List = struct {
pub fn delete(self: *List, name: String, element: *Element, frame: *Frame) !void {
const result = try self.getEntryAndNormalizedName(name, frame);
const entry = result.entry orelse return;
return self._delete(entry, result.normalized, element, frame);
}
pub fn deleteSafe(self: *List, name: String, element: *Element, frame: *Frame) void {
const entry = self.getEntryWithNormalizedName(name) orelse return;
self._delete(entry, name, element, frame);
}
fn _delete(self: *List, entry: *Entry, normalized: String, element: *Element, frame: *Frame) void {
const owner = element.ownerFrame(frame);
const is_id = shouldAddToIdMap(result.normalized, element);
const is_id = shouldAddToIdMap(normalized, element);
const old_value = entry.value();
if (is_id) {
@@ -333,7 +341,7 @@ pub const List = struct {
self._len -= 1;
owner.domChanged();
owner.attributeRemove(element, result.normalized, .wrap(old_value));
owner.attributeRemove(element, normalized, .wrap(old_value));
}
pub fn getNames(self: *const List, allocator: Allocator) ![][]const u8 {

View File

@@ -290,7 +290,7 @@ pub fn insertAdjacentHTML(
) !void {
const DocumentFragment = @import("../DocumentFragment.zig");
const fragment = (try DocumentFragment.init(frame)).asNode();
try frame.parseHtmlAsChildren(fragment, html);
try Frame.parse.htmlAsChildren(frame, fragment, html);
const target_node, const prev_node = try self.asNode().findAdjacentNodes(position, .html);
@@ -1726,7 +1726,7 @@ pub const JsApi = struct {
pub const innerText = bridge.accessor(_innerText, _setInnerText, .{ .ce_reactions = true });
fn _innerText(self: *HtmlElement, frame: *Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.getInnerText(&buf.writer, frame);
return buf.written();
}

View File

@@ -216,7 +216,7 @@ pub fn setName(self: *Anchor, value: []const u8, frame: *Frame) !void {
}
pub fn getText(self: *Anchor, frame: *Frame) ![:0]const u8 {
return self.asNode().getTextContentAlloc(frame.call_arena);
return self.asNode().getTextContentAlloc(frame.local_arena);
}
pub fn setText(self: *Anchor, value: []const u8, frame: *Frame) !void {

View File

@@ -53,7 +53,7 @@ pub fn getValue(self: *Option, frame: *Frame) []const u8 {
}
const node = self.asNode();
const text = node.getTextContentAlloc(frame.call_arena) catch return "";
const text = node.getTextContentAlloc(frame.local_arena) catch return "";
return std.mem.trim(u8, text, &std.ascii.whitespace);
}

View File

@@ -145,13 +145,13 @@ pub const JsApi = struct {
pub const supports = bridge.function(Script.supports, .{ .static = true });
pub const innerText = bridge.accessor(_innerText, Script.setInnerText, .{ .ce_reactions = true });
fn _innerText(self: *Script, frame: *const Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.asNode().getTextContent(&buf.writer);
return buf.written();
}
pub const text = bridge.accessor(_text, Script.setInnerText, .{ .ce_reactions = true });
fn _text(self: *Script, frame: *const Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.asNode().getChildTextContent(&buf.writer);
return buf.written();
}

View File

@@ -48,6 +48,45 @@ pub fn asConstNode(self: *const Select) *const Node {
return self.asConstElement().asConstNode();
}
// Walks the select's list of options in tree order: its option children plus
// the option children of its optgroup children, per HTML
// §the-select-element. Options nested any deeper are not in the list.
const OptionIterator = struct {
_node: ?*Node,
_in_group: bool = false,
fn init(select: *const Select) OptionIterator {
return .{ ._node = select.asConstNode().firstChild() };
}
fn next(self: *OptionIterator) ?*Option {
while (self._node) |node| {
if (self._in_group == false and node.is(Element.Html.OptGroup) != null) {
if (node.firstChild()) |first_child| {
self._in_group = true;
self._node = first_child;
continue;
}
}
self._node = node.nextSibling() orelse blk: {
if (self._in_group == false) {
break :blk null;
}
// Exhausted the optgroup, resume after it.
self._in_group = false;
const group = node.parentNode() orelse break :blk null;
break :blk group.nextSibling();
};
if (node.is(Option)) |option| {
return option;
}
}
return null;
}
};
// Resolves the option whose selectedness contributes to the select's value
// per HTML §form-elements§selectedness-setting-algorithm: an explicitly
// selected non-disabled option, falling back to the first non-disabled
@@ -56,11 +95,17 @@ pub fn asConstNode(self: *const Select) *const Node {
// and contributes no entry to a FormData set.
pub fn effectiveOption(self: *const Select) ?*Option {
var first_option: ?*Option = null;
var maybe_child = self.asConstNode().firstChild();
while (maybe_child) |child| : (maybe_child = child.nextSibling()) {
const option = child.is(Option) orelse continue;
if (option.getDisabled()) continue;
if (option.getSelected()) return option;
var it = OptionIterator.init(self);
while (it.next()) |option| {
// Element.isDisabled, not Option.getDisabled: an option is also
// disabled when its parent is an <optgroup disabled>
// (HTML "concept-option-disabled").
if (option.asElement().isDisabled()) {
continue;
}
if (option.getSelected()) {
return option;
}
if (first_option == null) first_option = option;
}
return first_option;
@@ -77,9 +122,8 @@ pub fn setValue(self: *Select, value: []const u8, frame: *Frame) !void {
// Find option with matching value and select it
// Note: This updates the current state (_selected), not the default state (attribute)
// Setting value always deselects all others, even for multiple selects
var iter = self.asNode().childrenIterator();
while (iter.next()) |child| {
const option = child.is(Option) orelse continue;
var it = OptionIterator.init(self);
while (it.next()) |option| {
option._selected = std.mem.eql(u8, option.getValue(frame), value);
}
}
@@ -87,9 +131,8 @@ pub fn setValue(self: *Select, value: []const u8, frame: *Frame) !void {
pub fn getSelectedIndex(self: *Select) i32 {
var index: i32 = 0;
var has_options = false;
var iter = self.asNode().childrenIterator();
while (iter.next()) |child| {
const option = child.is(Option) orelse continue;
var it = OptionIterator.init(self);
while (it.next()) |option| {
has_options = true;
if (option.getSelected()) {
return index;
@@ -112,9 +155,8 @@ pub fn setSelectedIndex(self: *Select, index: i32) !void {
// Note: This updates the current state (_selected), not the default state (attribute)
const is_multiple = self.getMultiple();
var current_index: i32 = 0;
var iter = self.asNode().childrenIterator();
while (iter.next()) |child| {
const option = child.is(Option) orelse continue;
var it = OptionIterator.init(self);
while (it.next()) |option| {
if (current_index == index) {
option._selected = true;
} else if (!is_multiple) {
@@ -200,8 +242,9 @@ pub fn setRequired(self: *Select, required: bool, frame: *Frame) !void {
}
pub fn getOptions(self: *Select, frame: *Frame) !*collections.HTMLOptionsCollection {
// For options, we use the child_tag mode to filter only <option> elements
const node_live = collections.NodeLive(.child_tag).init(self.asNode(), .option, frame);
// select_options mode is the select's list of options: option children
// plus the option children of optgroup children.
const node_live = collections.NodeLive(.select_options).init(self.asNode(), {}, frame);
const html_collection = try node_live.runtimeGenericWrap(frame);
// Create and return HTMLOptionsCollection
@@ -213,11 +256,9 @@ pub fn getOptions(self: *Select, frame: *Frame) !*collections.HTMLOptionsCollect
pub fn getLength(self: *Select) u32 {
var i: u32 = 0;
var it = self.asNode().childrenIterator();
while (it.next()) |child| {
if (child.is(Option) != null) {
i += 1;
}
var it = OptionIterator.init(self);
while (it.next()) |_| {
i += 1;
}
return i;
}
@@ -235,13 +276,10 @@ pub fn add(self: *Select, element: *Option, before_: ?AddBeforeOption, frame: *F
switch (before) {
.index => |idx| {
var i: u32 = 0;
var it = self_node.childrenIterator();
while (it.next()) |child| {
if (child.is(Option) == null) {
continue;
}
var it = OptionIterator.init(self);
while (it.next()) |option| {
if (i == idx) {
before_node = child;
before_node = option.asNode();
break;
}
i += 1;
@@ -250,7 +288,10 @@ pub fn add(self: *Select, element: *Option, before_: ?AddBeforeOption, frame: *F
.option => |before_option| before_node = before_option.asNode(),
}
}
_ = try self_node.insertBefore(element.asElement().asNode(), before_node, frame);
// Per HTML §dom-select-add, the insertion parent is `before`'s parent —
// which is the optgroup when `before` sits inside one.
const parent = if (before_node) |node| node.parentNode() orelse self_node else self_node;
_ = try parent.insertBefore(element.asElement().asNode(), before_node, frame);
}
pub fn getSelectedOptions(self: *Select, frame: *Frame) !collections.NodeLive(.selected_options) {
@@ -387,5 +428,6 @@ const std = @import("std");
const testing = @import("../../../../testing.zig");
test "WebApi: HTML.Select" {
try testing.htmlRunner("element/html/select.html", .{});
try testing.htmlRunner("element/html/select-optgroup.html", .{});
try testing.htmlRunner("element/html/select-validity.html", .{});
}

View File

@@ -108,7 +108,7 @@ pub const JsApi = struct {
pub const innerHTML = bridge.accessor(_getInnerHTML, _setInnerHTML, .{ .ce_reactions = true });
fn _getInnerHTML(self: *Template, frame: *Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self._content.getInnerHTML(&buf.writer, frame);
return buf.written();
}
@@ -119,7 +119,7 @@ pub const JsApi = struct {
pub const outerHTML = bridge.accessor(_getOuterHTML, null, .{});
fn _getOuterHTML(self: *Template, frame: *Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.getOuterHTML(&buf.writer, frame);
return buf.written();
}

View File

@@ -17,11 +17,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const Geometry = @import("Geometry.zig");
const AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Circle = @This();
_proto: *Geometry,
@@ -41,4 +43,18 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const cx = bridge.accessor(Circle.getCx, null, .{});
pub const cy = bridge.accessor(Circle.getCy, null, .{});
pub const r = bridge.accessor(Circle.getR, null, .{});
};
pub fn getCx(self: *Circle, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .cx, frame);
}
pub fn getCy(self: *Circle, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .cy, frame);
}
pub fn getR(self: *Circle, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .r, frame);
}

View File

@@ -17,11 +17,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const Geometry = @import("Geometry.zig");
const AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Ellipse = @This();
_proto: *Geometry,
@@ -41,4 +43,22 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const cx = bridge.accessor(Ellipse.getCx, null, .{});
pub const cy = bridge.accessor(Ellipse.getCy, null, .{});
pub const rx = bridge.accessor(Ellipse.getRx, null, .{});
pub const ry = bridge.accessor(Ellipse.getRy, null, .{});
};
pub fn getCx(self: *Ellipse, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .cx, frame);
}
pub fn getCy(self: *Ellipse, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .cy, frame);
}
pub fn getRx(self: *Ellipse, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .rx, frame);
}
pub fn getRy(self: *Ellipse, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .ry, frame);
}

View File

@@ -16,12 +16,19 @@
// 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 js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const DOMPoint = @import("../../DOMPoint.zig");
const Graphics = @import("Graphics.zig");
const AnimatedNumber = @import("../../svg/AnimatedNumber.zig");
const AnimatedLength = @import("../../svg/AnimatedLength.zig");
const PathData = @import("../../svg/PathData.zig");
pub const Rect = @import("Rect.zig");
pub const Circle = @import("Circle.zig");
pub const Ellipse = @import("Ellipse.zig");
@@ -70,4 +77,174 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const pathLength = bridge.accessor(Geometry.getPathLength, null, .{});
pub const getTotalLength = bridge.function(Geometry.getTotalLength, .{});
pub const getPointAtLength = bridge.function(Geometry.getPointAtLength, .{});
};
pub fn getPathLength(self: *Geometry, frame: *Frame) !*AnimatedNumber {
return AnimatedNumber.getOrCreate(self.asElement(), .path_length, frame);
}
pub fn getTotalLength(self: *Geometry, frame: *Frame) !f64 {
var path = try self.buildPath(frame);
defer path.deinit(frame.local_arena);
return path.totalLength(frame.local_arena);
}
pub fn getPointAtLength(self: *Geometry, distance: f64, frame: *Frame) !*DOMPoint {
if (!std.math.isFinite(distance)) return error.TypeError;
var path = try self.buildPath(frame);
defer path.deinit(frame.local_arena);
const point = try path.pointAtLength(distance, frame.local_arena);
return DOMPoint.create(point.x, point.y, 0, 1, frame._page);
}
pub fn buildPath(self: *Geometry, frame: *Frame) !PathData.Path {
return switch (self._type) {
.rect => |rect| buildRect(rect, frame),
.circle => |circle| buildCircle(circle, frame),
.ellipse => |ellipse| buildEllipse(ellipse, frame),
.line => |line| buildLine(line, frame),
.path => |path| PathData.parse(
path.asElement().getAttributeSafe(comptime .wrap("d")) orelse "",
frame.local_arena,
),
.polygon => |polygon| buildPoints(try polygon.getPoints(frame), true, frame),
.polyline => |polyline| buildPoints(try polyline.getPoints(frame), false, frame),
};
}
fn value(length: *AnimatedLength, frame: *Frame) ?f64 {
const base = length.getBaseVal();
if (!base.hasKnownUnit()) return null;
const result = base.getValue(frame);
return if (std.math.isFinite(result)) result else null;
}
fn buildRect(rect: *Rect, frame: *Frame) !PathData.Path {
var path: PathData.Path = .{};
errdefer path.deinit(frame.local_arena);
const x = value(try rect.getX(frame), frame) orelse 0;
const y = value(try rect.getY(frame), frame) orelse 0;
const width = value(try rect.getWidth(frame), frame) orelse 0;
const height = value(try rect.getHeight(frame), frame) orelse 0;
if (width <= 0 or height <= 0) return path;
const rx_value = optionalRadius(rect.asElement(), "rx", try rect.getRx(frame), frame);
const ry_value = optionalRadius(rect.asElement(), "ry", try rect.getRy(frame), frame);
var rx = rx_value orelse ry_value orelse 0;
var ry = ry_value orelse rx_value orelse 0;
rx = @min(rx, width / 2.0);
ry = @min(ry, height / 2.0);
const top_left = PathData.Point{ .x = x, .y = y };
if (rx == 0 or ry == 0) {
const top_right = PathData.Point{ .x = x + width, .y = y };
const bottom_right = PathData.Point{ .x = x + width, .y = y + height };
const bottom_left = PathData.Point{ .x = x, .y = y + height };
try path.appendLine(top_left, top_right, frame.local_arena);
try path.appendLine(top_right, bottom_right, frame.local_arena);
try path.appendLine(bottom_right, bottom_left, frame.local_arena);
try path.appendLine(bottom_left, top_left, frame.local_arena);
return path;
}
const start = PathData.Point{ .x = x + rx, .y = y };
const top_end = PathData.Point{ .x = x + width - rx, .y = y };
const right_start = PathData.Point{ .x = x + width, .y = y + ry };
const right_end = PathData.Point{ .x = x + width, .y = y + height - ry };
const bottom_start = PathData.Point{ .x = x + width - rx, .y = y + height };
const bottom_end = PathData.Point{ .x = x + rx, .y = y + height };
const left_start = PathData.Point{ .x = x, .y = y + height - ry };
const left_end = PathData.Point{ .x = x, .y = y + ry };
try path.appendLine(start, top_end, frame.local_arena);
try path.appendArc(top_end, rx, ry, 0, false, true, right_start, frame.local_arena);
try path.appendLine(right_start, right_end, frame.local_arena);
try path.appendArc(right_end, rx, ry, 0, false, true, bottom_start, frame.local_arena);
try path.appendLine(bottom_start, bottom_end, frame.local_arena);
try path.appendArc(bottom_end, rx, ry, 0, false, true, left_start, frame.local_arena);
try path.appendLine(left_start, left_end, frame.local_arena);
try path.appendArc(left_end, rx, ry, 0, false, true, start, frame.local_arena);
return path;
}
fn optionalRadius(element: *Element, comptime name: []const u8, length: *AnimatedLength, frame: *Frame) ?f64 {
const raw = element.getAttributeSafe(comptime .wrap(name)) orelse return null;
if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, raw, " \t\r\n\x0c"), "auto")) return null;
const result = value(length, frame) orelse return null;
return if (result < 0) null else result;
}
fn buildCircle(circle: *Circle, frame: *Frame) !PathData.Path {
const center = PathData.Point{
.x = value(try circle.getCx(frame), frame) orelse 0,
.y = value(try circle.getCy(frame), frame) orelse 0,
};
const radius = value(try circle.getR(frame), frame) orelse 0;
return buildEllipsePath(center, radius, radius, frame);
}
fn buildEllipse(ellipse: *Ellipse, frame: *Frame) !PathData.Path {
const center = PathData.Point{
.x = value(try ellipse.getCx(frame), frame) orelse 0,
.y = value(try ellipse.getCy(frame), frame) orelse 0,
};
const rx = optionalRadius(ellipse.asElement(), "rx", try ellipse.getRx(frame), frame);
const ry = optionalRadius(ellipse.asElement(), "ry", try ellipse.getRy(frame), frame);
return buildEllipsePath(center, rx orelse ry orelse 0, ry orelse rx orelse 0, frame);
}
fn buildEllipsePath(center: PathData.Point, rx: f64, ry: f64, frame: *Frame) !PathData.Path {
var path: PathData.Path = .{};
errdefer path.deinit(frame.local_arena);
if (rx <= 0 or ry <= 0) return path;
const right = PathData.Point{ .x = center.x + rx, .y = center.y };
const bottom = PathData.Point{ .x = center.x, .y = center.y + ry };
const left = PathData.Point{ .x = center.x - rx, .y = center.y };
const top = PathData.Point{ .x = center.x, .y = center.y - ry };
try path.appendArc(right, rx, ry, 0, false, true, bottom, frame.local_arena);
try path.appendArc(bottom, rx, ry, 0, false, true, left, frame.local_arena);
try path.appendArc(left, rx, ry, 0, false, true, top, frame.local_arena);
try path.appendArc(top, rx, ry, 0, false, true, right, frame.local_arena);
return path;
}
fn buildLine(line: *Line, frame: *Frame) !PathData.Path {
var path: PathData.Path = .{};
errdefer path.deinit(frame.local_arena);
const start = PathData.Point{
.x = value(try line.getX1(frame), frame) orelse 0,
.y = value(try line.getY1(frame), frame) orelse 0,
};
const end = PathData.Point{
.x = value(try line.getX2(frame), frame) orelse 0,
.y = value(try line.getY2(frame), frame) orelse 0,
};
try path.appendLine(start, end, frame.local_arena);
return path;
}
fn buildPoints(list: anytype, close: bool, frame: *Frame) !PathData.Path {
var path: PathData.Path = .{};
errdefer path.deinit(frame.local_arena);
const count = try list.getNumberOfItems(frame);
if (count == 0) return path;
const first_item = try list.getItem(0, frame);
const first = PathData.Point{ .x = first_item.getX(), .y = first_item.getY() };
path.first_point = first;
var previous = first;
for (1..count) |index| {
const item = try list.getItem(@intCast(index), frame);
const next = PathData.Point{ .x = item.getX(), .y = item.getY() };
try path.appendLine(previous, next, frame.local_arena);
previous = next;
}
if (close and count > 1) try path.appendLine(previous, first, frame.local_arena);
return path;
}

View File

@@ -16,10 +16,18 @@
// 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 js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const DOMRect = @import("../../DOMRect.zig");
const DOMMatrixReadOnly = @import("../../DOMMatrixReadOnly.zig");
const SvgElement = @import("../Svg.zig");
const AnimatedTransformList = @import("../../svg/AnimatedTransformList.zig");
const PathData = @import("../../svg/PathData.zig");
const StringList = @import("../../svg/StringList.zig");
pub const Svg = @import("Svg.zig");
pub const G = @import("G.zig");
@@ -72,4 +80,100 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const transform = bridge.accessor(Graphics.getTransform, null, .{});
pub const requiredExtensions = bridge.accessor(Graphics.getRequiredExtensions, null, .{});
pub const systemLanguage = bridge.accessor(Graphics.getSystemLanguage, null, .{});
pub const getBBox = bridge.function(Graphics.getBBox, .{});
};
// SVGBoundingBoxOptions is not modelled: Chrome declares no parameter at all
// and Firefox's flags degenerate to the fill geometry, so both engines answer
// every call with the fill box.
pub fn getBBox(self: *Graphics, frame: *Frame) !*DOMRect {
var bounds: PathData.Bounds = .{};
switch (self._type) {
.geometry => |geometry| {
var path = try geometry.buildPath(frame);
defer path.deinit(frame.local_arena);
bounds = path.bounds(.{});
},
.g, .a, .svg => try accumulateChildren(self, .{}, &bounds, frame, 0),
.defs, .use, .image => {},
}
if (bounds.isEmpty()) {
return DOMRect.create(.{}, frame._factory);
}
return DOMRect.create(.{
.x = bounds.min_x,
.y = bounds.min_y,
.width = bounds.width(),
.height = bounds.height(),
}, frame._factory);
}
const MAX_NESTING_DEPTH = 512;
fn accumulateChildren(parent: *Graphics, matrix: PathData.Matrix, bounds: *PathData.Bounds, frame: *Frame, depth: usize) !void {
if (depth == MAX_NESTING_DEPTH) {
return;
}
var child = parent.asNode().firstChild();
while (child) |node| : (child = node.nextSibling()) {
const element = node.is(Element) orelse continue;
if (element._namespace != .svg) continue;
const svg = element.as(SvgElement);
const graphics = svg.is(Graphics) orelse continue;
const child_matrix = matrix.multiply(transformMatrix(element));
switch (graphics._type) {
.geometry => |geometry| {
var path = try geometry.buildPath(frame);
defer path.deinit(frame.local_arena);
bounds.merge(path.bounds(child_matrix));
},
.g, .a => try accumulateChildren(graphics, child_matrix, bounds, frame, depth + 1),
// <defs> never renders. Nested viewports, <use> and <image> have
// geometry we cannot resolve yet, so they contribute nothing rather
// than making the whole box unavailable.
.defs, .svg, .use, .image => {},
}
}
}
fn transformMatrix(element: *Element) PathData.Matrix {
const raw = element.getAttributeSafe(comptime .wrap("transform")) orelse return .{};
const trimmed = std.mem.trim(u8, raw, " \t\r\n");
if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) {
return .{};
}
var matrix = DOMMatrixReadOnly.identity();
var iterator = DOMMatrixReadOnly.TransformFunctionIterator{ .input = trimmed, .allow_comma = true };
while (iterator.next() catch return .{}) |function| {
const parsed = DOMMatrixReadOnly.parseTransformFunction(function, .svg) catch return .{};
matrix = DOMMatrixReadOnly.multiplyMatrix(matrix, parsed.matrix);
}
return .{
.a = matrix[0],
.b = matrix[1],
.c = matrix[4],
.d = matrix[5],
.e = matrix[12],
.f = matrix[13],
};
}
pub fn getTransform(self: *Graphics, frame: *Frame) !*AnimatedTransformList {
return AnimatedTransformList.getOrCreate(self.asElement(), frame);
}
pub fn getRequiredExtensions(self: *Graphics, frame: *Frame) !*StringList {
return StringList.getOrCreate(self.asElement(), .required_extensions, frame);
}
pub fn getSystemLanguage(self: *Graphics, frame: *Frame) !*StringList {
return StringList.getOrCreate(self.asElement(), .system_language, frame);
}

View File

@@ -17,11 +17,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const Geometry = @import("Geometry.zig");
const AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Line = @This();
_proto: *Geometry,
@@ -41,4 +43,22 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const x1 = bridge.accessor(Line.getX1, null, .{});
pub const y1 = bridge.accessor(Line.getY1, null, .{});
pub const x2 = bridge.accessor(Line.getX2, null, .{});
pub const y2 = bridge.accessor(Line.getY2, null, .{});
};
pub fn getX1(self: *Line, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .x1, frame);
}
pub fn getY1(self: *Line, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .y1, frame);
}
pub fn getX2(self: *Line, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .x2, frame);
}
pub fn getY2(self: *Line, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .y2, frame);
}

View File

@@ -17,11 +17,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const Geometry = @import("Geometry.zig");
const PointList = @import("../../svg/PointList.zig");
const Polygon = @This();
_proto: *Geometry,
@@ -41,4 +43,15 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const points = bridge.accessor(Polygon.getPoints, null, .{});
pub const animatedPoints = bridge.accessor(Polygon.getAnimatedPoints, null, .{});
};
pub fn getPoints(self: *Polygon, frame: *Frame) !*PointList {
return PointList.getOrCreate(self.asElement(), .base, frame);
}
pub fn getAnimatedPoints(self: *Polygon, frame: *Frame) !*PointList {
return PointList.getOrCreate(self.asElement(), .animated, frame);
}

View File

@@ -17,11 +17,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const Geometry = @import("Geometry.zig");
const PointList = @import("../../svg/PointList.zig");
const Polyline = @This();
_proto: *Geometry,
@@ -41,4 +43,15 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const points = bridge.accessor(Polyline.getPoints, null, .{});
pub const animatedPoints = bridge.accessor(Polyline.getAnimatedPoints, null, .{});
};
pub fn getPoints(self: *Polyline, frame: *Frame) !*PointList {
return PointList.getOrCreate(self.asElement(), .base, frame);
}
pub fn getAnimatedPoints(self: *Polyline, frame: *Frame) !*PointList {
return PointList.getOrCreate(self.asElement(), .animated, frame);
}

View File

@@ -17,9 +17,11 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const Geometry = @import("Geometry.zig");
const AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Rect = @This();
_proto: *Geometry,
@@ -39,4 +41,30 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const x = bridge.accessor(Rect.getX, null, .{});
pub const y = bridge.accessor(Rect.getY, null, .{});
pub const width = bridge.accessor(Rect.getWidth, null, .{});
pub const height = bridge.accessor(Rect.getHeight, null, .{});
pub const rx = bridge.accessor(Rect.getRx, null, .{});
pub const ry = bridge.accessor(Rect.getRy, null, .{});
};
pub fn getX(self: *Rect, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .x, frame);
}
pub fn getY(self: *Rect, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .y, frame);
}
pub fn getWidth(self: *Rect, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .width, frame);
}
pub fn getHeight(self: *Rect, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .height, frame);
}
pub fn getRx(self: *Rect, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .rx, frame);
}
pub fn getRy(self: *Rect, frame: *Frame) !*AnimatedLength {
return AnimatedLength.getOrCreate(self.asElement(), .ry, frame);
}

View File

@@ -82,7 +82,9 @@ pub fn getPreserveAspectRatio(self: *Svg, frame: *Frame) !*AnimatedPreserveAspec
}
pub fn createSVGPoint(_: *Svg, frame: *Frame) !*DOMPoint {
return DOMPoint.create(0, 0, 0, 1, frame._page);
const point = try DOMPoint.create(0, 0, 0, 1, frame._page);
point._proto.restrict();
return point;
}
pub fn createSVGMatrix(_: *Svg, frame: *Frame) !*DOMMatrix {
@@ -100,7 +102,7 @@ pub fn createSVGRect(_: *Svg, frame: *Frame) !*DOMRect {
}
pub fn createSVGNumber(_: *Svg, frame: *Frame) !*SvgNumber {
return frame._factory.create(SvgNumber{});
return SvgNumber.detached(frame);
}
pub fn createSVGLength(_: *Svg, frame: *Frame) !*SvgLength {

View File

@@ -25,6 +25,7 @@ const Frame = @import("../../Frame.zig");
const Form = @import("../element/html/Form.zig");
const Element = @import("../Element.zig");
const File = @import("../File.zig");
const Blob = @import("../Blob.zig");
const KeyValueList = @import("../KeyValueList.zig");
const log = lp.log;
@@ -165,12 +166,63 @@ pub fn has(self: *const FormData, name: String) bool {
return false;
}
pub fn set(self: *FormData, name: String, value: []const u8, exec: *Execution) !void {
pub const EntryValue = union(enum) {
blob: *Blob, // can be of _type == .file
bytes: []const u8, //must be last, everything can coerce to a []const u8
};
pub fn set(self: *FormData, name: String, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void {
self.deleteByName(name, exec);
return self.append(name.str(), value);
return self.append(name.str(), value, filename, exec);
}
pub fn append(self: *FormData, name: []const u8, value: []const u8) !void {
// https://xhr.spec.whatwg.org/#create-an-entry
pub fn append(self: *FormData, name: []const u8, value: EntryValue, filename: ?[]const u8, exec: *Execution) !void {
const entry_value: Entry.Value = switch (value) {
.blob => |blob| blk: {
if (filename) |n| {
// A supplied filename means a new File over the same bytes rather
// than a rename of the caller's object.
break :blk .{ .file = try fileFrom(blob, n, exec.page) };
}
if (blob._type == .file) {
const file = blob._type.file;
file.acquireRef();
break :blk .{ .file = file };
}
// A Blob that is not a File becomes a File named "blob".
break :blk .{ .file = try fileFrom(blob, "blob", exec.page) };
},
.bytes => |b| .{ .string = try String.init(self._arena, b, .{}) },
};
try self._entries.append(self._arena, .{
.name = try String.init(self._arena, name, .{}),
.value = entry_value,
});
}
// Mirrors File.init — a Blob and File sharing one reference-counted arena —
// but over bytes we already hold rather than JS parts. Returned at refcount 1:
// the entry owns that reference and deleteByName releases it.
fn fileFrom(source: *Blob, name: []const u8, page: *Page) !*File {
const blob = try Blob.initFromBytes(source._slice, source._mime, page);
errdefer blob.deinit(page);
const file = try blob._arena.create(File);
file.* = .{
._proto = blob,
._name = try blob._arena.dupe(u8, name),
._last_modified = std.Io.Clock.now(.real, lp.io).toMilliseconds(),
};
blob._type = .{ .file = file };
blob.acquireRef();
return file;
}
pub fn appendText(self: *FormData, name: []const u8, value: []const u8) !void {
try self._entries.append(self._arena, .{
.name = try String.init(self._arena, name, .{}),
.value = .{ .string = try String.init(self._arena, value, .{}) },
@@ -535,8 +587,8 @@ test "FormData: multipart write" {
._arena = allocator,
._entries = .empty,
};
try fd.append("name", "John");
try fd.append("note", "two\r\nlines");
try fd.appendText("name", "John");
try fd.appendText("note", "two\r\nlines");
var buf = std.Io.Writer.Allocating.init(allocator);
try fd.write(.{
@@ -564,7 +616,7 @@ test "FormData: multipart escapes name CR/LF/quote" {
._arena = allocator,
._entries = .empty,
};
try fd.append("a\"b\r\nc", "v");
try fd.appendText("a\"b\r\nc", "v");
var buf = std.Io.Writer.Allocating.init(allocator);
try fd.write(.{
@@ -599,8 +651,6 @@ test "FormData: multipart empty body" {
try testing.expectString("--B--\r\n", buf.written());
}
const Blob = @import("../Blob.zig");
fn buildTestFile(arena: Allocator, page: *@import("../../Page.zig"), name: []const u8, mime: []const u8, body: []const u8) !*File {
const blob = try Blob.initFromBytes(body, mime, page);
blob.acquireRef();
@@ -626,7 +676,7 @@ test "FormData: multipart with file" {
._arena = allocator,
._entries = .empty,
};
try fd.append("field", "value");
try fd.appendText("field", "value");
try fd._entries.append(allocator, .{
.name = try String.init(allocator, "upload", .{}),
.value = .{ .file = file },
@@ -797,9 +847,9 @@ test "FormData: plaintext write" {
._arena = allocator,
._entries = .empty,
};
try fd.append("name", "John");
try fd.append("note", "two\r\nlines");
try fd.append("equals", "a=b");
try fd.appendText("name", "John");
try fd.appendText("note", "two\r\nlines");
try fd.appendText("equals", "a=b");
var buf = std.Io.Writer.Allocating.init(allocator);
try fd.write(.{ .encoding = .plaintext, .allocator = allocator }, &buf.writer);

View File

@@ -55,7 +55,7 @@ pub fn delete(self: *Headers, name: []const u8, exec: *const Execution) void {
pub fn get(self: *const Headers, name: []const u8, exec: *const Execution) !?[]const u8 {
const normalized_name = normalizeHeaderName(name, exec.buf);
const all_values = try self._list.getAll(exec.call_arena, normalized_name);
const all_values = try self._list.getAll(exec.local_arena, normalized_name);
if (all_values.len == 0) {
return null;
@@ -63,7 +63,7 @@ pub fn get(self: *const Headers, name: []const u8, exec: *const Execution) !?[]c
if (all_values.len == 1) {
return all_values[0];
}
return try std.mem.join(exec.call_arena, ", ", all_values);
return try std.mem.join(exec.local_arena, ", ", all_values);
}
pub fn has(self: *const Headers, name: []const u8, exec: *const Execution) bool {

View File

@@ -119,7 +119,7 @@ pub fn get(self: *const URLSearchParams, name: []const u8) ?[]const u8 {
}
pub fn getAll(self: *const URLSearchParams, name: []const u8, exec: *const Execution) ![]const []const u8 {
return self._params.getAll(exec.call_arena, name);
return self._params.getAll(exec.local_arena, name);
}
pub fn has(self: *const URLSearchParams, name: []const u8) bool {
@@ -378,7 +378,7 @@ pub const JsApi = struct {
pub const toString = bridge.function(_toString, .{});
fn _toString(self: *const URLSearchParams, exec: *const Execution) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(exec.call_arena);
var buf = std.Io.Writer.Allocating.init(exec.local_arena);
try self.toString(&buf.writer);
return buf.written();
}

View File

@@ -26,6 +26,7 @@ const Transfer = @import("../../../network/HttpClient.zig").Transfer;
const URL = @import("../../URL.zig");
const Mime = @import("../../Mime.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Node = @import("../Node.zig");
const Event = @import("../Event.zig");
@@ -353,7 +354,7 @@ pub fn getAllResponseHeaders(self: *const XMLHttpRequest, exec: *const Execution
return "";
}
var buf = std.Io.Writer.Allocating.init(exec.call_arena);
var buf = std.Io.Writer.Allocating.init(exec.local_arena);
for (self._response_headers.items) |entry| {
try buf.writer.writeAll(entry);
try buf.writer.writeAll("\r\n");
@@ -434,16 +435,18 @@ pub fn getResponse(self: *XMLHttpRequest, exec: *const Execution) !?Response {
.document => blk: {
// responseType=document is only meaningful in a Frame; workers
// have no DOM. Drastically different impls -> switch on global.
//
// TODO: per WHATWG XHR "set a document response", the final MIME
// type (_override_mime ?? _response_mime) should select an XML
// parser when it is an XML MIME type, and the HTML parser
// otherwise. We only have an HTML parser today, so the body is
// always parsed as HTML regardless of the override.
switch (exec.js.global) {
.frame => |frame| {
const final: Mime = self._override_mime orelse self._response_mime orelse .{ .content_type = .text_xml };
if (final.isXML()) {
const document = (try Frame.parse.xmlDocument(frame, data)) orelse return null;
break :blk .{ .document = document.asDocument() };
}
if (!final.isHTML()) {
return null;
}
const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic });
try frame.parseHtmlAsChildren(document.asNode(), data);
try Frame.parse.htmlAsChildren(frame, document.asNode(), data);
break :blk .{ .document = document };
},
.worker => return error.NotSupportedInWorker,
@@ -478,13 +481,15 @@ pub fn getResponseXML(self: *XMLHttpRequest, exec: *const Execution) !?*Node.Doc
return document;
}
const final = self._override_mime orelse self._response_mime orelse return null;
if (final.content_type != .text_xml) return null;
// With responseType "", only an XML final MIME type is parsed (an HTML
// one yields null); absent a Content-Type it defaults to text/xml.
const final: Mime = self._override_mime orelse self._response_mime orelse .{ .content_type = .text_xml };
if (!final.isXML()) return null;
switch (exec.js.global) {
.frame => |frame| {
const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic });
try frame.parseHtmlAsChildren(document.asNode(), self._response_data.items);
const xml_document = (try Frame.parse.xmlDocument(frame, self._response_data.items)) orelse return null;
const document = xml_document.asDocument();
self._response_xml = document;
return document;
},

View File

@@ -104,14 +104,13 @@ pub const BodyInit = union(enum) {
.content_type = null,
};
},
.stream => {
// A ReadableStream body cannot be serialized synchronously.
// Callers that support streaming bodies (Response) special-case
// the `.stream` arm before calling extract; the Request/XHR
// paths that reach here have no place to store a stream, so
// they send an empty body. Like other non-string sources, a
// stream carries no default Content-Type.
return .{ .bytes = "", .content_type = null };
.stream => |stream| {
// Response special-cases `.stream` before extract. Request/XHR
// paths buffer a closed stream synchronously; a stream that
// can't be drained here - still open, or already used as a
// body - rejects rather than send Content-Length: 0.
const bytes = try stream.collectBodyBytes(arena);
return .{ .bytes = bytes, .content_type = null };
},
}
}
@@ -163,8 +162,8 @@ test "BodyInit: FormData emits multipart with random boundary" {
const fd = try arena.create(FormData);
fd.* = .{ ._rc = .{}, ._arena = arena, ._entries = .empty };
try fd.append("username", "alice");
try fd.append("email", "alice@example.com");
try fd.appendText("username", "alice");
try fd.appendText("email", "alice@example.com");
const r = try (BodyInit{ .form_data = fd }).extract(arena);

View File

@@ -53,6 +53,7 @@ _pull_fn: ?js.Function.Global = null,
_pulling: bool = false,
_pull_again: bool = false,
_cancel: ?Cancel = null,
_collected: bool = false,
const UnderlyingSource = struct {
start: ?js.Function = null,
@@ -131,6 +132,48 @@ pub fn getLocked(self: *const ReadableStream) bool {
return self._reader != null;
}
/// Drain a closed stream's internal queue into bytes for outbound HTTP
/// request bodies. A stream that is locked, already used as a body, still
/// readable, or errored - or that holds a chunk which isn't string or binary
/// data - is `error.TypeError`. The queue is only consumed once every chunk
/// has converted, so a rejected body leaves the stream as it was.
pub fn collectBodyBytes(self: *ReadableStream, arena: std.mem.Allocator) ![]const u8 {
if (self._collected or self.getLocked()) {
return error.TypeError;
}
switch (self._state) {
.errored, .readable => return error.TypeError,
.closed => {},
}
const local = self._execution.js.local.?;
var buf = std.Io.Writer.Allocating.init(arena);
const queue = &self._controller._queue;
for (queue.items) |chunk| {
const bytes: []const u8 = switch (chunk) {
.string => |s| s,
.uint8array => |arr| arr.values,
.js_value => |global| blk: {
const value = local.toLocal(global);
// toStringSmart falls back to toString(), which would turn a
// stray number or object into "42" / "[object Object]" bytes.
if (value.isString() == null and !value.isTypedArray() and
!value.isArrayBufferView() and !value.isArrayBuffer())
{
return error.TypeError;
}
break :blk try value.toStringSmart();
},
};
try buf.writer.writeAll(bytes);
}
self._collected = true;
queue.clearRetainingCapacity();
return buf.written();
}
pub fn callPullIfNeeded(self: *ReadableStream) !void {
if (!self.shouldCallPull()) {
return;

View File

@@ -34,6 +34,15 @@ pub const Kind = enum {
y,
width,
height,
cx,
cy,
r,
rx,
ry,
x1,
y1,
x2,
y2,
fn attributeName(self: Kind) lp.String {
return switch (self) {
@@ -41,13 +50,23 @@ pub const Kind = enum {
.y => comptime .wrap("y"),
.width => comptime .wrap("width"),
.height => comptime .wrap("height"),
.cx => comptime .wrap("cx"),
.cy => comptime .wrap("cy"),
.r => comptime .wrap("r"),
.rx => comptime .wrap("rx"),
.ry => comptime .wrap("ry"),
.x1 => comptime .wrap("x1"),
.y1 => comptime .wrap("y1"),
.x2 => comptime .wrap("x2"),
.y2 => comptime .wrap("y2"),
};
}
fn direction(self: Kind) Length.Direction {
return switch (self) {
.x, .width => .horizontal,
.y, .height => .vertical,
.x, .width, .cx, .rx, .x1, .x2 => .horizontal,
.y, .height, .cy, .ry, .y1, .y2 => .vertical,
.r => .unspecified,
};
}
};

View File

@@ -0,0 +1,95 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Element = @import("../Element.zig");
const AnimatedNumber = @This();
_element: *Element,
_attr_name: lp.String,
pub const Kind = enum {
path_length,
fn attributeName(self: Kind) lp.String {
return switch (self) {
.path_length => comptime .wrap("pathLength"),
};
}
};
pub const Key = struct {
element: *Element,
kind: Kind,
};
pub const Lookup = std.AutoHashMapUnmanaged(Key, *AnimatedNumber);
pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*AnimatedNumber {
const key: Key = .{ .element = element, .kind = kind };
const gop = try frame._svg_animated_numbers.getOrPut(frame.arena, key);
if (!gop.found_existing) {
errdefer _ = frame._svg_animated_numbers.remove(key);
gop.value_ptr.* = try frame._factory.create(AnimatedNumber{
._element = element,
._attr_name = kind.attributeName(),
});
}
return gop.value_ptr.*;
}
pub fn getBaseVal(self: *const AnimatedNumber) f32 {
return self.currentValue();
}
pub fn setBaseVal(self: *AnimatedNumber, value: f32, frame: *Frame) !void {
if (!std.math.isFinite(value)) {
return error.TypeError;
}
const serialized = try std.fmt.allocPrint(frame.local_arena, "{d}", .{value});
try self._element.setAttributeSafe(self._attr_name, .wrap(serialized), frame);
}
pub fn getAnimVal(self: *const AnimatedNumber) f32 {
return self.currentValue();
}
fn currentValue(self: *const AnimatedNumber) f32 {
const raw = self._element.getAttributeSafe(self._attr_name) orelse return 0;
const trimmed = std.mem.trim(u8, raw, " \t\r\n\x0c");
const value = std.fmt.parseFloat(f32, trimmed) catch return 0;
return if (std.math.isFinite(value)) value else 0;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(AnimatedNumber);
pub const Meta = struct {
pub const name = "SVGAnimatedNumber";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const baseVal = bridge.accessor(AnimatedNumber.getBaseVal, AnimatedNumber.setBaseVal, .{});
pub const animVal = bridge.accessor(AnimatedNumber.getAnimVal, null, .{});
};

View File

@@ -0,0 +1,65 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// 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.
const std = @import("std");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const Element = @import("../Element.zig");
const TransformList = @import("TransformList.zig");
const AnimatedTransformList = @This();
_base_val: *TransformList,
_anim_val: *TransformList,
pub const Lookup = std.AutoHashMapUnmanaged(*Element, *AnimatedTransformList);
pub fn getOrCreate(element: *Element, frame: *Frame) !*AnimatedTransformList {
const gop = try frame._svg_animated_transform_lists.getOrPut(frame.arena, element);
if (!gop.found_existing) {
errdefer _ = frame._svg_animated_transform_lists.remove(element);
gop.value_ptr.* = try create(element, frame);
}
return gop.value_ptr.*;
}
pub fn create(element: *Element, frame: *Frame) !*AnimatedTransformList {
const base_val = try TransformList.create(element, false, frame);
const anim_val = try TransformList.create(element, true, frame);
return frame._factory.create(AnimatedTransformList{
._base_val = base_val,
._anim_val = anim_val,
});
}
pub fn deinit(self: *AnimatedTransformList, page: *Page) void {
self._base_val.deinit(page);
self._anim_val.deinit(page);
}
pub fn getBaseVal(self: *AnimatedTransformList) *TransformList {
return self._base_val;
}
pub fn getAnimVal(self: *AnimatedTransformList) *TransformList {
return self._anim_val;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(AnimatedTransformList);
pub const Meta = struct {
pub const name = "SVGAnimatedTransformList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const baseVal = bridge.accessor(AnimatedTransformList.getBaseVal, null, .{});
pub const animVal = bridge.accessor(AnimatedTransformList.getAnimVal, null, .{});
};

View File

@@ -73,6 +73,13 @@ pub fn getUnitType(self: *Length) u16 {
return @intFromEnum(self._unit);
}
// An attribute SVG could not parse reports the unknown unit type. Callers that
// need geometry substitute the property's default instead.
pub fn hasKnownUnit(self: *Length) bool {
self.syncFromAttribute();
return self._unit != .unknown;
}
pub fn getValue(self: *Length, frame: *Frame) f64 {
self.syncFromAttribute();
return self._value * self.unitToUserUnits(self._unit, frame);

View File

@@ -16,16 +16,27 @@
// 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 js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Number = @This();
_value: f32 = 0,
pub fn detached(frame: *Frame) !*Number {
return frame._factory.create(Number{});
}
pub fn getValue(self: *const Number) f32 {
return self._value;
}
pub fn setValue(self: *Number, value: f32) void {
pub fn setValue(self: *Number, value: f32) !void {
// WebIDL float is restricted, and the bridge does not police that for us.
if (!std.math.isFinite(value)) {
return error.TypeError;
}
self._value = value;
}

View File

@@ -0,0 +1,848 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 Allocator = std.mem.Allocator;
const tau = 2.0 * std.math.pi;
pub const Point = struct {
x: f64,
y: f64,
fn add(a: Point, b: Point) Point {
return .{ .x = a.x + b.x, .y = a.y + b.y };
}
fn midpoint(a: Point, b: Point) Point {
return .{ .x = (a.x + b.x) / 2.0, .y = (a.y + b.y) / 2.0 };
}
};
pub const Matrix = struct {
a: f64 = 1,
b: f64 = 0,
c: f64 = 0,
d: f64 = 1,
e: f64 = 0,
f: f64 = 0,
pub fn apply(self: Matrix, point: Point) Point {
return .{
.x = self.a * point.x + self.c * point.y + self.e,
.y = self.b * point.x + self.d * point.y + self.f,
};
}
pub fn multiply(self: Matrix, child: Matrix) Matrix {
return .{
.a = self.a * child.a + self.c * child.b,
.b = self.b * child.a + self.d * child.b,
.c = self.a * child.c + self.c * child.d,
.d = self.b * child.c + self.d * child.d,
.e = self.a * child.e + self.c * child.f + self.e,
.f = self.b * child.e + self.d * child.f + self.f,
};
}
};
pub const Bounds = struct {
min_x: f64 = std.math.inf(f64),
min_y: f64 = std.math.inf(f64),
max_x: f64 = -std.math.inf(f64),
max_y: f64 = -std.math.inf(f64),
pub fn include(self: *Bounds, point: Point) void {
self.min_x = @min(self.min_x, point.x);
self.min_y = @min(self.min_y, point.y);
self.max_x = @max(self.max_x, point.x);
self.max_y = @max(self.max_y, point.y);
}
pub fn merge(self: *Bounds, other: Bounds) void {
if (other.isEmpty()) return;
self.include(.{ .x = other.min_x, .y = other.min_y });
self.include(.{ .x = other.max_x, .y = other.max_y });
}
pub fn isEmpty(self: Bounds) bool {
return self.min_x > self.max_x or self.min_y > self.max_y;
}
pub fn width(self: Bounds) f64 {
return if (self.isEmpty()) 0 else self.max_x - self.min_x;
}
pub fn height(self: Bounds) f64 {
return if (self.isEmpty()) 0 else self.max_y - self.min_y;
}
};
pub const Line = struct { start: Point, end: Point };
pub const Quadratic = struct { start: Point, control: Point, end: Point };
pub const Cubic = struct { start: Point, control1: Point, control2: Point, end: Point };
pub const Arc = struct {
start: Point,
end: Point,
center: Point,
rx: f64,
ry: f64,
rotation: f64,
theta: f64,
delta: f64,
};
pub const Segment = union(enum) {
line: Line,
quadratic: Quadratic,
cubic: Cubic,
arc: Arc,
fn start(self: Segment) Point {
return switch (self) {
inline else => |segment| segment.start,
};
}
fn end(self: Segment) Point {
return switch (self) {
inline else => |segment| segment.end,
};
}
};
pub const Path = struct {
segments: std.ArrayList(Segment) = .empty,
first_point: ?Point = null,
pub fn deinit(self: *Path, allocator: Allocator) void {
self.segments.deinit(allocator);
}
pub fn appendLine(self: *Path, start: Point, end: Point, allocator: Allocator) !void {
if (self.first_point == null) self.first_point = start;
try self.segments.append(allocator, .{ .line = .{ .start = start, .end = end } });
}
pub fn appendQuadratic(self: *Path, segment: Quadratic, allocator: Allocator) !void {
if (self.first_point == null) self.first_point = segment.start;
try self.segments.append(allocator, .{ .quadratic = segment });
}
pub fn appendCubic(self: *Path, segment: Cubic, allocator: Allocator) !void {
if (self.first_point == null) self.first_point = segment.start;
try self.segments.append(allocator, .{ .cubic = segment });
}
pub fn appendArc(self: *Path, start: Point, rx: f64, ry: f64, rotation: f64, large: bool, sweep: bool, end: Point, allocator: Allocator) !void {
if (self.first_point == null) {
self.first_point = start;
}
if (pointsEqual(start, end)) {
return;
}
if (rx == 0 or ry == 0) {
return self.appendLine(start, end, allocator);
}
const arc = endpointArc(start, rx, ry, rotation, large, sweep, end) orelse
return self.appendLine(start, end, allocator);
try self.segments.append(allocator, .{ .arc = arc });
}
pub fn bounds(self: *const Path, matrix: Matrix) Bounds {
var result: Bounds = .{};
for (self.segments.items) |segment| {
includeSegmentBounds(&result, segment, matrix);
}
return result;
}
pub fn totalLength(self: *const Path, allocator: Allocator) !f64 {
// One buffer for every segment: growing a fresh list per segment leaves
// each intermediate copy behind in the arena.
var points: std.ArrayList(Point) = .empty;
defer points.deinit(allocator);
var total: f64 = 0;
for (self.segments.items) |segment| {
points.clearRetainingCapacity();
try flatten(segment, &points, allocator);
for (points.items[0 .. points.items.len - 1], points.items[1..]) |a, b| {
total += distance(a, b);
}
}
return total;
}
pub fn pointAtLength(self: *const Path, requested: f64, allocator: Allocator) !Point {
if (self.segments.items.len == 0) {
return self.first_point orelse .{ .x = 0, .y = 0 };
}
var points: std.ArrayList(Point) = .empty;
defer points.deinit(allocator);
var remaining = @max(requested, 0);
for (self.segments.items, 0..) |segment, segment_index| {
points.clearRetainingCapacity();
try flatten(segment, &points, allocator);
for (points.items[0 .. points.items.len - 1], points.items[1..]) |a, b| {
const length = distance(a, b);
if (remaining <= length) {
if (length == 0) return b;
const ratio = remaining / length;
return .{
.x = a.x + (b.x - a.x) * ratio,
.y = a.y + (b.y - a.y) * ratio,
};
}
remaining -= length;
}
if (segment_index + 1 == self.segments.items.len) {
return segment.end();
}
}
unreachable;
}
};
const Previous = enum { other, cubic, quadratic };
pub fn parse(input: []const u8, allocator: Allocator) !Path {
var path: Path = .{};
errdefer path.deinit(allocator);
var parser = Parser{ .input = input };
var command: ?u8 = null;
var command_fresh = false;
var current = Point{ .x = 0, .y = 0 };
var subpath_start = current;
var last_control = current;
var previous: Previous = .other;
var saw_moveto = false;
while (true) {
parser.skipWhitespace();
if (parser.atEnd()) break;
const byte = parser.peek();
if (isCommand(byte)) {
parser.index += 1;
command = byte;
command_fresh = true;
if (upper(byte) == 'Z') {
if (!saw_moveto) break;
try path.appendLine(current, subpath_start, allocator);
current = subpath_start;
previous = .other;
command = null;
continue;
}
} else if (std.ascii.isAlphabetic(byte) or command == null) {
break;
}
const cmd = command.?;
const kind = upper(cmd);
if (!saw_moveto and kind != 'M') {
break;
}
const relative = std.ascii.isLower(cmd);
const allow_first_comma = !command_fresh;
switch (kind) {
'M', 'L' => {
const x = parser.number(allow_first_comma) orelse break;
const y = parser.number(true) orelse break;
const end = absolutePoint(current, x, y, relative);
if (kind == 'M') {
current = end;
subpath_start = end;
if (path.first_point == null) path.first_point = end;
saw_moveto = true;
command = if (relative) 'l' else 'L';
} else {
try path.appendLine(current, end, allocator);
current = end;
}
previous = .other;
},
'H' => {
const x = parser.number(allow_first_comma) orelse break;
const end = Point{ .x = if (relative) current.x + x else x, .y = current.y };
try path.appendLine(current, end, allocator);
current = end;
previous = .other;
},
'V' => {
const y = parser.number(allow_first_comma) orelse break;
const end = Point{ .x = current.x, .y = if (relative) current.y + y else y };
try path.appendLine(current, end, allocator);
current = end;
previous = .other;
},
'C' => {
const x1 = parser.number(allow_first_comma) orelse break;
const y1 = parser.number(true) orelse break;
const x2 = parser.number(true) orelse break;
const y2 = parser.number(true) orelse break;
const x = parser.number(true) orelse break;
const y = parser.number(true) orelse break;
const control1 = absolutePoint(current, x1, y1, relative);
const control2 = absolutePoint(current, x2, y2, relative);
const end = absolutePoint(current, x, y, relative);
try path.appendCubic(.{ .start = current, .control1 = control1, .control2 = control2, .end = end }, allocator);
current = end;
last_control = control2;
previous = .cubic;
},
'S' => {
const x2 = parser.number(allow_first_comma) orelse break;
const y2 = parser.number(true) orelse break;
const x = parser.number(true) orelse break;
const y = parser.number(true) orelse break;
const control1 = if (previous == .cubic) reflect(last_control, current) else current;
const control2 = absolutePoint(current, x2, y2, relative);
const end = absolutePoint(current, x, y, relative);
try path.appendCubic(.{ .start = current, .control1 = control1, .control2 = control2, .end = end }, allocator);
current = end;
last_control = control2;
previous = .cubic;
},
'Q' => {
const x1 = parser.number(allow_first_comma) orelse break;
const y1 = parser.number(true) orelse break;
const x = parser.number(true) orelse break;
const y = parser.number(true) orelse break;
const control = absolutePoint(current, x1, y1, relative);
const end = absolutePoint(current, x, y, relative);
try path.appendQuadratic(.{ .start = current, .control = control, .end = end }, allocator);
current = end;
last_control = control;
previous = .quadratic;
},
'T' => {
const x = parser.number(allow_first_comma) orelse break;
const y = parser.number(true) orelse break;
const control = if (previous == .quadratic) reflect(last_control, current) else current;
const end = absolutePoint(current, x, y, relative);
try path.appendQuadratic(.{ .start = current, .control = control, .end = end }, allocator);
current = end;
last_control = control;
previous = .quadratic;
},
'A' => {
const rx = parser.number(allow_first_comma) orelse break;
const ry = parser.number(true) orelse break;
const rotation = parser.number(true) orelse break;
const large = parser.flag(true) orelse break;
const sweep = parser.flag(true) orelse break;
const x = parser.number(true) orelse break;
const y = parser.number(true) orelse break;
const end = absolutePoint(current, x, y, relative);
try path.appendArc(current, rx, ry, rotation, large, sweep, end, allocator);
current = end;
previous = .other;
},
else => break,
}
command_fresh = false;
}
return path;
}
const Parser = struct {
input: []const u8,
index: usize = 0,
fn atEnd(self: Parser) bool {
return self.index >= self.input.len;
}
fn peek(self: Parser) u8 {
return self.input[self.index];
}
fn skipWhitespace(self: *Parser) void {
while (!self.atEnd() and isWhitespace(self.peek())) {
self.index += 1;
}
}
fn separator(self: *Parser, allow_comma: bool) bool {
self.skipWhitespace();
if (!self.atEnd() and self.peek() == ',') {
if (!allow_comma) {
return false;
}
self.index += 1;
self.skipWhitespace();
if (self.atEnd() or self.peek() == ',') {
return false;
}
}
return true;
}
fn number(self: *Parser, allow_comma: bool) ?f64 {
if (!self.separator(allow_comma)) {
return null;
}
const start = self.index;
if (self.atEnd()) {
return null;
}
if (self.peek() == '+' or self.peek() == '-') self.index += 1;
var digits: usize = 0;
while (!self.atEnd() and std.ascii.isDigit(self.peek())) : (self.index += 1) digits += 1;
if (!self.atEnd() and self.peek() == '.') {
self.index += 1;
while (!self.atEnd() and std.ascii.isDigit(self.peek())) : (self.index += 1) {
digits += 1;
}
}
if (digits == 0) {
self.index = start;
return null;
}
if (!self.atEnd() and (self.peek() == 'e' or self.peek() == 'E')) {
self.index += 1;
if (!self.atEnd() and (self.peek() == '+' or self.peek() == '-')) self.index += 1;
const exponent_start = self.index;
while (!self.atEnd() and std.ascii.isDigit(self.peek())) self.index += 1;
if (self.index == exponent_start) {
self.index = start;
return null;
}
}
const value = std.fmt.parseFloat(f64, self.input[start..self.index]) catch {
self.index = start;
return null;
};
if (!std.math.isFinite(value)) {
self.index = start;
return null;
}
return value;
}
fn flag(self: *Parser, allow_comma: bool) ?bool {
if (!self.separator(allow_comma) or self.atEnd()) return null;
return switch (self.peek()) {
'0' => blk: {
self.index += 1;
break :blk false;
},
'1' => blk: {
self.index += 1;
break :blk true;
},
else => null,
};
}
};
fn isWhitespace(byte: u8) bool {
return byte == ' ' or byte == '\t' or byte == '\r' or byte == '\n' or byte == '\x0c';
}
fn isCommand(byte: u8) bool {
return switch (upper(byte)) {
'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A' => true,
else => false,
};
}
fn upper(byte: u8) u8 {
return if (byte >= 'a' and byte <= 'z') byte - ('a' - 'A') else byte;
}
fn absolutePoint(origin: Point, x: f64, y: f64, relative: bool) Point {
return if (relative) Point.add(origin, .{ .x = x, .y = y }) else .{ .x = x, .y = y };
}
fn reflect(control: Point, around: Point) Point {
return .{ .x = 2.0 * around.x - control.x, .y = 2.0 * around.y - control.y };
}
fn pointsEqual(a: Point, b: Point) bool {
return a.x == b.x and a.y == b.y;
}
fn distance(a: Point, b: Point) f64 {
return std.math.hypot(b.x - a.x, b.y - a.y);
}
fn endpointArc(start: Point, rx_input: f64, ry_input: f64, rotation_degrees: f64, large: bool, sweep: bool, end: Point) ?Arc {
var rx = @abs(rx_input);
var ry = @abs(ry_input);
if (rx == 0 or ry == 0 or pointsEqual(start, end)) {
return null;
}
const rotation = @mod(rotation_degrees, 360.0) * std.math.pi / 180.0;
const cosine = @cos(rotation);
const sine = @sin(rotation);
const dx = (start.x - end.x) / 2.0;
const dy = (start.y - end.y) / 2.0;
const x_prime = cosine * dx + sine * dy;
const y_prime = -sine * dx + cosine * dy;
const radii_scale = x_prime * x_prime / (rx * rx) + y_prime * y_prime / (ry * ry);
if (radii_scale > 1) {
const scale = @sqrt(radii_scale);
rx *= scale;
ry *= scale;
}
const rx2 = rx * rx;
const ry2 = ry * ry;
const numerator = @max(0, rx2 * ry2 - rx2 * y_prime * y_prime - ry2 * x_prime * x_prime);
const denominator = rx2 * y_prime * y_prime + ry2 * x_prime * x_prime;
if (denominator == 0) {
return null;
}
var coefficient = @sqrt(numerator / denominator);
if (large == sweep) {
coefficient = -coefficient;
}
const center_prime = Point{
.x = coefficient * rx * y_prime / ry,
.y = -coefficient * ry * x_prime / rx,
};
const center = Point{
.x = cosine * center_prime.x - sine * center_prime.y + (start.x + end.x) / 2.0,
.y = sine * center_prime.x + cosine * center_prime.y + (start.y + end.y) / 2.0,
};
const first = Point{
.x = (x_prime - center_prime.x) / rx,
.y = (y_prime - center_prime.y) / ry,
};
const second = Point{
.x = (-x_prime - center_prime.x) / rx,
.y = (-y_prime - center_prime.y) / ry,
};
const theta = std.math.atan2(first.y, first.x);
var delta = std.math.atan2(first.x * second.y - first.y * second.x, first.x * second.x + first.y * second.y);
if (sweep and delta < 0) {
delta += tau;
}
if (!sweep and delta > 0) {
delta -= tau;
}
return .{
.start = start,
.end = end,
.center = center,
.rx = rx,
.ry = ry,
.rotation = rotation,
.theta = theta,
.delta = delta,
};
}
fn includeSegmentBounds(bounds: *Bounds, segment: Segment, matrix: Matrix) void {
bounds.include(matrix.apply(segment.start()));
bounds.include(matrix.apply(segment.end()));
switch (segment) {
.line => {},
.quadratic => |quadratic| includeTransformedQuadraticExtrema(bounds, quadratic, matrix),
.cubic => |cubic| includeTransformedCubicExtrema(bounds, cubic, matrix),
.arc => |arc| includeArcExtrema(bounds, arc, matrix),
}
}
fn includeTransformedQuadraticExtrema(bounds: *Bounds, quadratic: Quadratic, matrix: Matrix) void {
const p0 = matrix.apply(quadratic.start);
const p1 = matrix.apply(quadratic.control);
const p2 = matrix.apply(quadratic.end);
for ([_][3]f64{ .{ p0.x, p1.x, p2.x }, .{ p0.y, p1.y, p2.y } }) |values| {
const denominator = values[0] - 2.0 * values[1] + values[2];
if (@abs(denominator) < 1e-14) {
continue;
}
const t = (values[0] - values[1]) / denominator;
if (t > 0 and t < 1) {
bounds.include(matrix.apply(evalQuadratic(quadratic, t)));
}
}
}
fn includeTransformedCubicExtrema(bounds: *Bounds, cubic: Cubic, matrix: Matrix) void {
const p0 = matrix.apply(cubic.start);
const p1 = matrix.apply(cubic.control1);
const p2 = matrix.apply(cubic.control2);
const p3 = matrix.apply(cubic.end);
for ([_][4]f64{ .{ p0.x, p1.x, p2.x, p3.x }, .{ p0.y, p1.y, p2.y, p3.y } }) |values| {
const a = -values[0] + 3.0 * values[1] - 3.0 * values[2] + values[3];
const b = 2.0 * (values[0] - 2.0 * values[1] + values[2]);
const c = values[1] - values[0];
for (quadraticRoots(a, b, c)) |root| {
const t = root orelse continue;
if (t > 0 and t < 1) {
bounds.include(matrix.apply(evalCubic(cubic, t)));
}
}
}
}
fn includeArcExtrema(bounds: *Bounds, arc: Arc, matrix: Matrix) void {
const cosine = @cos(arc.rotation);
const sine = @sin(arc.rotation);
const x_cos = arc.rx * cosine;
const x_sin = -arc.ry * sine;
const y_cos = arc.rx * sine;
const y_sin = arc.ry * cosine;
const transformed = [_][2]f64{
.{ matrix.a * x_cos + matrix.c * y_cos, matrix.a * x_sin + matrix.c * y_sin },
.{ matrix.b * x_cos + matrix.d * y_cos, matrix.b * x_sin + matrix.d * y_sin },
};
for (transformed) |coefficients| {
const candidate = std.math.atan2(coefficients[1], coefficients[0]);
for ([_]f64{ candidate, candidate + std.math.pi }) |angle| {
if (angleInSweep(angle, arc.theta, arc.delta)) {
bounds.include(matrix.apply(evalArc(arc, angle)));
}
}
}
}
fn quadraticRoots(a: f64, b: f64, c: f64) [2]?f64 {
if (@abs(a) < 1e-14) {
if (@abs(b) < 1e-14) {
return .{ null, null };
}
return .{ -c / b, null };
}
const discriminant = b * b - 4.0 * a * c;
if (discriminant < 0) {
return .{ null, null };
}
const root = @sqrt(discriminant);
return .{ (-b + root) / (2.0 * a), (-b - root) / (2.0 * a) };
}
fn angleInSweep(angle: f64, start: f64, delta: f64) bool {
if (delta >= 0) {
return positiveAngle(angle - start) <= delta + 1e-12;
}
return positiveAngle(start - angle) <= -delta + 1e-12;
}
fn positiveAngle(angle: f64) f64 {
var result = @mod(angle, tau);
if (result < 0) {
result += tau;
}
return result;
}
fn evalQuadratic(segment: Quadratic, t: f64) Point {
const inverse = 1.0 - t;
return .{
.x = inverse * inverse * segment.start.x + 2.0 * inverse * t * segment.control.x + t * t * segment.end.x,
.y = inverse * inverse * segment.start.y + 2.0 * inverse * t * segment.control.y + t * t * segment.end.y,
};
}
fn evalCubic(segment: Cubic, t: f64) Point {
const inverse = 1.0 - t;
return .{
.x = inverse * inverse * inverse * segment.start.x + 3.0 * inverse * inverse * t * segment.control1.x + 3.0 * inverse * t * t * segment.control2.x + t * t * t * segment.end.x,
.y = inverse * inverse * inverse * segment.start.y + 3.0 * inverse * inverse * t * segment.control1.y + 3.0 * inverse * t * t * segment.control2.y + t * t * t * segment.end.y,
};
}
fn evalArc(arc: Arc, angle: f64) Point {
const cosine = @cos(arc.rotation);
const sine = @sin(arc.rotation);
const x = arc.rx * @cos(angle);
const y = arc.ry * @sin(angle);
return .{
.x = arc.center.x + cosine * x - sine * y,
.y = arc.center.y + sine * x + cosine * y,
};
}
const flatten_tolerance = 0.001;
// Above roughly a thousand user units, absolute tolerance costs subdivisions
// nobody can observe, so it scales with the segment instead.
const flatten_relative_tolerance = 1e-6;
const flatten_depth = 18;
fn flatten(segment: Segment, points: *std.ArrayList(Point), allocator: Allocator) !void {
try points.append(allocator, segment.start());
switch (segment) {
.line => |line| try points.append(allocator, line.end),
.quadratic => |quadratic| {
const legs = distance(quadratic.start, quadratic.control) + distance(quadratic.control, quadratic.end);
try flattenQuadratic(quadratic, toleranceFor(legs), points, allocator, 0);
},
.cubic => |cubic| {
const legs = distance(cubic.start, cubic.control1) +
distance(cubic.control1, cubic.control2) +
distance(cubic.control2, cubic.end);
try flattenCubic(cubic, toleranceFor(legs), points, allocator, 0);
},
.arc => |arc| try flattenArc(
arc,
toleranceFor(@abs(arc.delta) * @max(arc.rx, arc.ry)),
arc.theta,
arc.theta + arc.delta,
arc.start,
arc.end,
points,
allocator,
0,
),
}
}
fn toleranceFor(size: f64) f64 {
return @max(flatten_tolerance, size * flatten_relative_tolerance);
}
fn flattenQuadratic(segment: Quadratic, tolerance: f64, points: *std.ArrayList(Point), allocator: Allocator, depth: usize) !void {
if (depth >= flatten_depth or pointSegmentDistance(segment.control, segment.start, segment.end) <= tolerance) {
return points.append(allocator, segment.end);
}
const a = Point.midpoint(segment.start, segment.control);
const b = Point.midpoint(segment.control, segment.end);
const middle = Point.midpoint(a, b);
try flattenQuadratic(.{ .start = segment.start, .control = a, .end = middle }, tolerance, points, allocator, depth + 1);
try flattenQuadratic(.{ .start = middle, .control = b, .end = segment.end }, tolerance, points, allocator, depth + 1);
}
fn flattenCubic(segment: Cubic, tolerance: f64, points: *std.ArrayList(Point), allocator: Allocator, depth: usize) !void {
const flatness = @max(
pointSegmentDistance(segment.control1, segment.start, segment.end),
pointSegmentDistance(segment.control2, segment.start, segment.end),
);
if (depth >= flatten_depth or flatness <= tolerance) {
return points.append(allocator, segment.end);
}
const a = Point.midpoint(segment.start, segment.control1);
const b = Point.midpoint(segment.control1, segment.control2);
const c = Point.midpoint(segment.control2, segment.end);
const d = Point.midpoint(a, b);
const e = Point.midpoint(b, c);
const middle = Point.midpoint(d, e);
try flattenCubic(.{ .start = segment.start, .control1 = a, .control2 = d, .end = middle }, tolerance, points, allocator, depth + 1);
try flattenCubic(.{ .start = middle, .control1 = e, .control2 = c, .end = segment.end }, tolerance, points, allocator, depth + 1);
}
fn flattenArc(arc: Arc, tolerance: f64, start_angle: f64, end_angle: f64, start: Point, end: Point, points: *std.ArrayList(Point), allocator: Allocator, depth: usize) !void {
const middle_angle = (start_angle + end_angle) / 2.0;
const middle = evalArc(arc, middle_angle);
if (depth >= flatten_depth or pointSegmentDistance(middle, start, end) <= tolerance) {
return points.append(allocator, end);
}
try flattenArc(arc, tolerance, start_angle, middle_angle, start, middle, points, allocator, depth + 1);
try flattenArc(arc, tolerance, middle_angle, end_angle, middle, end, points, allocator, depth + 1);
}
// Distance to the chord as a segment, not as an infinite line: control points
// collinear with the chord but past its ends make a curve that overshoots, and
// the distance to the line would call it flat and measure the chord instead.
fn pointSegmentDistance(point: Point, start: Point, end: Point) f64 {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length_squared = dx * dx + dy * dy;
if (length_squared == 0) {
return distance(point, start);
}
const projection = ((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared;
const clamped = std.math.clamp(projection, 0, 1);
return distance(point, .{ .x = start.x + clamped * dx, .y = start.y + clamped * dy });
}
const testing = @import("../../../testing.zig");
test "path parser preserves complete packs before an error" {
var path = try parse("M10 10 L20 20 30", testing.allocator);
defer path.deinit(testing.allocator);
try testing.expectEqual(1, path.segments.items.len);
const bounds = path.bounds(.{});
try testing.expectEqual(10, bounds.min_x);
try testing.expectEqual(20, bounds.max_x);
}
test "moveto coordinate pairs become lines and malformed exponents stop" {
var path = try parse("M0 0 10 10 20 1e L99 99", testing.allocator);
defer path.deinit(testing.allocator);
try testing.expectEqual(1, path.segments.items.len);
try testing.expectEqual(Point{ .x = 10, .y = 10 }, path.segments.items[0].end());
}
test "rotated arc bounds use ellipse derivative extrema" {
var path = try parse("M0 0 A80 20 45 1 1 100 100", testing.allocator);
defer path.deinit(testing.allocator);
const bounds = path.bounds(.{});
try testing.expectEqual(true, bounds.min_x < 0);
try testing.expectEqual(true, bounds.max_y >= 100);
}
test "length and point lookup share adaptive geometry" {
var path = try parse("M0 0 C0 100 100 100 100 0", testing.allocator);
defer path.deinit(testing.allocator);
const length = try path.totalLength(testing.allocator);
try testing.expectDelta(200, length, 0.01);
const middle = try path.pointAtLength(length / 2.0, testing.allocator);
try testing.expectDelta(50, middle.x, 0.01);
try testing.expectDelta(75, middle.y, 0.01);
}
test "curves overshooting a collinear chord are still subdivided" {
// Both control points sit on the line through the endpoints, so a
// distance-to-chord test would report these as flat and measure the chord.
var cubic = try parse("M0 0 C0 -50 0 150 0 100", testing.allocator);
defer cubic.deinit(testing.allocator);
try testing.expectDelta(132.38, try cubic.totalLength(testing.allocator), 0.01);
var quadratic = try parse("M0 0 Q0 200 0 100", testing.allocator);
defer quadratic.deinit(testing.allocator);
try testing.expectDelta(166.67, try quadratic.totalLength(testing.allocator), 0.01);
}
test "large geometry stays within the subdivision budget" {
var path = try parse("M0 0 A1e8 1e8 45 1 1 100 100", testing.allocator);
defer path.deinit(testing.allocator);
var points: std.ArrayList(Point) = .empty;
defer points.deinit(testing.allocator);
try flatten(path.segments.items[0], &points, testing.allocator);
try testing.expectEqual(true, points.items.len < 2048);
}

View File

@@ -0,0 +1,381 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// 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.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const DOMPoint = @import("../DOMPoint.zig");
const DOMPointReadOnly = @import("../DOMPointReadOnly.zig");
const Element = @import("../Element.zig");
const PointList = @This();
_frame: *Frame,
_element: *Element,
_read_only: bool,
_synced: bool = false,
_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList(*DOMPoint) = .empty,
_retired: std.ArrayList(*DOMPoint) = .empty,
pub const Kind = enum { base, animated };
pub const Key = struct {
element: *Element,
kind: Kind,
};
pub const Lookup = std.AutoHashMapUnmanaged(Key, *PointList);
pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*PointList {
const key: Key = .{
.element = element,
.kind = kind,
};
const gop = try frame._svg_point_lists.getOrPut(frame.arena, key);
if (!gop.found_existing) {
errdefer _ = frame._svg_point_lists.remove(key);
gop.value_ptr.* = try frame._factory.create(PointList{
._frame = frame,
._element = element,
._read_only = kind == .animated,
});
}
return gop.value_ptr.*;
}
pub fn deinit(self: *PointList, page: *Page) void {
for (self._items.items) |point| {
point._proto.detach(self);
point._proto.releaseRef(page);
}
self._items.clearRetainingCapacity();
self.releaseRetired(page);
}
pub fn getLength(self: *PointList, frame: *Frame) !u32 {
try self.sync(frame);
return @intCast(self._items.items.len);
}
pub fn getNumberOfItems(self: *PointList, frame: *Frame) !u32 {
return self.getLength(frame);
}
pub fn clear(self: *PointList, frame: *Frame) !void {
try self.requireMutable();
try self.sync(frame);
try self.retireAll(frame);
try self.setAttribute(&.{}, frame);
}
pub fn initialize(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
try self.requireMutable();
try self.sync(frame);
const prepared = try self.prepareItem(item, frame);
errdefer prepared._proto.releaseRef(frame._page);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
try self.setAttribute(&.{prepared}, frame);
self._items.appendAssumeCapacity(prepared);
self.attach(prepared);
return prepared;
}
pub fn getItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
return self._items.items[index];
}
pub fn insertItemBefore(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint {
try self.requireMutable();
try self.sync(frame);
const prepared = try self.prepareItem(item, frame);
errdefer prepared._proto.releaseRef(frame._page);
const at = @min(@as(usize, index), self._items.items.len);
const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len + 1);
@memcpy(next[0..at], self._items.items[0..at]);
next[at] = prepared;
@memcpy(next[at + 1 ..], self._items.items[at..]);
try self._items.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
self._items.insertAssumeCapacity(at, prepared);
self.attach(prepared);
return prepared;
}
pub fn replaceItem(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint {
try self.requireMutable();
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const prepared = try self.prepareItem(item, frame);
errdefer prepared._proto.releaseRef(frame._page);
const next = try frame.local_arena.dupe(*DOMPoint, self._items.items);
next[index] = prepared;
try self._retired.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
const replaced = self._items.items[index];
replaced._proto.detach(self);
self._retired.appendAssumeCapacity(replaced);
self._items.items[index] = prepared;
self.attach(prepared);
return prepared;
}
pub fn removeItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint {
try self.requireMutable();
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len - 1);
@memcpy(next[0..index], self._items.items[0..index]);
@memcpy(next[index..], self._items.items[index + 1 ..]);
try self._retired.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
const removed = self._items.orderedRemove(index);
removed._proto.detach(self);
self._retired.appendAssumeCapacity(removed);
return removed;
}
pub fn appendItem(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
return self.insertItemBefore(item, std.math.maxInt(u32), frame);
}
fn requireMutable(self: *const PointList) !void {
if (self._read_only) return error.NoModificationAllowed;
}
fn prepareItem(_: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint {
if (!std.math.isFinite(item._proto._x) or !std.math.isFinite(item._proto._y)) return error.TypeError;
const prepared = if (item._proto.isAttached())
try DOMPoint.create(item._proto._x, item._proto._y, item._proto._z, item._proto._w, frame._page)
else
item;
prepared._proto.acquireRef();
return prepared;
}
fn attach(self: *PointList, point: *DOMPoint) void {
point._proto.restrict();
point._proto.attach(.{
.owner = self,
.mutate = PointList.mutatePoint,
}, self._read_only);
}
fn mutatePoint(
context: *anyopaque,
point: *DOMPointReadOnly,
coordinate: DOMPointReadOnly.Coordinate,
value: f64,
) anyerror!void {
const self: *PointList = @ptrCast(@alignCast(context));
const frame = self._frame;
try self.sync(frame);
// An external attribute mutation detaches the old item during sync. The
// caller still owns that DOMPoint identity, but it no longer mutates the list.
if (!point.isAttachedTo(self)) {
point.setCoordinateRaw(coordinate, value);
return;
}
if (coordinate == .z or coordinate == .w) {
point.setCoordinateRaw(coordinate, value);
return;
}
if (!std.math.isFinite(value)) return error.TypeError;
const index = for (self._items.items, 0..) |candidate, i| {
if (candidate._proto == point) break i;
} else unreachable;
try self.setAttributeWithOverride(index, coordinate, value, frame);
point.setCoordinateRaw(coordinate, value);
}
fn sync(self: *PointList, frame: *Frame) !void {
self.releaseRetired(frame._page);
const raw = self._element.getAttributeSafe(comptime .wrap("points")) orelse "";
if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) {
return;
}
self._synced = false;
var parsed = parse(raw, frame) catch |err| switch (err) {
error.SyntaxError => std.ArrayList(*DOMPoint).empty,
else => return err,
};
errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page);
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, raw);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
for (parsed.items) |point| {
self._items.appendAssumeCapacity(point);
self.attach(point);
}
parsed.clearRetainingCapacity();
self._synced = true;
}
// A retired item must outlive the operation that retired it: removeItem's
// return value has no JS wrapper until the bridge wraps it after we return.
// By the next operation, anything still reachable holds its own ref.
fn releaseRetired(self: *PointList, page: *Page) void {
for (self._retired.items) |point| {
point._proto.releaseRef(page);
}
self._retired.clearRetainingCapacity();
}
fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) {
var scanner = NumberScanner{ .input = raw };
var parsed: std.ArrayList(*DOMPoint) = .empty;
errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page);
while (try scanner.next()) |x| {
// A trailing coordinate with no pair truncates the list; only a
// malformed number invalidates the whole attribute.
const y = (try scanner.next()) orelse break;
const point = try DOMPoint.create(x, y, 0, 1, frame._page);
point._proto.acquireRef();
parsed.append(frame.local_arena, point) catch |err| {
point._proto.releaseRef(frame._page);
return err;
};
}
return parsed;
}
fn retireAll(self: *PointList, frame: *Frame) !void {
self._synced = false;
try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
for (self._items.items) |point| {
point._proto.detach(self);
self._retired.appendAssumeCapacity(point);
}
self._items.clearRetainingCapacity();
}
fn setAttribute(self: *PointList, items: []const *DOMPoint, frame: *Frame) !void {
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
for (items, 0..) |point, i| {
if (i != 0) try writer.writeByte(' ');
try writer.print("{d} {d}", .{ point._proto._x, point._proto._y });
}
try self.commitAttribute(serialized.written(), frame);
}
fn setAttributeWithOverride(
self: *PointList,
index: usize,
coordinate: DOMPointReadOnly.Coordinate,
value: f64,
frame: *Frame,
) !void {
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
for (self._items.items, 0..) |point, i| {
if (i != 0) try writer.writeByte(' ');
const x = if (i == index and coordinate == .x) value else point._proto._x;
const y = if (i == index and coordinate == .y) value else point._proto._y;
try writer.print("{d} {d}", .{ x, y });
}
try self.commitAttribute(serialized.written(), frame);
}
fn commitAttribute(self: *PointList, serialized: []const u8, frame: *Frame) !void {
self._synced = false;
try self._element.setAttributeSafe(comptime .wrap("points"), .wrap(serialized), frame);
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, serialized);
self._synced = true;
}
const NumberScanner = struct {
input: []const u8,
index: usize = 0,
first: bool = true,
fn next(self: *NumberScanner) !?f64 {
var had_whitespace = false;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
had_whitespace = true;
self.index += 1;
}
if (!self.first and self.index < self.input.len and self.input[self.index] == ',') {
self.index += 1;
while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
} else if (!self.first and self.index < self.input.len and !had_whitespace and
self.input[self.index] != '+' and self.input[self.index] != '-')
{
return error.SyntaxError;
}
if (self.index == self.input.len) return null;
const start = self.index;
if (self.input[self.index] == '+' or self.input[self.index] == '-') self.index += 1;
var digits: usize = 0;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
if (self.index < self.input.len and self.input[self.index] == '.') {
self.index += 1;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
}
if (digits == 0) return error.SyntaxError;
if (self.index < self.input.len and (self.input[self.index] == 'e' or self.input[self.index] == 'E')) {
self.index += 1;
if (self.index < self.input.len and (self.input[self.index] == '+' or self.input[self.index] == '-')) self.index += 1;
const exponent_start = self.index;
while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) self.index += 1;
if (self.index == exponent_start) return error.SyntaxError;
}
const value = std.fmt.parseFloat(f64, self.input[start..self.index]) catch return error.SyntaxError;
if (!std.math.isFinite(value)) return error.SyntaxError;
self.first = false;
return value;
}
};
pub const JsApi = struct {
pub const bridge = js.Bridge(PointList);
pub const Meta = struct {
pub const name = "SVGPointList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const length = bridge.accessor(PointList.getLength, null, .{});
pub const numberOfItems = bridge.accessor(PointList.getNumberOfItems, null, .{});
pub const clear = bridge.function(PointList.clear, .{});
pub const initialize = bridge.function(PointList.initialize, .{});
pub const getItem = bridge.function(PointList.getItem, .{});
pub const insertItemBefore = bridge.function(PointList.insertItemBefore, .{});
pub const replaceItem = bridge.function(PointList.replaceItem, .{});
pub const removeItem = bridge.function(PointList.removeItem, .{});
pub const appendItem = bridge.function(PointList.appendItem, .{});
};

View File

@@ -0,0 +1,225 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// 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.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Element = @import("../Element.zig");
const StringList = @This();
pub const Delimiter = enum { whitespace, comma };
_element: *Element,
_attribute_name: lp.String,
_delimiter: Delimiter,
_synced: bool = false,
// An absent attribute is an empty list, but an empty attribute is not: a
// comma-separated value parses "" as a single empty token.
_present: bool = false,
_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList([]const u8) = .empty,
pub const Kind = enum {
required_extensions,
system_language,
fn attributeName(self: Kind) lp.String {
return switch (self) {
.required_extensions => .wrap("requiredExtensions"),
.system_language => .wrap("systemLanguage"),
};
}
fn delimiter(self: Kind) Delimiter {
return switch (self) {
.required_extensions => .whitespace,
.system_language => .comma,
};
}
};
pub const Key = struct {
element: *Element,
kind: Kind,
};
pub const Lookup = std.AutoHashMapUnmanaged(Key, *StringList);
pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*StringList {
const key: Key = .{
.element = element,
.kind = kind,
};
const gop = try frame._svg_string_lists.getOrPut(frame.arena, key);
if (!gop.found_existing) {
errdefer _ = frame._svg_string_lists.remove(key);
gop.value_ptr.* = try frame._factory.create(StringList{
._element = element,
._attribute_name = kind.attributeName(),
._delimiter = kind.delimiter(),
});
}
return gop.value_ptr.*;
}
pub fn getLength(self: *StringList, frame: *Frame) !u32 {
try self.sync(frame);
return @intCast(self._items.items.len);
}
pub fn getNumberOfItems(self: *StringList, frame: *Frame) !u32 {
return self.getLength(frame);
}
pub fn clear(self: *StringList, frame: *Frame) !void {
try self.commit(&.{}, frame);
}
pub fn initialize(self: *StringList, item: []const u8, frame: *Frame) ![]const u8 {
try self.commit(&.{item}, frame);
return item;
}
pub fn getItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
return self._items.items[index];
}
pub fn insertItemBefore(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 {
try self.sync(frame);
const at = @min(@as(usize, index), self._items.items.len);
const next = try frame.local_arena.alloc([]const u8, self._items.items.len + 1);
@memcpy(next[0..at], self._items.items[0..at]);
next[at] = item;
@memcpy(next[at + 1 ..], self._items.items[at..]);
try self.commit(next, frame);
return item;
}
pub fn replaceItem(self: *StringList, item: []const u8, index: u32, frame: *Frame) ![]const u8 {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const next = try frame.local_arena.dupe([]const u8, self._items.items);
next[index] = item;
try self.commit(next, frame);
return item;
}
pub fn removeItem(self: *StringList, index: u32, frame: *Frame) ![]const u8 {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const removed = try frame.local_arena.dupe(u8, self._items.items[index]);
const next = try frame.local_arena.alloc([]const u8, self._items.items.len - 1);
@memcpy(next[0..index], self._items.items[0..index]);
@memcpy(next[index..], self._items.items[index + 1 ..]);
try self.commit(next, frame);
return removed;
}
pub fn appendItem(self: *StringList, item: []const u8, frame: *Frame) ![]const u8 {
return self.insertItemBefore(item, std.math.maxInt(u32), frame);
}
fn sync(self: *StringList, frame: *Frame) !void {
const raw = self._element.getAttributeSafe(self._attribute_name);
if (self._synced and self._present == (raw != null) and
std.mem.eql(u8, self._snapshot.items, raw orelse ""))
{
return;
}
return self.rebuild(raw, frame);
}
// The list is authoritative: an item keeps its identity even when it contains
// the delimiter, so we record where each one landed rather than reparsing our
// own serialization.
fn commit(self: *StringList, items: []const []const u8, frame: *Frame) !void {
if (items.len == 0) {
self._synced = false;
self._element.removeAttributeSafe(self._attribute_name, frame);
self._present = false;
self._snapshot.clearRetainingCapacity();
self._items.clearRetainingCapacity();
self._synced = true;
return;
}
const separator: []const u8 = if (self._delimiter == .comma) "," else " ";
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
const bounds = try frame.local_arena.alloc([2]usize, items.len);
for (items, bounds, 0..) |item, *bound, i| {
if (i != 0) try writer.writeAll(separator);
const start = serialized.written().len;
try writer.writeAll(item);
bound.* = .{ start, serialized.written().len };
}
const bytes = serialized.written();
self._synced = false;
try self._element.setAttributeSafe(self._attribute_name, .wrap(bytes), frame);
self._present = true;
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, bytes);
self._items.clearRetainingCapacity();
for (bounds) |bound| {
try self._items.append(frame.arena, self._snapshot.items[bound[0]..bound[1]]);
}
self._synced = true;
}
fn rebuild(self: *StringList, raw: ?[]const u8, frame: *Frame) !void {
self._synced = false;
self._present = raw != null;
self._snapshot.clearRetainingCapacity();
self._items.clearRetainingCapacity();
if (raw) |value| {
try self._snapshot.appendSlice(frame.arena, value);
switch (self._delimiter) {
.whitespace => {
var iterator = std.mem.tokenizeAny(u8, self._snapshot.items, WHITESPACE);
while (iterator.next()) |item| try self._items.append(frame.arena, item);
},
// A set of comma-separated tokens: every segment is a token, even
// an empty one, and each is trimmed of surrounding whitespace.
.comma => {
var iterator = std.mem.splitScalar(u8, self._snapshot.items, ',');
while (iterator.next()) |part| {
try self._items.append(frame.arena, std.mem.trim(u8, part, WHITESPACE));
}
},
}
}
self._synced = true;
}
const WHITESPACE = " \t\r\n\x0c";
pub const JsApi = struct {
pub const bridge = js.Bridge(StringList);
pub const Meta = struct {
pub const name = "SVGStringList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const length = bridge.accessor(StringList.getLength, null, .{});
pub const numberOfItems = bridge.accessor(StringList.getNumberOfItems, null, .{});
pub const clear = bridge.function(StringList.clear, .{});
pub const initialize = bridge.function(StringList.initialize, .{});
pub const getItem = bridge.function(StringList.getItem, .{});
pub const insertItemBefore = bridge.function(StringList.insertItemBefore, .{});
pub const replaceItem = bridge.function(StringList.replaceItem, .{});
pub const removeItem = bridge.function(StringList.removeItem, .{});
pub const appendItem = bridge.function(StringList.appendItem, .{});
};

View File

@@ -43,7 +43,28 @@ pub const DOMMatrix2DInit = struct {
_type: u16 = 1,
_angle: f64 = 0,
_cx: f64 = 0,
_cy: f64 = 0,
_matrix: *DOMMatrix,
_attachment: ?Attachment = null,
// Sticky. An animVal item that a later external attribute change detaches from
// its list must not turn into a writable orphan.
_read_only: bool = false,
pub const State = struct {
typ: u16,
angle: f64,
cx: f64,
cy: f64,
matrix: [16]f64,
is_2d: bool,
};
pub const Attachment = struct {
owner: *anyopaque,
mutate: *const fn (*anyopaque, *Transform, State) anyerror!void,
};
// The transform owns the matrix arena even when no JS wrapper currently
// references `matrix`. Forwarding the transform's bridge lifetime keeps the
@@ -58,13 +79,57 @@ pub fn releaseRef(self: *Transform, page: *Page) void {
pub fn detached(frame: *Frame) !*Transform {
const matrix = try DOMMatrix.create(RO.identity(), true, frame._page);
return frame._factory.create(Transform{ ._matrix = matrix });
errdefer matrix._proto.deinit(frame._page);
const self = try frame._factory.create(Transform{ ._matrix = matrix });
self.attachMatrix();
return self;
}
pub fn fromMatrix(init: ?DOMMatrix2DInit, frame: *Frame) !*Transform {
const parsed = try fixup2D(init orelse .{});
const matrix = try DOMMatrix.create(parsed.m, true, frame._page);
return frame._factory.create(Transform{ ._matrix = matrix });
errdefer matrix._proto.deinit(frame._page);
const self = try frame._factory.create(Transform{ ._matrix = matrix });
self.attachMatrix();
return self;
}
pub fn fromParsed(parsed: RO.ParsedTransform, frame: *Frame) !*Transform {
const typ: u16 = switch (parsed.kind) {
.matrix => 1,
.translate => 2,
.scale => 3,
.rotate => 4,
.skew_x => 5,
.skew_y => 6,
else => return error.SyntaxError,
};
const matrix = try DOMMatrix.create(parsed.matrix, parsed.is_2d, frame._page);
errdefer matrix._proto.deinit(frame._page);
const self = try frame._factory.create(Transform{
._type = typ,
._angle = if (typ >= 4) parsed.values[0] else 0,
._cx = if (typ == 4 and parsed.count == 3) parsed.values[1] else 0,
._cy = if (typ == 4 and parsed.count == 3) parsed.values[2] else 0,
._matrix = matrix,
});
self.attachMatrix();
return self;
}
pub fn clone(self: *const Transform, frame: *Frame) !*Transform {
const current = self.getState();
const matrix = try DOMMatrix.create(current.matrix, current.is_2d, frame._page);
errdefer matrix._proto.deinit(frame._page);
const cloned = try frame._factory.create(Transform{
._type = current.typ,
._angle = current.angle,
._cx = current.cx,
._cy = current.cy,
._matrix = matrix,
});
cloned.attachMatrix();
return cloned;
}
pub fn getType(self: *const Transform) u16 {
@@ -81,23 +146,17 @@ pub fn getAngle(self: *const Transform) f64 {
pub fn setMatrix(self: *Transform, init: ?DOMMatrix2DInit) !void {
const parsed = try fixup2D(init orelse .{});
self.replaceMatrix(parsed.m, true);
self._type = 1;
self._angle = 0;
try self.applyState(.{ .typ = 1, .angle = 0, .cx = 0, .cy = 0, .matrix = parsed.m, .is_2d = true });
}
pub fn setTranslate(self: *Transform, tx: f64, ty: f64) !void {
try ensureFinite(&.{ tx, ty });
self.replaceMatrix(RO.translationMatrix(tx, ty, 0), true);
self._type = 2;
self._angle = 0;
try self.applyState(.{ .typ = 2, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.translationMatrix(tx, ty, 0), .is_2d = true });
}
pub fn setScale(self: *Transform, sx: f64, sy: f64) !void {
try ensureFinite(&.{ sx, sy });
self.replaceMatrix(RO.scaleMatrix(sx, sy, 1), true);
self._type = 3;
self._angle = 0;
try self.applyState(.{ .typ = 3, .angle = 0, .cx = 0, .cy = 0, .matrix = RO.scaleMatrix(sx, sy, 1), .is_2d = true });
}
pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void {
@@ -106,32 +165,116 @@ pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void {
var matrix = RO.translationMatrix(cx, cy, 0);
matrix = RO.multiplyMatrix(matrix, RO.rotateZMatrix(radians));
matrix = RO.multiplyMatrix(matrix, RO.translationMatrix(-cx, -cy, 0));
self.replaceMatrix(matrix, true);
self._type = 4;
self._angle = angle;
try self.applyState(.{ .typ = 4, .angle = angle, .cx = cx, .cy = cy, .matrix = matrix, .is_2d = true });
}
pub fn setSkewX(self: *Transform, angle: f64) !void {
try ensureFinite(&.{angle});
self.replaceMatrix(RO.skewMatrix(angle * std.math.pi / 180.0, 0), true);
self._type = 5;
self._angle = angle;
try self.applyState(.{ .typ = 5, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(angle * std.math.pi / 180.0, 0), .is_2d = true });
}
pub fn setSkewY(self: *Transform, angle: f64) !void {
try ensureFinite(&.{angle});
self.replaceMatrix(RO.skewMatrix(0, angle * std.math.pi / 180.0), true);
self._type = 6;
self._angle = angle;
try self.applyState(.{ .typ = 6, .angle = angle, .cx = 0, .cy = 0, .matrix = RO.skewMatrix(0, angle * std.math.pi / 180.0), .is_2d = true });
}
fn replaceMatrix(self: *Transform, matrix: [16]f64, is_2d: bool) void {
self._matrix._proto._m = matrix;
self._matrix._proto._is_2d = is_2d;
pub fn getState(self: *const Transform) State {
return .{
.typ = self._type,
.angle = self._angle,
.cx = self._cx,
.cy = self._cy,
.matrix = self._matrix._proto._m,
.is_2d = self._matrix._proto._is_2d,
};
}
pub fn applyStateRaw(self: *Transform, state: State) void {
self._type = state.typ;
self._angle = state.angle;
self._cx = state.cx;
self._cy = state.cy;
self._matrix._proto.applyStateRaw(.{
.matrix = state.matrix,
.is_2d = state.is_2d,
});
}
fn applyState(self: *Transform, state: State) !void {
if (self._read_only) return error.NoModificationAllowed;
try ensureFinite(&state.matrix);
if (self._attachment) |attachment| {
return attachment.mutate(attachment.owner, self, state);
}
self.applyStateRaw(state);
}
pub fn attach(self: *Transform, attachment: Attachment, read_only: bool) void {
self._attachment = attachment;
if (read_only) self._read_only = true;
}
pub fn detach(self: *Transform, owner: *anyopaque) void {
const attachment = self._attachment orelse return;
if (attachment.owner == owner) self._attachment = null;
}
pub fn isAttached(self: *const Transform) bool {
return self._attachment != null;
}
pub fn isAttachedTo(self: *const Transform, owner: *anyopaque) bool {
const attachment = self._attachment orelse return false;
return attachment.owner == owner;
}
pub fn writeState(state: State, writer: anytype) !void {
switch (state.typ) {
1 => try writer.print("matrix({d} {d} {d} {d} {d} {d})", .{
state.matrix[0], state.matrix[1], state.matrix[4], state.matrix[5], state.matrix[12], state.matrix[13],
}),
2 => try writer.print("translate({d} {d})", .{ state.matrix[12], state.matrix[13] }),
3 => try writer.print("scale({d} {d})", .{ state.matrix[0], state.matrix[5] }),
4 => if (state.cx == 0 and state.cy == 0)
try writer.print("rotate({d})", .{state.angle})
else
try writer.print("rotate({d} {d} {d})", .{ state.angle, state.cx, state.cy }),
5 => try writer.print("skewX({d})", .{state.angle}),
6 => try writer.print("skewY({d})", .{state.angle}),
else => return error.SyntaxError,
}
}
fn attachMatrix(self: *Transform) void {
self._matrix._proto.attach(.{
.owner = self,
.mutate = Transform.mutateMatrix,
});
}
// An SVGTransform is a 2D affine transform, and the `transform` attribute has
// no syntax for anything else, so a write through the live matrix keeps only
// the six components the transform can represent.
fn mutateMatrix(context: *anyopaque, _: *RO, state: RO.State) anyerror!void {
const self: *Transform = @ptrCast(@alignCast(context));
const m = state.matrix;
try self.applyState(.{
.typ = 1,
.angle = 0,
.cx = 0,
.cy = 0,
.matrix = .{
m[0], m[1], 0, 0,
m[4], m[5], 0, 0,
0, 0, 1, 0,
m[12], m[13], 0, 1,
},
.is_2d = true,
});
}
fn fixup2D(init: DOMMatrix2DInit) !RO.Parsed {
return RO.fixupDict(.{
const parsed = try RO.fixupDict(.{
.a = init.a,
.b = init.b,
.c = init.c,
@@ -146,6 +289,8 @@ fn fixup2D(init: DOMMatrix2DInit) !RO.Parsed {
.m42 = init.m42,
.is2D = true,
});
try ensureFinite(&parsed.m);
return parsed;
}
fn ensureFinite(values: []const f64) !void {

View File

@@ -0,0 +1,313 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// 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.
const std = @import("std");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const DOMMatrixReadOnly = @import("../DOMMatrixReadOnly.zig");
const Element = @import("../Element.zig");
const Transform = @import("Transform.zig");
const TransformList = @This();
_frame: *Frame,
_read_only: bool,
_element: *Element,
_synced: bool = false,
_snapshot: std.ArrayList(u8) = .empty,
_items: std.ArrayList(*Transform) = .empty,
_retired: std.ArrayList(*Transform) = .empty,
pub fn create(element: *Element, read_only: bool, frame: *Frame) !*TransformList {
return frame._factory.create(TransformList{
._frame = frame,
._element = element,
._read_only = read_only,
});
}
pub fn deinit(self: *TransformList, page: *Page) void {
for (self._items.items) |transform| {
transform.detach(self);
transform.releaseRef(page);
}
self._items.clearRetainingCapacity();
self.releaseRetired(page);
}
pub fn getLength(self: *TransformList, frame: *Frame) !u32 {
try self.sync(frame);
return @intCast(self._items.items.len);
}
pub fn getNumberOfItems(self: *TransformList, frame: *Frame) !u32 {
return self.getLength(frame);
}
pub fn clear(self: *TransformList, frame: *Frame) !void {
try self.requireMutable();
try self.sync(frame);
try self.retireAll(frame);
try self.setAttribute(&.{}, frame);
}
pub fn initialize(self: *TransformList, item: *Transform, frame: *Frame) !*Transform {
try self.requireMutable();
try self.sync(frame);
const prepared = try self.prepareItem(item, frame);
errdefer prepared.releaseRef(frame._page);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
try self.setAttribute(&.{prepared}, frame);
self._items.appendAssumeCapacity(prepared);
self.attach(prepared);
return prepared;
}
pub fn getItem(self: *TransformList, index: u32, frame: *Frame) !*Transform {
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
return self._items.items[index];
}
pub fn insertItemBefore(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform {
try self.requireMutable();
try self.sync(frame);
const prepared = try self.prepareItem(item, frame);
errdefer prepared.releaseRef(frame._page);
const at = @min(@as(usize, index), self._items.items.len);
const next = try frame.local_arena.alloc(*Transform, self._items.items.len + 1);
@memcpy(next[0..at], self._items.items[0..at]);
next[at] = prepared;
@memcpy(next[at + 1 ..], self._items.items[at..]);
try self._items.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
self._items.insertAssumeCapacity(at, prepared);
self.attach(prepared);
return prepared;
}
pub fn replaceItem(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform {
try self.requireMutable();
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const prepared = try self.prepareItem(item, frame);
errdefer prepared.releaseRef(frame._page);
const next = try frame.local_arena.dupe(*Transform, self._items.items);
next[index] = prepared;
try self._retired.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
const replaced = self._items.items[index];
replaced.detach(self);
self._retired.appendAssumeCapacity(replaced);
self._items.items[index] = prepared;
self.attach(prepared);
return prepared;
}
pub fn removeItem(self: *TransformList, index: u32, frame: *Frame) !*Transform {
try self.requireMutable();
try self.sync(frame);
if (index >= self._items.items.len) return error.IndexSizeError;
const next = try frame.local_arena.alloc(*Transform, self._items.items.len - 1);
@memcpy(next[0..index], self._items.items[0..index]);
@memcpy(next[index..], self._items.items[index + 1 ..]);
try self._retired.ensureUnusedCapacity(frame.arena, 1);
try self.setAttribute(next, frame);
const removed = self._items.orderedRemove(index);
removed.detach(self);
self._retired.appendAssumeCapacity(removed);
return removed;
}
pub fn appendItem(self: *TransformList, item: *Transform, frame: *Frame) !*Transform {
return self.insertItemBefore(item, std.math.maxInt(u32), frame);
}
pub fn consolidate(self: *TransformList, frame: *Frame) !?*Transform {
try self.requireMutable();
try self.sync(frame);
if (self._items.items.len == 0) return null;
var matrix = DOMMatrixReadOnly.identity();
for (self._items.items) |item| matrix = DOMMatrixReadOnly.multiplyMatrix(matrix, item.getState().matrix);
for (matrix) |value| if (!std.math.isFinite(value)) return error.TypeError;
var values: [16]f64 = undefined;
values[0] = matrix[0];
values[1] = matrix[1];
values[2] = matrix[4];
values[3] = matrix[5];
values[4] = matrix[12];
values[5] = matrix[13];
const consolidated = try Transform.fromParsed(.{
.kind = .matrix,
.matrix = matrix,
.values = values,
.count = 6,
.is_2d = true,
}, frame);
consolidated.acquireRef();
errdefer consolidated.releaseRef(frame._page);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, 1);
try self.setAttribute(&.{consolidated}, frame);
self._items.appendAssumeCapacity(consolidated);
self.attach(consolidated);
return consolidated;
}
fn requireMutable(self: *const TransformList) !void {
if (self._read_only) return error.NoModificationAllowed;
}
fn prepareItem(_: *TransformList, item: *Transform, frame: *Frame) !*Transform {
const prepared = if (item.isAttached()) try item.clone(frame) else item;
prepared.acquireRef();
return prepared;
}
fn attach(self: *TransformList, transform: *Transform) void {
transform.attach(.{
.owner = self,
.mutate = TransformList.mutateTransform,
}, self._read_only);
}
fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform.State) anyerror!void {
const self: *TransformList = @ptrCast(@alignCast(context));
const frame = self._frame;
try self.sync(frame);
if (!transform.isAttachedTo(self)) {
transform.applyStateRaw(state);
return;
}
const index = for (self._items.items, 0..) |candidate, i| {
if (candidate == transform) break i;
} else unreachable;
try self.setAttributeWithOverride(index, state, frame);
transform.applyStateRaw(state);
}
fn sync(self: *TransformList, frame: *Frame) !void {
self.releaseRetired(frame._page);
const raw = self._element.getAttributeSafe(comptime .wrap("transform")) orelse "";
if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) return;
self._synced = false;
var parsed = parse(raw, frame) catch |err| switch (err) {
error.SyntaxError => std.ArrayList(*Transform).empty,
else => return err,
};
errdefer for (parsed.items) |transform| transform.releaseRef(frame._page);
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, raw);
try self.retireAll(frame);
try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
for (parsed.items) |transform| {
self._items.appendAssumeCapacity(transform);
self.attach(transform);
}
parsed.clearRetainingCapacity();
self._synced = true;
}
// A retired item must outlive the operation that retired it: removeItem's
// return value has no JS wrapper until the bridge wraps it after we return.
// By the next operation, anything still reachable holds its own ref.
fn releaseRetired(self: *TransformList, page: *Page) void {
for (self._retired.items) |transform| {
transform.releaseRef(page);
}
self._retired.clearRetainingCapacity();
}
fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*Transform) {
var parsed: std.ArrayList(*Transform) = .empty;
errdefer for (parsed.items) |transform| transform.releaseRef(frame._page);
const trimmed = std.mem.trim(u8, raw, " \t\r\n");
if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return parsed;
var iterator = DOMMatrixReadOnly.TransformFunctionIterator{ .input = trimmed, .allow_comma = true };
while (try iterator.next()) |function| {
const value = try DOMMatrixReadOnly.parseTransformFunction(function, .svg);
const transform = try Transform.fromParsed(value, frame);
transform.acquireRef();
parsed.append(frame.local_arena, transform) catch |err| {
transform.releaseRef(frame._page);
return err;
};
}
return parsed;
}
fn retireAll(self: *TransformList, frame: *Frame) !void {
self._synced = false;
try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len);
for (self._items.items) |transform| {
transform.detach(self);
self._retired.appendAssumeCapacity(transform);
}
self._items.clearRetainingCapacity();
}
fn setAttribute(self: *TransformList, items: []const *Transform, frame: *Frame) !void {
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
for (items, 0..) |transform, i| {
if (i != 0) try writer.writeByte(' ');
try Transform.writeState(transform.getState(), writer);
}
try self.commitAttribute(serialized.written(), frame);
}
fn setAttributeWithOverride(self: *TransformList, index: usize, state: Transform.State, frame: *Frame) !void {
var serialized: std.Io.Writer.Allocating = .init(frame.local_arena);
const writer = &serialized.writer;
for (self._items.items, 0..) |transform, i| {
if (i != 0) try writer.writeByte(' ');
try Transform.writeState(if (i == index) state else transform.getState(), writer);
}
try self.commitAttribute(serialized.written(), frame);
}
fn commitAttribute(self: *TransformList, serialized: []const u8, frame: *Frame) !void {
self._synced = false;
try self._element.setAttributeSafe(comptime .wrap("transform"), .wrap(serialized), frame);
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, serialized);
self._synced = true;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(TransformList);
pub const Meta = struct {
pub const name = "SVGTransformList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const length = bridge.accessor(TransformList.getLength, null, .{});
pub const numberOfItems = bridge.accessor(TransformList.getNumberOfItems, null, .{});
pub const clear = bridge.function(TransformList.clear, .{});
pub const initialize = bridge.function(TransformList.initialize, .{});
pub const getItem = bridge.function(TransformList.getItem, .{});
pub const insertItemBefore = bridge.function(TransformList.insertItemBefore, .{});
pub const replaceItem = bridge.function(TransformList.replaceItem, .{});
pub const removeItem = bridge.function(TransformList.removeItem, .{});
pub const appendItem = bridge.function(TransformList.appendItem, .{});
pub const consolidate = bridge.function(TransformList.consolidate, .{});
};

View File

@@ -569,10 +569,6 @@ pub const BrowserContext = struct {
http_proxy_changed: bool = false,
user_agent_changed: bool = false,
// True once we've registered for the download notifications, so repeated
// Browser.setDownloadBehavior calls don't add duplicate listeners.
download_events_registered: bool = false,
// Extra headers to add to all requests.
extra_headers: std.ArrayList([*c]const u8) = .empty,
@@ -826,6 +822,7 @@ pub const BrowserContext = struct {
}
pub fn fetchEnable(self: *BrowserContext, authRequests: bool) !void {
self.fetchDisable(); //in case of multiple calls
try self.notification.register(.http_request_intercept, self, onHttpRequestIntercept);
if (authRequests) {
try self.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired);
@@ -868,21 +865,13 @@ pub const BrowserContext = struct {
// Registers for the download notifications dispatched by Frame when a
// navigation is treated as a file download. Idempotent. See issue #2701.
pub fn downloadEventsEnable(self: *BrowserContext) !void {
if (self.download_events_registered) {
return;
}
try self.notification.register(.download_will_begin, self, onDownloadWillBegin);
try self.notification.register(.download_progress, self, onDownloadProgress);
self.download_events_registered = true;
}
pub fn downloadEventsDisable(self: *BrowserContext) void {
if (self.download_events_registered == false) {
return;
}
self.notification.unregister(.download_will_begin, self);
self.notification.unregister(.download_progress, self);
self.download_events_registered = false;
}
pub fn onDownloadWillBegin(ctx: *anyopaque, msg: *const Notification.DownloadWillBegin) !void {

View File

@@ -320,7 +320,6 @@ test "cdp.browser: setDownloadBehavior stores config on the session" {
try testing.expectEqual(.allow_and_name, bc.session.download_behavior);
try testing.expectEqualSlices(u8, "/tmp/lp-downloads", bc.session.download_path.?);
try testing.expect(bc.session.download_events_enabled);
try testing.expect(bc.download_events_registered);
// `default` maps to `deny` and tears the registration down again.
try ctx.processMessage(.{
@@ -331,7 +330,6 @@ test "cdp.browser: setDownloadBehavior stores config on the session" {
try testing.expectEqual(.deny, bc.session.download_behavior);
try testing.expect(bc.session.download_path == null);
try testing.expect(bc.session.download_events_enabled == false);
try testing.expect(bc.download_events_registered == false);
}
test "cdp.browser: setDownloadBehavior is a no-op when no context is loaded" {

View File

@@ -328,6 +328,7 @@ pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.Request
// We're missing a bunch of fields, but, for now, this seems like enough
try bc.cdp.sendEvent("Network.loadingFailed", .{
.requestId = &id.toRequestId(msg.transfer),
.timestamp = timestamp(.monotonic),
// Seems to be what chrome answers with. I assume it depends on the type of error?
.type = "Ping",
.errorText = msg.err,
@@ -343,7 +344,7 @@ pub fn httpRequestStart(bc: *CDP.BrowserContext, msg: *const Notification.Reques
const transfer = msg.transfer;
const req = &transfer.req;
const frame_id = req.frame_id;
const frame_id = req.document_frame_id orelse req.frame_id;
const frame = bc.session.findFrameByFrameId(frame_id) orelse return;
// Modify request with extra CDP headers. Use set (replace by name) so a
@@ -379,9 +380,11 @@ pub fn httpResponseHeaderDone(arena: Allocator, bc: *CDP.BrowserContext, msg: *c
// We're missing a bunch of fields, but, for now, this seems like enough
try bc.cdp.sendEvent("Network.responseReceived", .{
.frameId = &id.toFrameId(req.frame_id),
.frameId = &id.toFrameId(req.document_frame_id orelse req.frame_id),
.requestId = &id.toRequestId(transfer),
.loaderId = &id.toLoaderId(req.loader_id),
.timestamp = timestamp(.monotonic),
.type = req.resource_type.string(),
.response = ResponseWriter.init(arena, msg.transfer),
.hasExtraInfo = false, // TODO change after adding Network.responseReceivedExtraInfo
}, .{ .session_id = session_id });
@@ -393,6 +396,7 @@ pub fn httpRequestDone(bc: *CDP.BrowserContext, msg: *const Notification.Request
const session_id = bc.session_id orelse return;
try bc.cdp.sendEvent("Network.loadingFinished", .{
.requestId = &id.toRequestId(msg.transfer),
.timestamp = timestamp(.monotonic),
.encodedDataLength = msg.content_length,
}, .{ .session_id = session_id });
}
@@ -447,6 +451,17 @@ pub const RequestWriter = struct {
try jws.write(request.body != null);
}
{
try jws.objectField("initialPriority");
try jws.write(initialPriority(request.resource_type));
}
{
// TODO implement proper referrerPolicy
try jws.objectField("referrerPolicy");
try jws.write("unsafe-url");
}
{
try jws.objectField("headers");
try jws.beginObject();
@@ -516,6 +531,19 @@ const ResponseWriter = struct {
try jws.write(transfer._from_cache);
}
{
try jws.objectField("connectionReused");
try jws.write(transfer._conn_reused);
try jws.objectField("connectionId");
try jws.write(transfer._conn_id);
// Bytes received so far, which is zero for a streaming response:
// its headers are reported before any of the body is read.
try jws.objectField("encodedDataLength");
try jws.write(transfer._content_length);
try jws.objectField("securityState");
try jws.write(securityState(transfer.req.url));
}
{
try jws.objectField("timing");
try jws.write(.{
@@ -533,6 +561,14 @@ const ResponseWriter = struct {
.sendStart = -1,
.sslEnd = -1,
.sslStart = -1,
// -1 is Chrome's "no service worker"; the push pair is 0 when
// nothing was pushed, not -1.
.workerStart = -1,
.workerReady = -1,
.workerFetchStart = -1,
.workerRespondWithSettled = -1,
.pushStart = 0,
.pushEnd = 0,
});
}
@@ -566,6 +602,23 @@ const ResponseWriter = struct {
}
};
fn initialPriority(resource_type: HttpClient.Request.ResourceType) []const u8 {
return switch (resource_type) {
.document, .stylesheet => "VeryHigh",
.script, .xhr, .fetch, .eventsource => "High",
};
}
fn securityState(url: [:0]const u8) []const u8 {
if (URL.isSecure(url)) {
return "secure";
}
if (std.mem.startsWith(u8, url, "http:") or std.mem.startsWith(u8, url, "ws:")) {
return "insecure";
}
return "unknown";
}
fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedResponseKey {
const key = std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams;
@@ -1021,3 +1074,63 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" {
}, .{ .session_id = "SID-BLOCK" });
try testing.expectEqual(error.UrlBlocked, error_context.err.?);
}
test "cdp.Network: worker requests emit network events" {
var ctx = try testing.context();
defer ctx.deinit();
const cdp = ctx.cdp();
_ = try cdp.createBrowserContext();
var bc = &cdp.browser_context.?;
bc.id = "BID-NW";
bc.session_id = "SID-NW";
bc.target_id = "TID-NW-0000000".*;
try ctx.processMessage(.{ .id = 1, .method = "Network.enable" });
try ctx.expectSentResult(null, .{ .id = 1 });
const fixture_root = "http://127.0.0.1:9582/src/browser/tests/cdp/";
const page_url = fixture_root ++ "worker_network.html";
const worker_url = fixture_root ++ "worker_network.js";
const api_url = "http://127.0.0.1:9582/echo_method";
const page = try bc.session.createPage();
try page.navigate(page_url, .{});
try testing.waitForPage(bc);
// Both the worker's script fetch and the fetch the worker itself issues are
// attributed to the document that created the worker, not to the worker's
// own frame id, which no client can resolve.
try ctx.expectSentEvent("Network.requestWillBeSent", .{
.documentURL = page_url,
.type = "Script",
.request = .{
.url = worker_url,
.initialPriority = "High",
.referrerPolicy = "unsafe-url",
},
}, .{ .session_id = "SID-NW" });
try ctx.expectSentEvent("Network.responseReceived", .{
.type = "Script",
.response = .{
.url = worker_url,
.securityState = "insecure",
// The document was fetched from this origin a moment ago, so the
// worker's script comes off the same socket. The id itself is a
// process-wide libcurl counter, so it isn't assertable here.
.connectionReused = true,
.timing = .{
.workerStart = -1,
.workerReady = -1,
.workerFetchStart = -1,
.workerRespondWithSettled = -1,
.pushStart = 0,
.pushEnd = 0,
},
},
}, .{ .session_id = "SID-NW" });
try ctx.expectSentEvent("Network.requestWillBeSent", .{
.documentURL = page_url,
.type = "Fetch",
.request = .{ .url = api_url },
}, .{ .session_id = "SID-NW" });
}

View File

@@ -1543,7 +1543,7 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
}
self.cacheStore(transfer);
transfer._cdp_content_length = transfer.getContentLength() orelse 0;
transfer._content_length = transfer.getContentLength() orelse 0;
try transfer.bufferEvents(transfer.res.buffer.items);
return true;
}
@@ -1644,6 +1644,10 @@ pub const Request = struct {
timeout_ms: u32 = 0,
skip_cache: bool = false,
// The document frame this request belongs to, for CDP attribution.
// This will be different than frame_id for Workers.
document_frame_id: ?u32 = null,
// Requests that are internal to the browser and skip various layers,
// these do not need to be deferred and do not obey robots.txt.
internal: bool = false,
@@ -1943,7 +1947,10 @@ pub const Transfer = struct {
_cache_key: [:0]const u8 = "",
// Content length reported on the CDP loadingFinished event.
_cdp_content_length: usize = 0,
_content_length: usize = 0,
_conn_id: i64 = 0,
_conn_reused: bool = false,
// Set by the first deinit. A retired transfer is unlinked from
// everything and sits on client.graveyard
@@ -2374,7 +2381,7 @@ pub const Transfer = struct {
self.setResponseHead(cached.metadata.status, cached.metadata.content_type);
self.res.headers = cached.metadata.headers;
self._from_cache = true;
self._cdp_content_length = body.len;
self._content_length = body.len;
try self.bufferEvents(body);
}
@@ -2400,7 +2407,7 @@ pub const Transfer = struct {
self.setResponseHead(status, content_type);
self.res.headers = owned;
self._cdp_content_length = owned_body.len;
self._content_length = owned_body.len;
try self.bufferEvents(owned_body);
}
@@ -2412,6 +2419,13 @@ pub const Transfer = struct {
// Streaming: already materialized at the first delivered chunk.
return;
}
// libcurl returns -1 if the connection wasn't used. We want to avoid
// negative, so -1 -> 0 and everything else += 1;
const conn_id = conn.getConnId() catch -1;
self._conn_id = if (conn_id < 0) 0 else conn_id + 1;
self._conn_reused = conn.isConnReused() catch false;
const arena = self.arena;
if (self.res.header == null) {
@@ -2913,7 +2927,7 @@ pub const Transfer = struct {
if (transfer._notify_cdp) {
req.notification.dispatch(.http_request_done, &.{
.transfer = transfer,
.content_length = transfer._cdp_content_length,
.content_length = transfer._content_length,
});
}
req.done_callback(req.ctx) catch |err| {
@@ -3045,7 +3059,7 @@ const Synthetic = struct {
h[0] = .{ .name = "content-type", .value = content_type };
transfer.res.headers = h;
}
transfer._cdp_content_length = body.len;
transfer._content_length = body.len;
try transfer.bufferEvents(body);
}
};

View File

@@ -136,6 +136,7 @@ fn fetchThenResume(self: *RobotsGate, robots_url: [:0]const u8, transfer: *Trans
.internal = true,
.resource_type = .fetch,
.frame_id = transfer.req.frame_id,
.document_frame_id = transfer.req.document_frame_id,
.loader_id = transfer.req.loader_id,
.notification = transfer.req.notification,
.cookie_jar = null,

View File

@@ -622,6 +622,19 @@ pub const Connection = struct {
return @intCast(count);
}
// -1 when the transfer used no connection.
pub fn getConnId(self: *const Connection) !c_long {
var conn_id: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .conn_id, &conn_id);
return conn_id;
}
pub fn isConnReused(self: *const Connection) !bool {
var opened: c_long = undefined;
try libcurl.curl_easy_getinfo(self._easy, .num_connects, &opened);
return opened == 0;
}
// Total transfer time (name lookup to completion) in microseconds.
pub fn getTotalTimeMicros(self: *const Connection) !c_long {
var micros: c_long = undefined;

View File

@@ -179,16 +179,24 @@ pub const String = packed struct {
return false;
}
const len = a.len;
if (len < 0 or b.len < 0) {
if (a.len < 0 or b.len < 0) {
return false;
}
return eqlWithSameLen(a, b);
}
// Dangerous. Use this only when you have to (and, obviously, when you know
// a.len == b.len)
pub fn eqlWithSameLen(a: String, b: String) bool {
if (comptime IS_DEBUG) {
std.debug.assert(a.len == b.len);
}
const len = a.len;
if (len <= 12) {
return a.payload.content == b.payload.content;
}
// a.len == b.len at this point
const al: usize = @intCast(len);
const bl: usize = @intCast(len);
const ap: [*]const u8 = @ptrFromInt(a.payload.heap.ptr);

View File

@@ -246,6 +246,8 @@ pub const CurlInfo = enum(c.CURLINFO) {
response_code = c.CURLINFO_RESPONSE_CODE,
connect_code = c.CURLINFO_HTTP_CONNECTCODE,
total_time_t = c.CURLINFO_TOTAL_TIME_T,
num_connects = c.CURLINFO_NUM_CONNECTS,
conn_id = c.CURLINFO_CONN_ID,
};
pub const Error = error{
@@ -661,11 +663,14 @@ pub fn curl_easy_getinfo(easy: *Curl, comptime info: CurlInfo, out: anytype) Err
.response_code,
.connect_code,
.redirect_count,
.num_connects,
=> blk: {
const p: *c_long = out;
break :blk c.curl_easy_getinfo(easy, inf, p);
},
.total_time_t => blk: {
.total_time_t,
.conn_id,
=> blk: {
const p: *c.curl_off_t = out;
break :blk c.curl_easy_getinfo(easy, inf, p);
},

View File

@@ -637,6 +637,24 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
});
}
const xhr_xml_body = "<?xml version=\"1.0\"?><catalog><item id=\"a\"/><item id=\"b\"/><pubDate>2026</pubDate></catalog>";
if (std.mem.eql(u8, path, "/xhr/xml")) {
return req.respond(xhr_xml_body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/xml" },
},
});
}
if (std.mem.eql(u8, path, "/xhr/xml_as_text")) {
return req.respond(xhr_xml_body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain" },
},
});
}
if (std.mem.eql(u8, path, "/xhr/json")) {
return req.respond("{\"over\":\"9000!!!\",\"updated_at\":1765867200000}", .{
.extra_headers = &.{
@@ -970,6 +988,21 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
});
}
if (std.mem.eql(u8, path, "/echo_body")) {
// Echo the request body back verbatim, so tests can assert on the bytes
// a request actually sent rather than just on its status.
var body_buf: [4096]u8 = undefined;
const body = if (req.head.method.requestHasBody())
try req.readerExpectNone(&body_buf).allocRemaining(arena_allocator, .limited(body_buf.len))
else
"";
return req.respond(body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain; charset=utf-8" },
},
});
}
if (std.mem.eql(u8, path, "/redirect_to_echo")) {
// 302 to /echo_method. Used by the Page.reload-after-redirect test to
// confirm a POST→302→GET chain doesn't replay POST on reload.