diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index f6a6eaf09..5b5541562 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -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 diff --git a/.github/actions/v8-snapshot/action.yml b/.github/actions/v8-snapshot/action.yml index b85b56d79..11641e7ac 100644 --- a/.github/actions/v8-snapshot/action.yml +++ b/.github/actions/v8-snapshot/action.yml @@ -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" diff --git a/Dockerfile b/Dockerfile index 70b5cf5db..d994dbcea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/build.zig.zon b/build.zig.zon index 725fbed4d..2391b63f9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 = .{ diff --git a/src/Config.zig b/src/Config.zig index 14b3ca652..7129f0082 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -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. diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 8ab70b904..9c2dda898 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 @@ -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) { 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/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); } 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/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/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 @@ } + + + +