summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:48:38 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commitb576c238fb0e3da3ac8d22baf3f29245a5a6f2bc (patch)
tree5e61547e88386d78868b23decd3956339ee23e9d
parent90eed6a89c66d6243ea43e799113409244308563 (diff)
downloadpardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.tar.gz
pardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.zip
add colorscheme-aware PDF tint modes
-rw-r--r--src/builtins.zig14
-rw-r--r--src/config.zig13
-rw-r--r--src/pardes.zig273
-rw-r--r--src/pdf.zig118
-rw-r--r--test/pdf_bench.zig97
-rw-r--r--test/snapshots/builtins.golden64
-rw-r--r--test/snapshots/leader.golden16
7 files changed, 531 insertions, 64 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index b89952cd..4f6db6e1 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -190,7 +190,7 @@ pub const Colors = struct {
/// clicks was never what made it useful.
pub const NextColor = struct {
pub fn run(c: Ctx) void {
- c.p.theme_idx = (c.p.theme_idx + 1) % pardes.themes.len;
+ c.p.setThemeIndex((c.p.theme_idx + 1) % pardes.themes.len);
}
};
@@ -209,7 +209,7 @@ pub const Theme = struct {
const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
for (pardes.themes, 0..) |t, i| {
if (std.mem.eql(u8, t.name, want)) {
- c.p.theme_idx = i;
+ c.p.setThemeIndex(i);
return;
}
}
@@ -301,6 +301,16 @@ pub const PdfFit = struct {
}
};
+/// Cycle a native PDF through original pixels, a chroma-preserving themed
+/// filter, and a full theme duotone. It has the same compile-time and
+/// pane-local shape as PdfFit: absent without MuPDF and inert off a PDF pane.
+pub const PdfTint = struct {
+ pub const run = if (pardes.pdf_enabled) apply else {};
+ fn apply(c: Ctx) void {
+ c.p.togglePdfTint(c.pane);
+ }
+};
+
// The image pane's three renderer toggles. They used to be words the image tag
// printed and the execute dispatcher matched by hand; as builtins they are
// executable anywhere, pressable under SPC and listed by `SPC ?`, the whole
diff --git a/src/config.zig b/src/config.zig
index 41a70e4a..3eedf707 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -164,10 +164,15 @@ pub const leader_path = paths: {
table.set(.FontSel, "tf");
table.set(.Font, null);
}
- // PdfFit exists only in MuPDF builds (its run declaration deliberately
- // changes shape when the feature is off), so name its toggle path through
- // the same comptime branch rather than making feature-off enums mention it.
- if (pardes.pdf_enabled) table.set(.PdfFit, "tz");
+ // The PDF toggles exist only in MuPDF builds (their run declarations
+ // deliberately change shape when the feature is off), so name their paths
+ // through the same comptime branch rather than making feature-off enums
+ // mention them. `ti` was the unused slot in the display-toggle group;
+ // PdfFit's established `tz` stays unchanged.
+ if (pardes.pdf_enabled) {
+ table.set(.PdfTint, "ti");
+ table.set(.PdfFit, "tz");
+ }
// ...and the property EnumArray.init used to give for free: every builtin
// this build HAS is a builtin someone decided a path (or a null) for.
for (std.enums.values(Builtin)) |b| if (table.get(b)) |p| {
diff --git a/src/pardes.zig b/src/pardes.zig
index 04668f9c..23611990 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -358,6 +358,7 @@ test "PDF continuous strip renders every intersecting short page" {
.request = request,
.request_valid = true,
.tried = true,
+ .tint_key = p.pdfTintKey(pv),
.revision = @intCast(page + 1),
});
}
@@ -374,6 +375,189 @@ test "PDF continuous strip renders every intersecting short page" {
}
}
+test "PdfTint cycles pane-local state and exposes it in the live PDF tag" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const p = try Pardes.init(std.testing.allocator, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse
+ return error.MissingPdfTintBuiltin;
+
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ const initial_tag = try p.tagText(p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ initial_tag,
+ "width PdfFit disabled PdfTint",
+ ) != null);
+
+ p.runBuiltin(tint_builtin, 0, "", null);
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ const filtered_tag = try p.tagText(p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, filtered_tag, "filtered PdfTint") != null);
+
+ // The launch companion is a terminal. Running the same pane-scoped word
+ // there cannot mutate the PDF next to it.
+ try std.testing.expect(!hasPdf(p.panes[1].?));
+ p.runBuiltin(tint_builtin, 1, "", null);
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+
+ p.runBuiltin(tint_builtin, 0, "", null);
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+ p.runBuiltin(tint_builtin, 0, "", null);
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ try std.testing.expectEqual(PdfFitMode.width, pv.fit);
+
+ // `dark` intentionally leaves page bg/fg null. PDF tint resolves those
+ // deterministically to its chrome colors rather than host defaults.
+ const dark_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "dark")) break i;
+ } else return error.MissingDarkTheme;
+ p.setThemeIndex(dark_index);
+ const colors = p.pdfTintColors();
+ try std.testing.expectEqual(p.theme().tag_bg, colors.background);
+ try std.testing.expectEqual(p.theme().tag_fg, colors.foreground);
+}
+
+test "PDF tint and tinted theme changes rebuild every visible raster only" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ _ = try p.render(frame.allocator());
+ const viewport = p.pdfViewport(pane).?;
+ pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2);
+ _ = frame.reset(.retain_capacity);
+ const plain_surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 2), plain_surface.nimages);
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len);
+
+ const Snapshot = struct {
+ page: u32,
+ revision: u32,
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ iw: usize,
+ ih: usize,
+ checksum: u64,
+ };
+ const Capture = struct {
+ fn get(surface: *const Surface) ![2]Snapshot {
+ if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount;
+ var out: [2]Snapshot = undefined;
+ for (&out, 0..) |*snapshot, i| {
+ const place = surface.images[i] orelse return error.MissingVisiblePdf;
+ snapshot.* = .{
+ .page = place.native.page,
+ .revision = place.native.revision,
+ .geometry = place.native.geometry orelse return error.MissingPdfGeometry,
+ .pixel_offset_y = place.native.pixel_offset_y,
+ .iw = place.iw,
+ .ih = place.ih,
+ .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba),
+ };
+ }
+ return out;
+ }
+
+ fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void {
+ for (before, after) |old, new| {
+ try std.testing.expectEqual(old.page, new.page);
+ try std.testing.expectEqual(old.geometry, new.geometry);
+ try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y);
+ try std.testing.expectEqual(old.iw, new.iw);
+ try std.testing.expectEqual(old.ih, new.ih);
+ }
+ }
+ };
+ const plain = try Capture.get(plain_surface);
+
+ p.togglePdfTint(pane);
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ for (pv.rasters.items) |raster| try std.testing.expect(!raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const filtered_surface = try p.render(frame.allocator());
+ const filtered = try Capture.get(filtered_surface);
+ try Capture.expectGeometry(plain, filtered);
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len);
+ for (plain, filtered) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ try std.testing.expect(new.checksum != old.checksum);
+ }
+
+ const dark_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "dark")) break i;
+ } else return error.MissingDarkTheme;
+ p.setThemeIndex(dark_index);
+ for (pv.rasters.items) |raster| try std.testing.expect(!raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const themed_surface = try p.render(frame.allocator());
+ const themed = try Capture.get(themed_surface);
+ try Capture.expectGeometry(filtered, themed);
+ for (filtered, themed) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ try std.testing.expect(new.checksum != old.checksum);
+ }
+
+ p.togglePdfTint(pane);
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+ _ = frame.reset(.retain_capacity);
+ const full_surface = try p.render(frame.allocator());
+ const full = try Capture.get(full_surface);
+ try Capture.expectGeometry(themed, full);
+ for (themed, full) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ }
+ for (pv.rasters.items) |raster|
+ try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode);
+
+ p.togglePdfTint(pane);
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ _ = frame.reset(.retain_capacity);
+ const disabled_surface = try p.render(frame.allocator());
+ const disabled = try Capture.get(disabled_surface);
+ try Capture.expectGeometry(full, disabled);
+ for (plain, disabled) |original, restored| {
+ try std.testing.expect(restored.revision > original.revision);
+ try std.testing.expectEqual(original.checksum, restored.checksum);
+ }
+
+ const acme_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "acme")) break i;
+ } else return error.MissingAcmeTheme;
+ for (pv.rasters.items) |raster| try std.testing.expect(raster.tried);
+ p.setThemeIndex(acme_index);
+ // Disabled keys ignore theme colors, so neither explicit invalidation nor
+ // the per-raster key comparison asks MuPDF for replacement pixels.
+ for (pv.rasters.items) |raster| try std.testing.expect(raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const unchanged_surface = try p.render(frame.allocator());
+ const unchanged = try Capture.get(unchanged_surface);
+ try Capture.expectGeometry(disabled, unchanged);
+ for (disabled, unchanged) |old, new| {
+ try std.testing.expectEqual(old.revision, new.revision);
+ try std.testing.expectEqual(old.checksum, new.checksum);
+ }
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len);
+}
+
test "PDF resize preserves page-relative document position" {
if (!pdf_enabled or platform == .web) return;
@@ -872,13 +1056,18 @@ const legacy_file_pane_tail = " Save Del";
// sits with the other two comptime folds (builtin_rows, the topbar check).
const Builtin = builtins.Builtin();
-test "PdfFit builtin and leader path follow the MuPDF feature gate" {
+test "PDF builtins and leader paths follow the MuPDF feature gate" {
const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit");
+ const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint");
try std.testing.expectEqual(pdf_enabled, maybe_fit != null);
+ try std.testing.expectEqual(pdf_enabled, maybe_tint != null);
if (pdf_enabled) {
- const path = config.leader_path.get(maybe_fit.?) orelse
+ const fit_path = config.leader_path.get(maybe_fit.?) orelse
return error.MissingPdfFitLeaderPath;
- try std.testing.expectEqualStrings("tz", path);
+ const tint_path = config.leader_path.get(maybe_tint.?) orelse
+ return error.MissingPdfTintLeaderPath;
+ try std.testing.expectEqualStrings("tz", fit_path);
+ try std.testing.expectEqualStrings("ti", tint_path);
}
}
@@ -1518,6 +1707,20 @@ pub const ImageCacheKey = if (pdf_enabled) struct {
/// The type remains available to shared core code, while PdfView itself and
/// the builtin which mutates it are compiled away with `-Dmupdf=false`.
const PdfFitMode = if (pdf_enabled) enum { width, height } else void;
+const PdfTintMode = if (pdf_enabled) pdf_impl.TintMode else void;
+
+/// Every resident page records the PDF-only pixel state it was rendered for.
+/// Disabled deliberately ignores colors: changing a theme must not replace an
+/// untouched PDF raster. In tinted modes colors are part of the generation.
+const PdfTintKey = if (pdf_enabled) struct {
+ mode: PdfTintMode,
+ colors: pdf_impl.TintColors,
+
+ fn eql(a: @This(), b: @This()) bool {
+ return a.mode == b.mode and
+ (a.mode == .disabled or std.meta.eql(a.colors, b.colors));
+ }
+} else void;
/// Dynamic placement exists only for native PDF pages. Static image panes need
/// only their pane identity, so feature-off builds carry a zero-bit payload.
@@ -2023,6 +2226,7 @@ const PdfRaster = if (pdf_enabled) struct {
request_valid: bool = false,
tried: bool = false,
decorated: bool = false,
+ tint_key: ?PdfTintKey = null,
revision: u32 = 0,
} else void;
@@ -2061,6 +2265,7 @@ const PdfView = if (pdf_enabled) struct {
layout_anchor_fraction: f64 = 0,
next_raster_revision: u32 = 0,
fit: PdfFitMode = .width,
+ tint: PdfTintMode = .disabled,
/// Normalized 0..65535 pan positions. NativeGeometry maps the extrema
/// exactly onto the source overflow, independent of page or cell size.
pan_x: u16 = 0,
@@ -3042,8 +3247,8 @@ pub const Pardes = struct {
const arena = p.scratch.allocator();
if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint(
arena,
- "pdf {d}/{d} {s} PdfFit {s}",
- .{ pv.page + 1, pv.page_count, @tagName(pv.fit), pv.path },
+ "pdf {d}/{d} {s} PdfFit {s} PdfTint {s}",
+ .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
);
if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
if (pane.file) |f| return arena.dupe(u8, f.path);
@@ -8005,6 +8210,18 @@ pub const Pardes = struct {
};
}
+ /// Tint and theme changes affect every page currently carried to a native
+ /// backend, including both sides of a continuous-strip page boundary.
+ /// Retain their old buffers until each replacement succeeds.
+ fn invalidateAllPdfRasters(p: *Pardes, pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ _ = p;
+ pv.render_tried = false;
+ for (pv.rasters.items) |*raster| {
+ if (raster.valid) raster.tried = false;
+ }
+ }
+
fn releasePdfRaster(p: *Pardes, raster: *PdfRaster) void {
if (comptime !pdf_enabled) return;
if (raster.rgba.len > 0) p.gpa.free(raster.rgba);
@@ -8211,6 +8428,16 @@ pub const Pardes = struct {
pv.search_reveal_pending = pv.search_query.len > 0;
}
+ /// Builtin entry point for the exact disabled -> filtered -> full ring.
+ /// State belongs to one PDF pane; other document and terminal panes are
+ /// deliberately inert.
+ pub fn togglePdfTint(p: *Pardes, pane: *Pane) void {
+ if (comptime !pdf_enabled) return;
+ const pv = &(pane.pdf orelse return);
+ pv.tint = pv.tint.next();
+ p.invalidateAllPdfRasters(pv);
+ }
+
fn stepPdfPage(p: *Pardes, pane: *Pane, delta: i64) void {
if (comptime !pdf_enabled) return;
const pv = pane.pdf orelse return;
@@ -8366,6 +8593,20 @@ pub const Pardes = struct {
};
}
+ fn pdfTintColors(p: *const Pardes) pdf_impl.TintColors {
+ if (comptime !pdf_enabled) return undefined;
+ const th = p.theme();
+ return .{
+ .background = th.bg orelse th.tag_bg,
+ .foreground = th.fg orelse th.tag_fg,
+ };
+ }
+
+ fn pdfTintKey(p: *const Pardes, pv: *const PdfView) PdfTintKey {
+ if (comptime !pdf_enabled) return undefined;
+ return .{ .mode = pv.tint, .colors = p.pdfTintColors() };
+ }
+
const PdfPlacedGeometry = struct {
geometry: image.NativeGeometry,
pixel_offset_y: f32,
@@ -9722,6 +9963,19 @@ pub const Pardes = struct {
return &themes[p.theme_idx];
}
+ /// The sole live-session theme mutation path. Text chrome reads the new
+ /// theme next frame; only PDFs whose pixels depend on theme colors are
+ /// marked stale. Untinted source rasters remain byte-for-byte resident.
+ pub fn setThemeIndex(p: *Pardes, index: usize) void {
+ if (index >= themes.len or index == p.theme_idx) return;
+ p.theme_idx = index;
+ if (comptime pdf_enabled) for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ const pv = &(pane.pdf orelse continue);
+ if (pv.tint != .disabled) p.invalidateAllPdfRasters(pv);
+ };
+ }
+
// ---- render: build the canonical surface ----
pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
@@ -10227,6 +10481,7 @@ pub const Pardes = struct {
visible: PdfVisiblePages,
) void {
if (comptime !pdf_enabled) return;
+ const tint_key = p.pdfTintKey(pv);
// Remove first: owned RGBA never accumulates with document length,
// and backends see stale keys disappear in this same frame.
@@ -10251,7 +10506,8 @@ pub const Pardes = struct {
const slot = raster.?;
const decorated = page == pv.page and highlights.len > 0;
if (!slot.tried or !slot.request_valid or !slot.request.eql(request) or
- slot.decorated != decorated)
+ slot.decorated != decorated or slot.tint_key == null or
+ !slot.tint_key.?.eql(tint_key))
{
slot.tried = true;
slot.request = request;
@@ -10264,11 +10520,16 @@ pub const Pardes = struct {
break :render fresh;
};
if (rendered) |fresh| {
+ pdf_impl.tintRgba(fresh.rgba, tint_key.mode, tint_key.colors) catch {
+ p.gpa.free(fresh.rgba);
+ continue;
+ };
if (slot.rgba.len > 0) p.gpa.free(slot.rgba);
slot.rgba = fresh.rgba;
slot.iw = fresh.width;
slot.ih = fresh.height;
slot.decorated = decorated;
+ slot.tint_key = tint_key;
pv.next_raster_revision +%= 1;
if (pv.next_raster_revision == 0) pv.next_raster_revision = 1;
slot.revision = pv.next_raster_revision;
diff --git a/src/pdf.zig b/src/pdf.zig
index 28f976e4..0895469a 100644
--- a/src/pdf.zig
+++ b/src/pdf.zig
@@ -72,6 +72,124 @@ pub const Render = struct {
height: usize,
};
+/// Pane-local PDF color treatment. The core owns when this changes; keeping
+/// the pixel transform here lets the renderer benchmark and any future PDF
+/// consumer exercise exactly the same fixed-point rule.
+pub const TintMode = enum {
+ disabled,
+ filtered,
+ full,
+
+ pub fn next(mode: TintMode) TintMode {
+ return switch (mode) {
+ .disabled => .filtered,
+ .filtered => .full,
+ .full => .disabled,
+ };
+ }
+};
+
+pub const TintColors = struct {
+ background: [3]u8,
+ foreground: [3]u8,
+};
+
+/// Rewrite packed straight-alpha RGBA in place without allocating.
+///
+/// Source luminance is Rec. 601's 77/150/29 integer approximation. `full`
+/// linearly maps source black to theme foreground and source white to theme
+/// background. `filtered` starts with that same themed target, then adds 3/4
+/// of each source channel's signed distance from source luminance, clamped to
+/// a byte. Thus grayscale is identical in both modes while filtered color
+/// retains the source hue/chroma. Alpha is never touched.
+pub fn tintRgba(rgba: []u8, mode: TintMode, colors: TintColors) !void {
+ if (rgba.len % 4 != 0) return error.InvalidRgbaLength;
+ if (mode == .disabled) return;
+ var at: usize = 0;
+ while (at < rgba.len) : (at += 4) {
+ const source = rgba[at..][0..3];
+ const luminance: u8 = @intCast((@as(u32, source[0]) * 77 +
+ @as(u32, source[1]) * 150 +
+ @as(u32, source[2]) * 29 + 128) >> 8);
+ for (0..3) |channel| {
+ const target: i32 = @intCast((@as(u32, colors.foreground[channel]) * (255 - @as(u32, luminance)) +
+ @as(u32, colors.background[channel]) * @as(u32, luminance) + 127) / 255);
+ const chroma = if (mode == .filtered)
+ @divTrunc(
+ (@as(i32, source[channel]) - @as(i32, luminance)) * 3,
+ 4,
+ )
+ else
+ 0;
+ rgba[at + channel] = @intCast(std.math.clamp(target + chroma, 0, 255));
+ }
+ }
+}
+
+test "PDF tint mode cycle is exact" {
+ try std.testing.expectEqual(TintMode.filtered, TintMode.disabled.next());
+ try std.testing.expectEqual(TintMode.full, TintMode.filtered.next());
+ try std.testing.expectEqual(TintMode.disabled, TintMode.full.next());
+}
+
+test "full PDF tint maps black and white to dark and light theme endpoints" {
+ const dark: TintColors = .{
+ .background = .{ 0x12, 0x12, 0x12 },
+ .foreground = .{ 0x94, 0x94, 0x94 },
+ };
+ var dark_pixels = [_]u8{ 0, 0, 0, 17, 255, 255, 255, 231 };
+ try tintRgba(&dark_pixels, .full, dark);
+ try std.testing.expectEqualSlices(u8, &.{ 0x94, 0x94, 0x94, 17 }, dark_pixels[0..4]);
+ try std.testing.expectEqualSlices(u8, &.{ 0x12, 0x12, 0x12, 231 }, dark_pixels[4..8]);
+
+ const light: TintColors = .{
+ .background = .{ 0xea, 0xff, 0xff },
+ .foreground = .{ 0x00, 0x00, 0x00 },
+ };
+ var light_pixels = [_]u8{ 0, 0, 0, 0, 255, 255, 255, 255 };
+ try tintRgba(&light_pixels, .full, light);
+ try std.testing.expectEqualSlices(u8, &.{ 0x00, 0x00, 0x00, 0 }, light_pixels[0..4]);
+ try std.testing.expectEqualSlices(u8, &.{ 0xea, 0xff, 0xff, 255 }, light_pixels[4..8]);
+}
+
+test "filtered PDF tint preserves fixed-point source chroma and alpha" {
+ const colors: TintColors = .{
+ .background = .{ 20, 40, 60 },
+ .foreground = .{ 220, 200, 180 },
+ };
+ var full = [_]u8{ 255, 0, 0, 37 };
+ var filtered = full;
+ try tintRgba(&full, .full, colors);
+ try tintRgba(&filtered, .filtered, colors);
+ // Red has integer luma 77. The themed target is {160,152,144}; adding
+ // 3/4 of its {178,-77,-77} chroma residual yields this clamped result.
+ try std.testing.expectEqualSlices(u8, &.{ 160, 152, 144, 37 }, &full);
+ try std.testing.expectEqualSlices(u8, &.{ 255, 95, 87, 37 }, &filtered);
+
+ var gray_full = [_]u8{ 128, 128, 128, 91 };
+ var gray_filtered = gray_full;
+ try tintRgba(&gray_full, .full, colors);
+ try tintRgba(&gray_filtered, .filtered, colors);
+ try std.testing.expectEqualSlices(u8, &gray_full, &gray_filtered);
+ try std.testing.expectEqual(@as(u8, 91), gray_filtered[3]);
+}
+
+test "disabled PDF tint is byte-identical and malformed RGBA is rejected unchanged" {
+ const colors: TintColors = .{
+ .background = .{ 1, 2, 3 },
+ .foreground = .{ 4, 5, 6 },
+ };
+ var pixels = [_]u8{ 9, 80, 170, 0, 250, 33, 71, 199 };
+ const before = pixels;
+ try tintRgba(&pixels, .disabled, colors);
+ try std.testing.expectEqualSlices(u8, &before, &pixels);
+
+ var malformed = [_]u8{ 11, 22, 33 };
+ const malformed_before = malformed;
+ try std.testing.expectError(error.InvalidRgbaLength, tintRgba(&malformed, .full, colors));
+ try std.testing.expectEqualSlices(u8, &malformed_before, &malformed);
+}
+
pub const PageSize = struct {
width: f32,
height: f32,
diff --git a/test/pdf_bench.zig b/test/pdf_bench.zig
index 1325def5..fb911bee 100644
--- a/test/pdf_bench.zig
+++ b/test/pdf_bench.zig
@@ -7,9 +7,11 @@
//!
//! The benchmark links the real src/pdf.zig wrapper and C exception bridge in
//! ReleaseFast. Timed regions include MuPDF rasterization directly into the
-//! allocator-owned RGBA output; checksum/free/reporting stay outside. "cold"
-//! means a fresh Document and MuPDF page cache (the OS filesystem cache is
-//! deliberately uncontrolled), while warm scenarios retain one Document.
+//! allocator-owned RGBA output; tint scenarios additionally include the exact
+//! in-place PDF transform used by the core. Checksum/free/reporting stay
+//! outside. "cold" means a fresh Document and MuPDF page cache (the OS
+//! filesystem cache is deliberately uncontrolled), while warm scenarios
+//! retain one Document.
const std = @import("std");
const pdf = @import("mupdf");
@@ -69,13 +71,29 @@ const Scenario = struct {
id: []const u8,
lifecycle: Lifecycle,
decoration: Decoration,
+ tint: pdf.TintMode = .disabled,
};
+// Fixed benchmark colors, independent of user config and host defaults. They
+// are Pardes's helix page background/foreground and make run-to-run tint
+// checksums a stable optimization target.
+const tint_colors: pdf.TintColors = .{
+ .background = .{ 0x08, 0x08, 0x08 },
+ .foreground = .{ 0xbd, 0xbd, 0xbd },
+};
+
+// Append only: baseline readers and the cross-scenario checks below rely on
+// the original four scenarios retaining their indices and meaning.
const scenarios = [_]Scenario{
.{ .id = "cold-document-plain", .lifecycle = .cold_document_page, .decoration = .plain },
.{ .id = "warm-page-plain", .lifecycle = .warm_page, .decoration = .plain },
.{ .id = "warm-page-highlighted", .lifecycle = .warm_page, .decoration = .highlighted },
.{ .id = "warm-document-pass", .lifecycle = .warm_document_pass, .decoration = .plain },
+ // The checked-in design page is grayscale. Reuse the original deterministic
+ // highlight decoration here so filtered/full exercise source chroma and
+ // must retain distinct correctness checksums.
+ .{ .id = "warm-page-filtered", .lifecycle = .warm_page, .decoration = .highlighted, .tint = .filtered },
+ .{ .id = "warm-page-full", .lifecycle = .warm_page, .decoration = .highlighted, .tint = .full },
};
const highlights = [_]pdf.Highlight{
@@ -232,7 +250,13 @@ fn measure(
if (scenario.lifecycle == .cold_document_page) {
for (0..rounds) |round| {
- const timed = try timeCold(config.path, config.page, profile.request, scenario.decoration);
+ const timed = try timeCold(
+ config.path,
+ config.page,
+ profile.request,
+ scenario.decoration,
+ scenario.tint,
+ );
try verifyIdentity(&expected, timed.identity, profile, scenario, round);
if (round >= config.warmup) {
samples[sample_at] = timed.ns;
@@ -250,12 +274,14 @@ fn measure(
config.page,
profile.request,
scenario.decoration,
+ scenario.tint,
),
.warm_document_pass => try timeDocumentPass(
&document,
multi_pages,
profile.request,
scenario.decoration,
+ scenario.tint,
),
.cold_document_page => unreachable,
};
@@ -288,11 +314,12 @@ fn timeCold(
page: usize,
request: pdf.RenderRequest,
decoration: Decoration,
+ tint: pdf.TintMode,
) !Timed {
const started = nowNs();
var document = try pdf.Document.open(path);
errdefer document.deinit();
- const rendered = try renderPage(&document, page, request, decoration);
+ const rendered = try renderPage(&document, page, request, decoration, tint);
const elapsed = @max(@as(u64, 1), nowNs() -| started);
const identity = try consumeRender(rendered, page, .{});
document.deinit();
@@ -304,9 +331,10 @@ fn timePage(
page: usize,
request: pdf.RenderRequest,
decoration: Decoration,
+ tint: pdf.TintMode,
) !Timed {
const started = nowNs();
- const rendered = try renderPage(document, page, request, decoration);
+ const rendered = try renderPage(document, page, request, decoration, tint);
const elapsed = @max(@as(u64, 1), nowNs() -| started);
return .{
.ns = elapsed,
@@ -319,6 +347,7 @@ fn timeDocumentPass(
page_count: usize,
request: pdf.RenderRequest,
decoration: Decoration,
+ tint: pdf.TintMode,
) !Timed {
const rendered = try gpa.alloc(pdf.Render, page_count);
defer gpa.free(rendered);
@@ -328,7 +357,7 @@ fn timeDocumentPass(
errdefer for (rendered[0..initialized]) |item| gpa.free(item.rgba);
const started = nowNs();
for (rendered, 0..) |*item, page| {
- item.* = try renderPage(document, page, request, decoration);
+ item.* = try renderPage(document, page, request, decoration, tint);
initialized += 1;
}
elapsed = @max(@as(u64, 1), nowNs() -| started);
@@ -344,11 +373,18 @@ fn renderPage(
page: usize,
request: pdf.RenderRequest,
decoration: Decoration,
+ tint: pdf.TintMode,
) !pdf.Render {
- return switch (decoration) {
+ const rendered = switch (decoration) {
.plain => document.renderAt(gpa, page, request),
.highlighted => document.renderWithHighlightsAt(gpa, page, request, &highlights),
};
+ const fresh = try rendered;
+ errdefer gpa.free(fresh.rgba);
+ // Keep the original scenarios instruction-for-instruction identical.
+ // Only the appended tint scenarios enter the linear pixel pass.
+ if (tint != .disabled) try pdf.tintRgba(fresh.rgba, tint, tint_colors);
+ return fresh;
}
/// Hash every output byte after the timer stops. Reporting and cross-round
@@ -400,12 +436,20 @@ fn checkProfileResults(results: []const Result, profile: Profile) void {
const cold = results[0].identity;
const warm = results[1].identity;
const marked = results[2].identity;
+ const filtered = results[4].identity;
+ const full = results[5].identity;
if (!cold.eql(warm))
fatal("{s}: cold and warm plain renders produced different pixels", .{profile.id});
if (warm.pixels != marked.pixels or warm.rgba_bytes != marked.rgba_bytes)
fatal("{s}: highlighted render changed raster dimensions", .{profile.id});
if (warm.checksum == marked.checksum)
fatal("{s}: highlighted render did not change pixels", .{profile.id});
+ if (marked.pixels != filtered.pixels or marked.rgba_bytes != filtered.rgba_bytes or
+ marked.pixels != full.pixels or marked.rgba_bytes != full.rgba_bytes)
+ fatal("{s}: tint changed raster dimensions", .{profile.id});
+ if (marked.checksum == filtered.checksum or marked.checksum == full.checksum or
+ filtered.checksum == full.checksum)
+ fatal("{s}: tint did not change pixels", .{profile.id});
}
fn medianMpixPerSecond(result: Result) f64 {
@@ -439,8 +483,22 @@ fn reportText(
gpa,
"pardes PDF render benchmark (ReleaseFast)\n" ++
"path: {s} ({d} pages), selected page: {d}, multi-page pass: {d} pages\n" ++
- "warmup: {d}, sampled reps: {d}; cold = fresh Document (OS cache uncontrolled)\n\n",
- .{ config.path, document_pages, config.page + 1, multi_pages, config.warmup, config.reps },
+ "warmup: {d}, sampled reps: {d}; cold = fresh Document (OS cache uncontrolled)\n" ++
+ "tint colors: background=#{x:0>2}{x:0>2}{x:0>2}, foreground=#{x:0>2}{x:0>2}{x:0>2}\n\n",
+ .{
+ config.path,
+ document_pages,
+ config.page + 1,
+ multi_pages,
+ config.warmup,
+ config.reps,
+ tint_colors.background[0],
+ tint_colors.background[1],
+ tint_colors.background[2],
+ tint_colors.foreground[0],
+ tint_colors.foreground[1],
+ tint_colors.foreground[2],
+ },
) catch return;
out.appendSlice(
gpa,
@@ -501,8 +559,23 @@ fn reportJson(
gpa,
"{{\"benchmark\":\"pardes-pdf-render\",\"build\":\"ReleaseFast\"," ++
"\"path\":{s},\"document_pages\":{d},\"selected_page\":{d}," ++
- "\"multi_pages\":{d},\"warmup\":{d},\"reps\":{d},\"profiles\":[",
- .{ path_json, document_pages, config.page + 1, multi_pages, config.warmup, config.reps },
+ "\"multi_pages\":{d},\"warmup\":{d},\"reps\":{d}," ++
+ "\"tint_colors\":{{\"background\":\"{x:0>2}{x:0>2}{x:0>2}\"," ++
+ "\"foreground\":\"{x:0>2}{x:0>2}{x:0>2}\"}},\"profiles\":[",
+ .{
+ path_json,
+ document_pages,
+ config.page + 1,
+ multi_pages,
+ config.warmup,
+ config.reps,
+ tint_colors.background[0],
+ tint_colors.background[1],
+ tint_colors.background[2],
+ tint_colors.foreground[0],
+ tint_colors.foreground[1],
+ tint_colors.foreground[2],
+ },
) catch return;
for (profiles, 0..) |profile, i| {
out.print(
diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden
index 6887c104..05980b0a 100644
--- a/test/snapshots/builtins.golden
+++ b/test/snapshots/builtins.golden
@@ -153,7 +153,6 @@
|
|
| /tmp/pardes-snap/builtins/cwd/+Help New Del
-| 21 SPC l a CodeAction
| 22 SPC l d Diagnostics
| 23 SPC l h SelectRefs
| 24 SPC l i Lspinfo
@@ -166,21 +165,22 @@
| 31 SPC t a Ascii
| 32 SPC t c Colors
| 33 SPC t d Debug topbar
-| 34 SPC t l Palette
-| 35 SPC t n NextColor topbar
-| 36 SPC t p Petscii
-| 37 SPC t r Crt
-| 38 SPC t t ThemeSel
-| 39 SPC t z PdfFit
-| 40 SPC w h Left C-w h, C-w left
-| 41 SPC w j Down C-w j, C-w down
-| 42 SPC w k Up C-w k, C-w up
-| 43 SPC w l Right C-w l, C-w right
-| 44 SPC w t Toggleterm
-| 45 Look enter, right-click
-| 46 Exec tab, middle-click
-| 47 Theme
-| 48
+| 34 SPC t i PdfTint
+| 35 SPC t l Palette
+| 36 SPC t n NextColor topbar
+| 37 SPC t p Petscii
+| 38 SPC t r Crt
+| 39 SPC t t ThemeSel
+| 40 SPC t z PdfFit
+| 41 SPC w h Left C-w h, C-w left
+| 42 SPC w j Down C-w j, C-w down
+| 43 SPC w k Up C-w k, C-w up
+| 44 SPC w l Right C-w l, C-w right
+| 45 SPC w t Toggleterm
+| 46 Look enter, right-click
+| 47 Exec tab, middle-click
+| 48 Theme
+| 49
== snap not-a-picker grid=120x60 cursor=7,58
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del /tmp/pardes-snap/builtins/cwd New Del
@@ -214,7 +214,6 @@
|
|
| /tmp/pardes-snap/builtins/cwd/+Help New Del
-| 21 SPC l a CodeAction
| 22 SPC l d Diagnostics
| 23 SPC l h SelectRefs
| 24 SPC l i Lspinfo
@@ -227,21 +226,22 @@
| 31 SPC t a Ascii
| 32 SPC t c Colors
| 33 SPC t d Debug topbar
-| 34 SPC t l Palette
-| 35 SPC t n NextColor topbar
-| 36 SPC t p Petscii
-| 37 SPC t r Crt
-| 38 SPC t t ThemeSel
-| 39 SPC t z PdfFit
-| 40 SPC w h Left C-w h, C-w left
-| 41 SPC w j Down C-w j, C-w down
-| 42 SPC w k Up C-w k, C-w up
-| 43 SPC w l Right C-w l, C-w right
-| 44 SPC w t Toggleterm
-| 45 Look enter, right-click
-| 46 Exec tab, middle-click
-| 47 Theme
-| 48
+| 34 SPC t i PdfTint
+| 35 SPC t l Palette
+| 36 SPC t n NextColor topbar
+| 37 SPC t p Petscii
+| 38 SPC t r Crt
+| 39 SPC t t ThemeSel
+| 40 SPC t z PdfFit
+| 41 SPC w h Left C-w h, C-w left
+| 42 SPC w j Down C-w j, C-w down
+| 43 SPC w k Up C-w k, C-w up
+| 44 SPC w l Right C-w l, C-w right
+| 45 SPC w t Toggleterm
+| 46 Look enter, right-click
+| 47 Exec tab, middle-click
+| 48 Theme
+| 49
== snap window-group grid=120x60 cursor=7,32
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del /tmp/pardes-snap/builtins/cwd New Del
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index 7e28f952..5dc67744 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -395,14 +395,14 @@
| 3 SPC t a Ascii
| 4 SPC t c Colors
| 5 SPC t d Debug topbar
-| 6 SPC t l Palette
-| 7 SPC t n NextColor topbar
-| 8 SPC t p Petscii
-| 9 SPC t r Crt
-| 10 SPC t t ThemeSel
-| 11 SPC t z PdfFit
-| 12
-|
+| 6 SPC t i PdfTint
+| 7 SPC t l Palette
+| 8 SPC t n NextColor topbar
+| 9 SPC t p Petscii
+| 10 SPC t r Crt
+| 11 SPC t t ThemeSel
+| 12 SPC t z PdfFit
+| 13
|
|
|