summaryrefslogtreecommitdiff
path: root/src/pdf.zig
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 /src/pdf.zig
parent90eed6a89c66d6243ea43e799113409244308563 (diff)
downloadpardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.tar.gz
pardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.zip
add colorscheme-aware PDF tint modes
Diffstat (limited to 'src/pdf.zig')
-rw-r--r--src/pdf.zig118
1 files changed, 118 insertions, 0 deletions
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,