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