Merge pull request #3548 from lightpanda-io/img-srcset

webapi: img srcset support
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-18 08:04:21 +08:00
commit 6db04f694a
8 files changed
+701 -15

No files matched your search

+7
View File
@@ -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;
+4
View File
@@ -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;
+3 -1
View File
@@ -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
@@ -256,3 +256,32 @@
testing.expectEqual(true, img.complete);
}
</script>
<script id="img-srcset-fetches" type=module>
// A srcset-only image fetches its candidate, and a <picture> source wins
// over the img's own src.
const state = await testing.async();
const img = document.createElement("img");
const srcset = await new Promise(resolve => {
img.addEventListener("load", () => resolve("load"));
img.addEventListener("error", () => resolve("error"));
img.srcset = "/images/small.png 1x";
});
const picture = document.createElement("picture");
const source = document.createElement("source");
const picture_img = document.createElement("img");
picture_img.src = "/images/small.png";
picture.append(source, picture_img);
const from_source = await new Promise(resolve => {
picture_img.addEventListener("load", () => resolve("load"));
picture_img.addEventListener("error", () => resolve("error"));
source.srcset = "/images/404.png";
});
state.resolve();
await state.done(() => {
testing.expectEqual("load", srcset);
testing.expectEqual("error", from_source);
});
</script>
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<body>
<picture id="parsed-picture">
<source media="(max-width: 1px)" srcset="/never.png">
<source type="image/x-unknown" srcset="/unsupported.png">
<source srcset="/from-source.png 1x, /from-source-2x.png 2x">
<img id="parsed-img" src="/from-src.png">
</picture>
<img id="parsed-srcset" srcset="/one.png 1x, /two.png 2x">
<script>
// load doesn't bubble, but it can be captured before the elements exist
const fostered_loads = {};
document.addEventListener("load", (e) => {
fostered_loads[e.target.id] = (fostered_loads[e.target.id] ?? 0) + 1;
}, true);
</script>
<!-- foster parented: inserted before the table, not through the parser's append -->
<table><img id="fostered" src="/fostered.png"></table>
<picture>
<source srcset="/fostered-source.png">
<table><img id="fostered-picture" src="/fostered-src.png"></table>
</picture>
<script id="parsed">
{
testing.expectEqual(testing.ORIGIN + "/from-source.png", document.getElementById("parsed-img").currentSrc);
testing.expectEqual(testing.ORIGIN + "/from-src.png", document.getElementById("parsed-img").src);
testing.expectEqual(testing.ORIGIN + "/one.png", document.getElementById("parsed-srcset").currentSrc);
}
</script>
<script id="fostered" type=module>
const state = await testing.async();
await new Promise(resolve => setTimeout(resolve, 20));
state.resolve();
await state.done(() => {
testing.expectEqual("TABLE", document.getElementById("fostered").nextElementSibling.tagName);
testing.expectEqual(1, fostered_loads["fostered"]);
testing.expectEqual(1, fostered_loads["fostered-picture"]);
testing.expectEqual(testing.ORIGIN + "/fostered-source.png", document.getElementById("fostered-picture").currentSrc);
});
</script>
<script id="img_srcset" type=module>
// Browsers run the source selection, which updates currentSrc, after the
// current script. Lightpanda does it synchronously.
const state = await testing.async();
const checks = [];
const img = document.createElement("img");
// resolves once the mutation's selection has run and its fetch settled
async function check(expected) {
await new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
});
checks.push([expected, img.currentSrc]);
}
checks.push(["", img.currentSrc]);
img.srcset = "/a.png";
await check(testing.ORIGIN + "/a.png");
// a 2x-only srcset leaves src as the 1x candidate
img.src = "/src.png";
img.srcset = "/a.png 2x";
await check(testing.ORIGIN + "/src.png");
// smallest density >= 1
img.removeAttribute("src");
img.srcset = "/half.png 0.5x, /three.png 3x, /one-five.png 1.5x";
await check(testing.ORIGIN + "/one-five.png");
img.srcset = "/half.png 0.5x, /quarter.png 0.25x";
await check(testing.ORIGIN + "/half.png");
// URLs may contain commas; a trailing comma ends the candidate
img.srcset = "/a,b.png, /c.png 2x";
await check(testing.ORIGIN + "/a,b.png");
// invalid descriptors drop the candidate
img.srcset = "/bad.png 1q, /bad2.png 1x 2x, /bad3.png 1_0w, /bad4.png +10w, /good.png";
await check(testing.ORIGIN + "/good.png");
// width descriptors are measured against the viewport width
img.srcset = "/small.png 10w, /huge.png 100000w";
await check(testing.ORIGIN + "/huge.png");
img.srcset = "/small.png 10w, /big.png " + (window.innerWidth * 2) + "w, /bigger.png " + (window.innerWidth * 3) + "w";
await check(testing.ORIGIN + "/big.png");
state.resolve();
await state.done(() => {
for (const [expected, actual] of checks) {
testing.expectEqual(expected, actual);
}
});
</script>
<script id="picture_mutations" type=module>
const state = await testing.async();
const checks = [];
const picture = document.createElement("picture");
const source = document.createElement("source");
const img = document.createElement("img");
// resolves once the mutation's selection has run and its fetch settled
async function check(expected) {
await new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
});
checks.push([expected, img.currentSrc]);
}
img.src = "/img.png";
picture.append(source, img);
await check(testing.ORIGIN + "/img.png");
source.srcset = "/source.png";
await check(testing.ORIGIN + "/source.png");
source.media = "(max-width: 1px)";
await check(testing.ORIGIN + "/img.png");
source.removeAttribute("media");
await check(testing.ORIGIN + "/source.png");
// a source after the img doesn't apply
const late = document.createElement("source");
late.srcset = "/late.png";
picture.append(late);
source.remove();
await check(testing.ORIGIN + "/img.png");
picture.prepend(source);
await check(testing.ORIGIN + "/source.png");
// outside a picture, sources are ignored
document.body.append(img);
await check(testing.ORIGIN + "/img.png");
state.resolve();
await state.done(() => {
for (const [expected, actual] of checks) {
testing.expectEqual(expected, actual);
}
});
</script>
<script id="picture_load_events" type=module>
const state = await testing.async();
const picture = document.createElement("picture");
const source = document.createElement("source");
const img = document.createElement("img");
picture.append(source, img);
document.body.append(picture);
let loads = 0;
img.addEventListener("load", () => loads += 1);
await new Promise(resolve => {
img.addEventListener("load", resolve, { once: true });
source.srcset = "/source.png";
});
// a mutation that keeps the selection doesn't restart the load
source.sizes = "50vw";
picture.append(document.createElement("source"));
await new Promise(resolve => setTimeout(resolve, 10));
state.resolve();
await state.done(() => {
testing.expectEqual(1, loads);
});
</script>
<script id="picture_fragment_parse" type=module>
// A fragment parse links its children onto the target in one go, without
// going through the insertion path, so the selection has to be redone
// afterwards; otherwise the img keeps the src it picked while it was still
// under the parser's wrapper, and later mutations compare against that.
const state = await testing.async();
const picture = document.createElement("picture");
document.body.append(picture);
picture.innerHTML = '<source srcset="/inner-source.png"><img src="/inner-img.png">';
const img = picture.querySelector("img");
const parsed = img.currentSrc;
// insertAdjacentHTML parses into a fragment and moves the children in one
// at a time, so it goes through the insertion path
const adjacent_picture = document.createElement("picture");
document.body.append(adjacent_picture);
adjacent_picture.insertAdjacentHTML("beforeend", '<source srcset="/adjacent-source.png"><img src="/adjacent-img.png">');
const adjacent = adjacent_picture.querySelector("img").currentSrc;
// let the parses' load events flush before counting
await new Promise(resolve => setTimeout(resolve, 10));
let loads = 0;
img.addEventListener("load", () => loads += 1);
// the source is gone, so the selection falls back to the img's own src
picture.querySelector("source").remove();
await new Promise(resolve => setTimeout(resolve, 10));
state.resolve();
await state.done(() => {
testing.expectEqual(testing.ORIGIN + "/inner-source.png", parsed);
testing.expectEqual(testing.ORIGIN + "/adjacent-source.png", adjacent);
testing.expectEqual(testing.ORIGIN + "/inner-img.png", img.currentSrc);
testing.expectEqual(1, loads);
});
</script>
+270 -12
View File
@@ -1,12 +1,35 @@
const lp = @import("lightpanda");
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const 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 <picture> 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 <picture> 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 <picture> 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
// <picture> parent's <source> 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 } });
}
+80 -1
View File
@@ -1,10 +1,36 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const 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 <source> entering the picture. Frame handles an inserted <img> 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 <img> among
// them never saw its <picture> 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);
+93 -1
View File
@@ -1,11 +1,37 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const 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", .{});