diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 23:48:38 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc (patch) | |
| tree | 5e61547e88386d78868b23decd3956339ee23e9d /src/pdf.zig | |
| parent | 90eed6a89c66d6243ea43e799113409244308563 (diff) | |
| download | pardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.tar.gz pardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.zip | |
add colorscheme-aware PDF tint modes
Diffstat (limited to 'src/pdf.zig')
| -rw-r--r-- | src/pdf.zig | 118 |
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, |
