mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 09:46:05 -04:00
Merge branch 'main' into python-bindings-v1
This commit is contained in:
9
Makefile
9
Makefile
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
129
src/browser/frame/parse.zig
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
4
src/browser/tests/cdp/worker_network.html
Normal file
4
src/browser/tests/cdp/worker_network.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<!doctype html>
|
||||
<script>
|
||||
new Worker("./worker_network.js");
|
||||
</script>
|
||||
1
src/browser/tests/cdp/worker_network.js
Normal file
1
src/browser/tests/cdp/worker_network.js
Normal file
@@ -0,0 +1 @@
|
||||
fetch("/echo_method");
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
208
src/browser/tests/element/html/select-optgroup.html
Normal file
208
src/browser/tests/element/html/select-optgroup.html
Normal 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>
|
||||
@@ -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');
|
||||
|
||||
720
src/browser/tests/element/svg/collections.html
Normal file
720
src/browser/tests/element/svg/collections.html
Normal 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>
|
||||
212
src/browser/tests/element/svg/geometry.html
Normal file
212
src/browser/tests/element/svg/geometry.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
95
src/browser/webapi/svg/AnimatedNumber.zig
Normal file
95
src/browser/webapi/svg/AnimatedNumber.zig
Normal 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, .{});
|
||||
};
|
||||
65
src/browser/webapi/svg/AnimatedTransformList.zig
Normal file
65
src/browser/webapi/svg/AnimatedTransformList.zig
Normal 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, .{});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
848
src/browser/webapi/svg/PathData.zig
Normal file
848
src/browser/webapi/svg/PathData.zig
Normal 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);
|
||||
}
|
||||
381
src/browser/webapi/svg/PointList.zig
Normal file
381
src/browser/webapi/svg/PointList.zig
Normal 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, .{});
|
||||
};
|
||||
225
src/browser/webapi/svg/StringList.zig
Normal file
225
src/browser/webapi/svg/StringList.zig
Normal 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, .{});
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
313
src/browser/webapi/svg/TransformList.zig
Normal file
313
src/browser/webapi/svg/TransformList.zig
Normal 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, .{});
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user