summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-16 14:46:16 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit717afaf3177a2e0925b18ae3445119efe808b129 (patch)
tree45a2196711de78d8d1e2db6cb5e5c51271676c2f /src
parent7cbd44dd1cfe97126e7fc996214e458525d2980c (diff)
downloadpardes-717afaf3177a2e0925b18ae3445119efe808b129.tar.gz
pardes-717afaf3177a2e0925b18ae3445119efe808b129.zip
Add macOS backdrop blur and preserve PDF ink opacity
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig1
-rw-r--r--src/config.zig49
-rw-r--r--src/macos.zig29
-rw-r--r--src/macos/Sources/AppDelegate.swift24
-rw-r--r--src/macos/Sources/PardesView.swift16
-rw-r--r--src/macos/pardes.h4
-rw-r--r--src/panes.zig7
-rw-r--r--src/pardes.zig2
-rw-r--r--src/pdf.zig48
-rw-r--r--src/pdf_bridge.c21
-rw-r--r--src/pdf_bridge.h6
11 files changed, 186 insertions, 21 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 1cd10a99..39d70e5d 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -18,6 +18,7 @@ pub const capabilities: config.Runtime.Capabilities = .{
// whatever terminal is on the other end of the serial line.
.tagline_font_size = pardes.platform != .tty and pardes.platform != .esp32p4,
.window_opacity = pardes.platform == .gui or pardes.platform == .macos,
+ .window_blur = pardes.platform == .macos,
};
pub const Ctx = struct {
diff --git a/src/config.zig b/src/config.zig
index f91d95c6..440549b3 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -102,6 +102,7 @@ pub const leader_path = paths: {
}
if (pardes.platform == .gui) table.set(.Pet, null);
if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null);
+ if (builtins.capabilities.window_blur) table.set(.WindowBlur, null);
if (builtins.capabilities.panel_transitions) {
table.set(.PanelSlide, "as");
table.set(.PanelZoom, "az");
@@ -540,6 +541,7 @@ pub const Runtime = struct {
column_tags: bool = true,
syntax_bold: bool = false,
window_opacity: u8 = 100,
+ window_blur: u8 = 0,
window_opacity_pending: bool = true,
pet: enum { off, cat, frog } = .off,
@@ -630,6 +632,7 @@ pub const Runtime = struct {
scene_shaders: bool,
tagline_font_size: bool,
window_opacity: bool = false,
+ window_blur: bool = false,
};
pub const Capability = std.meta.FieldEnum(Capabilities);
@@ -644,6 +647,7 @@ pub const Runtime = struct {
font,
tagline_size,
window_opacity,
+ window_blur,
transition: layout.Transition,
scene: Scene,
};
@@ -655,7 +659,7 @@ pub const Runtime = struct {
pub fn takesArg(setting: Setting) bool {
return switch (setting.action) {
- .shell, .theme, .font, .tagline_size, .window_opacity => true,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur => true,
else => false,
};
}
@@ -683,6 +687,7 @@ pub const Runtime = struct {
.{ .word = "Font", .action = .font, .availability = .font_picker },
.{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker },
.{ .word = "WindowOpacity", .action = .window_opacity, .availability = .window_opacity },
+ .{ .word = "WindowBlur", .action = .window_blur, .availability = .window_blur },
.{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions },
.{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions },
.{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions },
@@ -726,6 +731,8 @@ pub const Runtime = struct {
@compileError("native font settings must use the font-picker capability"),
.window_opacity => if (setting.availability != .window_opacity)
@compileError("window opacity must use the window-opacity capability"),
+ .window_blur => if (setting.availability != .window_blur)
+ @compileError("window blur must use the window-blur capability"),
.transition => if (setting.availability != .panel_transitions)
@compileError("panel effects must use the panel-transition capability"),
.scene => if (setting.availability != .scene_shaders)
@@ -737,8 +744,8 @@ pub const Runtime = struct {
for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1)
@compileError("runtime toggle must occur exactly once: " ++ @tagName(field));
if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or
- actionCount(.tagline_size) != 1 or actionCount(.window_opacity) != 1)
- @compileError("Shell, Theme, Font, TaglineSize, and WindowOpacity actions must each occur exactly once");
+ actionCount(.tagline_size) != 1 or actionCount(.window_opacity) != 1 or actionCount(.window_blur) != 1)
+ @compileError("Shell, Theme, Font, TaglineSize, WindowOpacity, and WindowBlur actions must each occur exactly once");
for (std.enums.values(layout.Transition)) |effect| {
const expected: comptime_int = @intFromBool(effect != .off);
if (actionCount(.{ .transition = effect }) != expected)
@@ -783,6 +790,13 @@ pub const Runtime = struct {
state.window_opacity = percent;
state.window_opacity_pending = true;
},
+ .window_blur => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ for (text) |byte| if (!std.ascii.isDigit(byte)) return false;
+ const percent = std.fmt.parseInt(u8, text, 10) catch return false;
+ if (percent > 100) return false;
+ state.window_blur = percent;
+ },
.transition => |effect| state.toggleTransition(effect),
.scene => |effect| switch (effect) {
inline else => |field| {
@@ -849,6 +863,12 @@ pub const Runtime = struct {
else
try out.print("{s}: unsupported\n", .{setting.word});
},
+ .window_blur => {
+ if (setting.enabled(context.capabilities))
+ try out.print("{s}: {d}%\n", .{ setting.word, state.window_blur })
+ else
+ try out.print("{s}: unsupported\n", .{setting.word});
+ },
.shell => {
const chosen = state.shell.requested.get();
try out.print(
@@ -947,7 +967,7 @@ pub const Runtime = struct {
for (settings[i + 1 ..]) |later|
try std.testing.expect(!std.mem.eql(u8, setting.word, later.word));
try std.testing.expectEqual(switch (setting.action) {
- .shell, .theme, .font, .tagline_size, .window_opacity => true,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur => true,
else => false,
}, setting.takesArg());
}
@@ -1011,6 +1031,22 @@ pub const Runtime = struct {
try std.testing.expect(state.window_opacity_pending);
}
+ test "window blur defaults off and validates without changing opacity" {
+ const setting = find("WindowBlur").?;
+ var state: Runtime = .{};
+ try std.testing.expectEqual(@as(u8, 0), state.window_blur);
+ try std.testing.expectEqual(Capability.window_blur, setting.availability.?);
+ for ([_][]const u8{ "0", " 40\n", "100" }, [_]u8{ 0, 40, 100 }) |argument, expected| {
+ try std.testing.expect(apply(&state, setting, argument));
+ try std.testing.expectEqual(expected, state.window_blur);
+ try std.testing.expectEqual(@as(u8, 100), state.window_opacity);
+ }
+ for ([_]?[]const u8{ null, "", " \t\n", "101", "-1", "+40", "40%", "0.5", "40 20", "4_0", "0x28", "999999999999999" }) |argument| {
+ try std.testing.expect(!apply(&state, setting, argument));
+ try std.testing.expectEqual(@as(u8, 100), state.window_blur);
+ }
+ }
+
test "font request tuple rejects atomically" {
var state: Runtime = .{};
try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old", 1200));
@@ -1052,6 +1088,7 @@ pub const Runtime = struct {
.scene_shaders = true,
.tagline_font_size = true,
.window_opacity = true,
+ .window_blur = true,
},
.state = &state,
};
@@ -1065,6 +1102,7 @@ pub const Runtime = struct {
.shell => "fish",
.tagline_size => "73",
.window_opacity => "85",
+ .window_blur => "40",
else => null,
};
try std.testing.expect(apply(&state, setting, argument));
@@ -1086,6 +1124,7 @@ pub const Runtime = struct {
.shell => "Shell requested (new panes): fish\n",
.tagline_size => "TaglineSize: 73%\n",
.window_opacity => "WindowOpacity: 85% (pending)\n",
+ .window_blur => "WindowBlur: 40%\n",
.transition => |transition| switch (transition) {
.off => unreachable,
.slide => "Panel transition: PanelSlide\n",
@@ -1160,6 +1199,8 @@ pub const Runtime = struct {
const defaults_report = storage[0..out.end];
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowOpacity: unsupported\n") != null);
try std.testing.expect(!find("WindowOpacity").?.enabled(defaults_context.capabilities));
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowBlur: unsupported\n") != null);
+ try std.testing.expect(!find("WindowBlur").?.enabled(defaults_context.capabilities));
try std.testing.expect(find("WindowOpacity").?.enabled(context.capabilities));
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null);
diff --git a/src/macos.zig b/src/macos.zig
index bbfb8c08..d0d379de 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -92,6 +92,7 @@ pub const Image = extern struct {
iw: u32,
ih: u32,
rgba: [*]const u8,
+ paper_bg: u32,
};
pub const Runtime = extern struct {
@@ -902,6 +903,11 @@ export fn pardes_window_opacity() u8 {
return st.core.settings.window_opacity;
}
+/// Public AppKit backdrop strength, independent of background paint opacity.
+export fn pardes_window_blur() u8 {
+ return if (state) |st| st.core.settings.window_blur else 0;
+}
+
fn taglineFontPercent(core: ?*const pardes.Pardes) u8 {
return if (core) |p| p.settings.font.tagline_percent else pardes.config.gui_tagline_font_percent;
}
@@ -1879,6 +1885,10 @@ fn collectImages(st: *State, surface: *const pardes.Surface) void {
.iw = @intCast(place.iw),
.ih = @intCast(place.ih),
.rgba = place.rgba.ptr,
+ .paper_bg = if (place.native.paper_bg) |bg|
+ @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2]
+ else
+ color_default,
};
st.images_len += 1;
}
@@ -2611,6 +2621,7 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_active_dirty), @TypeOf(pardes_active_dirty));
try expectSameAbi(@TypeOf(c.pardes_theme_bg), @TypeOf(pardes_theme_bg));
try expectSameAbi(@TypeOf(c.pardes_window_opacity), @TypeOf(pardes_window_opacity));
+ try expectSameAbi(@TypeOf(c.pardes_window_blur), @TypeOf(pardes_window_blur));
}
test "pardes.h matches the Zig boundary" {
@@ -2941,3 +2952,21 @@ test "mac tag layer ABI preserves logical capacity and physical grip" {
try std.testing.expectEqual(@as(u8, 'G'), canonical[18].text[0]);
try std.testing.expectEqual(encodeCell(.{}).len, canonical[10].len);
}
+
+test "mac window blur builtin reaches the ABI without changing opacity" {
+ try std.testing.expectEqual(@as(u8, 0), pardes_window_blur());
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 8, .rows = 3 });
+ defer core.deinit();
+ state = .{ .gpa = gpa, .threaded = undefined, .io = std.testing.io, .core = core, .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{} };
+ defer {
+ state.?.config_arena.deinit();
+ state = null;
+ }
+ try std.testing.expect(core.executeBuiltinLine(0, "WindowOpacity 71"));
+ for ([_][]const u8{ "WindowBlur 0", "WindowBlur 40", "WindowBlur 100", "WindowBlur 101", "WindowBlur 0" }, [_]u8{ 0, 40, 100, 100, 0 }) |command, expected| {
+ try std.testing.expect(core.executeBuiltinLine(0, command));
+ try std.testing.expectEqual(expected, pardes_window_blur());
+ try std.testing.expectEqual(@as(u8, 71), pardes_window_opacity());
+ }
+}
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index 4e91e407..cbe9cdc9 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -81,8 +81,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// The grid and the blur are SIBLINGS in a plain container, not parent
// and child. A transparent theme has to show something through the
// grid, and AppKit will not blur what is behind a window unless an
- // NSVisualEffectView asks it to — but the backdrop is hidden for every
- // theme that brings its own background, and hiding a superview hides
+ // NSVisualEffectView asks it to. The backdrop can be hidden independently
+ // when blur is disabled or the ground is opaque; hiding a superview hides
// its subviews with it. Nested, an opaque theme drew a blank window.
let container = NSView(frame: NSRect(origin: .zero, size: want))
container.autoresizesSubviews = true
@@ -460,6 +460,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
/// theme whose background happened to equal the view's starting guess
/// would otherwise leave the window in AppKit's default clothes forever.
private var dressed = false
+ private var appliedBlur: UInt8 = 0
private func applyTheme() {
// Both reads happen every pump, and neither may be short-circuited by
@@ -468,15 +469,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// tick would leave one of them unworn.
let themeChanged = view.adoptThemeBG(pardes_theme_bg())
let opacityChanged = view.adoptWindowOpacity(pardes_window_opacity())
- guard themeChanged || opacityChanged || !dressed else { return }
+ let blur = pardes_window_blur()
+ let blurChanged = blur != appliedBlur
+ guard themeChanged || opacityChanged || blurChanged || !dressed else { return }
+ appliedBlur = blur
+ backdrop.alphaValue = CGFloat(blur) / 100
+ backdrop.isHidden = blur == 0 || (view.themeBG != nil && view.backgroundAlpha >= 1)
dressed = true
- // A ground short of solid needs the same non-opaque window a
- // background-less theme does — but not the blur behind it. The backdrop
- // answers "this theme brings no background"; WindowOpacity says how
- // much of the one it brings to paint, and what shows through the rest
- // is the desktop, which is what asking for a translucent window means.
+ // Opacity paints the theme ground; blur controls the native material
+ // behind it. An opaque ground hides the backdrop without losing the
+ // requested strength, so lowering opacity restores the effect.
if let rgb = view.themeBG {
- backdrop.isHidden = true
window.isOpaque = view.backgroundAlpha >= 1
window.backgroundColor = view.backgroundAlpha < 1 ? .clear : NSColor(
srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255,
@@ -493,10 +496,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
window.appearance = NSAppearance(named: luma > 0.5 ? .aqua : .darkAqua)
} else {
// Transparent: the window stops painting anything of its own and
- // the blur takes over. isOpaque false is what lets the desktop
+ // the optional blur sits behind it. isOpaque false lets the desktop
// reach the backdrop at all — a titled window is opaque by default
// and would composite over it.
- backdrop.isHidden = false
window.isOpaque = false
window.backgroundColor = .clear
window.appearance = NSAppearance(named: .darkAqua)
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index 7c537055..7981cf46 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -1446,6 +1446,7 @@ final class PardesView: NSView {
let source: CGRect
let body: CGRect
let destination: CGRect
+ let paperBG: UInt32
}
/// Rasterized attachments, decoded once each. The bytes the core lends are
@@ -1496,7 +1497,7 @@ final class PardesView: NSView {
height: CGFloat(place.dst_h) / scale)
let frozen = FrozenImage(
key: key, image: crop, source: source,
- body: body, destination: dst)
+ body: body, destination: dst, paperBG: place.paper_bg)
frameImages.append(frozen)
drawFrozenImage(ctx, frozen)
}
@@ -1545,11 +1546,24 @@ final class PardesView: NSView {
ctx.clip(to: placed.body)
// isFlipped gives us a y-down CTM and CGImage draws +y up, so flip
// about this destination rather than changing the grid arithmetic.
+ let isPDF = placed.paperBG != UInt32(PARDES_COLOR_DEFAULT)
+ if isPDF {
+ fillBackground(ctx, placed.destination, placed.paperBG, backgroundAlpha)
+ }
ctx.translateBy(x: placed.destination.minX, y: placed.destination.maxY)
ctx.scaleBy(x: 1, y: -1)
ctx.interpolationQuality = .high
let rect = CGRect(x: 0, y: 0,
width: placed.destination.width, height: placed.destination.height)
+ if isPDF {
+ // Paper was painted at WindowOpacity. Content keeps the PDF's own
+ // coverage, so glyphs, photos and paths stay legible at zero opacity.
+ ctx.setAlpha(1)
+ ctx.setBlendMode(.normal)
+ ctx.draw(placed.image, in: rect)
+ ctx.restoreGState()
+ return
+ }
// Match image.frag.glsl/backgroundLayerBlend: source coverage removes
// the destination independently of WindowOpacity, then the source is
// added at that opacity. Ordinary source-over would compound alpha;
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index a2e34f53..df3ba04f 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -262,6 +262,8 @@ uint32_t pardes_theme_bg(void);
// Read it once per pump beside pardes_theme_bg(): reading also acknowledges
// whatever `WindowOpacity` asked for. 100 is opaque and is the default.
uint8_t pardes_window_opacity(void);
+// Backdrop material strength, 0..100; zero disables blur. Not a pixel radius.
+uint8_t pardes_window_blur(void);
// Pane taglines use this percentage of the current body font size. It is the
// embedded default before init and the live core setting afterwards; the host
@@ -475,6 +477,8 @@ typedef struct {
uint32_t ih;
// iw * ih * 4 bytes, RGBA8, borrowed until the next pardes_frame
const uint8_t *rgba;
+ // PDF paper RGB; PARDES_COLOR_DEFAULT means an ordinary image.
+ uint32_t paper_bg;
} pardes_image_s;
// This frame's attachments, in paint order. Ask after pardes_frame; both are
diff --git a/src/panes.zig b/src/panes.zig
index 93abae30..49e5d624 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -4577,13 +4577,14 @@ pub const Pdf = struct {
{
const tz_render = tracy.zone(@src(), "pdf.render_into");
defer tz_render.end();
- state.document.renderIntoAt(
+ state.document.renderIntoAtWithPaper(
page,
request,
shape,
want,
page_highlights,
fresh,
+ pardes.platform == .macos,
) catch break :filled false;
}
const tz_tint = tracy.zone(@src(), "pdf.tint");
@@ -5852,6 +5853,10 @@ pub const Pdf = struct {
.pan_x = placed.pan_x,
.geometry = placed.geometry,
.pixel_offset_y = placed.pixel_offset_y,
+ .paper_bg = if (pardes.platform == .macos)
+ (if (key.mode == .disabled) .{ 255, 255, 255 } else key.colors.background)
+ else
+ null,
},
.x = text_x,
.y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H,
diff --git a/src/pardes.zig b/src/pardes.zig
index 94f67ced..5c0ac263 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -3593,6 +3593,8 @@ pub const NativePlacement = if (pdf_enabled) struct {
geometry: ?image.NativeGeometry = null,
/// Subpixel vertical displacement retained from proportional SDL wheel dy.
pixel_offset_y: f32 = 0,
+ /// Separate paper behind straight-alpha PDF content; null for other images.
+ paper_bg: ?[3]u8 = null,
} else struct {};
pub const ImagePlace = struct {
diff --git a/src/pdf.zig b/src/pdf.zig
index 052d34db..712750dc 100644
--- a/src/pdf.zig
+++ b/src/pdf.zig
@@ -976,6 +976,20 @@ pub const Document = struct {
highlights: []const Highlight,
rgba: []u8,
) !void {
+ return document.renderIntoAtWithPaper(page, request, raster, band, highlights, rgba, false);
+ }
+
+ /// Preserve the PDF's content coverage when the host paints paper separately.
+ pub fn renderIntoAtWithPaper(
+ document: *Document,
+ page: usize,
+ request: RenderRequest,
+ raster: Raster,
+ band: Raster.Band,
+ highlights: []const Highlight,
+ rgba: []u8,
+ transparent_paper: bool,
+ ) !void {
if (rgba.len != band.len) return error.BadPixmap;
if (band.height == 0 or band.y + band.height > raster.height)
return error.BadPixmap;
@@ -999,6 +1013,7 @@ pub const Document = struct {
@intCast(raster.stride),
@intCast(band.y),
@intCast(band.height),
+ @intFromBool(transparent_paper),
) != c.PARDES_PDF_OK) return error.RenderFailed;
}
@@ -1832,6 +1847,7 @@ test "RGBA allocation and buffer-validation failures leave the document renderab
raw_layout.stride,
0,
raw_layout.height,
+ 0,
));
for (scratch) |byte|
try std.testing.expectEqual(@as(u8, 0xa5), byte);
@@ -2074,3 +2090,35 @@ test "offset crop and rotation keep normalized highlights pixel-aligned" {
marked.rgba[left + 1] < 10 and marked.rgba[left + 2] > 240);
try std.testing.expectEqualSlices(u8, plain.rgba[right .. right + 4], marked.rgba[right .. right + 4]);
}
+
+test "transparent PDF paper preserves ink and band coverage" {
+ var document = try Document.open("docs/9p.pdf");
+ defer document.deinit();
+ const request: RenderRequest = .{ .dpi = 96, .max_dimension = 1200 };
+ const raster = try document.measureRenderAt(0, request);
+ const flattened = try std.testing.allocator.alloc(u8, raster.len);
+ defer std.testing.allocator.free(flattened);
+ const content = try std.testing.allocator.alloc(u8, raster.len);
+ defer std.testing.allocator.free(content);
+ try document.renderIntoAt(0, request, raster, raster.wholePage(), &.{}, flattened);
+ try document.renderIntoAtWithPaper(0, request, raster, raster.wholePage(), &.{}, content, true);
+ var paper: usize = 0;
+ var ink: usize = 0;
+ var edges: usize = 0;
+ for (std.mem.bytesAsSlice([4]u8, content), std.mem.bytesAsSlice([4]u8, flattened)) |pixel, reference| {
+ const a: u32 = pixel[3];
+ paper += @intFromBool(a == 0);
+ ink += @intFromBool(a == 255);
+ edges += @intFromBool(a > 0 and a < 255);
+ for (0..3) |channel| {
+ const over_white = (a * pixel[channel] + (255 - a) * 255 + 127) / 255;
+ try std.testing.expect(@abs(@as(i32, @intCast(over_white)) - @as(i32, reference[channel])) <= 2);
+ }
+ }
+ try std.testing.expect(paper > 100 and ink > 100 and edges > 100);
+ const band = raster.band(raster.height / 3, raster.height / 4);
+ const rows = try std.testing.allocator.alloc(u8, band.len);
+ defer std.testing.allocator.free(rows);
+ try document.renderIntoAtWithPaper(0, request, raster, band, &.{}, rows, true);
+ try std.testing.expectEqualSlices(u8, content[band.y * raster.stride ..][0..band.len], rows);
+}
diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c
index bdc7b891..976ed838 100644
--- a/src/pdf_bridge.c
+++ b/src/pdf_bridge.c
@@ -588,7 +588,8 @@ pardes_pdf_render_into(
int height,
int stride,
int band_y,
- int band_height)
+ int band_height,
+ int transparent_paper)
{
fz_context *ctx;
fz_pixmap *pixmap = NULL;
@@ -654,7 +655,8 @@ pardes_pdf_render_into(
* paid either way by the glyph spans and the tint pass that walk the
* same buffer immediately afterwards. Leave it alone.
*/
- fz_clear_pixmap_with_value(ctx, pixmap, 0xFF);
+ if (transparent_paper) fz_clear_pixmap(ctx, pixmap);
+ else fz_clear_pixmap_with_value(ctx, pixmap, 0xFF);
if (document->cached_display_list != NULL ||
document->display_list_candidate_page_number == page_number) {
@@ -719,6 +721,21 @@ pardes_pdf_render_into(
return PARDES_PDF_ERROR;
}
+ /* MuPDF draws premultiplied samples. The public RGBA contract and tint
+ * pass use straight alpha; preserve coverage and undo premultiplication. */
+ if (transparent_paper) {
+ size_t pixel;
+ for (pixel = 0; pixel < samples_len; pixel += 4) {
+ unsigned int a = samples[pixel + 3];
+ int channel;
+ if (a == 0 || a == 255) continue;
+ for (channel = 0; channel < 3; ++channel) {
+ unsigned int straight = (samples[pixel + channel] * 255u + a / 2) / a;
+ samples[pixel + channel] = (unsigned char)(straight > 255 ? 255 : straight);
+ }
+ }
+ }
+
if (document->cached_display_list == NULL)
document->display_list_candidate_page_number = page_number;
diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h
index 8c73f688..cda426aa 100644
--- a/src/pdf_bridge.h
+++ b/src/pdf_bridge.h
@@ -220,7 +220,8 @@ int pardes_pdf_measure_render(
* A full page is the band `(0, height)`.
*
* MuPDF wraps but never owns or frees samples. Every successful pixel is
- * opaque, including the caller-supplied highlights composited onto the page.
+ * opaque unless transparent_paper is nonzero. That mode preserves content
+ * coverage and returns straight-alpha RGBA over an unpainted page.
* The CTM does not depend on the band, so a band's rows are bit-identical to
* the same rows of the whole page: it is a clip, not a different rendering.
*/
@@ -239,7 +240,8 @@ int pardes_pdf_render_into(
int height,
int stride,
int band_y,
- int band_height
+ int band_height,
+ int transparent_paper
);
int pardes_pdf_page_text(