summaryrefslogtreecommitdiff
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
parent7cbd44dd1cfe97126e7fc996214e458525d2980c (diff)
downloadpardes-717afaf3177a2e0925b18ae3445119efe808b129.tar.gz
pardes-717afaf3177a2e0925b18ae3445119efe808b129.zip
Add macOS backdrop blur and preserve PDF ink opacity
-rw-r--r--docs/macos.md42
-rw-r--r--docs/rendering-parity-design.md6
-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
-rw-r--r--test/macos-snapshots/rendering-parity.golden14
-rw-r--r--test/macos_e2e.swift21
15 files changed, 249 insertions, 41 deletions
diff --git a/docs/macos.md b/docs/macos.md
index 3fdf399f..9190ca67 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -696,7 +696,7 @@ A theme with `bg = null` — the curated `dark`, and every vendored
`pardes_theme_bg` answers `PARDES_COLOR_DEFAULT` and the host goes see-through:
`window.isOpaque = false`, a clear background colour, and an
`NSVisualEffectView` (`.underWindowBackground`, `.behindWindow`, `.active`)
-behind the grid. `PardesView` stops painting the ground at all — it *clears*,
+behind the grid when `WindowBlur` is enabled. `PardesView` stops painting the ground at all — it *clears*,
because AppKit does not blank a non-opaque view — and any cell whose background
is still the default resolves to `bgClear` and is skipped by the run loop.
Reversed cells are not: a reverse puts the text colour in the background, and
@@ -710,10 +710,42 @@ blank window for every opaque theme the moment it was hidden.
Background fills replace existing coverage, so overlapping ground, cell, tag
and context layers cannot increase the requested opacity. The window itself
stays clear below 100%; a second tinted window backdrop would compound it.
-Text and cursor ink stay opaque. PDF and image pixels follow the SDL image
-pipeline: remove the destination by source coverage, then add the image at the
-requested opacity. This also preserves the background under transparent image
-pixels. Theme colours and attachment rasters are interpreted as sRGB.
+Text and cursor ink stay opaque. PDFs render onto a transparent MuPDF pixmap,
+which preserves the coverage of text, paths, photos, and highlights. The host
+paints paper at `WindowOpacity`, then composites PDF content at its original
+opacity. Blank paper therefore reveals the blurred backdrop while lettering
+stays readable even at `WindowOpacity 0`. Paper is white with `PdfTint` disabled
+and uses the theme background otherwise. Explicit PDF background rectangles
+and scanned page images remain content: this does not guess paper from pixel
+brightness or remove white objects from a document. Scrolling and transitions
+retain both the content raster and its paper color.
+
+Ordinary image attachments still follow the SDL image pipeline: remove the
+destination by source coverage, then add the image at the requested opacity.
+Theme colours and attachment rasters are interpreted as sRGB. The PDF paper
+separation is currently macOS-specific; other hosts keep the opaque raster path.
+
+`WindowBlur <0..100>` controls the strength of that native backdrop independently
+of `WindowOpacity`. It is a macOS-only builtin, also accepted in the startup
+config and reported by `Config`. It defaults to 0 (off); 100 shows the full
+AppKit material and intermediate values blend the material with the unblurred
+backdrop using the effect view's alpha. An opaque themed background hides the
+effect; lowering `WindowOpacity` makes the remembered strength visible again.
+For example:
+
+```text
+WindowOpacity 71
+WindowBlur 60
+```
+
+This is material strength, not a Gaussian radius in pixels. AppKit's public
+`NSVisualEffectView` API chooses its own blur and tint for the material. It
+samples behind the window; text and cursors are drawn in a separate sibling
+above the effect and remain sharp. macOS accessibility settings such as Reduce
+Transparency can override the material's appearance. `WindowBlur 0` also turns
+off the formerly implicit blur for background-less themes; set it to 100 to
+restore that appearance. The compositor must be checked in a live window:
+offscreen grid captures cannot verify behind-window blur.
`test/macos-snapshots/rendering-parity.snap` checks background colour and alpha,
regular and bottom tags, compact context separators, and PDF fit, tint and
diff --git a/docs/rendering-parity-design.md b/docs/rendering-parity-design.md
index b6036543..14a5bc62 100644
--- a/docs/rendering-parity-design.md
+++ b/docs/rendering-parity-design.md
@@ -76,8 +76,10 @@ out.rgb = p * a * c + (1 - a) * dst.rgb
out.a = p * a + (1 - a) * dst.a
```
-Thus PDF text is part of a raster page and fades with the page; native text does
-not. Transparent image pixels preserve the destination. Ordinary source-over
+This describes ordinary image attachments and the existing SDL PDF path.
+macOS PDFs now separate unpainted paper from content: paper uses p and content
+uses its own coverage, without multiplying that coverage by p. A future shared
+scene must carry this paper/content distinction explicitly. Transparent image pixels preserve the destination. Ordinary source-over
with source alpha p*a is not equivalent. Any future operation API needs to
express this distinction directly and specify sampling and premultiplication.
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(
diff --git a/test/macos-snapshots/rendering-parity.golden b/test/macos-snapshots/rendering-parity.golden
index 536f6800..cbb56ab2 100644
--- a/test/macos-snapshots/rendering-parity.golden
+++ b/test/macos-snapshots/rendering-parity.golden
@@ -5,10 +5,10 @@
== backgrounds invalid-stays-0 uniform
== context context-100 compact separators verified
== context context-71 compact separators verified
-== pdf pdf-100 native page opacity verified
-== pdf pdf-0 native page opacity verified
-== pdf pdf-71 native page opacity verified
-== pdf pdf-fit-height native page opacity verified
-== pdf pdf-full-tint native page opacity verified
-== pdf pdf-original-colors native page opacity verified
-== pdf pdf-scrolled native page opacity verified
+== pdf pdf-100 paper opacity and opaque ink verified
+== pdf pdf-0 paper opacity and opaque ink verified
+== pdf pdf-71 paper opacity and opaque ink verified
+== pdf pdf-fit-height paper opacity and opaque ink verified
+== pdf pdf-full-tint paper opacity and opaque ink verified
+== pdf pdf-original-colors paper opacity and opaque ink verified
+== pdf pdf-scrolled paper opacity and opaque ink verified
diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift
index 5427c3ad..5efc366c 100644
--- a/test/macos_e2e.swift
+++ b/test/macos_e2e.swift
@@ -687,13 +687,22 @@ private final class Driver: PardesViewDelegate {
throw ScriptError(message: "PDF: placement is outside its physical-pixel viewport")
}
let backing = CGFloat(rep.pixelsWide) / view.bounds.width
- var matched = 0
- for fraction in [CGFloat(0.2), 0.4, 0.6, 0.8] {
- if let c = rep.colorAt(x: Int((x + width * fraction) * backing),
- y: Int((y + height * fraction) * backing)), abs(c.alphaComponent - view.backgroundAlpha) < 2.0 / 255 { matched += 1 }
+ guard image.paper_bg != UInt32(PARDES_COLOR_DEFAULT) else {
+ throw ScriptError(message: "PDF: missing separate paper background")
}
- guard matched == 4 else { throw ScriptError(message: "PDF: raster opacity does not match WindowOpacity") }
- output += "== pdf \(label) native page opacity verified\n"
+ var lowest: CGFloat = 1, highest: CGFloat = 0
+ for py in stride(from: Int(y * backing) + 2, to: Int((y + height) * backing) - 2, by: 2) {
+ for px in stride(from: Int(x * backing) + 2, to: Int((x + width) * backing) - 2, by: 2) {
+ if let c = rep.colorAt(x: px, y: py) {
+ lowest = min(lowest, c.alphaComponent)
+ highest = max(highest, c.alphaComponent)
+ }
+ }
+ }
+ guard abs(lowest - view.backgroundAlpha) < 2.0 / 255, highest > 0.98 else {
+ throw ScriptError(message: "PDF: expected translucent paper and opaque ink, got alpha \(lowest)...\(highest)")
+ }
+ output += "== pdf \(label) paper opacity and opaque ink verified\n"
}
// MARK: - the script