mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 04:42:30 -04:00
Merge pull request #3548 from lightpanda-io/img-srcset
webapi: img srcset support
This commit is contained in:
8 files changed
+701
-15
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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", .{});
|
||||
|
||||
Reference in new issue
Block a user