From 6352863d6ed083e31b3d077b680e1d8b902ddd35 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 17 Jul 2026 17:54:40 +0800 Subject: [PATCH 1/7] css: Respect @layer priority Builds ontop of https://github.com/lightpanda-io/browser/pull/2719 and adds the missing layer-priority. This can be broken down into two parts. The first is the hot read path. This code is unchanged...same buckets and same u64 priority comparison. What has changed is the build/rebuild step and how / what the VisibilityRule.priority means. priority used to be 32bits for specificity and 32bits for doc order (specificity tie-breaker). Priority is now 12bits for layer rank, 30bits for specificity and 22bits for doc order. Specificity was only using 30bits, and the smaller doc order means we can now only represent 4 million rules (Facebook, which has _a lot_ of rules, has ~50K and like 95% of those are not visibility rules..so we have plenty of space still). But layer ordering is complicated because you need to parse everything before you know the final order. So our build now accumulates intermediary results and does a final iteration through the VisibilityRule to |= the rank_layer bits of their priority. This is no-op if there are no layers (common case for now). Because of this intermediary work, I've added a build_arena that is build/ rebuild specific. Possibly overkill since we don't expect sites to have thousands of layers, but it's cheap to add (except now you need to think: should I use the build_arena or self.arena). --- src/browser/StyleManager.zig | 399 ++++++++++++++++-- .../tests/css/layer_at_rule_cascade.html | 12 +- .../tests/css/layer_order_cascade.html | 182 ++++++++ src/browser/webapi/css/CSSStyleSheet.zig | 4 + 4 files changed, 546 insertions(+), 51 deletions(-) create mode 100644 src/browser/tests/css/layer_order_cascade.html diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index b16ed27ee..868545c19 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . 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 `` 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 , ;`. 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); +} diff --git a/src/browser/tests/css/layer_at_rule_cascade.html b/src/browser/tests/css/layer_at_rule_cascade.html index 0d89ae61c..760db29e6 100644 --- a/src/browser/tests/css/layer_at_rule_cascade.html +++ b/src/browser/tests/css/layer_at_rule_cascade.html @@ -4,12 +4,12 @@ + + +
unlayered
+
later-layer
+
statement
+
reopened
+
parent-direct
+
dotted
+
interleaved
+
anon
+
media-rank
+
flip
+
vis
+
dyn
+
dyn2
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig index b54eb67e2..7e8350cc3 100644 --- a/src/browser/webapi/css/CSSStyleSheet.zig +++ b/src/browser/webapi/css/CSSStyleSheet.zig @@ -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", .{}); +} From 5b12c42df2a089b9105c8aa9100f9901c80a63b0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 22 Jul 2026 14:59:33 +0800 Subject: [PATCH 2/7] cdp: Sanitize non-UTF 8 values Builds on https://github.com/lightpanda-io/browser/pull/2972 in order to fix https://github.com/lightpanda-io/browser/issues/2992 I couldn't think of a generic way to fix this. Created a SafeString which wraps a `[]const u8` with a custom jsonStringify function. This is now applied to both header names and values. The existing latin1 to utf8 conversion is now extracted into string.js and used in Frame for the filename. This was moved to Frame because the same issue could cause issues trying to save the file. --- src/browser/Frame.zig | 12 +++- src/cdp/SafeString.zig | 126 ++++++++++++++++++++++++++++++++++++ src/cdp/domains/network.zig | 76 +++------------------- src/string.zig | 50 ++++++++++++++ 4 files changed, 194 insertions(+), 70 deletions(-) create mode 100644 src/cdp/SafeString.zig diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index c15abd7e6..dc311b53e 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1286,6 +1286,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 => {}, @@ -1313,11 +1316,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) { diff --git a/src/cdp/SafeString.zig b/src/cdp/SafeString.zig new file mode 100644 index 000000000..ceef3c720 --- /dev/null +++ b/src/cdp/SafeString.zig @@ -0,0 +1,126 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +/// 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"); +} diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 89db15679..23152cda8 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -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"); @@ -282,7 +283,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, @@ -435,8 +438,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"); @@ -540,8 +543,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(); } @@ -549,33 +552,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; @@ -712,42 +688,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; diff --git a/src/string.zig b/src/string.zig index 181371114..b62458094 100644 --- a/src/string.zig +++ b/src/string.zig @@ -17,7 +17,9 @@ // along with this program. If not, see . const std = @import("std"); + const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("builtin").mode == .Debug; const M = @This(); @@ -409,6 +411,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 @@ -455,6 +491,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, .{}); From 084e83572d6309e6a3f33e7bf84a3d1c47af98ff Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 23 Jul 2026 10:51:48 +0800 Subject: [PATCH 3/7] v8: Expose Float16Array Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/191 If nothing else, it allows more WPT tests to pass, e.g.: /websockets/Send-binary-arraybufferview-float16.any.html?default --- src/browser/js/Local.zig | 9 +++++++++ src/browser/js/Value.zig | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index d3b8f66f2..f5cdf97be 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -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 = {} }; }, diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index c6089a277..c57f5ef26 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -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); } From 4840a9664526980d20bffb22c6266a38ce216994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 23 Jul 2026 12:16:19 +0200 Subject: [PATCH 4/7] cli: fix help pager broken by lp.io's failing allocator std.Io.Threaded.init_single_threaded sets .allocator = .failing, which spawnPosix uses to build the child's argv/env, so std.process.spawn(lp.io, ...) always returns OutOfMemory and printPaged silently fell back to plain output. Spawn the pager through a local Threaded instance with a real allocator and the real environ (the single-threaded one is empty, breaking PATH lookup of the less fallback). --- src/Config.zig | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 9609642b0..fbdfd9553 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -922,22 +922,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. From c40a2ccac6957914c432943c76e3f4dc8b8bf6c0 Mon Sep 17 00:00:00 2001 From: Madison Steiner <8176115+mh0pe@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:02:49 -0700 Subject: [PATCH 5/7] webapi: add live SVG scalar values --- src/browser/Frame.zig | 4 + src/browser/js/bridge.zig | 6 + src/browser/tests/element/svg/svgsvg.html | 159 ++++++- src/browser/webapi/element/svg/Svg.zig | 100 ++++- src/browser/webapi/svg/Angle.zig | 252 +++++++++++ src/browser/webapi/svg/AnimatedLength.zig | 103 +++++ .../svg/AnimatedPreserveAspectRatio.zig | 70 +++ src/browser/webapi/svg/AnimatedString.zig | 4 +- src/browser/webapi/svg/Length.zig | 425 ++++++++++++++++++ .../webapi/svg/PreserveAspectRatio.zig | 161 +++++++ src/browser/webapi/svg/Transform.zig | 183 ++++++++ 11 files changed, 1441 insertions(+), 26 deletions(-) create mode 100644 src/browser/webapi/svg/Angle.zig create mode 100644 src/browser/webapi/svg/AnimatedLength.zig create mode 100644 src/browser/webapi/svg/AnimatedPreserveAspectRatio.zig create mode 100644 src/browser/webapi/svg/Length.zig create mode 100644 src/browser/webapi/svg/PreserveAspectRatio.zig create mode 100644 src/browser/webapi/svg/Transform.zig diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 8ab70b904..6c221b85d 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -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 diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 8dcc00568..cdb6c021d 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -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"), diff --git a/src/browser/tests/element/svg/svgsvg.html b/src/browser/tests/element/svg/svgsvg.html index e73b55587..8fa1379f8 100644 --- a/src/browser/tests/element/svg/svgsvg.html +++ b/src/browser/tests/element/svg/svgsvg.html @@ -1,7 +1,7 @@ - + @@ -27,6 +27,163 @@ } + + + +