diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index e0804e55a..62696cb87 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2696,6 +2696,7 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt child._parent = null; Element.Html.Select.childRemoved(parent, child); + Element.Html.Picture.childRemoved(parent, child, next_sibling, self); // Update live ranges for removal (DOM spec remove steps 4-7) if (child_index_for_ranges) |idx| { @@ -2939,6 +2940,12 @@ fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *Node, child._parent = parent; Element.Html.Select.childInserted(parent, child); + if (child.is(Element.Html.Image)) |img| { + // noop if it didn't actually change + try img.sourceChanged(self); + } else { + try Element.Html.Picture.childInserted(parent, child, self); + } // Update live ranges for insertion (DOM spec insert step 6). // For .before/.after the child was inserted at a specific position; diff --git a/src/browser/frame/node_factory.zig b/src/browser/frame/node_factory.zig index 44b1ced41..edd716983 100644 --- a/src/browser/frame/node_factory.zig +++ b/src/browser/frame/node_factory.zig @@ -1070,6 +1070,10 @@ fn createHtmlElementT(document: *const Node.Document, comptime E: type, namespac const node = element.asNode(); if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) { + if (comptime @TypeOf(attribute_iterator) == Parser.AttributeIterator and @hasDecl(E.Build, "parser_created_on_insert")) { + // The element wants its parent, it'll do this work when inserted. + return node; + } @call(.auto, @field(E.Build, "created"), .{ node, frame }) catch |err| { log.err(.frame, "build.created", .{ .tag = node.getNodeName(&frame.buf), .err = err, .type = frame._type, .url = frame.url }); return err; diff --git a/src/browser/frame/parse.zig b/src/browser/frame/parse.zig index 338b85916..c4251e3a8 100644 --- a/src/browser/frame/parse.zig +++ b/src/browser/frame/parse.zig @@ -95,8 +95,10 @@ pub fn fragment(frame: *Frame, node: *Node, html: []const u8, opts: FragmentPars while (it.next()) |child| { child._parent = node; } - // Nor did the options among them reach their select. + // Nor did the options among them reach their select, nor the images + // their picture. Element.Html.Select.childrenInserted(node); + try Element.Html.Picture.childrenInserted(node, frame); } // Build a detached XMLDocument from `xml` (DOMParser.parseFromString and diff --git a/src/browser/tests/element/html/image_fetch.html b/src/browser/tests/element/html/image_fetch.html index 01a9f7bbd..995a987bc 100644 --- a/src/browser/tests/element/html/image_fetch.html +++ b/src/browser/tests/element/html/image_fetch.html @@ -256,3 +256,32 @@ testing.expectEqual(true, img.complete); } + + diff --git a/src/browser/tests/element/html/image_srcset.html b/src/browser/tests/element/html/image_srcset.html new file mode 100644 index 000000000..4887d38bd --- /dev/null +++ b/src/browser/tests/element/html/image_srcset.html @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + + + + + + + diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index 5a1726823..946428f37 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -1,12 +1,35 @@ -const lp = @import("lightpanda"); +// Copyright (C) 2023-2025 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 . + const std = @import("std"); +const lp = @import("lightpanda"); + const js = @import("../../../js/js.zig"); -const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); +const Factory = @import("../../../Factory.zig"); + const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const Picture = @import("Picture.zig"); +const Source = @import("Source.zig"); + const log = lp.log; const String = lp.String; @@ -17,6 +40,9 @@ pub const Proto = HtmlElement; _generation: u32 = 0, // Per spec, false only while a fetch is in flight. _complete: bool = true, +// Hash of the URL the last update selected, so a mutation that +// leaves the selection unchanged doesn't restart the load. +_selected_hash: u64 = 0, _proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, @@ -54,6 +80,14 @@ pub fn getSrc(self: *const Image, frame: *Frame) ![]const u8 { return element.asConstNode().resolveURLReflect(src, frame, .{}); } +fn getCurrentSrc(self: *Image, frame: *Frame) ![]const u8 { + const src = self.selectSource(frame); + if (src.len == 0) { + return ""; + } + return self.asNode().resolveURLReflect(src, frame, .{}); +} + fn setSrc(self: *Image, value: []const u8, frame: *Frame) !void { return self.asElement().setAttributeSafe(comptime .wrap("src"), .wrap(value), frame); } @@ -87,9 +121,9 @@ pub fn decode(_: *const Image, frame: *Frame) !js.Promise { return frame.js.local.?.resolvePromise(js.Undefined{}); } -/// The one funnel for "this element's src became current": parser-created -/// images, `img.src = ...` and `setAttribute`/`removeAttribute("src")` all -/// land here. +/// The one funnel for "this element's selected source may have changed": +/// parser-created images, `img.src = ...`, `setAttribute`/`removeAttribute` +/// of src/srcset and mutations all land here. fn imageAddedCallback(self: *Image, frame: *Frame) !void { // if we're planning on navigating to another frame, don't trigger a load event // or start fetching a resource. @@ -105,10 +139,11 @@ fn imageAddedCallback(self: *Image, frame: *Frame) !void { self._generation +%= 1; self._complete = true; - const element = self.asElement(); - // Exit if src not set. - const src = element.getAttributeInterned("src") orelse return; - if (src.len == 0) return; + const src = self.selectSource(frame); + self._selected_hash = std.hash.Wyhash.hash(0, src); + if (src.len == 0) { + return; + } // If image loading not desired, we just do fake "load" event. if (frame._session.load_resources.image == false) { @@ -121,6 +156,217 @@ fn imageAddedCallback(self: *Image, frame: *Frame) !void { }; } +/// An insertion or mutation: only restart the load if it changed the +/// selection. A parser-created image has selected nothing until its insertion. +pub fn sourceChanged(self: *Image, frame: *Frame) !void { + // Yes, we can get collisions, but even if it happens, the impact is low. + if (std.hash.Wyhash.hash(0, self.selectSource(frame)) == self._selected_hash) { + return; + } + return self.imageAddedCallback(frame); +} + +fn selectSource(self: *Image, frame: *Frame) []const u8 { + const node = self.asNode(); + const viewport_width = frame.page.getViewport().width; + + if (node._parent) |parent| { + if (parent.is(Picture) != null) { + var it = parent.childrenIterator(); + while (it.next()) |child| { + if (child == node) { + break; + } + const source = child.is(Source) orelse continue; + const srcset = source.selectableSrcset(frame) orelse continue; + if (pickCandidate(srcset, "", viewport_width)) |url| { + return url; + } + } + } + } + + const element = self.asElement(); + const src = element.getAttributeInterned("src") orelse ""; + if (element.getAttributeInterned("srcset")) |srcset| { + if (pickCandidate(srcset, src, viewport_width)) |url| { + return url; + } + } + return src; +} + +// Picks the candidate with the smallest density that is still >= 1, falling +// back to the densest one. `sizes` isn't parsed: a width descriptor is always +// measured against the full viewport width (the 100vw default). +fn pickCandidate(srcset: []const u8, src: []const u8, viewport_width: u32) ?[]const u8 { + var best: ?Candidate = null; + var densest: ?Candidate = null; + var has_1x = false; + var has_width = false; + + var it: SrcsetIterator = .{ .input = srcset }; + while (it.next()) |candidate| { + const density = switch (candidate.descriptor) { + .density => |d| d, + .width => |w| blk: { + has_width = true; + break :blk @as(f64, @floatFromInt(w)) / @as(f64, @floatFromInt(@max(viewport_width, 1))); + }, + }; + if (density == 1) { + has_1x = true; + } + if (density >= 1 and (best == null or density < best.?.density)) { + best = .{ .url = candidate.url, .density = density }; + } + if (densest == null or density > densest.?.density) { + densest = .{ .url = candidate.url, .density = density }; + } + } + + if (src.len > 0 and !has_1x and !has_width) { + // src is the 1x candidate, nothing in srcset can beat it. + return src; + } + const chosen = best orelse densest orelse return null; + return chosen.url; +} + +const Candidate = struct { + url: []const u8, + density: f64, +}; + +// Candidates with invalid descriptors are skipped. +const SrcsetIterator = struct { + input: []const u8, + pos: usize = 0, + + const Parsed = struct { + url: []const u8, + descriptor: union(enum) { + density: f64, + width: u32, + }, + }; + + fn next(self: *SrcsetIterator) ?Parsed { + const input = self.input; + while (true) { + while (self.pos < input.len and (std.ascii.isWhitespace(input[self.pos]) or input[self.pos] == ',')) { + self.pos += 1; + } + if (self.pos >= input.len) { + return null; + } + + const url_start = self.pos; + while (self.pos < input.len and !std.ascii.isWhitespace(input[self.pos])) { + self.pos += 1; + } + var url = input[url_start..self.pos]; + + var descriptors: []const u8 = ""; + if (url[url.len - 1] == ',') { + url = std.mem.trimEnd(u8, url, ","); + } else { + const start = self.pos; + var in_parens = false; + while (self.pos < input.len) : (self.pos += 1) { + switch (input[self.pos]) { + '(' => in_parens = true, + ')' => in_parens = false, + ',' => if (!in_parens) break, + else => {}, + } + } + descriptors = input[start..self.pos]; + } + + if (url.len == 0) { + continue; + } + if (parseDescriptors(descriptors)) |descriptor| { + return .{ .url = url, .descriptor = descriptor }; + } + } + } + + fn parseDescriptors(input: []const u8) ?@FieldType(Parsed, "descriptor") { + var density: ?f64 = null; + var width: ?u32 = null; + var has_height = false; + + var it = std.mem.tokenizeAny(u8, input, " \t\n\r\x0c"); + while (it.next()) |token| { + const value = token[0 .. token.len - 1]; + switch (token[token.len - 1]) { + 'x' => { + if (density != null or width != null or has_height) { + return null; + } + const d = std.fmt.parseFloat(f64, value) catch return null; + if (!std.math.isFinite(d) or d < 0 or !isFloatSyntax(value)) { + return null; + } + density = d; + }, + 'w' => { + if (density != null or width != null) { + return null; + } + const w = parseNonNegativeInt(value) orelse return null; + if (w == 0) { + return null; + } + width = w; + }, + 'h' => { + if (density != null or has_height) { + return null; + } + _ = parseNonNegativeInt(value) orelse return null; + has_height = true; + }, + else => return null, + } + } + + if (width) |w| { + return .{ .width = w }; + } + if (has_height) { + return null; + } + return .{ .density = density orelse 1 }; + } + + // parseInt also accepts a sign and '_' separators. + fn parseNonNegativeInt(value: []const u8) ?u32 { + for (value) |c| { + if (!std.ascii.isDigit(c)) { + return null; + } + } + return std.fmt.parseInt(u32, value, 10) catch null; + } + + // parseFloat also accepts "inf", "nan", hex and a leading '+'. + fn isFloatSyntax(value: []const u8) bool { + if (value.len == 0 or value[0] == '+') { + return false; + } + for (value) |c| { + switch (c) { + '0'...'9', '.', 'e', 'E', '-', '+' => {}, + else => return false, + } + } + return true; + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(Image); @@ -133,7 +379,7 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(Image.constructor, .{}); pub const src = bridge.accessor(Image.getSrc, Image.setSrc, .{ .ce_reactions = true }); - pub const currentSrc = bridge.accessor(Image.getSrc, null, .{}); + pub const currentSrc = bridge.accessor(Image.getCurrentSrc, null, .{}); pub const alt = reflect.string("alt"); pub const width = reflect.unsignedLong("width", .{}); pub const height = reflect.unsignedLong("height", .{}); @@ -161,13 +407,17 @@ pub const JsApi = struct { }; pub const Build = struct { + // The parser's images wait until they're inserted (sourceChanged), so a + // parent's elements take part in the selection. + pub const parser_created_on_insert = true; + pub fn created(node: *Node, frame: *Frame) !void { const self = node.as(Image); return self.imageAddedCallback(frame); } pub fn attributeChange(element: *Element, name: String, _: String, frame: *Frame) !void { - if (!name.eql(comptime .wrap("src"))) { + if (!isSourceAttribute(name)) { return; } return element.as(Image).imageAddedCallback(frame); @@ -176,11 +426,15 @@ pub const Build = struct { // Removing the src leaves no request to make, but any in-flight one still // has to be invalidated. pub fn attributeRemove(element: *Element, name: String, frame: *Frame) !void { - if (!name.eql(comptime .wrap("src"))) { + if (!isSourceAttribute(name)) { return; } return element.as(Image).imageAddedCallback(frame); } + + fn isSourceAttribute(name: String) bool { + return name.eql(comptime .wrap("src")) or name.eql(comptime .wrap("srcset")); + } }; const testing = @import("../../../../testing.zig"); @@ -188,6 +442,10 @@ test "WebApi: HTML.Image" { try testing.htmlRunner("element/html/image.html", .{}); } +test "WebApi: HTML.Image srcset" { + try testing.htmlRunner("element/html/image_srcset.html", .{}); +} + test "WebApi: HTML.Image fetch" { try testing.htmlRunner("element/html/image_fetch.html", .{ .load_resources = .{ .image = true } }); } diff --git a/src/browser/webapi/element/html/Picture.zig b/src/browser/webapi/element/html/Picture.zig index dc69571e4..005c2e107 100644 --- a/src/browser/webapi/element/html/Picture.zig +++ b/src/browser/webapi/element/html/Picture.zig @@ -1,10 +1,36 @@ +// Copyright (C) 2023-2025 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 . + const lp = @import("lightpanda"); -const Factory = @import("../../../Factory.zig"); + const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); +const Factory = @import("../../../Factory.zig"); + const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const Image = @import("Image.zig"); +const Source = @import("Source.zig"); + +const log = lp.log; + const Picture = @This(); pub const Proto = HtmlElement; @@ -19,6 +45,59 @@ pub fn asNode(self: *Picture) *Node { return self.asElement().asNode(); } +// A entering the picture. Frame handles an inserted itself. +pub fn childInserted(parent: *Node, child: *Node, frame: *Frame) !void { + if (parent.is(Picture) == null) { + return; + } + if (child.is(Source) != null) { + return imagesFrom(child.nextSibling(), frame); + } +} + +// `next_sibling` is the child's sibling from before it was unlinked. +pub fn childRemoved(parent: *Node, child: *Node, next_sibling: ?*Node, frame: *Frame) void { + if (parent.is(Picture) == null) { + return; + } + const result = if (child.is(Image)) |img| + img.sourceChanged(frame) + else if (child.is(Source) != null) + imagesFrom(next_sibling, frame) + else + return; + + result catch |err| { + log.warn(.frame, "picture child removed", .{ .err = err }); + }; +} + +// parse.fragment links every child of a parent at once, so the among +// them never saw its parent. +pub fn childrenInserted(parent: *Node, frame: *Frame) !void { + if (parent.is(Picture) == null) { + return; + } + return imagesFrom(parent.firstChild(), frame); +} + +pub fn sourceChanged(source: *Node, frame: *Frame) !void { + const parent = source._parent orelse return; + if (parent.is(Picture) == null) { + return; + } + return imagesFrom(source.nextSibling(), frame); +} + +fn imagesFrom(start: ?*Node, frame: *Frame) !void { + var it = start; + while (it) |node| : (it = node.nextSibling()) { + if (node.is(Image)) |img| { + try img.sourceChanged(frame); + } + } +} + pub const JsApi = struct { pub const bridge = js.Bridge(Picture); diff --git a/src/browser/webapi/element/html/Source.zig b/src/browser/webapi/element/html/Source.zig index ab519d693..c3d5a4ba5 100644 --- a/src/browser/webapi/element/html/Source.zig +++ b/src/browser/webapi/element/html/Source.zig @@ -1,11 +1,37 @@ +// Copyright (C) 2023-2025 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 . + +const std = @import("std"); const lp = @import("lightpanda"); + const js = @import("../../../js/js.zig"); -const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); +const Factory = @import("../../../Factory.zig"); +const MediaQuery = @import("../../../css/MediaQuery.zig"); + const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const Picture = @import("Picture.zig"); + +const String = lp.String; + const Source = @This(); pub const Proto = HtmlElement; @@ -36,6 +62,52 @@ fn setSrc(self: *Source, value: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("src"), .wrap(value), frame); } +pub fn selectableSrcset(self: *const Source, frame: *Frame) ?[]const u8 { + const element = self.asConstElement(); + const srcset = element.getAttributeInterned("srcset") orelse return null; + if (srcset.len == 0) { + return null; + } + if (element.getAttributeInterned("media")) |media| { + if (std.mem.trim(u8, media, &std.ascii.whitespace).len > 0 and !MediaQuery.matches(media, frame.page.getViewport())) { + return null; + } + } + if (element.getAttributeInterned("type")) |mime| { + if (!isSupportedImageType(mime)) { + return null; + } + } + return srcset; +} + +// Nothing is decoded, so claim the formats a mainstream browser does. +fn isSupportedImageType(mime: []const u8) bool { + const essence = std.mem.trim(u8, mime[0 .. std.mem.indexOfScalar(u8, mime, ';') orelse mime.len], &std.ascii.whitespace); + if (essence.len == 0) { + return true; + } + const supported = [_][]const u8{ + "image/apng", + "image/avif", + "image/bmp", + "image/gif", + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + "image/vnd.microsoft.icon", + "image/webp", + "image/x-icon", + }; + for (supported) |s| { + if (std.ascii.eqlIgnoreCase(essence, s)) { + return true; + } + } + return false; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Source); @@ -56,6 +128,26 @@ pub const JsApi = struct { pub const width = reflect.unsignedLong("width", .{}); }; +pub const Build = struct { + pub fn attributeChange(element: *Element, name: String, _: String, frame: *Frame) !void { + if (!isSelectionAttribute(name)) { + return; + } + return Picture.sourceChanged(element.asNode(), frame); + } + + pub fn attributeRemove(element: *Element, name: String, frame: *Frame) !void { + if (!isSelectionAttribute(name)) { + return; + } + return Picture.sourceChanged(element.asNode(), frame); + } + + fn isSelectionAttribute(name: String) bool { + return name.eql(comptime .wrap("srcset")) or name.eql(comptime .wrap("media")) or name.eql(comptime .wrap("type")); + } +}; + const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Source" { try testing.htmlRunner("element/html/source.html", .{});