Merge branch 'main' into python-bindings-v1

This commit is contained in:
Adrià Arrufat
2026-07-24 10:41:24 +02:00
26 changed files with 2211 additions and 156 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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 && \

View File

@@ -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 = .{

View File

@@ -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.

View File

@@ -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) {

View File

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

View File

@@ -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 = {} };
},

View File

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

View File

@@ -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"),

View File

@@ -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>

View 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>

View File

@@ -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

View File

@@ -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.

View File

@@ -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", .{});
}

View File

@@ -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, .{});
};

View 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, .{});
};

View 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, .{});
};

View 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, .{});
};

View File

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

View 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, .{});
};

View 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, .{});
};

View 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
View 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");
}

View File

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

View File

@@ -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, .{});