screenshot: simplify the rasterizer

Move the ICU bindings to src/sys/icu.zig, read the face metrics from the
font tables instead of hardcoding DejaVu's, use the zlib header through
translate-c rather than hand-declared externs, encode the PNG straight
from the surface strip, and share the build's dev-optimize rule between
z2d and zlib. Render output is unchanged.
This commit is contained in:
Adrià Arrufat committed 2026-08-25 00:07:30 +02:00
1 parent e10de2f602
commit 5ce92ffa6f
6 files changed
+660 -691

No files matched your search

+16 -5
View File
@@ -105,11 +105,10 @@ pub fn build(b: *Build) !void {
lightpanda_module.addImport("lightpanda", lightpanda_module); // allow circular "lightpanda" import
lightpanda_module.addImport("build_config", opts.createModule());
// Rasterization is hot in screenshot/raster.zig; keep z2d optimized in
// dev builds, like the Rust workspace does for its dependencies.
// Rasterization is hot in screenshot/raster.zig.
const z2d_dep = b.dependency("z2d", .{
.target = target,
.optimize = if (optimize == .Debug) .ReleaseFast else optimize,
.optimize = depOptimize(optimize),
});
lightpanda_module.addImport("z2d", z2d_dep.module("z2d"));
@@ -443,9 +442,15 @@ fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) void {
mod.addImport("curl", translate_c.createModule());
// Deflate is on the screenshot hot path; -O0 zlib is ~10x slower.
const zlib_optimize: std.builtin.OptimizeMode = if (mod.optimize.? == .Debug) .ReleaseFast else mod.optimize.?;
const zlib = buildZlib(b, target, zlib_optimize, is_tsan);
const zlib = buildZlib(b, target, depOptimize(mod.optimize.?), is_tsan);
curl.root_module.linkLibrary(zlib);
mod.linkLibrary(zlib);
const zlib_c = b.addTranslateC(.{
.root_source_file = b.dependency("zlib", .{}).path("zlib.h"),
.target = target,
.optimize = mod.optimize.?,
});
mod.addImport("zlib", zlib_c.createModule());
const brotli = buildBrotli(b, target, mod.optimize.?, is_tsan);
for (brotli) |lib| curl.root_module.linkLibrary(lib);
@@ -473,6 +478,12 @@ fn cLibModule(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opt
});
}
/// Dependencies on hot paths stay optimized in dev builds; their own debug
/// checks are not what a Debug build of lightpanda is for.
fn depOptimize(optimize: std.builtin.OptimizeMode) std.builtin.OptimizeMode {
return if (optimize == .Debug) .ReleaseFast else optimize;
}
fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile {
const dep = b.dependency("zlib", .{});
+109 -160
View File
@@ -43,8 +43,7 @@ pub fn png(arena: Allocator, node: *Node, opts: Opts, writer: *std.Io.Writer, fr
// get the height of the PNG if we were to render it.
pub fn contentHeight(arena: Allocator, node: *Node, width: u32, frame: *Frame) !u32 {
const prepared = try prepare(arena, node, .{ .width = width }, frame);
var discard: std.Io.Writer.Discarding = .init(&.{});
return prepared.stream(&discard.writer, true);
return prepared.stream(null);
}
// The DOM walk, done up front so it can fail (allocation) before any output
@@ -69,11 +68,12 @@ pub const Prepared = struct {
blocks: []const raster.Block,
pub fn write(self: *const Prepared, writer: *std.Io.Writer) std.Io.Writer.Error!u32 {
return self.stream(writer, false);
return self.stream(writer);
}
fn stream(self: *const Prepared, writer: *std.Io.Writer, measure_only: bool) std.Io.Writer.Error!u32 {
return raster.run(self.arena, self.blocks, self.opts, writer, measure_only) catch |err| switch (err) {
// A null writer only measures.
fn stream(self: *const Prepared, writer: ?*std.Io.Writer) std.Io.Writer.Error!u32 {
return raster.run(self.arena, self.blocks, self.opts, writer) catch |err| switch (err) {
// The layout pass fails before any output starts, so this can't
// hand back a truncated PNG. WriteFailed is the only error
// jsonStringify's signature can carry, hence the log line.
@@ -98,15 +98,6 @@ pub const Prepared = struct {
}
};
const SPAN_BOLD = raster.SPAN_BOLD;
const SPAN_ITALIC = raster.SPAN_ITALIC;
const SPAN_UNDERLINE = raster.SPAN_UNDERLINE;
const SPAN_MONO = raster.SPAN_MONO;
const SPAN_STRIKE = raster.SPAN_STRIKE;
const SPAN_HAS_COLOR = raster.SPAN_HAS_COLOR;
const BLOCK_TIGHT = raster.BLOCK_TIGHT;
const LINK_COLOR: u32 = 0x1a0dab;
const MUTED_COLOR: u32 = 0x6b6b6b;
@@ -126,7 +117,6 @@ const Builder = struct {
text: std.ArrayList(u8) = .empty,
text_flags: u32 = 0,
text_color: u32 = 0,
has_content: bool = false,
pending_space: bool = false,
// Nothing appended since an <a> closed. Adjacent links with no whitespace
// between them (nav bars) would otherwise fuse into one word.
@@ -166,22 +156,22 @@ const Builder = struct {
fn currentFlags(self: *const Builder) u32 {
var flags: u32 = 0;
if (self.bold > 0) {
flags |= SPAN_BOLD;
flags |= raster.SPAN_BOLD;
}
if (self.italic > 0) {
flags |= SPAN_ITALIC;
flags |= raster.SPAN_ITALIC;
}
if (self.underline > 0 or self.link > 0) {
flags |= SPAN_UNDERLINE;
flags |= raster.SPAN_UNDERLINE;
}
if (self.mono > 0) {
flags |= SPAN_MONO;
flags |= raster.SPAN_MONO;
}
if (self.strike > 0) {
flags |= SPAN_STRIKE;
flags |= raster.SPAN_STRIKE;
}
if (self.link > 0 or self.muted > 0) {
flags |= SPAN_HAS_COLOR;
flags |= raster.SPAN_HAS_COLOR;
}
return flags;
}
@@ -196,6 +186,10 @@ const Builder = struct {
return 0;
}
fn hasContent(self: *const Builder) bool {
return self.spans.items.len > 0 or self.text.items.len > 0;
}
fn openBlock(self: *Builder, kind: raster.Block.Kind, level: u8) Error!void {
try self.closeBlock();
self.block_open = true;
@@ -234,7 +228,7 @@ const Builder = struct {
.level = self.level,
.list_depth = self.list_depth,
.quote_depth = self.quote_depth,
.flags = if (self.tight > 0) BLOCK_TIGHT else 0,
.flags = if (self.tight > 0) raster.BLOCK_TIGHT else 0,
});
} else if (self.marker.len > 0) {
// Empty <li>: keep the marker for whatever block comes next.
@@ -243,7 +237,6 @@ const Builder = struct {
self.block_open = false;
self.marker = "";
self.spans.clearRetainingCapacity();
self.has_content = false;
self.pending_space = false;
self.after_anchor = false;
}
@@ -259,12 +252,11 @@ const Builder = struct {
self.text_flags = flags;
self.text_color = color;
try self.text.appendSlice(self.arena, text);
self.has_content = true;
self.after_anchor = false;
}
fn appendWord(self: *Builder, word: []const u8) Error!void {
if (self.pending_space and self.has_content) {
if (self.pending_space and self.hasContent()) {
try self.appendSpace();
}
self.pending_space = false;
@@ -276,10 +268,10 @@ const Builder = struct {
fn appendSpace(self: *Builder) Error!void {
var flags = self.text_flags & self.currentFlags();
var color = self.text_color;
if (flags & SPAN_HAS_COLOR != 0 and color != self.currentColor()) {
flags &= ~SPAN_HAS_COLOR;
if (flags & raster.SPAN_HAS_COLOR != 0 and color != self.currentColor()) {
flags &= ~raster.SPAN_HAS_COLOR;
}
if (flags & SPAN_HAS_COLOR == 0) color = 0;
if (flags & raster.SPAN_HAS_COLOR == 0) color = 0;
if (flags != self.text_flags or color != self.text_color) {
try self.flushSpan();
@@ -391,8 +383,9 @@ const Builder = struct {
},
.br => {
if (self.pre_node != null) return self.append("\n");
if (!self.has_content) return;
// A hard break within the block: parley honors '\n'.
if (!self.hasContent()) return;
// A hard break within the block: the wrap treats '\n' as a
// mandatory break.
try self.append("\n");
self.pending_space = false;
return;
@@ -486,7 +479,7 @@ const Builder = struct {
},
.slot => return self.renderSlotContent(el.as(Slot)),
.td, .th => {
if (self.has_content) {
if (self.hasContent()) {
self.pending_space = true;
self.muted += 1;
try self.appendWord("|");
@@ -542,120 +535,129 @@ const Builder = struct {
};
const testing = @import("../testing.zig");
// A frame with `html` parsed under a div; the div is the node to render.
fn testRoot(html: []const u8) !struct { frame: *Frame, node: *Node } {
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const div = try frame.window._document.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
return .{ .frame = frame, .node = div.asNode() };
}
fn testBlocks(html: []const u8) ![]const raster.Block {
const root = try testRoot(html);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = root.frame };
try builder.render(root.node);
try builder.closeBlock();
return builder.blocks.items;
}
fn spanText(block: raster.Block) ![]const u8 {
var out: std.ArrayList(u8) = .empty;
for (block.spans) |s| try out.appendSlice(testing.arena_allocator, s.text);
return out.items;
}
fn testPng(html: []const u8, width: u32) ![]const u8 {
const root = try testRoot(html);
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
_ = try png(testing.arena_allocator, root.node, .{ .width = width }, &aw.writer, root.frame);
return aw.written();
}
/// (width, height) from the IHDR chunk.
fn pngSize(data: []const u8) struct { u32, u32 } {
return .{ std.mem.readInt(u32, data[16..20], .big), std.mem.readInt(u32, data[20..24], .big) };
}
test "browser.screenshot: png signature and dimensions" {
defer testing.test_session.closeAllPages();
const out = try testPng("<h1>Title</h1><p>Hello <b>world</b> <a href='/x'>link</a></p>", 640);
try testing.expectEqual(true, out.len > 100);
try testing.expectEqual("\x89PNG\r\n\x1a\n", out[0..8]);
// IHDR width/height are big-endian at offsets 16 and 20.
try testing.expectEqual(640, std.mem.readInt(u32, out[16..20], .big));
const height = std.mem.readInt(u32, out[20..24], .big);
const width, const height = pngSize(out);
try testing.expectEqual(640, width);
// Two blocks plus margins.
try testing.expectEqual(true, height > 60 and height < 200);
}
test "browser.screenshot: fixed height, clip and scale" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<p>one</p><p>two</p><p>three</p>");
const root = try testRoot("<p>one</p><p>two</p><p>three</p>");
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
const content_height = try png(testing.arena_allocator, div.asNode(), .{ .width = 300, .height = 50, .scale = 2.0 }, &aw.writer, frame);
const content_height = try png(testing.arena_allocator, root.node, .{ .width = 300, .height = 50, .scale = 2.0 }, &aw.writer, root.frame);
try testing.expectEqual(true, content_height > 50);
try testing.expectEqual(600, std.mem.readInt(u32, aw.written()[16..20], .big));
try testing.expectEqual(100, std.mem.readInt(u32, aw.written()[20..24], .big));
try testing.expectEqual(.{ 600, 100 }, pngSize(aw.written()));
try testing.expectEqual(content_height, try contentHeight(frame.call_arena, div.asNode(), 300, frame));
try testing.expectEqual(content_height, try contentHeight(root.frame.call_arena, root.node, 300, root.frame));
aw.clearRetainingCapacity();
_ = try png(frame.call_arena, div.asNode(), .{
_ = try png(root.frame.call_arena, root.node, .{
.width = 300,
.clip = .{ .x = 10, .y = 10, .width = 100, .height = 40 },
}, &aw.writer, frame);
try testing.expectEqual(100, std.mem.readInt(u32, aw.written()[16..20], .big));
try testing.expectEqual(40, std.mem.readInt(u32, aw.written()[20..24], .big));
}, &aw.writer, root.frame);
try testing.expectEqual(.{ 100, 40 }, pngSize(aw.written()));
}
test "browser.screenshot: a clip past the viewport extends the strip" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<p>one</p><p>two</p><p>three</p><p>four</p><p>five</p><p>six</p>");
const root = try testRoot("<p>one</p><p>two</p><p>three</p><p>four</p><p>five</p><p>six</p>");
const full = try contentHeight(testing.arena_allocator, div.asNode(), 300, frame);
const full = try contentHeight(testing.arena_allocator, root.node, 300, root.frame);
try testing.expectEqual(true, full > 100);
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
_ = try png(frame.call_arena, div.asNode(), .{
_ = try png(root.frame.call_arena, root.node, .{
.width = 300,
.height = 100,
.clip = .{ .x = 0, .y = 0, .width = 300, .height = @floatFromInt(full) },
}, &aw.writer, frame);
try testing.expectEqual(300, std.mem.readInt(u32, aw.written()[16..20], .big));
try testing.expectEqual(full, std.mem.readInt(u32, aw.written()[20..24], .big));
}, &aw.writer, root.frame);
try testing.expectEqual(.{ 300, full }, pngSize(aw.written()));
// Never past the content, so an absurd probe resolves to the full page
// instead of a 1e8-tall raster.
aw.clearRetainingCapacity();
_ = try png(testing.arena_allocator, div.asNode(), .{
_ = try png(testing.arena_allocator, root.node, .{
.width = 300,
.height = 100,
.clip = .{ .x = 0, .y = 0, .width = 300, .height = 1e8 },
}, &aw.writer, frame);
try testing.expectEqual(full, std.mem.readInt(u32, aw.written()[20..24], .big));
}, &aw.writer, root.frame);
_, const height = pngSize(aw.written());
try testing.expectEqual(full, height);
}
test "browser.screenshot: raster is bounded" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<p>hello</p>");
const root = try testRoot("<p>hello</p>");
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
_ = try png(testing.arena_allocator, div.asNode(), .{ .width = 300000, .height = 8 }, &aw.writer, frame);
try testing.expectEqual(16384, std.mem.readInt(u32, aw.written()[16..20], .big));
try testing.expectEqual(8, std.mem.readInt(u32, aw.written()[20..24], .big));
_ = try png(testing.arena_allocator, root.node, .{ .width = 300000, .height = 8 }, &aw.writer, root.frame);
try testing.expectEqual(.{ 16384, 8 }, pngSize(aw.written()));
aw.clearRetainingCapacity();
_ = try png(testing.arena_allocator, div.asNode(), .{ .width = 8, .height = 100000 }, &aw.writer, frame);
try testing.expectEqual(8, std.mem.readInt(u32, aw.written()[16..20], .big));
try testing.expectEqual(16384, std.mem.readInt(u32, aw.written()[20..24], .big));
_ = try png(testing.arena_allocator, root.node, .{ .width = 8, .height = 100000 }, &aw.writer, root.frame);
try testing.expectEqual(.{ 8, 16384 }, pngSize(aw.written()));
}
test "browser.screenshot: a refused write fails the capture" {
defer testing.test_session.closeAllPages();
testing.silenceLog(&.{.browser});
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<p>hello</p>");
const root = try testRoot("<p>hello</p>");
// Far too small for the PNG, so the sink refuses partway through. That
// has to surface as an error and not as a truncated image.
var buf: [64]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try testing.expectError(error.WriteFailed, png(testing.arena_allocator, div.asNode(), .{ .width = 300 }, &w, frame));
try testing.expectError(error.WriteFailed, png(testing.arena_allocator, root.node, .{ .width = 300 }, &w, root.frame));
}
test "browser.screenshot: json streams base64" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<p>hello</p>");
const prepared = try prepare(testing.arena_allocator, div.asNode(), .{ .width = 200 }, frame);
const root = try testRoot("<p>hello</p>");
const prepared = try prepare(testing.arena_allocator, root.node, .{ .width = 200 }, root.frame);
var raw: std.Io.Writer.Allocating = .init(testing.allocator);
defer raw.deinit();
@@ -676,11 +678,7 @@ test "browser.screenshot: json streams base64" {
test "browser.screenshot: block extraction" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(),
const blocks = try testBlocks(
\\<h2> Head ing </h2>
\\<p>Some <b>bold <i>both</i></b> text <a href="/l">a link</a><span> tail</span></p>
\\<ul><li>one</li><li><p>two</p><ol><li>nested</li></ol></li></ul>
@@ -692,110 +690,75 @@ test "browser.screenshot: block extraction" {
\\<table><tr><th>A</th><td>B</td></tr><tr><td>C</td></tr></table>
\\<div> </div>
);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame };
try builder.render(div.asNode());
try builder.closeBlock();
const blocks = builder.blocks.items;
try testing.expectEqual(11, blocks.len);
const S = struct {
fn text(b: raster.Block, arena: Allocator) ![]const u8 {
var out: std.ArrayList(u8) = .empty;
for (b.spans) |s| try out.appendSlice(arena, s.text);
return out.items;
}
};
const arena = testing.arena_allocator;
try testing.expectEqual(.heading, blocks[0].kind);
try testing.expectEqual(2, blocks[0].level);
try testing.expectEqual("Head ing", try S.text(blocks[0], arena));
try testing.expectEqual("Head ing", try spanText(blocks[0]));
try testing.expectEqual(.paragraph, blocks[1].kind);
try testing.expectEqual("Some bold both text a link tail", try S.text(blocks[1], arena));
try testing.expectEqual("Some bold both text a link tail", try spanText(blocks[1]));
try testing.expectEqual(6, blocks[1].spans.len);
try testing.expectEqual("Some ", blocks[1].spans[0].text);
try testing.expectEqual(SPAN_BOLD, blocks[1].spans[1].flags);
try testing.expectEqual(raster.SPAN_BOLD, blocks[1].spans[1].flags);
try testing.expectEqual("bold ", blocks[1].spans[1].text);
try testing.expectEqual(SPAN_BOLD | SPAN_ITALIC, blocks[1].spans[2].flags);
try testing.expectEqual(raster.SPAN_BOLD | raster.SPAN_ITALIC, blocks[1].spans[2].flags);
try testing.expectEqual("both", blocks[1].spans[2].text);
try testing.expectEqual(0, blocks[1].spans[3].flags);
try testing.expectEqual(" text ", blocks[1].spans[3].text);
try testing.expectEqual(SPAN_UNDERLINE | SPAN_HAS_COLOR, blocks[1].spans[4].flags);
try testing.expectEqual(raster.SPAN_UNDERLINE | raster.SPAN_HAS_COLOR, blocks[1].spans[4].flags);
try testing.expectEqual(LINK_COLOR, blocks[1].spans[4].color);
try testing.expectEqual("a link", blocks[1].spans[4].text);
try testing.expectEqual(" tail", blocks[1].spans[5].text);
try testing.expectEqual("one", try S.text(blocks[2], arena));
try testing.expectEqual("one", try spanText(blocks[2]));
try testing.expectEqual(1, blocks[2].list_depth);
try testing.expectEqual("", blocks[2].marker);
try testing.expectEqual("two", try S.text(blocks[3], arena));
try testing.expectEqual("two", try spanText(blocks[3]));
try testing.expectEqual("", blocks[3].marker);
try testing.expectEqual("nested", try S.text(blocks[4], arena));
try testing.expectEqual("nested", try spanText(blocks[4]));
try testing.expectEqual(2, blocks[4].list_depth);
try testing.expectEqual("1.", blocks[4].marker);
try testing.expectEqual("quote", try S.text(blocks[5], arena));
try testing.expectEqual("quote", try spanText(blocks[5]));
try testing.expectEqual(1, blocks[5].quote_depth);
try testing.expectEqual(0, blocks[5].list_depth);
try testing.expectEqual(.pre, blocks[6].kind);
try testing.expectEqual(" keep\n this", try S.text(blocks[6], arena));
try testing.expectEqual(" keep\n this", try spanText(blocks[6]));
try testing.expectEqual(.rule, blocks[7].kind);
try testing.expectEqual("a picture", try S.text(blocks[8], arena));
try testing.expectEqual(SPAN_ITALIC | SPAN_HAS_COLOR, blocks[8].spans[0].flags);
try testing.expectEqual("A | B", try S.text(blocks[9], arena));
try testing.expectEqual(SPAN_BOLD, blocks[9].spans[0].flags);
try testing.expectEqual("C", try S.text(blocks[10], arena));
try testing.expectEqual("a picture", try spanText(blocks[8]));
try testing.expectEqual(raster.SPAN_ITALIC | raster.SPAN_HAS_COLOR, blocks[8].spans[0].flags);
try testing.expectEqual("A | B", try spanText(blocks[9]));
try testing.expectEqual(raster.SPAN_BOLD, blocks[9].spans[0].flags);
try testing.expectEqual("C", try spanText(blocks[10]));
}
test "browser.screenshot: adjacent anchors" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
// Inside <p> (not a layout block) so markdown's standalone rule doesn't
// apply and the anchors flow inline.
try Frame.parse.htmlAsChildren(frame, div.asNode(),
const blocks = try testBlocks(
\\<p><a href="/a">Log In</a><a href="/b">Sign Up</a><b>!</b> see <a href="/c">this</a>.</p>
);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame };
try builder.render(div.asNode());
try builder.closeBlock();
const blocks = builder.blocks.items;
try testing.expectEqual(1, blocks.len);
var out: std.ArrayList(u8) = .empty;
for (blocks[0].spans) |sp| try out.appendSlice(testing.arena_allocator, sp.text);
try testing.expectString("Log In Sign Up! see this.", out.items);
try testing.expectString("Log In Sign Up! see this.", try spanText(blocks[0]));
}
test "browser.screenshot: standalone anchors get their own block" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(),
const blocks = try testBlocks(
\\<nav><a href="/a">bsky</a><a href="/b">rss</a></nav>
\\<div><a href="/p"><h3>Post title</h3><span>Aug 04</span></a></div>
\\<p>inline <a href="/x">link</a> here</p>
);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame };
try builder.render(div.asNode());
try builder.closeBlock();
const blocks = builder.blocks.items;
try testing.expectEqual(5, blocks.len);
try testing.expectString("bsky", blocks[0].spans[0].text);
try testing.expectEqual(BLOCK_TIGHT, blocks[0].flags);
try testing.expectEqual(raster.BLOCK_TIGHT, blocks[0].flags);
try testing.expectString("rss", blocks[1].spans[0].text);
try testing.expectEqual(.heading, blocks[2].kind);
try testing.expectString("Post title", blocks[2].spans[0].text);
try testing.expectEqual(SPAN_UNDERLINE | SPAN_HAS_COLOR, blocks[2].spans[0].flags);
try testing.expectEqual(raster.SPAN_UNDERLINE | raster.SPAN_HAS_COLOR, blocks[2].spans[0].flags);
try testing.expectString("Aug 04", blocks[3].spans[0].text);
try testing.expectEqual(3, blocks[4].spans.len);
try testing.expectEqual(0, blocks[4].flags);
@@ -805,8 +768,7 @@ test "browser.screenshot: shadow dom and slots" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
const div = try frame.window._document.createElement("div", null, frame);
try div.setHTMLUnsafe(
\\<x-host><template shadowrootmode="open"><p>shadow <slot></slot></p></template>light</x-host>
, frame);
@@ -827,16 +789,3 @@ test "browser.screenshot: stacked marks on a later line" {
const out = try testPng("<p>first line<br>\xd8\xb4\xd9\x8e\xd8\xaf\xd9\x91\xd9\x8e\xd8\xa9 caf\xc3\xa9 e\xcc\x81</p>", 400);
try testing.expectEqual("\x89PNG\r\n\x1a\n", out[0..8]);
}
fn testPng(html: []const u8, width: u32) ![]const u8 {
const frame = try testing.createFrame();
frame.url = "http://localhost/";
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
_ = try png(testing.arena_allocator, div.asNode(), .{ .width = width }, &aw.writer, frame);
return aw.written();
}
+76 -38
View File
@@ -17,10 +17,10 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! Reader for the OpenType GPOS mark-attachment lookups (mark-to-base,
//! mark-to-ligature, mark-to-mark) and GDEF glyph classes, straight off the
//! font bytes. Lookups are gathered from the mark/mkmk features of every
//! script and applied in lookup-index order; the first covering subtable
//! wins. LookupFlag filtering is not applied.
//! mark-to-ligature, mark-to-mark), GDEF glyph classes and the vertical
//! metrics tables, straight off the font bytes. Lookups are gathered from
//! the mark/mkmk features of every script and applied in lookup-index order;
//! the first covering subtable wins. LookupFlag filtering is not applied.
const std = @import("std");
@@ -47,6 +47,33 @@ pub const Offset = struct {
pub const Error = error{ MalformedFont, OutOfMemory };
/// Font units, as the tables store them (descent is negative).
pub const Metrics = struct {
units_per_em: u16,
ascent: i16,
descent: i16,
underline_position: i16,
underline_thickness: i16,
strikeout_position: i16,
strikeout_size: i16,
};
pub fn readMetrics(self: *const Gpos) Error!Metrics {
const head = try self.findTable("head") orelse return error.MalformedFont;
const hhea = try self.findTable("hhea") orelse return error.MalformedFont;
const post = try self.findTable("post") orelse return error.MalformedFont;
const os2 = try self.findTable("OS/2") orelse return error.MalformedFont;
return .{
.units_per_em = try self.intAt(u16, head + 18),
.ascent = try self.intAt(i16, hhea + 4),
.descent = try self.intAt(i16, hhea + 6),
.underline_position = try self.intAt(i16, post + 8),
.underline_thickness = try self.intAt(i16, post + 10),
.strikeout_size = try self.intAt(i16, os2 + 26),
.strikeout_position = try self.intAt(i16, os2 + 28),
};
}
const MARK_CLASS: u16 = 3;
pub fn init(allocator: Allocator, data: []const u8) Error!Gpos {
@@ -150,25 +177,24 @@ fn attach(self: *const Gpos, parent: u16, mark: u16, parent_is_mark: bool) Error
if (class >= class_count) return error.MalformedFont;
const mark_anchor = mark_array + try self.u16At(mark_record + 2);
// Anchor offsets are relative to the array holding them: the
// BaseArray/Mark2Array, or the ligature's own attach table.
const record_size = @as(usize, class_count) * 2;
const parent_anchor_off = switch (lookup.kind) {
.mark_base, .mark_mark => try self.u16At(parent_array + 2 + @as(usize, pi) * record_size + class * 2),
const anchors_base, const anchor_off = switch (lookup.kind) {
.mark_base, .mark_mark => .{ parent_array, try self.u16At(parent_array + 2 + @as(usize, pi) * record_size + class * 2) },
.mark_lig => blk: {
const lig_attach = parent_array + try self.u16At(parent_array + 2 + @as(usize, pi) * 2);
const component_count = try self.u16At(lig_attach);
if (component_count == 0) break :blk 0;
break :blk try self.u16At(lig_attach + 2 + (component_count - 1) * record_size + class * 2);
if (component_count == 0) break :blk .{ lig_attach, 0 };
break :blk .{ lig_attach, try self.u16At(lig_attach + 2 + (component_count - 1) * record_size + class * 2) };
},
};
if (parent_anchor_off == 0) continue;
const parent_anchor = if (lookup.kind == .mark_lig)
parent_array + try self.u16At(parent_array + 2 + @as(usize, pi) * 2) + parent_anchor_off
else
parent_array + parent_anchor_off;
if (anchor_off == 0) continue;
const parent_anchor = anchors_base + anchor_off;
return .{
.dx = try self.i16At(parent_anchor + 2) - try self.i16At(mark_anchor + 2),
.dy = try self.i16At(parent_anchor + 4) - try self.i16At(mark_anchor + 4),
.dx = try self.intAt(i16, parent_anchor + 2) - try self.intAt(i16, mark_anchor + 2),
.dy = try self.intAt(i16, parent_anchor + 4) - try self.intAt(i16, mark_anchor + 4),
};
}
}
@@ -190,20 +216,25 @@ fn coverage(self: *const Gpos, off: usize, gid: u16) Error!?u16 {
return null;
},
2 => {
const count = try self.u16At(off + 2);
for (0..count) |i| {
const record = off + 4 + i * 6;
const start = try self.u16At(record);
const end = try self.u16At(record + 2);
if (gid < start) return null;
if (gid <= end) return try self.u16At(record + 4) + (gid - start);
}
return null;
const record = try self.rangeRecord(off, gid) orelse return null;
return try self.u16At(record + 4) + (gid - try self.u16At(record));
},
else => return error.MalformedFont,
}
}
// Coverage and ClassDef format 2 share the sorted (start, end, value)
// record layout; returns the record holding `gid`.
fn rangeRecord(self: *const Gpos, off: usize, gid: u16) Error!?usize {
const count = try self.u16At(off + 2);
for (0..count) |i| {
const record = off + 4 + i * 6;
if (gid < try self.u16At(record)) return null;
if (gid <= try self.u16At(record + 2)) return record;
}
return null;
}
fn classOf(self: *const Gpos, off: usize, gid: u16) Error!u16 {
switch (try self.u16At(off)) {
1 => {
@@ -213,15 +244,8 @@ fn classOf(self: *const Gpos, off: usize, gid: u16) Error!u16 {
return try self.u16At(off + 6 + (gid - start) * 2);
},
2 => {
const count = try self.u16At(off + 2);
for (0..count) |i| {
const record = off + 4 + i * 6;
const start = try self.u16At(record);
const end = try self.u16At(record + 2);
if (gid < start) return 0;
if (gid <= end) return try self.u16At(record + 4);
}
return 0;
const record = try self.rangeRecord(off, gid) orelse return 0;
return try self.u16At(record + 4);
},
else => return error.MalformedFont,
}
@@ -245,16 +269,17 @@ fn bytesAt(self: *const Gpos, off: usize, len: usize) Error![]const u8 {
return self.data[off..][0..len];
}
fn u16At(self: *const Gpos, off: usize) Error!u16 {
return std.mem.readInt(u16, (try self.bytesAt(off, 2))[0..2], .big);
fn intAt(self: *const Gpos, comptime T: type, off: usize) Error!T {
const n = @sizeOf(T);
return std.mem.readInt(T, (try self.bytesAt(off, n))[0..n], .big);
}
fn i16At(self: *const Gpos, off: usize) Error!i16 {
return std.mem.readInt(i16, (try self.bytesAt(off, 2))[0..2], .big);
fn u16At(self: *const Gpos, off: usize) Error!u16 {
return self.intAt(u16, off);
}
fn u32At(self: *const Gpos, off: usize) Error!u32 {
return std.mem.readInt(u32, (try self.bytesAt(off, 4))[0..4], .big);
return self.intAt(u32, off);
}
const testing = @import("../../testing.zig");
@@ -304,10 +329,23 @@ test "browser.screenshot.gpos: DejaVu Sans mark attachment" {
try testing.expectEqual(null, g.attachToMark(68, 690));
}
test "browser.screenshot.gpos: DejaVu Sans metrics" {
const g = try Gpos.init(testing.arena_allocator, @embedFile("fonts/DejaVuSans.ttf"));
const m = try g.readMetrics();
try testing.expectEqual(2048, m.units_per_em);
try testing.expectEqual(1901, m.ascent);
try testing.expectEqual(-483, m.descent);
try testing.expectEqual(-40, m.underline_position);
try testing.expectEqual(90, m.underline_thickness);
try testing.expectEqual(530, m.strikeout_position);
try testing.expectEqual(102, m.strikeout_size);
}
test "browser.screenshot.gpos: all bundled faces parse" {
inline for (.{ "DejaVuSans-Bold.ttf", "DejaVuSansMono.ttf", "DejaVuSansMono-Bold.ttf" }) |name| {
const g = try Gpos.init(testing.arena_allocator, @embedFile("fonts/" ++ name));
try testing.expectEqual(true, g.lookups.len > 0);
try testing.expectEqual(true, g.glyph_class_def != null);
try testing.expectEqual(2048, (try g.readMetrics()).units_per_em);
}
}
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -340,16 +340,16 @@ pub fn fetch(app: *App, browser: *Browser, urls: []const [:0]const u8, opts: Fet
}
const frame = page.frame();
if (opts.dump_mode == .png and frame != null) {
if (opts.dump_mode == .png) if (frame) |f| {
const arena = try app.arena_pool.acquire(.large, "screenshot.dump");
defer arena.release();
const shot = try screenshot.prepare(arena.allocator(), frame.?.window._document.asNode(), .{
.width = frame.?._page.getViewport().width,
}, frame.?);
const shot = try screenshot.prepare(arena.allocator(), f.window._document.asNode(), .{
.width = f._page.getViewport().width,
}, f);
try writeJsonEnvelope(writer, frame, opts.dump_mode, shot);
continue;
}
};
var aw: std.Io.Writer.Allocating = .init(app.allocator);
defer aw.deinit();
@@ -382,8 +382,8 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, dump_opts: dump.Opts, frame:
.html => try dump.root(frame.window._document, dump_opts, writer, frame),
.markdown => try markdown.dump(frame.window._document.asNode(), .{}, writer, frame),
.png => {
var arena: std.heap.ArenaAllocator = .init(app.allocator);
defer arena.deinit();
const arena = try app.arena_pool.acquire(.large, "screenshot.dump");
defer arena.release();
_ = try screenshot.png(arena.allocator(), frame.window._document.asNode(), .{
.width = frame._page.getViewport().width,
}, writer, frame);
+55
View File
@@ -0,0 +1,55 @@
// Copyright (C) 2023-2026 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/>.
//! The ICU C API, from the copy bundled in V8's archive: bidi, Arabic
//! shaping and line breaking. Symbols carry ICU's major version, so this
//! moves with V8. The shared dev build of V8 only exports them from
//! lightpanda-io/zig-v8-fork#201 on; before that, link the static archive.
const version = "77";
fn sym(comptime name: []const u8, comptime T: type) *const T {
return @extern(*const T, .{ .name = name ++ "_" ++ version });
}
pub const UBiDi = opaque {};
pub const UBreakIterator = opaque {};
pub const ubidi_open = sym("ubidi_open", fn () callconv(.c) ?*UBiDi);
pub const ubidi_close = sym("ubidi_close", fn (*UBiDi) callconv(.c) void);
pub const ubidi_setPara = sym("ubidi_setPara", fn (*UBiDi, [*]const u16, i32, u8, ?[*]u8, *c_int) callconv(.c) void);
pub const ubidi_getParaLevel = sym("ubidi_getParaLevel", fn (*const UBiDi) callconv(.c) u8);
pub const ubidi_getLevels = sym("ubidi_getLevels", fn (*UBiDi, *c_int) callconv(.c) ?[*]const u8);
pub const ubidi_reorderVisual = sym("ubidi_reorderVisual", fn ([*]const u8, i32, [*]i32) callconv(.c) void);
pub const u_shapeArabic = sym("u_shapeArabic", fn ([*]const u16, i32, [*]u16, i32, u32, *c_int) callconv(.c) i32);
pub const u_charMirror = sym("u_charMirror", fn (i32) callconv(.c) i32);
pub const ubrk_open = sym("ubrk_open", fn (c_int, ?[*:0]const u8, ?[*]const u16, i32, *c_int) callconv(.c) ?*UBreakIterator);
pub const ubrk_close = sym("ubrk_close", fn (*UBreakIterator) callconv(.c) void);
pub const ubrk_next = sym("ubrk_next", fn (*UBreakIterator) callconv(.c) i32);
pub const ubrk_getRuleStatus = sym("ubrk_getRuleStatus", fn (*UBreakIterator) callconv(.c) i32);
pub const UBIDI_DEFAULT_LTR: u8 = 0xfe;
pub const UBRK_LINE: c_int = 2;
pub const UBRK_DONE: i32 = -1;
pub const UBRK_LINE_HARD: i32 = 100;
pub const U_SHAPE_LETTERS_SHAPE_TASHKEEL_ISOLATED: u32 = 0x18;
/// UErrorCode: warnings are negative, U_ZERO_ERROR is 0, failures positive.
pub fn failed(err: c_int) bool {
return err > 0;
}