mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 01:36:15 -04:00
Merge branch 'main' into python-bindings-v1
This commit is contained in:
2
.github/actions/install/action.yml
vendored
2
.github/actions/install/action.yml
vendored
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.5.1'
|
||||
default: 'v0.5.2'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
|
||||
2
.github/actions/v8-snapshot/action.yml
vendored
2
.github/actions/v8-snapshot/action.yml
vendored
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig-v8 release tag the prebuilt lib came from'
|
||||
required: false
|
||||
default: 'v0.5.1'
|
||||
default: 'v0.5.2'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM debian:stable-slim
|
||||
ARG MINISIG=0.12
|
||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||
ARG V8=14.9.207.35
|
||||
ARG ZIG_V8=v0.5.1
|
||||
ARG ZIG_V8=v0.5.2
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{
|
||||
.v8 = .{
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/d2f997de95dd35174969e38599d98be50c057d85.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH6zTvAgB67DOl86uWsVwNpK8VXaEOYzu8fkl9glNe",
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/78b6c77d5f6f040a575539203e6f3fad42453890.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH6xHyAgDVbf39iQaZfmzZCdNi7m3iTqOKbKz74Ggx",
|
||||
},
|
||||
// .v8 = .{ .path = "../zig-v8-fork" },
|
||||
.brotli = .{
|
||||
|
||||
@@ -931,22 +931,27 @@ fn printPaged(allocator: Allocator, text: []const u8) void {
|
||||
var environ_map = lp.environMap(allocator) catch return printPlain(text);
|
||||
defer environ_map.deinit();
|
||||
|
||||
var child = std.process.spawn(lp.io, .{
|
||||
// lp.io cannot spawn children: failing allocator, empty environ (no PATH).
|
||||
var pager_threaded: std.Io.Threaded = .init(allocator, .{ .environ = lp.environ() });
|
||||
defer pager_threaded.deinit();
|
||||
const pager_io = pager_threaded.io();
|
||||
|
||||
var child = std.process.spawn(pager_io, .{
|
||||
.argv = argv,
|
||||
.environ_map = &environ_map,
|
||||
.stdin = .pipe,
|
||||
}) catch return printPlain(text);
|
||||
|
||||
if (child.stdin) |stdin| {
|
||||
var writer = stdin.writerStreaming(lp.io, &.{});
|
||||
var writer = stdin.writerStreaming(pager_io, &.{});
|
||||
// A write error here is the pager exiting early (user quit, or the
|
||||
// command failed) — wait() below decides which.
|
||||
writer.interface.writeAll(text) catch {};
|
||||
stdin.close(lp.io);
|
||||
stdin.close(pager_io);
|
||||
child.stdin = null;
|
||||
}
|
||||
|
||||
const term_result = child.wait(lp.io) catch return printPlain(text);
|
||||
const term_result = child.wait(pager_io) catch return printPlain(text);
|
||||
const clean_exit = term_result == .exited and term_result.exited == 0;
|
||||
// Quitting the pager early is still exit 0; a non-zero exit means the
|
||||
// pager failed (e.g. $PAGER not found) and the help was never shown.
|
||||
|
||||
@@ -42,6 +42,8 @@ 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");
|
||||
@@ -142,6 +144,8 @@ _element_shadow_roots: Element.ShadowRootLookup = .empty,
|
||||
_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_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty,
|
||||
_svg_animated_strings: AnimatedString.Lookup = .empty,
|
||||
|
||||
// Same as above, but for Nodes (slot assigments apply to both Element AND
|
||||
@@ -1287,6 +1291,9 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.
|
||||
// (allow/allowAndName) and the response carries Content-Disposition: attachment.
|
||||
// See issue #2701.
|
||||
fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool {
|
||||
const uuidv4 = @import("../id.zig").uuidv4;
|
||||
const latin1ToUtf8 = @import("../string.zig").latin1ToUtf8;
|
||||
|
||||
const session = self._session;
|
||||
switch (session.download_behavior) {
|
||||
.allow, .allow_and_name => {},
|
||||
@@ -1314,11 +1321,16 @@ fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool {
|
||||
// `guid` is the CDP "Global Unique Identifier" that ties the
|
||||
// downloadWillBegin / downloadProgress events to one download.
|
||||
var guid_buf: [36]u8 = undefined;
|
||||
@import("../id.zig").uuidv4(&guid_buf);
|
||||
uuidv4(&guid_buf);
|
||||
const guid = try self.arena.dupe(u8, &guid_buf);
|
||||
|
||||
// Content-Disposition filenames can be in a legacy encoding (e.g.
|
||||
// Shift_JIS) but must yield valid UTF-8
|
||||
const suggested = dispositionFilename(disposition) orelse (try urlBasename(self.arena, self.url)) orelse guid;
|
||||
const suggested_filename = try self.arena.dupe(u8, suggested);
|
||||
const suggested_filename = if (std.unicode.utf8ValidateSlice(suggested))
|
||||
try self.arena.dupe(u8, suggested)
|
||||
else
|
||||
try latin1ToUtf8(self.arena, suggested);
|
||||
|
||||
// allowAndName stores the file under its guid; allow uses the suggested name.
|
||||
const on_disk_name = switch (session.download_behavior) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const Frame = @import("Frame.zig");
|
||||
@@ -59,8 +60,22 @@ class_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
|
||||
tag_rules: std.AutoHashMapUnmanaged(Tag, RuleList) = .empty,
|
||||
other_rules: RuleList = .empty, // universal, attribute, pseudo-class endings
|
||||
|
||||
// Document order counter for tie-breaking equal specificity
|
||||
next_doc_order: u32 = 0,
|
||||
/// The thing to remember about layers is that we can't determine priority's
|
||||
/// layer_rank until everything is parsed. So we need to build up meta data when
|
||||
/// rebuilding and do one final pass to apply the resulting layering rank.
|
||||
|
||||
// Layer registry rebuilt with the rules. Append-only.
|
||||
layers: std.ArrayList(Layer) = .empty,
|
||||
// layer full name -> layers index
|
||||
layer_ids: std.StringHashMapUnmanaged(u16) = .empty,
|
||||
// rule -> layers index, e.g. for rule at index N, rule_layers[N] is its layer
|
||||
rule_layers: std.ArrayList(u16) = .empty,
|
||||
|
||||
next_anon_layer: u32 = 0,
|
||||
|
||||
// Document order counter for tie-breaking equal specificity. Starts at 1, 0
|
||||
// is used as a sentinel is isElementHidden()
|
||||
next_doc_order: u32 = 1,
|
||||
|
||||
// When true, rules need to be rebuilt
|
||||
dirty: bool = false,
|
||||
@@ -76,24 +91,24 @@ pub fn deinit(self: *StyleManager) void {
|
||||
self.frame.releaseArena(self.arena);
|
||||
}
|
||||
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
/// Hard cap on `@media` / `@layer` nesting depth. CSS allows arbitrarily-deep
|
||||
/// at-rule nesting; without a cap a hostile inline stylesheet could blow the
|
||||
/// Zig stack via the mutually-recursive `applyMediaAtRule` / `applyLayerAtRule`
|
||||
/// / `applyInnerRules` frames. 32 is well past anything seen in the wild.
|
||||
/// `applyInnerRules` frames. 32 is well past anything seen in the wild.
|
||||
const MAX_AT_RULE_NESTING: u8 = 32;
|
||||
|
||||
fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||
fn parseSheet(self: *StyleManager, build_arena: Allocator, sheet: *CSSStyleSheet) !void {
|
||||
if (sheet._css_rules) |css_rules| {
|
||||
for (css_rules._rules.items) |rule| {
|
||||
switch (rule._type) {
|
||||
.style => |sr| try self.addRule(sr),
|
||||
.style => |sr| try self.addRule(build_arena, sr),
|
||||
// Re-parse the stored source so an `@media` rule inserted via
|
||||
// `insertRule` / `replaceSync` participates in the cascade
|
||||
// when its query matches the viewport.
|
||||
.media => try self.applyMediaAtRule(rule._text, 0),
|
||||
// `@layer` blocks flatten into the cascade (see
|
||||
// applyLayerAtRule for the deliberate scope).
|
||||
.layer => try self.applyLayerAtRule(rule._text, 0),
|
||||
.media => try self.applyMediaAtRule(build_arena, rule._text, 0, NO_LAYER),
|
||||
.layer => try self.applyLayerAtRule(build_arena, rule._text, 0, NO_LAYER),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
@@ -106,7 +121,7 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||
var it = CssParser.parseStylesheet(text);
|
||||
while (it.next()) |parsed_rule| {
|
||||
switch (parsed_rule) {
|
||||
.style => |s| try self.addRawRule(s.selector, s.block),
|
||||
.style => |s| try self.addRawRule(build_arena, s.selector, s.block, NO_LAYER),
|
||||
.at_rule => |a| {
|
||||
// Only `@media` and `@layer` participate in the cascade
|
||||
// here. Other at-rules (`@keyframes`, `@supports`,
|
||||
@@ -114,9 +129,9 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||
// relevant to the visibility filter and stay skipped as
|
||||
// before.
|
||||
if (std.ascii.eqlIgnoreCase(a.keyword, "media")) {
|
||||
try self.applyMediaAtRule(a.text, 0);
|
||||
try self.applyMediaAtRule(build_arena, a.text, 0, NO_LAYER);
|
||||
} else if (std.ascii.eqlIgnoreCase(a.keyword, "layer")) {
|
||||
try self.applyLayerAtRule(a.text, 0);
|
||||
try self.applyLayerAtRule(build_arena, a.text, 0, NO_LAYER);
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -129,46 +144,89 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||
/// lived at the top level. Non-matching queries silently drop the inner
|
||||
/// rules. Inline-only by design: external `<link rel="stylesheet">` is out
|
||||
/// of scope for the headless engine.
|
||||
fn applyMediaAtRule(self: *StyleManager, text: []const u8, depth: u8) Allocator.Error!void {
|
||||
if (depth >= MAX_AT_RULE_NESTING) return;
|
||||
fn applyMediaAtRule(self: *StyleManager, build_arena: Allocator, text: []const u8, depth: u8, layer: u16) Allocator.Error!void {
|
||||
if (depth >= MAX_AT_RULE_NESTING) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block = atRuleBlock(text, "@media") orelse return;
|
||||
const query = std.mem.trim(u8, block.prelude, &std.ascii.whitespace);
|
||||
|
||||
if (!MediaQuery.matches(query, self.frame._page.getViewport())) return;
|
||||
if (MediaQuery.matches(query, self.frame._page.getViewport()) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try self.applyInnerRules(block.body, depth + 1);
|
||||
try self.applyInnerRules(build_arena, block.body, depth + 1, layer);
|
||||
}
|
||||
|
||||
/// Apply an `@layer` block rule by parsing its inner block into the cascade
|
||||
/// as if its rules lived at the top level, recursing into nested `@media` /
|
||||
/// `@layer`. Cascade-layer priority ordering (css-cascade-5 §6.4) is
|
||||
/// deliberately not implemented: layers are flattened and ties keep breaking
|
||||
/// on specificity + document order. Handles named blocks (including dotted
|
||||
/// sub-layer names like `@layer theme.dark`) and anonymous blocks; the
|
||||
/// statement form (`@layer a, b;`) declares ordering only and carries no
|
||||
/// block (`atRuleBlock` returns null), so it applies nothing.
|
||||
fn applyLayerAtRule(self: *StyleManager, text: []const u8, depth: u8) Allocator.Error!void {
|
||||
if (depth >= MAX_AT_RULE_NESTING) return;
|
||||
/// Apply an `@layer` rule (css-cascade-5). Layers have two forms:
|
||||
/// 1 - Block form which may or may not have a name. We register the layer and
|
||||
// parse the inner block. This can recurse.
|
||||
/// 2 - Statement form only registes the name for ordering.
|
||||
///
|
||||
/// The layer parameter is the enclosing layer, or NO_LAYER at the top level.
|
||||
/// Priority is not assigned here, we need all the layers parsed to do that
|
||||
/// (since a layer statement can alter the ordering). So we'll do one final pass
|
||||
// in finalizerLayerRanks
|
||||
fn applyLayerAtRule(self: *StyleManager, build_arena: Allocator, text: []const u8, depth: u8, layer: u16) Allocator.Error!void {
|
||||
if (depth >= MAX_AT_RULE_NESTING) {
|
||||
return;
|
||||
}
|
||||
if (std.ascii.startsWithIgnoreCase(text, "@layer") == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The layer-name prelude is intentionally ignored — layers are flattened.
|
||||
const block = atRuleBlock(text, "@layer") orelse return;
|
||||
try self.applyInnerRules(block.body, depth + 1);
|
||||
const block = atRuleBlock(text, "@layer") orelse {
|
||||
// Statement form: `@layer <name>, <name>;`. One invalid name
|
||||
// invalidates the whole statement. Validate everything before
|
||||
// registering anything.
|
||||
var names = text["@layer".len..];
|
||||
if (std.mem.indexOfScalar(u8, names, ';')) |semi| {
|
||||
names = names[0..semi];
|
||||
}
|
||||
|
||||
var check = std.mem.splitScalar(u8, names, ',');
|
||||
while (check.next()) |raw| {
|
||||
if (isValidLayerName(std.mem.trim(u8, raw, &std.ascii.whitespace)) == false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var it = std.mem.splitScalar(u8, names, ',');
|
||||
while (it.next()) |raw| {
|
||||
_ = try self.registerLayerPath(build_arena, layer, std.mem.trim(u8, raw, &std.ascii.whitespace));
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
const prelude = std.mem.trim(u8, block.prelude, &std.ascii.whitespace);
|
||||
if (prelude.len != 0 and isValidLayerName(prelude) == false) {
|
||||
// An invalid layer name invalidates the whole rule.
|
||||
return;
|
||||
}
|
||||
|
||||
const id = if (prelude.len == 0)
|
||||
try self.internAnonymousLayer(build_arena, layer)
|
||||
else
|
||||
try self.registerLayerPath(build_arena, layer, prelude);
|
||||
|
||||
try self.applyInnerRules(build_arena, block.body, depth + 1, id);
|
||||
}
|
||||
|
||||
/// Parse a conditional at-rule's inner block, adding style rules to the
|
||||
/// cascade and recursing into nested `@media` / `@layer`. `depth` is the
|
||||
/// nesting depth of the *nested* at-rules (callers pass their own depth + 1).
|
||||
fn applyInnerRules(self: *StyleManager, inner: []const u8, depth: u8) Allocator.Error!void {
|
||||
/// cascade and recursing into nested `@media` / `@layer`. `layer` is the
|
||||
/// enclosing cascade layer (NO_LAYER when unlayered); `depth` is the nesting
|
||||
/// depth of the *nested* at-rules (callers pass their own depth + 1).
|
||||
fn applyInnerRules(self: *StyleManager, build_arena: Allocator, inner: []const u8, depth: u8, layer: u16) Allocator.Error!void {
|
||||
var it = CssParser.parseStylesheet(inner);
|
||||
while (it.next()) |nested_rule| {
|
||||
switch (nested_rule) {
|
||||
.style => |s| try self.addRawRule(s.selector, s.block),
|
||||
.style => |s| try self.addRawRule(build_arena, s.selector, s.block, layer),
|
||||
.at_rule => |nested| {
|
||||
if (std.ascii.eqlIgnoreCase(nested.keyword, "media")) {
|
||||
try self.applyMediaAtRule(nested.text, depth);
|
||||
try self.applyMediaAtRule(build_arena, nested.text, depth, layer);
|
||||
} else if (std.ascii.eqlIgnoreCase(nested.keyword, "layer")) {
|
||||
try self.applyLayerAtRule(nested.text, depth);
|
||||
try self.applyLayerAtRule(build_arena, nested.text, depth, layer);
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -188,9 +246,13 @@ fn applyInnerRules(self: *StyleManager, inner: []const u8, depth: u8) Allocator.
|
||||
/// the wrong place; the block's own trivia is handled by the CssParser re-parse
|
||||
/// in `applyInnerRules`, so only this outer boundary needs the special-case scan.
|
||||
fn atRuleBlock(text: []const u8, keyword: []const u8) ?struct { prelude: []const u8, body: []const u8 } {
|
||||
if (!std.ascii.startsWithIgnoreCase(text, keyword)) return null;
|
||||
if (std.ascii.startsWithIgnoreCase(text, keyword) == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = text[keyword.len..];
|
||||
const open = indexOfOpenBraceSkippingComments(rest) orelse return null;
|
||||
|
||||
// Search only past the opening brace — the matching `}` lives there, and
|
||||
// any returned position is naturally `> open` (since `rest[open] == '{'`).
|
||||
const close = open + (std.mem.lastIndexOfScalar(u8, rest[open..], '}') orelse return null);
|
||||
@@ -213,7 +275,185 @@ fn indexOfOpenBraceSkippingComments(s: []const u8) ?usize {
|
||||
return null;
|
||||
}
|
||||
|
||||
fn addRawRule(self: *StyleManager, selector_text: []const u8, block_text: []const u8) !void {
|
||||
/// Register a (possibly dotted) layer name declared inside `parent`,
|
||||
/// creating any missing ancestors, and return the layer's id. The name must
|
||||
/// already have passed isValidLayerName — per css-cascade-5 an invalid name
|
||||
/// invalidates the containing rule, which callers handle before registering.
|
||||
fn registerLayerPath(self: *StyleManager, build_arena: Allocator, parent: u16, dotted: []const u8) Allocator.Error!u16 {
|
||||
var current = parent;
|
||||
var it = std.mem.splitScalar(u8, dotted, '.');
|
||||
while (it.next()) |component| {
|
||||
// should have been verified by the caller, via isValidLayerName
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(isValidLayerComponent(component));
|
||||
}
|
||||
|
||||
// Components past the depth cap collapse into their ancestor;
|
||||
// MAX_AT_RULE_NESTING also bounds finalizeLayerRanks' path buffers.
|
||||
if (current != NO_LAYER and self.layers.items[current].depth >= MAX_AT_RULE_NESTING) {
|
||||
break;
|
||||
}
|
||||
current = try self.internLayer(build_arena, current, component);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/// Each anonymous `@layer { … }` block is its own distinct layer
|
||||
fn internAnonymousLayer(self: *StyleManager, build_arena: Allocator, parent: u16) Allocator.Error!u16 {
|
||||
const id = self.next_anon_layer;
|
||||
// \x00{d} isn't a valid layer name, so this can't conflict
|
||||
const name = try std.fmt.allocPrint(build_arena, "\x00{d}", .{id});
|
||||
self.next_anon_layer = id + 1;
|
||||
return self.internLayer(build_arena, parent, name);
|
||||
}
|
||||
|
||||
fn internLayer(self: *StyleManager, build_arena: Allocator, parent: u16, name: []const u8) Allocator.Error!u16 {
|
||||
const path = if (parent == NO_LAYER)
|
||||
try build_arena.dupe(u8, name)
|
||||
else
|
||||
try std.fmt.allocPrint(build_arena, "{s}.{s}", .{ self.layers.items[parent].path, name });
|
||||
|
||||
const gop = try self.layer_ids.getOrPut(build_arena, path);
|
||||
if (gop.found_existing) {
|
||||
return gop.value_ptr.*;
|
||||
}
|
||||
|
||||
if (self.layers.items.len >= MAX_LAYERS) {
|
||||
_ = self.layer_ids.remove(path);
|
||||
return parent;
|
||||
}
|
||||
|
||||
const id: u16 = @intCast(self.layers.items.len);
|
||||
gop.value_ptr.* = id;
|
||||
const depth = if (parent == NO_LAYER) 1 else self.layers.items[parent].depth + 1;
|
||||
try self.layers.append(build_arena, .{ .path = path, .parent = parent, .depth = depth });
|
||||
return id;
|
||||
}
|
||||
|
||||
fn isValidLayerName(dotted: []const u8) bool {
|
||||
var it = std.mem.splitScalar(u8, dotted, '.');
|
||||
while (it.next()) |component| {
|
||||
if (isValidLayerComponent(component) == false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn isValidLayerComponent(component: []const u8) bool {
|
||||
if (component.len == 0) {
|
||||
return false;
|
||||
}
|
||||
if (std.ascii.isDigit(component[0])) {
|
||||
return false;
|
||||
}
|
||||
for (component) |c| {
|
||||
switch (c) {
|
||||
'a'...'z', 'A'...'Z', '0'...'9', '-', '_' => {},
|
||||
else => if (c < 0x80) return false,
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Compute every layer's rank and apply it to every VisibilityRule we have.
|
||||
/// We can only do this now that we've parsed every parsed every sheet since
|
||||
/// @layer statement can change the ordering/
|
||||
fn finalizeLayerRanks(self: *StyleManager, build_arena: Allocator) Allocator.Error!void {
|
||||
const layers = self.layers.items;
|
||||
|
||||
if (layers.len == 0) {
|
||||
// No layers. Every VisibilityRule already has the correct layerless
|
||||
// priority, and with no layers, there's nothing to adjust.
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
// If we have three layers:
|
||||
// @layer a.x { ... }
|
||||
// @layer b { ... }
|
||||
// @layer a.y { ... }
|
||||
// If we prioritize them by the order they are defined, we'd get:
|
||||
// a.x => 1 (lowest priority), b => 2 and a.y => 3 (highest priority)
|
||||
//
|
||||
// But it should really be:
|
||||
// a.x => 1, a.y => 2, b => 3
|
||||
//
|
||||
// because a.y "inherits" the lower priority of the "a" from "a.x" having
|
||||
// been declared first.
|
||||
//
|
||||
// So we need to create something:
|
||||
// a.x => [0, 1], b => [2], a.y => [0, 3]
|
||||
//
|
||||
// And now when we sort them, we'll get the correct order because[2] > [0, 3]
|
||||
|
||||
const paths = try build_arena.alloc([]const u16, layers.len);
|
||||
for (layers, 0..) |layer, i| {
|
||||
const path = try build_arena.alloc(u16, layer.depth);
|
||||
var id: u16 = @intCast(i);
|
||||
var d = layer.depth;
|
||||
while (d > 0) {
|
||||
d -= 1;
|
||||
path[d] = id;
|
||||
id = layers[id].parent;
|
||||
}
|
||||
paths[i] = path;
|
||||
}
|
||||
|
||||
const order = try build_arena.alloc(u16, layers.len);
|
||||
for (order, 0..) |*slot, i| {
|
||||
slot.* = @intCast(i);
|
||||
}
|
||||
|
||||
std.mem.sort(u16, order, paths, struct {
|
||||
fn lessThan(ctx: []const []const u16, a: u16, b: u16) bool {
|
||||
const pa = ctx[a];
|
||||
const pb = ctx[b];
|
||||
const common = @min(pa.len, pb.len);
|
||||
for (pa[0..common], pb[0..common]) |ca, cb| {
|
||||
if (ca != cb) {
|
||||
return ca < cb;
|
||||
}
|
||||
}
|
||||
return pa.len > pb.len;
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
for (order, 0..) |id, rank| {
|
||||
layers[id].rank = @intCast(rank);
|
||||
}
|
||||
}
|
||||
|
||||
self.stampRuleList(&self.other_rules);
|
||||
|
||||
var id_it = self.id_rules.valueIterator();
|
||||
while (id_it.next()) |rules| {
|
||||
self.stampRuleList(rules);
|
||||
}
|
||||
|
||||
var class_it = self.class_rules.valueIterator();
|
||||
while (class_it.next()) |rules| {
|
||||
self.stampRuleList(rules);
|
||||
}
|
||||
|
||||
var tag_it = self.tag_rules.valueIterator();
|
||||
while (tag_it.next()) |rules| {
|
||||
self.stampRuleList(rules);
|
||||
}
|
||||
}
|
||||
|
||||
fn stampRuleList(self: *StyleManager, rules: *RuleList) void {
|
||||
const layers = self.layers.items;
|
||||
const rule_layers = self.rule_layers.items;
|
||||
for (rules.items(.priority)) |*priority| {
|
||||
const doc_order: u32 = @as(u22, @truncate(priority.*));
|
||||
const layer = rule_layers[doc_order - 1];
|
||||
const rank = if (layer == NO_LAYER) UNLAYERED_RANK else layers[layer].rank;
|
||||
priority.* |= @as(u64, rank) << RANK_SHIFT;
|
||||
}
|
||||
}
|
||||
|
||||
fn addRawRule(self: *StyleManager, build_arena: Allocator, selector_text: []const u8, block_text: []const u8, layer: u16) !void {
|
||||
if (selector_text.len == 0) return;
|
||||
|
||||
var props = VisibilityProperties{};
|
||||
@@ -241,9 +481,10 @@ fn addRawRule(self: *StyleManager, selector_text: []const u8, block_text: []cons
|
||||
const rule = VisibilityRule{
|
||||
.props = props,
|
||||
.selector = selector,
|
||||
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
|
||||
.priority = (@as(u64, computeSpecificity(selector)) << SPEC_SHIFT) | @min(self.next_doc_order, MAX_DOC_ORDER),
|
||||
};
|
||||
self.next_doc_order += 1;
|
||||
try self.rule_layers.append(build_arena, layer);
|
||||
|
||||
switch (bucket_key) {
|
||||
.id => |id| {
|
||||
@@ -283,6 +524,18 @@ fn rebuildIfDirty(self: *StyleManager) !void {
|
||||
return;
|
||||
}
|
||||
|
||||
// There are some allocations that only need to live for the duration of
|
||||
// this rebuild. Having two arena (build_arena and self.arena) isn't ideal
|
||||
// as it's easy to use the wrong one.
|
||||
const build_arena = try self.frame.getArena(.medium, "StyleManager.rebuild");
|
||||
defer {
|
||||
self.layers = .empty;
|
||||
self.layer_ids = .empty;
|
||||
self.next_anon_layer = 0;
|
||||
self.rule_layers = .empty;
|
||||
self.frame.releaseArena(build_arena);
|
||||
}
|
||||
|
||||
self.dirty = false;
|
||||
errdefer self.dirty = true;
|
||||
const id_rules_count = self.id_rules.count();
|
||||
@@ -292,7 +545,7 @@ fn rebuildIfDirty(self: *StyleManager) !void {
|
||||
|
||||
self.frame._session.arena_pool.resetRetain(self.arena);
|
||||
|
||||
self.next_doc_order = 0;
|
||||
self.next_doc_order = 1;
|
||||
|
||||
self.id_rules = .empty;
|
||||
try self.id_rules.ensureTotalCapacity(self.arena, id_rules_count);
|
||||
@@ -308,11 +561,13 @@ fn rebuildIfDirty(self: *StyleManager) !void {
|
||||
|
||||
const sheets = self.frame.document._style_sheets orelse return;
|
||||
for (sheets._sheets.items) |sheet| {
|
||||
self.parseSheet(sheet) catch |err| {
|
||||
self.parseSheet(build_arena, sheet) catch |err| {
|
||||
log.err(.browser, "StyleManager parseSheet", .{ .err = err });
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
try self.finalizeLayerRanks(build_arena);
|
||||
}
|
||||
|
||||
// Check if an element is hidden based on options.
|
||||
@@ -500,7 +755,7 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
const selectors = rules.items(.selector);
|
||||
|
||||
for (priorities, props_list, selectors) |p, props, selector| {
|
||||
// Fast skip using packed u64 priority
|
||||
// Fast skip using packed priority
|
||||
if (p <= ctx.display_priority.* and p <= ctx.visibility_priority.* and p <= ctx.opacity_priority.*) {
|
||||
continue;
|
||||
}
|
||||
@@ -674,7 +929,7 @@ fn elementHasPointerEventsNone(self: *StyleManager, el: *Element) bool {
|
||||
// Extracts visibility-relevant rules from a CSS rule.
|
||||
// Creates one VisibilityRule per selector (not per selector list) so each has correct specificity.
|
||||
// Buckets rules by their rightmost selector part for fast lookup.
|
||||
fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void {
|
||||
fn addRule(self: *StyleManager, build_arena: Allocator, style_rule: *CSSStyleRule) !void {
|
||||
const selector_text = style_rule._selector_text;
|
||||
if (selector_text.len == 0) {
|
||||
return;
|
||||
@@ -708,9 +963,10 @@ fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void {
|
||||
const rule = VisibilityRule{
|
||||
.props = props,
|
||||
.selector = selector,
|
||||
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
|
||||
.priority = (@as(u64, computeSpecificity(selector)) << SPEC_SHIFT) | @min(self.next_doc_order, MAX_DOC_ORDER),
|
||||
};
|
||||
self.next_doc_order += 1;
|
||||
try self.rule_layers.append(build_arena, NO_LAYER);
|
||||
|
||||
// Add to appropriate bucket
|
||||
switch (bucket_key) {
|
||||
@@ -884,10 +1140,45 @@ const VisibilityRule = struct {
|
||||
selector: Selector.Selector, // Single selector, not a list
|
||||
props: VisibilityProperties,
|
||||
|
||||
// Packed priority: (specificity << 32) | doc_order
|
||||
// Packed priority: layer_rank:12 | specificity:30 | doc_order:22.
|
||||
// The rank bits are 0 until finalizeLayerRanks stamps them (the rank
|
||||
// isn't known until all sheets are parsed); the rule's layer lives in
|
||||
// the build-only rule_layers side array, indexed by doc_order - 1.
|
||||
priority: u64,
|
||||
};
|
||||
|
||||
const Layer = struct {
|
||||
// dotted path from the root
|
||||
path: []const u8,
|
||||
|
||||
// Number of path components
|
||||
depth: u16,
|
||||
|
||||
// Index of the parent layer in `layers` (NO_LAYER for top level)
|
||||
parent: u16,
|
||||
|
||||
/// Position in the final cascade order, lowest priority first.
|
||||
rank: u32 = 0,
|
||||
};
|
||||
|
||||
/// This sentinel has two meanings. AS a rule's layer, it's the highest priority
|
||||
/// (rules outside of a layer are highest priority). As a Layer.parent, it means
|
||||
// no paren (top-level layer)
|
||||
const NO_LAYER: u16 = std.math.maxInt(u16);
|
||||
|
||||
const UNLAYERED_RANK: u32 = std.math.maxInt(u12);
|
||||
|
||||
/// Protect against hostile input. Must be < UNLAYERED_RANK so that it fits in
|
||||
// its 12 bits of VisibleRule.priority
|
||||
const MAX_LAYERS: usize = 1024;
|
||||
|
||||
// VisibilityRule.priority field offsets (layer_rank:12 | spec:30 | doc:22).
|
||||
const SPEC_SHIFT: u6 = 22;
|
||||
const RANK_SHIFT: u6 = 52;
|
||||
|
||||
// - 1 since INLINE_PRIORITY is _always_ higher
|
||||
const MAX_DOC_ORDER: u32 = std.math.maxInt(u22) - 1;
|
||||
|
||||
const CheckVisibilityOptions = struct {
|
||||
check_display: bool = true,
|
||||
check_visibility: bool = false,
|
||||
@@ -895,7 +1186,9 @@ const CheckVisibilityOptions = struct {
|
||||
ua_display_none: bool = true,
|
||||
};
|
||||
|
||||
// Inline styles always win over stylesheets - use max u64 as sentinel
|
||||
// Inline styles always win over stylesheets - use max u64 as sentinel.
|
||||
// Strictly above any packed rule priority: doc_order saturates one below
|
||||
// its field max, so a real rule can never pack to all-ones.
|
||||
const INLINE_PRIORITY: u64 = std.math.maxInt(u64);
|
||||
|
||||
fn getInlineStyleProperty(el: *Element, property_name: String, frame: *Frame) ?*CSSStyleProperty {
|
||||
@@ -1072,3 +1365,19 @@ test "StyleManager: document order tie-breaking" {
|
||||
// Equal specificity and doc_order: no win
|
||||
try testing.expect(!beats(1, 5, 1, 5));
|
||||
}
|
||||
|
||||
test "StyleManager: packed priority bounds" {
|
||||
// The three fields fill the u64 exactly, without overlap.
|
||||
try testing.expectEqual(64, @as(u32, RANK_SHIFT) + 12);
|
||||
try testing.expectEqual(RANK_SHIFT, @as(u32, SPEC_SHIFT) + 30);
|
||||
|
||||
// The maximum packable rule priority (unlayered rank, fully-clamped
|
||||
// specificity, saturated doc_order) stays below INLINE_PRIORITY.
|
||||
const max_packed = (@as(u64, UNLAYERED_RANK) << RANK_SHIFT) |
|
||||
(@as(u64, std.math.maxInt(u30)) << SPEC_SHIFT) |
|
||||
MAX_DOC_ORDER;
|
||||
try testing.expect(max_packed < INLINE_PRIORITY);
|
||||
|
||||
// Real layer ranks fit the 12 rank bits below the unlayered sentinel.
|
||||
try testing.expect(MAX_LAYERS < UNLAYERED_RANK);
|
||||
}
|
||||
|
||||
@@ -931,6 +931,12 @@ fn jsValueToTypedArray(comptime T: type, js_val: js.Value) !?[]T {
|
||||
return ptr[0..num_elements];
|
||||
}
|
||||
},
|
||||
f16 => {
|
||||
if (js_val.isFloat16Array()) {
|
||||
const ptr = @as([*]f16, @ptrCast(@alignCast(base)));
|
||||
return ptr[0..num_elements];
|
||||
}
|
||||
},
|
||||
f32 => {
|
||||
if (js_val.isFloat32Array()) {
|
||||
const ptr = @as([*]f32, @ptrCast(@alignCast(base)));
|
||||
@@ -1071,6 +1077,9 @@ fn probeJsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !Pr
|
||||
i64 => if (js_val.isBigInt64Array()) {
|
||||
return .{ .ok = {} };
|
||||
},
|
||||
f16 => if (js_val.isFloat16Array()) {
|
||||
return .{ .ok = {} };
|
||||
},
|
||||
f32 => if (js_val.isFloat32Array()) {
|
||||
return .{ .ok = {} };
|
||||
},
|
||||
|
||||
@@ -166,6 +166,10 @@ pub fn isBigInt64Array(self: Value) bool {
|
||||
return v8.v8__Value__IsBigInt64Array(self.handle);
|
||||
}
|
||||
|
||||
pub fn isFloat16Array(self: Value) bool {
|
||||
return v8.v8__Value__IsFloat16Array(self.handle);
|
||||
}
|
||||
|
||||
pub fn isFloat32Array(self: Value) bool {
|
||||
return v8.v8__Value__IsFloat32Array(self.handle);
|
||||
}
|
||||
|
||||
@@ -1070,6 +1070,12 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/element/svg/Polyline.zig"),
|
||||
@import("../webapi/svg/AnimatedString.zig"),
|
||||
@import("../webapi/svg/Number.zig"),
|
||||
@import("../webapi/svg/Length.zig"),
|
||||
@import("../webapi/svg/Angle.zig"),
|
||||
@import("../webapi/svg/Transform.zig"),
|
||||
@import("../webapi/svg/AnimatedLength.zig"),
|
||||
@import("../webapi/svg/PreserveAspectRatio.zig"),
|
||||
@import("../webapi/svg/AnimatedPreserveAspectRatio.zig"),
|
||||
@import("../webapi/encoding/TextDecoder.zig"),
|
||||
@import("../webapi/encoding/TextEncoder.zig"),
|
||||
@import("../webapi/encoding/TextEncoderStream.zig"),
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
</head>
|
||||
|
||||
<!--
|
||||
`@layer` block rules (CSS Cascade 5) are flattened into the cascade: the
|
||||
inner rules participate exactly as if they were written at the top level,
|
||||
recursing into nested `@media` / `@layer`. Layer-priority ordering
|
||||
(css-cascade-5 §6.4) is deliberately not implemented — ties keep breaking
|
||||
on specificity + document order. The statement form (`@layer a, b;`)
|
||||
declares ordering only and carries no block, so it is inert.
|
||||
`@layer` parsing surface: block/statement forms, named/anonymous/dotted
|
||||
names, nesting with `@media`, the dynamic CSSOM paths, and the depth cap.
|
||||
Every hidden-element assertion here is uncontested (no competing rule), so
|
||||
it holds under any layer order. The ordering matrix — unlayered vs layered,
|
||||
inter-layer rank, sub-layers, statement-declared order — lives in
|
||||
layer_order_cascade.html.
|
||||
-->
|
||||
|
||||
<style>
|
||||
|
||||
182
src/browser/tests/css/layer_order_cascade.html
Normal file
182
src/browser/tests/css/layer_order_cascade.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<script src="../testing.js"></script>
|
||||
</head>
|
||||
|
||||
<!--
|
||||
Cascade-layer priority ordering (css-cascade-5 §6.4). Every case here pits
|
||||
two rules against each other with INVERTED specificity, so it only passes
|
||||
when layer order — not specificity — breaks the tie:
|
||||
- unlayered styles beat layered ones,
|
||||
- a later-declared layer beats an earlier one,
|
||||
- a parent layer's direct styles beat its sub-layers',
|
||||
- the statement form (`@layer a, b;`) fixes first-declaration order.
|
||||
The parsing surface (block/statement forms, nesting, CSSOM paths) is
|
||||
covered by layer_at_rule_cascade.html.
|
||||
-->
|
||||
|
||||
<style>
|
||||
/* Unlayered beats layered regardless of specificity. */
|
||||
@layer any { #unlayered-wins { display: none; } }
|
||||
.unlayered-wins { display: block; }
|
||||
|
||||
/* Later-declared layer beats an earlier one regardless of specificity. */
|
||||
@layer early { #later-layer-wins { display: none; } }
|
||||
@layer late { .later-layer-wins { display: block; } }
|
||||
|
||||
/* The statement form fixes the order up front: `late2` is declared last,
|
||||
so it outranks `early2` even though its block appears first. */
|
||||
@layer early2, late2;
|
||||
@layer late2 { .statement-order { display: block; } }
|
||||
@layer early2 { #statement-order { display: none; } }
|
||||
|
||||
/* A re-opened layer keeps its first-declaration rank. */
|
||||
@layer one { }
|
||||
@layer two { .reopened { display: block; } }
|
||||
@layer one { #reopened { display: none; } }
|
||||
|
||||
/* A parent's direct styles beat its sub-layers'. */
|
||||
@layer parent {
|
||||
@layer child { #parent-direct { display: none; } }
|
||||
.parent-direct { display: block; }
|
||||
}
|
||||
|
||||
/* A dotted name addresses a sub-layer of an existing layer, not a new
|
||||
top-level layer that happens to contain a dot. */
|
||||
@layer m { }
|
||||
@layer m.n { #dotted-sublayer { display: none; } }
|
||||
@layer m { .dotted-sublayer { display: block; } }
|
||||
|
||||
/* Interleaved sub-layer creation: i1.y is declared AFTER i2, but it lives
|
||||
under i1, which was declared (via i1.x) BEFORE i2 — so i1.y still ranks
|
||||
below i2. This is why ranks can't be assigned on first sight. */
|
||||
@layer i1.x { }
|
||||
@layer i2 { .interleaved { display: block; } }
|
||||
@layer i1.y { #interleaved { display: none; } }
|
||||
|
||||
/* Two anonymous blocks are distinct layers, ordered by declaration. */
|
||||
@layer { #anon-order { display: none; } }
|
||||
@layer { .anon-order { display: block; } }
|
||||
|
||||
/* Rules inside @media keep the enclosing layer's rank. */
|
||||
@layer med { @media screen { #media-in-layer-rank { display: none; } } }
|
||||
.media-in-layer-rank { display: block; }
|
||||
|
||||
/* A @layer statement inside a non-matching @media must not register:
|
||||
without it, flip1/flip2 order by their blocks and flip2 wins. */
|
||||
@media print { @layer flip2, flip1; }
|
||||
@layer flip1 { .flip { display: block; } }
|
||||
@layer flip2 { #flip { display: none; } }
|
||||
|
||||
/* visibility follows the same layer ordering. */
|
||||
@layer vis { #vis-unlayered { visibility: hidden; } }
|
||||
.vis-unlayered { visibility: visible; }
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<div id="unlayered-wins" class="unlayered-wins">unlayered</div>
|
||||
<div id="later-layer-wins" class="later-layer-wins">later-layer</div>
|
||||
<div id="statement-order" class="statement-order">statement</div>
|
||||
<div id="reopened" class="reopened">reopened</div>
|
||||
<div id="parent-direct" class="parent-direct">parent-direct</div>
|
||||
<div id="dotted-sublayer" class="dotted-sublayer">dotted</div>
|
||||
<div id="interleaved" class="interleaved">interleaved</div>
|
||||
<div id="anon-order" class="anon-order">anon</div>
|
||||
<div id="media-in-layer-rank" class="media-in-layer-rank">media-rank</div>
|
||||
<div id="flip" class="flip">flip</div>
|
||||
<div id="vis-unlayered" class="vis-unlayered">vis</div>
|
||||
<div id="dyn-order" class="dyn-order">dyn</div>
|
||||
<div id="dyn2-order" class="dyn2-order">dyn2</div>
|
||||
<style id="dyn"></style>
|
||||
<style id="dyn2"></style>
|
||||
</body>
|
||||
|
||||
<script id=layer_unlayered_beats_layered>
|
||||
{
|
||||
testing.expectTrue($('#unlayered-wins').checkVisibility());
|
||||
// getComputedStyle consumes the same cascade.
|
||||
testing.expectEqual('block', getComputedStyle($('#unlayered-wins')).display);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_later_beats_earlier>
|
||||
{
|
||||
testing.expectTrue($('#later-layer-wins').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_statement_fixes_order>
|
||||
{
|
||||
testing.expectTrue($('#statement-order').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_reopened_keeps_rank>
|
||||
{
|
||||
testing.expectTrue($('#reopened').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_parent_direct_beats_sublayer>
|
||||
{
|
||||
testing.expectTrue($('#parent-direct').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_dotted_is_sublayer>
|
||||
{
|
||||
testing.expectTrue($('#dotted-sublayer').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_interleaved_sublayer_creation>
|
||||
{
|
||||
testing.expectTrue($('#interleaved').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_anonymous_are_distinct>
|
||||
{
|
||||
testing.expectTrue($('#anon-order').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_media_keeps_layer_rank>
|
||||
{
|
||||
testing.expectTrue($('#media-in-layer-rank').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_statement_in_false_media_ignored>
|
||||
{
|
||||
testing.expectFalse($('#flip').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_visibility_ordering>
|
||||
{
|
||||
testing.expectTrue(
|
||||
$('#vis-unlayered').checkVisibility({ visibilityProperty: true })
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_order_insertRule>
|
||||
{
|
||||
// The cssRules path (insertRule) participates in the same layer order.
|
||||
const sheet = $('#dyn').sheet;
|
||||
sheet.insertRule('@layer d1 { #dyn-order { display: none; } }', 0);
|
||||
sheet.insertRule('.dyn-order { display: block; }', 1);
|
||||
testing.expectTrue($('#dyn-order').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_order_replaceSync>
|
||||
{
|
||||
$('#dyn2').sheet.replaceSync(
|
||||
'@layer a { #dyn2-order { display: none; } }' +
|
||||
'@layer b { .dyn2-order { display: block; } }'
|
||||
);
|
||||
testing.expectTrue($('#dyn2-order').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<svg id=first>
|
||||
<svg id=first x="10" y="20px" width="25%" height="2.54cm" preserveAspectRatio="xMinYMax slice">
|
||||
<g>
|
||||
<rect id=inner-rect></rect>
|
||||
<svg id=nested>
|
||||
@@ -27,6 +27,163 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=scalarFactories>
|
||||
{
|
||||
const svg = $('#first');
|
||||
|
||||
const length = svg.createSVGLength();
|
||||
testing.expectEqual(true, length instanceof SVGLength);
|
||||
testing.expectEqual(false, length === svg.createSVGLength());
|
||||
testing.expectEqual(SVGLength.SVG_LENGTHTYPE_NUMBER, length.unitType);
|
||||
testing.expectEqual(0, length.value);
|
||||
testing.expectEqual('0', length.valueAsString);
|
||||
|
||||
length.valueAsString = '2.54cm';
|
||||
testing.expectEqual(SVGLength.SVG_LENGTHTYPE_CM, length.unitType);
|
||||
testing.expectEqual(2.54, length.valueInSpecifiedUnits);
|
||||
testing.expectEqual(96, Math.round(length.value));
|
||||
testing.expectEqual('2.54cm', length.valueAsString);
|
||||
|
||||
// `value` is an absolute user-unit setter and resets the unit to NUMBER.
|
||||
length.value = 12;
|
||||
testing.expectEqual(SVGLength.SVG_LENGTHTYPE_NUMBER, length.unitType);
|
||||
testing.expectEqual('12', length.valueAsString);
|
||||
length.newValueSpecifiedUnits(SVGLength.SVG_LENGTHTYPE_PX, 4.5);
|
||||
testing.expectEqual('4.5px', length.valueAsString);
|
||||
testing.expectError('NotSupportedError', () => length.newValueSpecifiedUnits(0, 1));
|
||||
testing.expectError('TypeError', () => { length.value = Infinity; });
|
||||
testing.expectError('SyntaxError', () => { length.valueAsString = 'calc(1px + 2%)'; });
|
||||
testing.expectError('SyntaxError', () => { length.valueAsString = 'NaN'; });
|
||||
|
||||
const angle = svg.createSVGAngle();
|
||||
testing.expectEqual(true, angle instanceof SVGAngle);
|
||||
testing.expectEqual(false, angle === svg.createSVGAngle());
|
||||
testing.expectEqual(SVGAngle.SVG_ANGLETYPE_UNSPECIFIED, angle.unitType);
|
||||
angle.valueAsString = '3.141592653589793rad';
|
||||
testing.expectEqual(SVGAngle.SVG_ANGLETYPE_RAD, angle.unitType);
|
||||
testing.expectEqual(180, Math.round(angle.value));
|
||||
angle.convertToSpecifiedUnits(SVGAngle.SVG_ANGLETYPE_DEG);
|
||||
testing.expectEqual(180, Math.round(angle.valueInSpecifiedUnits));
|
||||
testing.expectEqual('180deg', angle.valueAsString);
|
||||
angle.value = 45;
|
||||
testing.expectEqual(SVGAngle.SVG_ANGLETYPE_UNSPECIFIED, angle.unitType);
|
||||
testing.expectEqual('45', angle.valueAsString);
|
||||
testing.expectError('NotSupportedError', () => angle.convertToSpecifiedUnits(0));
|
||||
testing.expectError('TypeError', () => { angle.value = NaN; });
|
||||
|
||||
const transform = svg.createSVGTransform();
|
||||
testing.expectEqual(true, transform instanceof SVGTransform);
|
||||
testing.expectEqual(false, transform === svg.createSVGTransform());
|
||||
testing.expectEqual(SVGTransform.SVG_TRANSFORM_MATRIX, transform.type);
|
||||
testing.expectEqual(true, transform.matrix === transform.matrix);
|
||||
testing.expectEqual(1, transform.matrix.a);
|
||||
transform.setTranslate(3, 4);
|
||||
testing.expectEqual(SVGTransform.SVG_TRANSFORM_TRANSLATE, transform.type);
|
||||
testing.expectEqual(3, transform.matrix.e);
|
||||
testing.expectEqual(4, transform.matrix.f);
|
||||
|
||||
const source = svg.createSVGMatrix();
|
||||
source.e = 8;
|
||||
const copied = svg.createSVGTransformFromMatrix(source);
|
||||
testing.expectEqual(8, copied.matrix.e);
|
||||
source.e = 9;
|
||||
testing.expectEqual(8, copied.matrix.e);
|
||||
copied.setRotate(90, 10, 20);
|
||||
testing.expectEqual(SVGTransform.SVG_TRANSFORM_ROTATE, copied.type);
|
||||
testing.expectEqual(90, copied.angle);
|
||||
testing.expectEqual(30, Math.round(copied.matrix.e));
|
||||
testing.expectEqual(10, Math.round(copied.matrix.f));
|
||||
testing.expectError('TypeError', () => copied.setScale(Infinity, 1));
|
||||
|
||||
const matrix3d = new DOMMatrix();
|
||||
matrix3d.m13 = 7;
|
||||
const copied2d = svg.createSVGTransformFromMatrix(matrix3d);
|
||||
testing.expectEqual(true, copied2d.matrix.is2D);
|
||||
testing.expectEqual(0, copied2d.matrix.m13);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=liveScalarProperties>
|
||||
{
|
||||
const svg = $('#first');
|
||||
|
||||
testing.expectEqual(true, svg.x instanceof SVGAnimatedLength);
|
||||
testing.expectEqual(true, svg.x === svg.x);
|
||||
testing.expectEqual(true, svg.x.baseVal === svg.x.baseVal);
|
||||
testing.expectEqual(true, svg.x.animVal === svg.x.animVal);
|
||||
testing.expectEqual(false, svg.x.baseVal === svg.x.animVal);
|
||||
testing.expectEqual(SVGLength.SVG_LENGTHTYPE_NUMBER, svg.x.baseVal.unitType);
|
||||
testing.expectEqual(SVGLength.SVG_LENGTHTYPE_PX, svg.y.baseVal.unitType);
|
||||
testing.expectEqual(SVGLength.SVG_LENGTHTYPE_PERCENTAGE, svg.width.baseVal.unitType);
|
||||
testing.expectEqual(SVGLength.SVG_LENGTHTYPE_CM, svg.height.baseVal.unitType);
|
||||
testing.expectEqual(96, Math.round(svg.height.baseVal.value));
|
||||
|
||||
const nested = $('#nested');
|
||||
nested.setAttribute('width', '50%');
|
||||
testing.expectEqual(
|
||||
Math.round(svg.width.baseVal.value / 2),
|
||||
Math.round(nested.width.baseVal.value),
|
||||
);
|
||||
nested.setAttribute('style', 'font-size: 20px');
|
||||
nested.setAttribute('x', '2em');
|
||||
testing.expectEqual(40, nested.x.baseVal.value);
|
||||
nested.setAttribute('font-size', '30px');
|
||||
testing.expectEqual(40, nested.x.baseVal.value);
|
||||
nested.style.fontSize = '';
|
||||
testing.expectEqual(60, nested.x.baseVal.value);
|
||||
|
||||
const deep = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
deep.setAttribute('width', '50%');
|
||||
let deepest = deep;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const child = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
child.setAttribute('width', '50%');
|
||||
child.setAttribute('font-size', '1em');
|
||||
deepest.appendChild(child);
|
||||
deepest = child;
|
||||
}
|
||||
deepest.setAttribute('x', '1em');
|
||||
document.body.appendChild(deep);
|
||||
testing.expectEqual(true, Number.isFinite(deepest.width.baseVal.value));
|
||||
testing.expectEqual(16, deepest.x.baseVal.value);
|
||||
deep.remove();
|
||||
|
||||
svg.setAttribute('x', '12px');
|
||||
testing.expectEqual(SVGLength.SVG_LENGTHTYPE_PX, svg.x.baseVal.unitType);
|
||||
testing.expectEqual(12, svg.x.baseVal.value);
|
||||
testing.expectEqual(12, svg.x.animVal.value);
|
||||
svg.x.baseVal.valueInSpecifiedUnits = 4.5;
|
||||
testing.expectEqual('4.5px', svg.getAttribute('x'));
|
||||
svg.x.baseVal.value = 7;
|
||||
testing.expectEqual('7', svg.getAttribute('x'));
|
||||
testing.expectEqual(SVGLength.SVG_LENGTHTYPE_NUMBER, svg.x.animVal.unitType);
|
||||
testing.expectError('NoModificationAllowedError', () => { svg.x.animVal.value = 3; });
|
||||
testing.expectError('NoModificationAllowedError', () => { svg.x.animVal.valueAsString = '3px'; });
|
||||
|
||||
const aspect = svg.preserveAspectRatio;
|
||||
testing.expectEqual(true, aspect instanceof SVGAnimatedPreserveAspectRatio);
|
||||
testing.expectEqual(true, aspect === svg.preserveAspectRatio);
|
||||
testing.expectEqual(true, aspect.baseVal === aspect.baseVal);
|
||||
testing.expectEqual(true, aspect.animVal === aspect.animVal);
|
||||
testing.expectEqual(false, aspect.baseVal === aspect.animVal);
|
||||
testing.expectEqual(SVGPreserveAspectRatio.SVG_PRESERVEASPECTRATIO_XMINYMAX, aspect.baseVal.align);
|
||||
testing.expectEqual(SVGPreserveAspectRatio.SVG_MEETORSLICE_SLICE, aspect.baseVal.meetOrSlice);
|
||||
|
||||
aspect.baseVal.align = SVGPreserveAspectRatio.SVG_PRESERVEASPECTRATIO_XMIDYMID;
|
||||
testing.expectEqual('xMidYMid slice', svg.getAttribute('preserveAspectRatio'));
|
||||
svg.setAttribute('preserveAspectRatio', 'none meet');
|
||||
testing.expectEqual(SVGPreserveAspectRatio.SVG_PRESERVEASPECTRATIO_NONE, aspect.baseVal.align);
|
||||
testing.expectEqual(SVGPreserveAspectRatio.SVG_PRESERVEASPECTRATIO_NONE, aspect.animVal.align);
|
||||
testing.expectError('TypeError', () => { aspect.baseVal.align = 0; });
|
||||
testing.expectError('NoModificationAllowedError', () => { aspect.animVal.meetOrSlice = 2; });
|
||||
|
||||
const empty = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
testing.expectEqual(SVGPreserveAspectRatio.SVG_PRESERVEASPECTRATIO_XMIDYMID, empty.preserveAspectRatio.baseVal.align);
|
||||
testing.expectEqual(SVGPreserveAspectRatio.SVG_MEETORSLICE_MEET, empty.preserveAspectRatio.baseVal.meetOrSlice);
|
||||
testing.expectEqual(null, empty.getAttribute('preserveAspectRatio'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=factories>
|
||||
{
|
||||
// SVG2 defines SVGPoint/SVGMatrix/SVGRect as aliases of the DOM geometry
|
||||
|
||||
@@ -624,6 +624,9 @@ pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void {
|
||||
}
|
||||
|
||||
const event = error_event.asEvent();
|
||||
event.acquireRef();
|
||||
defer event.releaseRef(frame._page);
|
||||
|
||||
event._prevent_default = prevent_default;
|
||||
// Pass null as handler: onerror was already called above with 5 args.
|
||||
// We still dispatch so that addEventListener('error', ...) listeners fire.
|
||||
|
||||
@@ -224,3 +224,7 @@ test "WebApi: CSSStyleSheet" {
|
||||
test "WebApi: layer @-rule cascade" {
|
||||
try testing.htmlRunner("css/layer_at_rule_cascade.html", .{});
|
||||
}
|
||||
|
||||
test "WebApi: layer order cascade" {
|
||||
try testing.htmlRunner("css/layer_order_cascade.html", .{});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ const DOMRect = @import("../../DOMRect.zig");
|
||||
const DOMPoint = @import("../../DOMPoint.zig");
|
||||
const DOMMatrix = @import("../../DOMMatrix.zig");
|
||||
const SvgNumber = @import("../../svg/Number.zig");
|
||||
const SvgLength = @import("../../svg/Length.zig");
|
||||
const SvgAngle = @import("../../svg/Angle.zig");
|
||||
const SvgTransform = @import("../../svg/Transform.zig");
|
||||
const AnimatedLength = @import("../../svg/AnimatedLength.zig");
|
||||
const AnimatedPreserveAspectRatio = @import("../../svg/AnimatedPreserveAspectRatio.zig");
|
||||
|
||||
const Graphics = @import("Graphics.zig");
|
||||
|
||||
@@ -56,6 +61,64 @@ pub fn getElementById(self: *Svg, id: []const u8) ?*Element {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn getX(self: *Svg, frame: *Frame) !*AnimatedLength {
|
||||
return AnimatedLength.getOrCreate(self.asElement(), .x, frame);
|
||||
}
|
||||
|
||||
pub fn getY(self: *Svg, frame: *Frame) !*AnimatedLength {
|
||||
return AnimatedLength.getOrCreate(self.asElement(), .y, frame);
|
||||
}
|
||||
|
||||
pub fn getWidth(self: *Svg, frame: *Frame) !*AnimatedLength {
|
||||
return AnimatedLength.getOrCreate(self.asElement(), .width, frame);
|
||||
}
|
||||
|
||||
pub fn getHeight(self: *Svg, frame: *Frame) !*AnimatedLength {
|
||||
return AnimatedLength.getOrCreate(self.asElement(), .height, frame);
|
||||
}
|
||||
|
||||
pub fn getPreserveAspectRatio(self: *Svg, frame: *Frame) !*AnimatedPreserveAspectRatio {
|
||||
return AnimatedPreserveAspectRatio.getOrCreate(self.asElement(), frame);
|
||||
}
|
||||
|
||||
pub fn createSVGPoint(_: *Svg, frame: *Frame) !*DOMPoint {
|
||||
return DOMPoint.create(0, 0, 0, 1, frame._page);
|
||||
}
|
||||
|
||||
pub fn createSVGMatrix(_: *Svg, frame: *Frame) !*DOMMatrix {
|
||||
const identity: [16]f64 = .{
|
||||
1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
};
|
||||
return DOMMatrix.create(identity, true, frame._page);
|
||||
}
|
||||
|
||||
pub fn createSVGRect(_: *Svg, frame: *Frame) !*DOMRect {
|
||||
return DOMRect.create(.{}, frame._factory);
|
||||
}
|
||||
|
||||
pub fn createSVGNumber(_: *Svg, frame: *Frame) !*SvgNumber {
|
||||
return frame._factory.create(SvgNumber{});
|
||||
}
|
||||
|
||||
pub fn createSVGLength(_: *Svg, frame: *Frame) !*SvgLength {
|
||||
return SvgLength.detached(frame);
|
||||
}
|
||||
|
||||
pub fn createSVGAngle(_: *Svg, frame: *Frame) !*SvgAngle {
|
||||
return SvgAngle.detached(frame);
|
||||
}
|
||||
|
||||
pub fn createSVGTransform(_: *Svg, frame: *Frame) !*SvgTransform {
|
||||
return SvgTransform.detached(frame);
|
||||
}
|
||||
|
||||
pub fn createSVGTransformFromMatrix(_: *Svg, init: ?SvgTransform.DOMMatrix2DInit, frame: *Frame) !*SvgTransform {
|
||||
return SvgTransform.fromMatrix(init, frame);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Svg);
|
||||
|
||||
@@ -67,29 +130,18 @@ pub const JsApi = struct {
|
||||
|
||||
pub const getElementById = bridge.function(Svg.getElementById, .{});
|
||||
|
||||
pub const createSVGPoint = bridge.function(_createSVGPoint, .{});
|
||||
fn _createSVGPoint(_: *Svg, frame: *Frame) !*DOMPoint {
|
||||
return DOMPoint.create(0, 0, 0, 1, frame._page);
|
||||
}
|
||||
pub const x = bridge.accessor(Svg.getX, null, .{});
|
||||
pub const y = bridge.accessor(Svg.getY, null, .{});
|
||||
pub const width = bridge.accessor(Svg.getWidth, null, .{});
|
||||
pub const height = bridge.accessor(Svg.getHeight, null, .{});
|
||||
pub const preserveAspectRatio = bridge.accessor(Svg.getPreserveAspectRatio, null, .{});
|
||||
|
||||
pub const createSVGMatrix = bridge.function(_createSVGMatrix, .{});
|
||||
fn _createSVGMatrix(_: *Svg, frame: *Frame) !*DOMMatrix {
|
||||
const identity: [16]f64 = .{
|
||||
1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
};
|
||||
return DOMMatrix.create(identity, true, frame._page);
|
||||
}
|
||||
|
||||
pub const createSVGRect = bridge.function(_createSVGRect, .{});
|
||||
fn _createSVGRect(_: *Svg, frame: *Frame) !*DOMRect {
|
||||
return DOMRect.create(.{}, frame._factory);
|
||||
}
|
||||
|
||||
pub const createSVGNumber = bridge.function(_createSVGNumber, .{});
|
||||
fn _createSVGNumber(_: *Svg, frame: *Frame) !*SvgNumber {
|
||||
return frame._factory.create(SvgNumber{});
|
||||
}
|
||||
pub const createSVGPoint = bridge.function(Svg.createSVGPoint, .{});
|
||||
pub const createSVGMatrix = bridge.function(Svg.createSVGMatrix, .{});
|
||||
pub const createSVGRect = bridge.function(Svg.createSVGRect, .{});
|
||||
pub const createSVGNumber = bridge.function(Svg.createSVGNumber, .{});
|
||||
pub const createSVGLength = bridge.function(Svg.createSVGLength, .{});
|
||||
pub const createSVGAngle = bridge.function(Svg.createSVGAngle, .{});
|
||||
pub const createSVGTransform = bridge.function(Svg.createSVGTransform, .{});
|
||||
pub const createSVGTransformFromMatrix = bridge.function(Svg.createSVGTransformFromMatrix, .{});
|
||||
};
|
||||
|
||||
252
src/browser/webapi/svg/Angle.zig
Normal file
252
src/browser/webapi/svg/Angle.zig
Normal file
@@ -0,0 +1,252 @@
|
||||
// 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 String = lp.String;
|
||||
const Angle = @This();
|
||||
|
||||
_value: f64 = 0,
|
||||
_unit: Unit = .unspecified,
|
||||
_element: ?*Element = null,
|
||||
_attr_name: String = .empty,
|
||||
_read_only: bool = false,
|
||||
|
||||
const Unit = enum(u16) {
|
||||
unknown = 0,
|
||||
unspecified = 1,
|
||||
deg = 2,
|
||||
rad = 3,
|
||||
grad = 4,
|
||||
turn = 5,
|
||||
};
|
||||
|
||||
pub fn detached(frame: *Frame) !*Angle {
|
||||
return frame._factory.create(Angle{});
|
||||
}
|
||||
|
||||
pub fn getUnitType(self: *Angle) u16 {
|
||||
self.syncFromAttribute();
|
||||
return switch (self._unit) {
|
||||
.turn => 0,
|
||||
else => @intFromEnum(self._unit),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn getValue(self: *Angle) f64 {
|
||||
self.syncFromAttribute();
|
||||
return toDegrees(self._value, self._unit);
|
||||
}
|
||||
|
||||
pub fn setValue(self: *Angle, value: f64, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
try ensureFinite(value);
|
||||
self._value = value;
|
||||
self._unit = .unspecified;
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
pub fn getValueInSpecifiedUnits(self: *Angle) f64 {
|
||||
self.syncFromAttribute();
|
||||
return self._value;
|
||||
}
|
||||
|
||||
pub fn setValueInSpecifiedUnits(self: *Angle, value: f64, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
try ensureFinite(value);
|
||||
self.syncFromAttribute();
|
||||
if (self._unit == .unknown) {
|
||||
self._unit = .unspecified;
|
||||
}
|
||||
self._value = value;
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
pub fn getValueAsString(self: *Angle, frame: *Frame) ![]const u8 {
|
||||
self.syncFromAttribute();
|
||||
return self.serialize(frame);
|
||||
}
|
||||
|
||||
pub fn setValueAsString(self: *Angle, value: String, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
const parsed = parse(value.str()) catch return error.SyntaxError;
|
||||
self._value = parsed.value;
|
||||
self._unit = parsed.unit;
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
pub fn newValueSpecifiedUnits(self: *Angle, unit_type: u16, value: f64, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
const unit = try checkedUnit(unit_type);
|
||||
try ensureFinite(value);
|
||||
self._unit = unit;
|
||||
self._value = value;
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
pub fn convertToSpecifiedUnits(self: *Angle, unit_type: u16, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
const target = try checkedUnit(unit_type);
|
||||
const degrees = self.getValue();
|
||||
self._unit = target;
|
||||
self._value = fromDegrees(degrees, target);
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
fn ensureWritable(self: *const Angle) !void {
|
||||
if (self._read_only) {
|
||||
return error.NoModificationAllowed;
|
||||
}
|
||||
}
|
||||
|
||||
fn ensureFinite(value: f64) !void {
|
||||
if (!std.math.isFinite(value)) {
|
||||
return error.TypeError;
|
||||
}
|
||||
}
|
||||
|
||||
fn syncFromAttribute(self: *Angle) void {
|
||||
const element = self._element orelse return;
|
||||
const raw = element.getAttributeSafe(self._attr_name) orelse {
|
||||
self._value = 0;
|
||||
self._unit = .unspecified;
|
||||
return;
|
||||
};
|
||||
const parsed = parse(raw) catch {
|
||||
self._value = 0;
|
||||
self._unit = .unknown;
|
||||
return;
|
||||
};
|
||||
self._value = parsed.value;
|
||||
self._unit = parsed.unit;
|
||||
}
|
||||
|
||||
fn writeBack(self: *Angle, frame: *Frame) !void {
|
||||
const element = self._element orelse return;
|
||||
const value = try self.serialize(frame);
|
||||
try element.setAttributeSafe(self._attr_name, .wrap(value), frame);
|
||||
}
|
||||
|
||||
fn serialize(self: *const Angle, frame: *Frame) ![]const u8 {
|
||||
return std.fmt.allocPrint(frame.local_arena, "{d}{s}", .{ self._value, unitSuffix(self._unit) });
|
||||
}
|
||||
|
||||
const Parsed = struct {
|
||||
value: f64,
|
||||
unit: Unit,
|
||||
};
|
||||
|
||||
fn parse(input: []const u8) !Parsed {
|
||||
const value = std.mem.trim(u8, input, " \t\r\n\x0c");
|
||||
if (value.len == 0) {
|
||||
return error.SyntaxError;
|
||||
}
|
||||
|
||||
const suffixes = [_]struct { []const u8, Unit }{
|
||||
.{ "turn", .turn },
|
||||
.{ "grad", .grad },
|
||||
.{ "deg", .deg },
|
||||
.{ "rad", .rad },
|
||||
};
|
||||
for (suffixes) |entry| {
|
||||
const suffix, const unit = entry;
|
||||
if (value.len <= suffix.len) {
|
||||
continue;
|
||||
}
|
||||
if (!std.ascii.eqlIgnoreCase(value[value.len - suffix.len ..], suffix)) {
|
||||
continue;
|
||||
}
|
||||
const number = std.mem.trim(u8, value[0 .. value.len - suffix.len], " \t\r\n\x0c");
|
||||
return .{ .value = try parseNumber(number), .unit = unit };
|
||||
}
|
||||
return .{ .value = try parseNumber(value), .unit = .unspecified };
|
||||
}
|
||||
|
||||
fn parseNumber(value: []const u8) !f64 {
|
||||
const number = std.fmt.parseFloat(f64, value) catch return error.SyntaxError;
|
||||
if (!std.math.isFinite(number)) {
|
||||
return error.SyntaxError;
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
fn checkedUnit(value: u16) !Unit {
|
||||
return switch (value) {
|
||||
1 => .unspecified,
|
||||
2 => .deg,
|
||||
3 => .rad,
|
||||
4 => .grad,
|
||||
else => error.NotSupported,
|
||||
};
|
||||
}
|
||||
|
||||
fn toDegrees(value: f64, unit: Unit) f64 {
|
||||
return switch (unit) {
|
||||
.unknown, .unspecified, .deg => value,
|
||||
.rad => value * 180.0 / std.math.pi,
|
||||
.grad => value * 0.9,
|
||||
.turn => value * 360.0,
|
||||
};
|
||||
}
|
||||
|
||||
fn fromDegrees(value: f64, unit: Unit) f64 {
|
||||
return switch (unit) {
|
||||
.unknown, .unspecified, .deg => value,
|
||||
.rad => value * std.math.pi / 180.0,
|
||||
.grad => value / 0.9,
|
||||
.turn => value / 360.0,
|
||||
};
|
||||
}
|
||||
|
||||
fn unitSuffix(unit: Unit) []const u8 {
|
||||
return switch (unit) {
|
||||
.unknown, .unspecified => "",
|
||||
.deg => "deg",
|
||||
.rad => "rad",
|
||||
.grad => "grad",
|
||||
.turn => "turn",
|
||||
};
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Angle);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "SVGAngle";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const SVG_ANGLETYPE_UNKNOWN = bridge.property(0, .{ .template = true });
|
||||
pub const SVG_ANGLETYPE_UNSPECIFIED = bridge.property(1, .{ .template = true });
|
||||
pub const SVG_ANGLETYPE_DEG = bridge.property(2, .{ .template = true });
|
||||
pub const SVG_ANGLETYPE_RAD = bridge.property(3, .{ .template = true });
|
||||
pub const SVG_ANGLETYPE_GRAD = bridge.property(4, .{ .template = true });
|
||||
|
||||
pub const unitType = bridge.accessor(Angle.getUnitType, null, .{});
|
||||
pub const value = bridge.accessor(Angle.getValue, Angle.setValue, .{});
|
||||
pub const valueInSpecifiedUnits = bridge.accessor(Angle.getValueInSpecifiedUnits, Angle.setValueInSpecifiedUnits, .{});
|
||||
pub const valueAsString = bridge.accessor(Angle.getValueAsString, Angle.setValueAsString, .{});
|
||||
pub const newValueSpecifiedUnits = bridge.function(Angle.newValueSpecifiedUnits, .{});
|
||||
pub const convertToSpecifiedUnits = bridge.function(Angle.convertToSpecifiedUnits, .{});
|
||||
};
|
||||
103
src/browser/webapi/svg/AnimatedLength.zig
Normal file
103
src/browser/webapi/svg/AnimatedLength.zig
Normal file
@@ -0,0 +1,103 @@
|
||||
// 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 Length = @import("Length.zig");
|
||||
|
||||
const AnimatedLength = @This();
|
||||
|
||||
_base_val: *Length,
|
||||
_anim_val: *Length,
|
||||
|
||||
pub const Kind = enum {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
|
||||
fn attributeName(self: Kind) lp.String {
|
||||
return switch (self) {
|
||||
.x => comptime .wrap("x"),
|
||||
.y => comptime .wrap("y"),
|
||||
.width => comptime .wrap("width"),
|
||||
.height => comptime .wrap("height"),
|
||||
};
|
||||
}
|
||||
|
||||
fn direction(self: Kind) Length.Direction {
|
||||
return switch (self) {
|
||||
.x, .width => .horizontal,
|
||||
.y, .height => .vertical,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const Key = struct {
|
||||
element: *Element,
|
||||
kind: Kind,
|
||||
};
|
||||
|
||||
pub const Lookup = std.AutoHashMapUnmanaged(Key, *AnimatedLength);
|
||||
|
||||
pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*AnimatedLength {
|
||||
const key: Key = .{
|
||||
.element = element,
|
||||
.kind = kind,
|
||||
};
|
||||
const gop = try frame._svg_animated_lengths.getOrPut(frame.arena, key);
|
||||
if (!gop.found_existing) {
|
||||
errdefer _ = frame._svg_animated_lengths.remove(key);
|
||||
gop.value_ptr.* = try create(element, kind.attributeName(), kind.direction(), frame);
|
||||
}
|
||||
return gop.value_ptr.*;
|
||||
}
|
||||
|
||||
pub fn create(element: *Element, attr_name: lp.String, direction: Length.Direction, frame: *Frame) !*AnimatedLength {
|
||||
const base_val = try Length.reflected(element, attr_name, direction, false, frame);
|
||||
const anim_val = try Length.reflected(element, attr_name, direction, true, frame);
|
||||
return frame._factory.create(AnimatedLength{
|
||||
._base_val = base_val,
|
||||
._anim_val = anim_val,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn getBaseVal(self: *AnimatedLength) *Length {
|
||||
return self._base_val;
|
||||
}
|
||||
|
||||
pub fn getAnimVal(self: *AnimatedLength) *Length {
|
||||
return self._anim_val;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(AnimatedLength);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "SVGAnimatedLength";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const baseVal = bridge.accessor(AnimatedLength.getBaseVal, null, .{});
|
||||
pub const animVal = bridge.accessor(AnimatedLength.getAnimVal, null, .{});
|
||||
};
|
||||
70
src/browser/webapi/svg/AnimatedPreserveAspectRatio.zig
Normal file
70
src/browser/webapi/svg/AnimatedPreserveAspectRatio.zig
Normal file
@@ -0,0 +1,70 @@
|
||||
// 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 js = @import("../../js/js.zig");
|
||||
const Frame = @import("../../Frame.zig");
|
||||
const Element = @import("../Element.zig");
|
||||
const PreserveAspectRatio = @import("PreserveAspectRatio.zig");
|
||||
|
||||
const AnimatedPreserveAspectRatio = @This();
|
||||
|
||||
_base_val: *PreserveAspectRatio,
|
||||
_anim_val: *PreserveAspectRatio,
|
||||
|
||||
pub const Lookup = std.AutoHashMapUnmanaged(*Element, *AnimatedPreserveAspectRatio);
|
||||
|
||||
pub fn getOrCreate(element: *Element, frame: *Frame) !*AnimatedPreserveAspectRatio {
|
||||
const gop = try frame._svg_animated_preserve_aspect_ratios.getOrPut(frame.arena, element);
|
||||
if (!gop.found_existing) {
|
||||
errdefer _ = frame._svg_animated_preserve_aspect_ratios.remove(element);
|
||||
gop.value_ptr.* = try create(element, frame);
|
||||
}
|
||||
return gop.value_ptr.*;
|
||||
}
|
||||
|
||||
pub fn create(element: *Element, frame: *Frame) !*AnimatedPreserveAspectRatio {
|
||||
const base_val = try PreserveAspectRatio.create(element, false, frame);
|
||||
const anim_val = try PreserveAspectRatio.create(element, true, frame);
|
||||
return frame._factory.create(AnimatedPreserveAspectRatio{
|
||||
._base_val = base_val,
|
||||
._anim_val = anim_val,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn getBaseVal(self: *AnimatedPreserveAspectRatio) *PreserveAspectRatio {
|
||||
return self._base_val;
|
||||
}
|
||||
|
||||
pub fn getAnimVal(self: *AnimatedPreserveAspectRatio) *PreserveAspectRatio {
|
||||
return self._anim_val;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(AnimatedPreserveAspectRatio);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "SVGAnimatedPreserveAspectRatio";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const baseVal = bridge.accessor(AnimatedPreserveAspectRatio.getBaseVal, null, .{});
|
||||
pub const animVal = bridge.accessor(AnimatedPreserveAspectRatio.getAnimVal, null, .{});
|
||||
};
|
||||
@@ -40,8 +40,10 @@ pub const Key = struct {
|
||||
|
||||
// Identity map for AnimatedString, help by the frame
|
||||
pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*AnimatedString {
|
||||
const gop = try frame._svg_animated_strings.getOrPut(frame.arena, .{ .element = element, .kind = kind });
|
||||
const key: Key = .{ .element = element, .kind = kind };
|
||||
const gop = try frame._svg_animated_strings.getOrPut(frame.arena, key);
|
||||
if (!gop.found_existing) {
|
||||
errdefer _ = frame._svg_animated_strings.remove(key);
|
||||
gop.value_ptr.* = try frame._factory.create(AnimatedString{
|
||||
._element = element,
|
||||
._kind = kind,
|
||||
|
||||
425
src/browser/webapi/svg/Length.zig
Normal file
425
src/browser/webapi/svg/Length.zig
Normal file
@@ -0,0 +1,425 @@
|
||||
// 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 String = lp.String;
|
||||
const Length = @This();
|
||||
|
||||
_value: f64 = 0,
|
||||
_unit: Unit = .number,
|
||||
_element: ?*Element = null,
|
||||
_attr_name: String = .empty,
|
||||
_direction: Direction = .unspecified,
|
||||
_read_only: bool = false,
|
||||
|
||||
pub const Direction = enum {
|
||||
horizontal,
|
||||
vertical,
|
||||
unspecified,
|
||||
};
|
||||
|
||||
const Unit = enum(u16) {
|
||||
unknown = 0,
|
||||
number = 1,
|
||||
percentage = 2,
|
||||
em = 3,
|
||||
ex = 4,
|
||||
px = 5,
|
||||
cm = 6,
|
||||
mm = 7,
|
||||
in = 8,
|
||||
pt = 9,
|
||||
pc = 10,
|
||||
};
|
||||
|
||||
const MAX_ANCESTOR_DEPTH = 32;
|
||||
|
||||
pub fn detached(frame: *Frame) !*Length {
|
||||
return frame._factory.create(Length{});
|
||||
}
|
||||
|
||||
pub fn reflected(element: *Element, attr_name: String, direction: Direction, read_only: bool, frame: *Frame) !*Length {
|
||||
return frame._factory.create(Length{
|
||||
._element = element,
|
||||
._attr_name = attr_name,
|
||||
._direction = direction,
|
||||
._read_only = read_only,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn getUnitType(self: *Length) u16 {
|
||||
self.syncFromAttribute();
|
||||
return @intFromEnum(self._unit);
|
||||
}
|
||||
|
||||
pub fn getValue(self: *Length, frame: *Frame) f64 {
|
||||
self.syncFromAttribute();
|
||||
return self._value * self.unitToUserUnits(self._unit, frame);
|
||||
}
|
||||
|
||||
pub fn setValue(self: *Length, value: f64, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
try ensureFinite(value);
|
||||
self._value = value;
|
||||
self._unit = .number;
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
pub fn getValueInSpecifiedUnits(self: *Length) f64 {
|
||||
self.syncFromAttribute();
|
||||
return self._value;
|
||||
}
|
||||
|
||||
pub fn setValueInSpecifiedUnits(self: *Length, value: f64, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
try ensureFinite(value);
|
||||
self.syncFromAttribute();
|
||||
if (self._unit == .unknown) {
|
||||
self._unit = .number;
|
||||
}
|
||||
self._value = value;
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
pub fn getValueAsString(self: *Length, frame: *Frame) ![]const u8 {
|
||||
self.syncFromAttribute();
|
||||
return self.serialize(frame);
|
||||
}
|
||||
|
||||
pub fn setValueAsString(self: *Length, value: String, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
const parsed = parse(value.str()) catch return error.SyntaxError;
|
||||
self._value = parsed.value;
|
||||
self._unit = parsed.unit;
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
pub fn newValueSpecifiedUnits(self: *Length, unit_type: u16, value: f64, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
const unit = try checkedUnit(unit_type);
|
||||
try ensureFinite(value);
|
||||
self._unit = unit;
|
||||
self._value = value;
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
pub fn convertToSpecifiedUnits(self: *Length, unit_type: u16, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
const target = try checkedUnit(unit_type);
|
||||
const absolute = self.getValue(frame);
|
||||
const factor = self.unitToUserUnits(target, frame);
|
||||
self._unit = target;
|
||||
self._value = if (factor == 0) 0 else absolute / factor;
|
||||
try self.writeBack(frame);
|
||||
}
|
||||
|
||||
fn ensureWritable(self: *const Length) !void {
|
||||
if (self._read_only) {
|
||||
return error.NoModificationAllowed;
|
||||
}
|
||||
}
|
||||
|
||||
fn ensureFinite(value: f64) !void {
|
||||
if (!std.math.isFinite(value)) {
|
||||
return error.TypeError;
|
||||
}
|
||||
}
|
||||
|
||||
fn syncFromAttribute(self: *Length) void {
|
||||
const element = self._element orelse return;
|
||||
const raw = element.getAttributeSafe(self._attr_name) orelse {
|
||||
self._value = 0;
|
||||
self._unit = .number;
|
||||
return;
|
||||
};
|
||||
const parsed = parse(raw) catch {
|
||||
self._value = 0;
|
||||
self._unit = .unknown;
|
||||
return;
|
||||
};
|
||||
self._value = parsed.value;
|
||||
self._unit = parsed.unit;
|
||||
}
|
||||
|
||||
fn writeBack(self: *Length, frame: *Frame) !void {
|
||||
const element = self._element orelse return;
|
||||
const value = try self.serialize(frame);
|
||||
try element.setAttributeSafe(self._attr_name, .wrap(value), frame);
|
||||
}
|
||||
|
||||
fn serialize(self: *const Length, frame: *Frame) ![]const u8 {
|
||||
return std.fmt.allocPrint(frame.local_arena, "{d}{s}", .{ self._value, unitSuffix(self._unit) });
|
||||
}
|
||||
|
||||
fn unitToUserUnits(self: *const Length, unit: Unit, frame: *Frame) f64 {
|
||||
if (absoluteUnitFactor(unit)) |factor| {
|
||||
return factor;
|
||||
}
|
||||
return switch (unit) {
|
||||
.unknown => 1,
|
||||
.percentage => self.percentageBasis(frame) / 100.0,
|
||||
.em => self.fontSize(frame),
|
||||
// Lightpanda does not load font metrics for DOM-only SVG values. CSS
|
||||
// defines 0.5em as the fallback when the x-height is unavailable.
|
||||
.ex => self.fontSize(frame) / 2.0,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
fn percentageBasis(self: *const Length, frame: *Frame) f64 {
|
||||
const element = self._element orelse return 100;
|
||||
|
||||
return switch (self._direction) {
|
||||
.horizontal => ancestorViewportDimension(element, .horizontal, frame),
|
||||
.vertical => ancestorViewportDimension(element, .vertical, frame),
|
||||
.unspecified => blk: {
|
||||
const width = ancestorViewportDimension(element, .horizontal, frame);
|
||||
const height = ancestorViewportDimension(element, .vertical, frame);
|
||||
break :blk @sqrt((width * width + height * height) / 2.0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn ancestorViewportDimension(element: *Element, direction: Direction, frame: *Frame) f64 {
|
||||
return ancestorViewportDimensionAt(element, direction, frame, 0);
|
||||
}
|
||||
|
||||
fn ancestorViewportDimensionAt(element: *Element, direction: Direction, frame: *Frame, depth: u8) f64 {
|
||||
if (depth >= MAX_ANCESTOR_DEPTH) {
|
||||
return pageViewportDimension(direction, frame);
|
||||
}
|
||||
const viewport_element = nearestSvgViewport(element) orelse return pageViewportDimension(direction, frame);
|
||||
const attr_name: String = switch (direction) {
|
||||
.horizontal => comptime .wrap("width"),
|
||||
.vertical => comptime .wrap("height"),
|
||||
.unspecified => unreachable,
|
||||
};
|
||||
|
||||
const raw = viewport_element.getAttributeSafe(attr_name) orelse {
|
||||
// SVG2 treats an omitted nested width/height as auto, whose used value
|
||||
// is 100% of the containing SVG viewport.
|
||||
return ancestorViewportDimensionAt(viewport_element, direction, frame, depth + 1);
|
||||
};
|
||||
const parsed = parse(raw) catch return ancestorViewportDimensionAt(viewport_element, direction, frame, depth + 1);
|
||||
return resolveParsedLength(parsed, viewport_element, direction, frame, depth + 1);
|
||||
}
|
||||
|
||||
fn nearestSvgViewport(element: *Element) ?*Element {
|
||||
var current = element.parentElement();
|
||||
while (current) |parent| : (current = parent.parentElement()) {
|
||||
if (parent._namespace != .svg) return null;
|
||||
if (std.mem.eql(u8, parent.getTagNameLower(), "svg")) return parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn pageViewportDimension(direction: Direction, frame: *Frame) f64 {
|
||||
const viewport = frame._page.getViewport();
|
||||
return switch (direction) {
|
||||
.horizontal => @floatFromInt(viewport.width),
|
||||
.vertical => @floatFromInt(viewport.height),
|
||||
.unspecified => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
fn resolveParsedLength(parsed: Parsed, element: *Element, direction: Direction, frame: *Frame, depth: u8) f64 {
|
||||
const factor = absoluteUnitFactor(parsed.unit) orelse switch (parsed.unit) {
|
||||
.unknown => 1,
|
||||
.percentage => ancestorViewportDimensionAt(element, direction, frame, depth) / 100.0,
|
||||
.em => resolvedFontSizeAt(element, frame, depth),
|
||||
.ex => resolvedFontSizeAt(element, frame, depth) / 2.0,
|
||||
else => unreachable,
|
||||
};
|
||||
return parsed.value * factor;
|
||||
}
|
||||
|
||||
fn fontSize(self: *const Length, frame: *Frame) f64 {
|
||||
return resolvedFontSize(self._element, frame);
|
||||
}
|
||||
|
||||
// The style engine currently exposes inline declarations but does not compute
|
||||
// stylesheet font inheritance. Resolve the sources it can represent exactly,
|
||||
// then fall back to CSS's initial medium size (16px).
|
||||
fn resolvedFontSize(element: ?*Element, frame: *Frame) f64 {
|
||||
return resolvedFontSizeAt(element, frame, 0);
|
||||
}
|
||||
|
||||
fn resolvedFontSizeAt(element: ?*Element, frame: *Frame, depth: u8) f64 {
|
||||
if (depth >= MAX_ANCESTOR_DEPTH) {
|
||||
return 16;
|
||||
}
|
||||
const current = element orelse return 16;
|
||||
const parent = current.parentElement();
|
||||
|
||||
if (frame._style_manager.inlineStyleValue(current, comptime .wrap("font-size"))) |raw| {
|
||||
if (parseFontSize(raw, parent, frame, depth + 1)) |size| {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
if (current.getAttributeSafe(comptime .wrap("font-size"))) |raw| {
|
||||
if (parseFontSize(raw, parent, frame, depth + 1)) |size| {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
return resolvedFontSizeAt(parent, frame, depth + 1);
|
||||
}
|
||||
|
||||
fn parseFontSize(raw: []const u8, parent: ?*Element, frame: *Frame, depth: u8) ?f64 {
|
||||
const value = std.mem.trim(u8, raw, " \t\r\n\x0c");
|
||||
if (std.ascii.eqlIgnoreCase(value, "inherit") or std.ascii.eqlIgnoreCase(value, "unset")) {
|
||||
return resolvedFontSizeAt(parent, frame, depth);
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(value, "initial") or std.ascii.eqlIgnoreCase(value, "medium")) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
const parsed = parse(value) catch return null;
|
||||
const parent_size = resolvedFontSizeAt(parent, frame, depth);
|
||||
const factor = absoluteUnitFactor(parsed.unit) orelse switch (parsed.unit) {
|
||||
.unknown => return null,
|
||||
.percentage => parent_size / 100.0,
|
||||
.em => parent_size,
|
||||
.ex => parent_size / 2.0,
|
||||
else => unreachable,
|
||||
};
|
||||
return parsed.value * factor;
|
||||
}
|
||||
|
||||
fn absoluteUnitFactor(unit: Unit) ?f64 {
|
||||
return switch (unit) {
|
||||
.number, .px => 1,
|
||||
.cm => 96.0 / 2.54,
|
||||
.mm => 96.0 / 25.4,
|
||||
.in => 96,
|
||||
.pt => 96.0 / 72.0,
|
||||
.pc => 16,
|
||||
.unknown, .percentage, .em, .ex => null,
|
||||
};
|
||||
}
|
||||
|
||||
const Parsed = struct {
|
||||
value: f64,
|
||||
unit: Unit,
|
||||
};
|
||||
|
||||
fn parse(input: []const u8) !Parsed {
|
||||
const value = std.mem.trim(u8, input, " \t\r\n\x0c");
|
||||
if (value.len == 0) {
|
||||
return error.SyntaxError;
|
||||
}
|
||||
|
||||
const suffixes = [_]struct { []const u8, Unit }{
|
||||
.{ "%", .percentage },
|
||||
.{ "em", .em },
|
||||
.{ "ex", .ex },
|
||||
.{ "px", .px },
|
||||
.{ "cm", .cm },
|
||||
.{ "mm", .mm },
|
||||
.{ "in", .in },
|
||||
.{ "pt", .pt },
|
||||
.{ "pc", .pc },
|
||||
};
|
||||
|
||||
for (suffixes) |entry| {
|
||||
const suffix, const unit = entry;
|
||||
if (value.len <= suffix.len) {
|
||||
continue;
|
||||
}
|
||||
if (!std.ascii.eqlIgnoreCase(value[value.len - suffix.len ..], suffix)) {
|
||||
continue;
|
||||
}
|
||||
const number = std.mem.trim(u8, value[0 .. value.len - suffix.len], " \t\r\n\x0c");
|
||||
return .{ .value = try parseNumber(number), .unit = unit };
|
||||
}
|
||||
|
||||
return .{ .value = try parseNumber(value), .unit = .number };
|
||||
}
|
||||
|
||||
fn parseNumber(value: []const u8) !f64 {
|
||||
const number = std.fmt.parseFloat(f64, value) catch return error.SyntaxError;
|
||||
if (!std.math.isFinite(number)) {
|
||||
return error.SyntaxError;
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
fn checkedUnit(value: u16) !Unit {
|
||||
return switch (value) {
|
||||
1 => .number,
|
||||
2 => .percentage,
|
||||
3 => .em,
|
||||
4 => .ex,
|
||||
5 => .px,
|
||||
6 => .cm,
|
||||
7 => .mm,
|
||||
8 => .in,
|
||||
9 => .pt,
|
||||
10 => .pc,
|
||||
else => error.NotSupported,
|
||||
};
|
||||
}
|
||||
|
||||
fn unitSuffix(unit: Unit) []const u8 {
|
||||
return switch (unit) {
|
||||
.unknown, .number => "",
|
||||
.percentage => "%",
|
||||
.em => "em",
|
||||
.ex => "ex",
|
||||
.px => "px",
|
||||
.cm => "cm",
|
||||
.mm => "mm",
|
||||
.in => "in",
|
||||
.pt => "pt",
|
||||
.pc => "pc",
|
||||
};
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Length);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "SVGLength";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const SVG_LENGTHTYPE_UNKNOWN = bridge.property(0, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_NUMBER = bridge.property(1, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_PERCENTAGE = bridge.property(2, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_EMS = bridge.property(3, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_EXS = bridge.property(4, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_PX = bridge.property(5, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_CM = bridge.property(6, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_MM = bridge.property(7, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_IN = bridge.property(8, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_PT = bridge.property(9, .{ .template = true });
|
||||
pub const SVG_LENGTHTYPE_PC = bridge.property(10, .{ .template = true });
|
||||
|
||||
pub const unitType = bridge.accessor(Length.getUnitType, null, .{});
|
||||
pub const value = bridge.accessor(Length.getValue, Length.setValue, .{});
|
||||
pub const valueInSpecifiedUnits = bridge.accessor(Length.getValueInSpecifiedUnits, Length.setValueInSpecifiedUnits, .{});
|
||||
pub const valueAsString = bridge.accessor(Length.getValueAsString, Length.setValueAsString, .{});
|
||||
pub const newValueSpecifiedUnits = bridge.function(Length.newValueSpecifiedUnits, .{});
|
||||
pub const convertToSpecifiedUnits = bridge.function(Length.convertToSpecifiedUnits, .{});
|
||||
};
|
||||
161
src/browser/webapi/svg/PreserveAspectRatio.zig
Normal file
161
src/browser/webapi/svg/PreserveAspectRatio.zig
Normal file
@@ -0,0 +1,161 @@
|
||||
// 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 String = lp.String;
|
||||
const PreserveAspectRatio = @This();
|
||||
|
||||
_element: *Element,
|
||||
_read_only: bool = false,
|
||||
|
||||
const Value = struct {
|
||||
alignment: u16 = 6,
|
||||
meet_or_slice: u16 = 1,
|
||||
};
|
||||
|
||||
pub fn create(element: *Element, read_only: bool, frame: *Frame) !*PreserveAspectRatio {
|
||||
return frame._factory.create(PreserveAspectRatio{
|
||||
._element = element,
|
||||
._read_only = read_only,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn getAlign(self: *const PreserveAspectRatio) u16 {
|
||||
return self.current().alignment;
|
||||
}
|
||||
|
||||
pub fn setAlign(self: *PreserveAspectRatio, alignment: u16, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
if (alignName(alignment) == null) return error.TypeError;
|
||||
var value = self.current();
|
||||
value.alignment = alignment;
|
||||
try self.write(value, frame);
|
||||
}
|
||||
|
||||
pub fn getMeetOrSlice(self: *const PreserveAspectRatio) u16 {
|
||||
return self.current().meet_or_slice;
|
||||
}
|
||||
|
||||
pub fn setMeetOrSlice(self: *PreserveAspectRatio, meet_or_slice: u16, frame: *Frame) !void {
|
||||
try self.ensureWritable();
|
||||
if (meetOrSliceName(meet_or_slice) == null) return error.TypeError;
|
||||
var value = self.current();
|
||||
value.meet_or_slice = meet_or_slice;
|
||||
try self.write(value, frame);
|
||||
}
|
||||
|
||||
fn ensureWritable(self: *const PreserveAspectRatio) !void {
|
||||
if (self._read_only) {
|
||||
return error.NoModificationAllowed;
|
||||
}
|
||||
}
|
||||
|
||||
fn current(self: *const PreserveAspectRatio) Value {
|
||||
const raw = self._element.getAttributeSafe(String.wrap("preserveAspectRatio")) orelse return .{};
|
||||
var parts = std.mem.tokenizeAny(u8, raw, " \t\r\n\x0c");
|
||||
const alignment = alignValue(parts.next() orelse return .{}) orelse return .{};
|
||||
const meet_or_slice = meetOrSliceValue(parts.next() orelse "meet") orelse return .{};
|
||||
if (parts.next() != null) {
|
||||
return .{};
|
||||
}
|
||||
return .{ .alignment = alignment, .meet_or_slice = meet_or_slice };
|
||||
}
|
||||
|
||||
fn write(self: *PreserveAspectRatio, value: Value, frame: *Frame) !void {
|
||||
const alignment = alignName(value.alignment) orelse return error.TypeError;
|
||||
const meet_or_slice = meetOrSliceName(value.meet_or_slice) orelse return error.TypeError;
|
||||
const serialized = try std.fmt.allocPrint(frame.local_arena, "{s} {s}", .{ alignment, meet_or_slice });
|
||||
try self._element.setAttributeSafe(String.wrap("preserveAspectRatio"), .wrap(serialized), frame);
|
||||
}
|
||||
|
||||
fn alignName(value: u16) ?[]const u8 {
|
||||
return switch (value) {
|
||||
1 => "none",
|
||||
2 => "xMinYMin",
|
||||
3 => "xMidYMin",
|
||||
4 => "xMaxYMin",
|
||||
5 => "xMinYMid",
|
||||
6 => "xMidYMid",
|
||||
7 => "xMaxYMid",
|
||||
8 => "xMinYMax",
|
||||
9 => "xMidYMax",
|
||||
10 => "xMaxYMax",
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn alignValue(value: []const u8) ?u16 {
|
||||
inline for (1..11) |i| {
|
||||
if (std.mem.eql(u8, value, alignName(i).?)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn meetOrSliceName(value: u16) ?[]const u8 {
|
||||
return switch (value) {
|
||||
1 => "meet",
|
||||
2 => "slice",
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn meetOrSliceValue(value: []const u8) ?u16 {
|
||||
if (std.mem.eql(u8, value, "meet")) {
|
||||
return 1;
|
||||
}
|
||||
if (std.mem.eql(u8, value, "slice")) {
|
||||
return 2;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(PreserveAspectRatio);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "SVGPreserveAspectRatio";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const SVG_PRESERVEASPECTRATIO_UNKNOWN = bridge.property(0, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_NONE = bridge.property(1, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_XMINYMIN = bridge.property(2, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_XMIDYMIN = bridge.property(3, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_XMAXYMIN = bridge.property(4, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_XMINYMID = bridge.property(5, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_XMIDYMID = bridge.property(6, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_XMAXYMID = bridge.property(7, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_XMINYMAX = bridge.property(8, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_XMIDYMAX = bridge.property(9, .{ .template = true });
|
||||
pub const SVG_PRESERVEASPECTRATIO_XMAXYMAX = bridge.property(10, .{ .template = true });
|
||||
pub const SVG_MEETORSLICE_UNKNOWN = bridge.property(0, .{ .template = true });
|
||||
pub const SVG_MEETORSLICE_MEET = bridge.property(1, .{ .template = true });
|
||||
pub const SVG_MEETORSLICE_SLICE = bridge.property(2, .{ .template = true });
|
||||
|
||||
pub const @"align" = bridge.accessor(PreserveAspectRatio.getAlign, PreserveAspectRatio.setAlign, .{});
|
||||
pub const meetOrSlice = bridge.accessor(PreserveAspectRatio.getMeetOrSlice, PreserveAspectRatio.setMeetOrSlice, .{});
|
||||
};
|
||||
183
src/browser/webapi/svg/Transform.zig
Normal file
183
src/browser/webapi/svg/Transform.zig
Normal file
@@ -0,0 +1,183 @@
|
||||
// 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 js = @import("../../js/js.zig");
|
||||
const Frame = @import("../../Frame.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const DOMMatrix = @import("../DOMMatrix.zig");
|
||||
const RO = @import("../DOMMatrixReadOnly.zig");
|
||||
|
||||
const Transform = @This();
|
||||
|
||||
pub const DOMMatrix2DInit = struct {
|
||||
a: ?f64 = null,
|
||||
b: ?f64 = null,
|
||||
c: ?f64 = null,
|
||||
d: ?f64 = null,
|
||||
e: ?f64 = null,
|
||||
f: ?f64 = null,
|
||||
m11: ?f64 = null,
|
||||
m12: ?f64 = null,
|
||||
m21: ?f64 = null,
|
||||
m22: ?f64 = null,
|
||||
m41: ?f64 = null,
|
||||
m42: ?f64 = null,
|
||||
};
|
||||
|
||||
_type: u16 = 1,
|
||||
_angle: f64 = 0,
|
||||
_matrix: *DOMMatrix,
|
||||
|
||||
// The transform owns the matrix arena even when no JS wrapper currently
|
||||
// references `matrix`. Forwarding the transform's bridge lifetime keeps the
|
||||
// stable SameObject pointer valid across garbage collections.
|
||||
pub fn acquireRef(self: *Transform) void {
|
||||
self._matrix._proto.acquireRef();
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *Transform, page: *Page) void {
|
||||
self._matrix._proto.releaseRef(page);
|
||||
}
|
||||
|
||||
pub fn detached(frame: *Frame) !*Transform {
|
||||
const matrix = try DOMMatrix.create(RO.identity(), true, frame._page);
|
||||
return frame._factory.create(Transform{ ._matrix = matrix });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
pub fn getType(self: *const Transform) u16 {
|
||||
return self._type;
|
||||
}
|
||||
|
||||
pub fn getMatrix(self: *Transform) *DOMMatrix {
|
||||
return self._matrix;
|
||||
}
|
||||
|
||||
pub fn getAngle(self: *const Transform) f64 {
|
||||
return self._angle;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
pub fn setRotate(self: *Transform, angle: f64, cx: f64, cy: f64) !void {
|
||||
try ensureFinite(&.{ angle, cx, cy });
|
||||
const radians = angle * std.math.pi / 180.0;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
fn replaceMatrix(self: *Transform, matrix: [16]f64, is_2d: bool) void {
|
||||
self._matrix._proto._m = matrix;
|
||||
self._matrix._proto._is_2d = is_2d;
|
||||
}
|
||||
|
||||
fn fixup2D(init: DOMMatrix2DInit) !RO.Parsed {
|
||||
return RO.fixupDict(.{
|
||||
.a = init.a,
|
||||
.b = init.b,
|
||||
.c = init.c,
|
||||
.d = init.d,
|
||||
.e = init.e,
|
||||
.f = init.f,
|
||||
.m11 = init.m11,
|
||||
.m12 = init.m12,
|
||||
.m21 = init.m21,
|
||||
.m22 = init.m22,
|
||||
.m41 = init.m41,
|
||||
.m42 = init.m42,
|
||||
.is2D = true,
|
||||
});
|
||||
}
|
||||
|
||||
fn ensureFinite(values: []const f64) !void {
|
||||
for (values) |value| {
|
||||
if (!std.math.isFinite(value)) return error.TypeError;
|
||||
}
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Transform);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "SVGTransform";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const SVG_TRANSFORM_UNKNOWN = bridge.property(0, .{ .template = true });
|
||||
pub const SVG_TRANSFORM_MATRIX = bridge.property(1, .{ .template = true });
|
||||
pub const SVG_TRANSFORM_TRANSLATE = bridge.property(2, .{ .template = true });
|
||||
pub const SVG_TRANSFORM_SCALE = bridge.property(3, .{ .template = true });
|
||||
pub const SVG_TRANSFORM_ROTATE = bridge.property(4, .{ .template = true });
|
||||
pub const SVG_TRANSFORM_SKEWX = bridge.property(5, .{ .template = true });
|
||||
pub const SVG_TRANSFORM_SKEWY = bridge.property(6, .{ .template = true });
|
||||
|
||||
pub const @"type" = bridge.accessor(Transform.getType, null, .{});
|
||||
pub const matrix = bridge.accessor(Transform.getMatrix, null, .{});
|
||||
pub const angle = bridge.accessor(Transform.getAngle, null, .{});
|
||||
pub const setMatrix = bridge.function(Transform.setMatrix, .{});
|
||||
pub const setTranslate = bridge.function(Transform.setTranslate, .{});
|
||||
pub const setScale = bridge.function(Transform.setScale, .{});
|
||||
pub const setRotate = bridge.function(Transform.setRotate, .{});
|
||||
pub const setSkewX = bridge.function(Transform.setSkewX, .{});
|
||||
pub const setSkewY = bridge.function(Transform.setSkewY, .{});
|
||||
};
|
||||
126
src/cdp/SafeString.zig
Normal file
126
src/cdp/SafeString.zig
Normal file
@@ -0,0 +1,126 @@
|
||||
// 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/>.
|
||||
|
||||
/// Wraps bytes that may not be valid UTF-8 — raw network octets like header
|
||||
/// values. std.json serializes invalid UTF-8 as a JSON array of numbers,
|
||||
/// which crashes CDP clients that expect a string (#2972, #2992). Non-UTF-8
|
||||
/// bytes are interpreted as Latin-1 (ISO-8859-1) and transcoded, matching
|
||||
/// Chrome's behavior for DevTools.
|
||||
const std = @import("std");
|
||||
|
||||
const SafeString = @This();
|
||||
|
||||
bytes: []const u8,
|
||||
|
||||
pub fn wrap(bytes: []const u8) SafeString {
|
||||
return .{ .bytes = bytes };
|
||||
}
|
||||
|
||||
pub fn jsonStringify(self: *const SafeString, jws: anytype) !void {
|
||||
if (std.unicode.utf8ValidateSlice(self.bytes)) {
|
||||
return jws.write(self.bytes);
|
||||
}
|
||||
try jws.beginWriteRaw();
|
||||
try writeQuoted(jws, self.bytes);
|
||||
jws.endWriteRaw();
|
||||
}
|
||||
|
||||
// Object keys can't take std.json's byte-array fallback: objectField writes
|
||||
// invalid UTF-8 straight into the frame, and RFC 6455 lets strict clients
|
||||
// kill the connection over an invalid UTF-8 text frame.
|
||||
pub fn writeObjectField(jws: anytype, name: []const u8) !void {
|
||||
if (std.unicode.utf8ValidateSlice(name)) {
|
||||
return jws.objectField(name);
|
||||
}
|
||||
try jws.beginObjectFieldRaw();
|
||||
try writeQuoted(jws, name);
|
||||
jws.endObjectFieldRaw();
|
||||
}
|
||||
|
||||
// Latin-1 -> UTF-8: each byte is a codepoint U+0000..U+00FF (max 2 bytes)
|
||||
fn writeQuoted(jws: anytype, value: []const u8) !void {
|
||||
try jws.writer.writeByte('"');
|
||||
var start: usize = 0;
|
||||
for (value, 0..) |b, i| {
|
||||
if (b < 0x80) {
|
||||
continue;
|
||||
}
|
||||
try std.json.Stringify.encodeJsonStringChars(value[start..i], jws.options, jws.writer);
|
||||
var buf: [2]u8 = undefined;
|
||||
const n = std.unicode.utf8Encode(b, &buf) catch unreachable;
|
||||
try jws.writer.writeAll(buf[0..n]);
|
||||
start = i + 1;
|
||||
}
|
||||
try std.json.Stringify.encodeJsonStringChars(value[start..], jws.options, jws.writer);
|
||||
try jws.writer.writeByte('"');
|
||||
}
|
||||
|
||||
test "cdp.SafeString: jsonStringify" {
|
||||
const expectJson = struct {
|
||||
fn expect(expected: []const u8, value: []const u8) !void {
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buf);
|
||||
var jws: std.json.Stringify = .{ .writer = &writer };
|
||||
try jws.write(wrap(value));
|
||||
try std.testing.expectEqualStrings(expected, writer.buffered());
|
||||
}
|
||||
}.expect;
|
||||
|
||||
// valid UTF-8 is written as-is
|
||||
try expectJson(
|
||||
"\"mié, 15 jul 2026 13:19:10 GMT\"",
|
||||
"mié, 15 jul 2026 13:19:10 GMT",
|
||||
);
|
||||
|
||||
// Latin-1 bytes are transcoded to UTF-8 instead of a byte array
|
||||
try expectJson(
|
||||
"\"mié, 15 jul 2026 13:19:10 GMT\"",
|
||||
"mi\xE9, 15 jul 2026 13:19:10 GMT",
|
||||
);
|
||||
|
||||
// JSON escaping still applies around transcoded bytes
|
||||
try expectJson(
|
||||
"\"a\\\"é\\nb\"",
|
||||
"a\"\xE9\nb",
|
||||
);
|
||||
|
||||
// pure ASCII untouched
|
||||
try expectJson(
|
||||
"\"max-age=180, s-maxage=180, public\"",
|
||||
"max-age=180, s-maxage=180, public",
|
||||
);
|
||||
}
|
||||
|
||||
test "cdp.SafeString: writeObjectField" {
|
||||
const expectJson = struct {
|
||||
fn expect(expected: []const u8, name: []const u8) !void {
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buf);
|
||||
var jws: std.json.Stringify = .{ .writer = &writer };
|
||||
try jws.beginObject();
|
||||
try writeObjectField(&jws, name);
|
||||
try jws.write(true);
|
||||
try jws.endObject();
|
||||
try std.testing.expectEqualStrings(expected, writer.buffered());
|
||||
}
|
||||
}.expect;
|
||||
|
||||
try expectJson("{\"etag\":true}", "etag");
|
||||
try expectJson("{\"naïve\":true}", "na\xEFve");
|
||||
try expectJson("{\"a\\\"é\":true}", "a\"\xE9");
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
|
||||
|
||||
const id = @import("../id.zig");
|
||||
const CDP = @import("../CDP.zig");
|
||||
const SafeString = @import("../SafeString.zig");
|
||||
|
||||
const Config = @import("../../Config.zig");
|
||||
const URL = @import("../../browser/URL.zig");
|
||||
@@ -295,7 +296,9 @@ fn getResponseBody(cmd: *CDP.Command) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const resp = bc.captured_responses.getPtr(key) orelse return error.RequestNotFound;
|
||||
|
||||
if (!resp.must_encode) {
|
||||
// must_encode trusts the declared charset; a server can declare UTF-8 and
|
||||
// still send invalid bytes.
|
||||
if (!resp.must_encode and std.unicode.utf8ValidateSlice(resp.data.items)) {
|
||||
return cmd.sendResult(.{
|
||||
.body = resp.data.items,
|
||||
.base64Encoded = false,
|
||||
@@ -449,8 +452,8 @@ pub const RequestWriter = struct {
|
||||
try jws.beginObject();
|
||||
var it = request.headers.iterator();
|
||||
while (it.next()) |hdr| {
|
||||
try jws.objectField(hdr.name);
|
||||
try writeHeaderValue(jws, hdr.value);
|
||||
try SafeString.writeObjectField(jws, hdr.name);
|
||||
try jws.write(SafeString.wrap(hdr.value));
|
||||
}
|
||||
if (try request.getCookieString(transfer.arena)) |cookies| {
|
||||
try jws.objectField("Cookie");
|
||||
@@ -554,8 +557,8 @@ const ResponseWriter = struct {
|
||||
try jws.beginObject();
|
||||
var map_it = map.iterator();
|
||||
while (map_it.next()) |entry| {
|
||||
try jws.objectField(entry.key_ptr.*);
|
||||
try writeHeaderValue(jws, entry.value_ptr.*);
|
||||
try SafeString.writeObjectField(jws, entry.key_ptr.*);
|
||||
try jws.write(SafeString.wrap(entry.value_ptr.*));
|
||||
}
|
||||
try jws.endObject();
|
||||
}
|
||||
@@ -563,33 +566,6 @@ const ResponseWriter = struct {
|
||||
}
|
||||
};
|
||||
|
||||
// HTTP header values are octets; per historical practice non-UTF-8 bytes are
|
||||
// interpreted as Latin-1 (ISO-8859-1), which is what Chrome does for DevTools.
|
||||
// Transcode so we emit a JSON string — std.json would otherwise serialize
|
||||
// invalid UTF-8 as a JSON array of numbers.
|
||||
fn writeHeaderValue(jws: anytype, value: []const u8) !void {
|
||||
if (std.unicode.utf8ValidateSlice(value)) {
|
||||
return jws.write(value);
|
||||
}
|
||||
// Latin-1 -> UTF-8: each byte is a codepoint U+0000..U+00FF (max 2 bytes)
|
||||
try jws.beginWriteRaw();
|
||||
try jws.writer.writeByte('"');
|
||||
var start: usize = 0;
|
||||
for (value, 0..) |b, i| {
|
||||
if (b < 0x80) {
|
||||
continue;
|
||||
}
|
||||
try std.json.Stringify.encodeJsonStringChars(value[start..i], jws.options, jws.writer);
|
||||
var buf: [2]u8 = undefined;
|
||||
const n = std.unicode.utf8Encode(b, &buf) catch unreachable;
|
||||
try jws.writer.writeAll(buf[0..n]);
|
||||
start = i + 1;
|
||||
}
|
||||
try std.json.Stringify.encodeJsonStringChars(value[start..], jws.options, jws.writer);
|
||||
try jws.writer.writeByte('"');
|
||||
jws.endWriteRaw();
|
||||
}
|
||||
|
||||
fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedResponseKey {
|
||||
const key = std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams;
|
||||
|
||||
@@ -726,42 +702,6 @@ test "cdp.network setExtraHTTPHeaders rejects a header that smuggles CRLF" {
|
||||
try testing.expectEqual("x-keep: ok", std.mem.span(bc.extra_headers.items[0]));
|
||||
}
|
||||
|
||||
test "cdp.network writeHeaderValue" {
|
||||
const expectHeaderJson = struct {
|
||||
fn expect(expected: []const u8, value: []const u8) !void {
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buf);
|
||||
var jws: std.json.Stringify = .{ .writer = &writer };
|
||||
try writeHeaderValue(&jws, value);
|
||||
try std.testing.expectEqualStrings(expected, writer.buffered());
|
||||
}
|
||||
}.expect;
|
||||
|
||||
// valid UTF-8 is written as-is
|
||||
try expectHeaderJson(
|
||||
"\"mié, 15 jul 2026 13:19:10 GMT\"",
|
||||
"mié, 15 jul 2026 13:19:10 GMT",
|
||||
);
|
||||
|
||||
// Latin-1 bytes are transcoded to UTF-8 instead of a byte array
|
||||
try expectHeaderJson(
|
||||
"\"mié, 15 jul 2026 13:19:10 GMT\"",
|
||||
"mi\xE9, 15 jul 2026 13:19:10 GMT",
|
||||
);
|
||||
|
||||
// JSON escaping still applies around transcoded bytes
|
||||
try expectHeaderJson(
|
||||
"\"a\\\"é\\nb\"",
|
||||
"a\"\xE9\nb",
|
||||
);
|
||||
|
||||
// pure ASCII untouched
|
||||
try expectHeaderJson(
|
||||
"\"max-age=180, s-maxage=180, public\"",
|
||||
"max-age=180, s-maxage=180, public",
|
||||
);
|
||||
}
|
||||
|
||||
test "cdp.Network: cookies" {
|
||||
const ResCookie = CdpStorage.ResCookie;
|
||||
const CdpCookie = CdpStorage.CdpCookie;
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const M = @This();
|
||||
|
||||
@@ -412,6 +414,40 @@ pub fn truncateUtf8(bytes: []const u8, max_bytes: usize) []const u8 {
|
||||
return bytes[0..i];
|
||||
}
|
||||
|
||||
/// Reinterprets `bytes` as Latin-1 (each byte one codepoint) and encodes it
|
||||
/// as UTF-8. For bytes that aren't valid UTF-8 but must become a valid UTF-8
|
||||
/// string (JSON, filenames).
|
||||
pub fn latin1ToUtf8(allocator: Allocator, bytes: []const u8) ![]u8 {
|
||||
var extra: usize = 0;
|
||||
for (bytes) |b| {
|
||||
if (b >= 0x80) {
|
||||
extra += 1;
|
||||
}
|
||||
}
|
||||
if (comptime IS_DEBUG) {
|
||||
// The way this is currently used:
|
||||
// 1 - the caller always wants the value duped,
|
||||
// 2 - the caller only got here because utf8ValidateSlice failed.
|
||||
// If both of those ever change, maybe it's worth reconsidering whether
|
||||
// this API unconditionally dupes.
|
||||
std.debug.assert(extra != 0);
|
||||
}
|
||||
|
||||
const out = try allocator.alloc(u8, bytes.len + extra);
|
||||
var i: usize = 0;
|
||||
for (bytes) |b| {
|
||||
if (b < 0x80) {
|
||||
out[i] = b;
|
||||
i += 1;
|
||||
} else {
|
||||
out[i] = 0xC0 | (b >> 6);
|
||||
out[i + 1] = 0x80 | (b & 0x3F);
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Discriminatory type that signals the bridge to use arena instead of call_arena
|
||||
// Use this for strings that need to persist beyond the current call
|
||||
// The caller can unwrap and store just the underlying .str field
|
||||
@@ -458,6 +494,20 @@ test "truncateUtf8" {
|
||||
try testing.expectEqual("\xFFx", truncateUtf8("\xFFx", 2));
|
||||
}
|
||||
|
||||
test "latin1ToUtf8" {
|
||||
const cases = [_]struct { in: []const u8, out: []const u8 }{
|
||||
.{ .in = "caf\xE9.txt", .out = "café.txt" },
|
||||
.{ .in = "report\xFF.csv", .out = "report\xC3\xBF.csv" },
|
||||
.{ .in = "\x82\xd3\x82\xe9.xlsx", .out = "\xC2\x82\xC3\x93\xC2\x82\xC3\xA9.xlsx" },
|
||||
};
|
||||
for (cases) |case| {
|
||||
const out = try latin1ToUtf8(testing.allocator, case.in);
|
||||
defer testing.allocator.free(out);
|
||||
try testing.expectEqual(case.out, out);
|
||||
try testing.expectEqual(true, std.unicode.utf8ValidateSlice(out));
|
||||
}
|
||||
}
|
||||
|
||||
test "String" {
|
||||
const other_short = try String.init(undefined, "other_short", .{});
|
||||
const other_long = try String.init(testing.allocator, "other_long" ** 100, .{});
|
||||
|
||||
Reference in New Issue
Block a user