//! Exception-safe MuPDF runtime wrapper. MuPDF's public API throws with //! setjmp/longjmp; pdf_bridge.c catches every such throw before control returns //! to Zig. This module is present in native builds unless `-Dmupdf=false`. const std = @import("std"); const c = @cImport({ @cInclude("pdf_bridge.h"); }); /// One raster-quality request. `minimum_*` asks MuPDF for enough source pixels /// to avoid backend upscaling; `max_dimension` remains the hard allocation /// ceiling for hostile page sizes and very large displays. pub const RenderRequest = struct { dpi: u16 = 144, minimum_width: u32 = 0, minimum_height: u32 = 0, max_dimension: u16 = 1600, pub fn eql(a: RenderRequest, b: RenderRequest) bool { return std.meta.eql(a, b); } }; pub const default_render_request: RenderRequest = .{}; pub const absolute_max_render_dimension: u16 = 4096; pub const max_owned_raster_bytes: usize = @as(usize, absolute_max_render_dimension) * absolute_max_render_dimension * 4; /// MuPDF's per-document eviction cache ceiling. The owned RGBA render buffer /// is allocated separately by Pardes and does not count against this store. pub const store_limit_bytes: usize = c.PARDES_PDF_STORE_LIMIT_BYTES; test "MuPDF document cache has an explicit conservative ceiling" { try std.testing.expectEqual(@as(usize, 64 * 1024 * 1024), store_limit_bytes); try std.testing.expectEqual(@as(usize, 64 * 1024 * 1024), max_owned_raster_bytes); } /// Page-space geometry is normalized to the page bounds, not a render. It /// therefore survives DPI changes and can be shared by Kitty and SDL. pub const Point = c.pardes_pdf_point; pub const Quad = c.pardes_pdf_quad; pub const HighlightKind = enum(c_int) { custom = c.PARDES_PDF_HIGHLIGHT_CUSTOM, search = c.PARDES_PDF_HIGHLIGHT_SEARCH, selection = c.PARDES_PDF_HIGHLIGHT_SELECTION, }; /// RGBA is straight (not premultiplied). `kind` remains available to the UI as /// semantic metadata; the caller-supplied RGBA is authoritative for rendering. pub const Highlight = extern struct { quad: Quad, rgba: [4]u8, kind: HighlightKind, pub fn init(quad: Quad, rgba: [4]u8, kind: HighlightKind) Highlight { return .{ .quad = quad, .rgba = rgba, .kind = kind }; } }; comptime { if (@sizeOf(Highlight) != @sizeOf(c.pardes_pdf_highlight) or @alignOf(Highlight) != @alignOf(c.pardes_pdf_highlight) or @offsetOf(Highlight, "quad") != @offsetOf(c.pardes_pdf_highlight, "quad") or @offsetOf(Highlight, "rgba") != @offsetOf(c.pardes_pdf_highlight, "rgba") or @offsetOf(Highlight, "kind") != @offsetOf(c.pardes_pdf_highlight, "kind")) @compileError("Highlight must match pardes_pdf_highlight's C ABI"); } pub const Render = struct { rgba: []u8, width: usize, 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, }; fn tintTargets(colors: TintColors) [256][3]u8 { var targets: [256][3]u8 = undefined; for (&targets, 0..) |*target, luminance| { inline for (0..3) |channel| { target[channel] = @intCast( (@as(u32, colors.foreground[channel]) * @as(u32, @intCast(255 - luminance)) + @as(u32, colors.background[channel]) * @as(u32, @intCast(luminance)) + 127) / 255, ); } } return targets; } const tint_chroma_residuals: [511]i16 = table: { var values: [511]i16 = undefined; for (&values, 0..) |*value, index| { const residual: i16 = @as(i16, @intCast(index)) - 255; value.* = @divTrunc(residual * 3, 4); } break :table values; }; const tint_clamped_sums: [638]u8 = table: { @setEvalBranchQuota(2000); var values: [638]u8 = undefined; for (&values, 0..) |*value, index| { const sum: i16 = @as(i16, @intCast(index)) - 191; value.* = @intCast(std.math.clamp(sum, 0, 255)); } break :table values; }; inline fn filteredTintChannel(target: u8, source: u8, residual_base: i16) u8 { const chroma = tint_chroma_residuals[@intCast(residual_base + source)]; return tint_clamped_sums[@intCast(@as(i16, target) + chroma + 191)]; } /// 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; const targets = tintTargets(colors); const pixels = std.mem.bytesAsSlice([4]u8, rgba); switch (mode) { .disabled => unreachable, .full => { for (pixels) |*pixel| { const red = pixel[0]; const green = pixel[1]; const blue = pixel[2]; const luminance: u8 = @intCast((@as(u32, red) * 77 + @as(u32, green) * 150 + @as(u32, blue) * 29 + 128) >> 8); const target = targets[luminance]; pixel[0] = target[0]; pixel[1] = target[1]; pixel[2] = target[2]; } }, .filtered => { for (pixels) |*pixel| { const red = pixel[0]; const green = pixel[1]; const blue = pixel[2]; const luminance: u8 = @intCast((@as(u32, red) * 77 + @as(u32, green) * 150 + @as(u32, blue) * 29 + 128) >> 8); const target = targets[luminance]; const residual_base: i16 = 255 - @as(i16, luminance); pixel[0] = filteredTintChannel(target[0], red, residual_base); pixel[1] = filteredTintChannel(target[1], green, residual_base); pixel[2] = filteredTintChannel(target[2], blue, residual_base); } }, } } fn tintRgbaReference(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)); } } } fn tintTestByte(state: *u64) u8 { state.* = state.* *% 6364136223846793005 +% 1442695040888963407; return @truncate(state.* >> 32); } test "PDF tint lookup tables are exact over every signed chroma residual" { for (tint_chroma_residuals, 0..) |actual, index| { const residual: i16 = @as(i16, @intCast(index)) - 255; try std.testing.expectEqual(@divTrunc(residual * 3, 4), actual); } for (tint_clamped_sums, 0..) |actual, index| { const sum: i16 = @as(i16, @intCast(index)) - 191; try std.testing.expectEqual( @as(u8, @intCast(std.math.clamp(sum, 0, 255))), actual, ); } } test "optimized PDF tint matches scalar rule for every endpoint combination" { const modes = [_]TintMode{ .full, .filtered }; for (0..8) |background_mask| { for (0..8) |foreground_mask| { const colors: TintColors = .{ .background = .{ if (background_mask & 1 == 0) 0 else 255, if (background_mask & 2 == 0) 0 else 255, if (background_mask & 4 == 0) 0 else 255, }, .foreground = .{ if (foreground_mask & 1 == 0) 0 else 255, if (foreground_mask & 2 == 0) 0 else 255, if (foreground_mask & 4 == 0) 0 else 255, }, }; var source: [8 * 2 * 4]u8 = undefined; var at: usize = 0; for (0..8) |source_mask| { for ([_]u8{ 0, 255 }) |alpha| { source[at..][0..4].* = .{ if (source_mask & 1 == 0) 0 else 255, if (source_mask & 2 == 0) 0 else 255, if (source_mask & 4 == 0) 0 else 255, alpha, }; at += 4; } } for (modes) |mode| { var expected = source; var actual = source; try tintRgbaReference(&expected, mode, colors); try tintRgba(&actual, mode, colors); try std.testing.expectEqualSlices(u8, &expected, &actual); } } } } test "optimized PDF tint matches scalar rule across deterministic broad samples" { const modes = [_]TintMode{ .full, .filtered }; var state: u64 = 0x7061_7264_6573_5449; for (0..64) |_| { const colors: TintColors = .{ .background = .{ tintTestByte(&state), tintTestByte(&state), tintTestByte(&state) }, .foreground = .{ tintTestByte(&state), tintTestByte(&state), tintTestByte(&state) }, }; var source: [512 * 4]u8 = undefined; for (&source) |*byte| byte.* = tintTestByte(&state); for (modes) |mode| { var expected = source; var actual = source; try tintRgbaReference(&expected, mode, colors); try tintRgba(&actual, mode, colors); try std.testing.expectEqualSlices(u8, &expected, &actual); } } } 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, }; pub const SearchQuad = struct { quad: Quad, /// Zero-based logical hit. One hit may have several oriented quads. hit: usize, }; pub const SearchResults = struct { quads: []SearchQuad, hit_count: usize, pub fn deinit(results: *SearchResults, gpa: std.mem.Allocator) void { gpa.free(results.quads); results.* = undefined; } }; pub const Selection = struct { quads: []Quad, /// Word-snapped endpoints; pass these to `Document.copySelection`. start: Point, end: Point, /// Hit-test without flattening oriented text quads to axis-aligned boxes. /// The bridge delegates to MuPDF's geometry predicate and cannot throw. pub fn contains(selection: *const Selection, point: Point) bool { if (!validPoint(point)) return false; for (selection.quads) |quad| { if (c.pardes_pdf_point_inside_quad(point, quad) != 0) return true; } return false; } pub fn deinit(selection: *Selection, gpa: std.mem.Allocator) void { gpa.free(selection.quads); selection.* = undefined; } }; pub const OutlineInternalDestination = struct { /// Zero-based document page number. page: usize, /// MuPDF page-space viewing coordinates; null when the PDF destination /// omits that axis (for example, a Fit destination omits both). x: ?f32, y: ?f32, }; pub const OutlineDestination = union(enum) { none, internal: OutlineInternalDestination, external: []const u8, }; pub const OutlineEntry = struct { /// Zero for a root row; rows are in stable pre-order depth-first order. depth: u8, /// Null preserves a missing /Title; a present empty title is "". title: ?[]const u8, is_open: bool, flags: u8, color: [3]u8, destination: OutlineDestination, pub fn isBold(entry: OutlineEntry) bool { return entry.flags & 1 != 0; } pub fn isItalic(entry: OutlineEntry) bool { return entry.flags & 2 != 0; } }; /// All title and external-URI slices point into `bytes`. This fixed two-allocation /// representation avoids one allocation per row while keeping deinit deterministic. pub const Outline = struct { entries: []OutlineEntry, bytes: []u8, pub fn deinit(outline: *Outline, gpa: std.mem.Allocator) void { gpa.free(outline.entries); gpa.free(outline.bytes); outline.* = undefined; } }; test "oriented selection containment delegates to MuPDF quad geometry" { const quads = [_]Quad{.{ .ul = .{ .x = 0.10, .y = 0.10 }, .ur = .{ .x = 0.70, .y = 0.20 }, .ll = .{ .x = 0.20, .y = 0.80 }, .lr = .{ .x = 0.80, .y = 0.90 }, }}; const selection = Selection{ .quads = @constCast(quads[0..]), .start = quads[0].ul, .end = quads[0].lr, }; try std.testing.expect(selection.contains(.{ .x = 0.45, .y = 0.50 })); try std.testing.expect(!selection.contains(.{ .x = 0.05, .y = 0.85 })); try std.testing.expect(!selection.contains(.{ .x = -0.1, .y = 0.5 })); } pub const Document = struct { handle: *c.pardes_pdf_document, pages: usize, pub fn open(path: []const u8) !Document { var path_buf: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&path_buf, "{s}", .{path}, 0) catch return error.PathTooLong; var page_count: c_int = 0; const handle = c.pardes_pdf_open(path_z.ptr, &page_count) orelse return error.OpenFailed; if (page_count < 1) { c.pardes_pdf_close(handle); return error.EmptyDocument; } return .{ .handle = handle, .pages = @intCast(page_count) }; } pub fn deinit(document: *Document) void { c.pardes_pdf_close(document.handle); document.* = undefined; } /// Load and flatten the PDF-native outline/bookmarks. MuPDF's temporary /// tree and the bridge's flat view are both dropped before this returns. pub fn outline( document: *Document, gpa: std.mem.Allocator, ) !Outline { var raw: c.pardes_pdf_outline_result = std.mem.zeroes(c.pardes_pdf_outline_result); const status = c.pardes_pdf_load_outline(document.handle, &raw); if (status == c.PARDES_PDF_LIMIT_EXCEEDED) return error.OutlineLimitExceeded; if (status != c.PARDES_PDF_OK) return error.OutlineFailed; defer c.pardes_pdf_drop_outline_result(document.handle, raw.handle); if (raw.item_count > c.PARDES_PDF_MAX_OUTLINE_ITEMS or raw.bytes_len > c.PARDES_PDF_MAX_OUTLINE_BYTES or (raw.item_count != 0 and raw.items == null) or (raw.bytes_len != 0 and raw.bytes == null)) return error.BadOutline; const bytes = try gpa.alloc(u8, raw.bytes_len); errdefer gpa.free(bytes); if (raw.bytes_len != 0) { const source: [*]const u8 = @ptrCast(raw.bytes); @memcpy(bytes, source[0..raw.bytes_len]); } const entries = try gpa.alloc(OutlineEntry, raw.item_count); errdefer gpa.free(entries); if (raw.item_count != 0) { const source: [*]const c.pardes_pdf_outline_item = @ptrCast(raw.items); var previous_depth: u8 = 0; for (entries, source[0..raw.item_count], 0..) |*entry, item, index| { if (item.depth >= c.PARDES_PDF_MAX_OUTLINE_DEPTH or item.depth > std.math.maxInt(u8) or item.title_present > 1 or item.has_x > 1 or item.has_y > 1 or item.is_open > 1) return error.BadOutline; const depth: u8 = @intCast(item.depth); if ((index == 0 and depth != 0) or (index != 0 and depth > previous_depth + 1)) return error.BadOutline; previous_depth = depth; const title = if (item.title_present != 0) title: { const value = try outlineBytes(bytes, item.title_offset, item.title_len); if (!std.unicode.utf8ValidateSlice(value)) return error.BadOutline; break :title value; } else title: { if (item.title_offset != 0 or item.title_len != 0) return error.BadOutline; break :title null; }; const destination: OutlineDestination = switch (item.destination_kind) { c.PARDES_PDF_OUTLINE_DESTINATION_NONE => .none, c.PARDES_PDF_OUTLINE_DESTINATION_INTERNAL => internal: { if (item.page < 0 or @as(usize, @intCast(item.page)) >= document.pages or (item.has_x != 0 and !std.math.isFinite(item.x)) or (item.has_y != 0 and !std.math.isFinite(item.y))) return error.BadOutline; break :internal .{ .internal = .{ .page = @intCast(item.page), .x = if (item.has_x != 0) item.x else null, .y = if (item.has_y != 0) item.y else null, } }; }, c.PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL => external: { const uri = try outlineBytes(bytes, item.uri_offset, item.uri_len); if (!std.unicode.utf8ValidateSlice(uri)) return error.BadOutline; break :external .{ .external = uri }; }, else => return error.BadOutline, }; entry.* = .{ .depth = depth, .title = title, .is_open = item.is_open != 0, .flags = item.flags, .color = .{ item.r, item.g, item.b }, .destination = destination, }; } } return .{ .entries = entries, .bytes = bytes }; } /// Return crop/rotation-aware page dimensions without allocating pixels. pub fn pageSize(document: *Document, page: usize) !PageSize { const page_number = try document.checkedPage(page); var size: c.pardes_pdf_page_size = std.mem.zeroes(c.pardes_pdf_page_size); if (c.pardes_pdf_get_page_size(document.handle, page_number, &size) != c.PARDES_PDF_OK or !std.math.isFinite(size.width) or !std.math.isFinite(size.height) or size.width <= 0 or size.height <= 0) return error.BadPageSize; return .{ .width = size.width, .height = size.height }; } /// Render one zero-based page directly into allocator-owned packed RGBA. pub fn render(document: *Document, gpa: std.mem.Allocator, page: usize) !Render { return document.renderAt(gpa, page, default_render_request); } pub fn renderAt( document: *Document, gpa: std.mem.Allocator, page: usize, request: RenderRequest, ) !Render { return document.renderInternal(gpa, page, request, null); } /// Render and bake normalized oriented highlights through MuPDF's draw /// device. An empty list takes the exact same plain-render path as render. pub fn renderWithHighlights( document: *Document, gpa: std.mem.Allocator, page: usize, highlights: []const Highlight, ) !Render { return document.renderWithHighlightsAt( gpa, page, default_render_request, highlights, ); } pub fn renderWithHighlightsAt( document: *Document, gpa: std.mem.Allocator, page: usize, request: RenderRequest, highlights: []const Highlight, ) !Render { return document.renderInternal(gpa, page, request, highlights); } fn renderInternal( document: *Document, gpa: std.mem.Allocator, page: usize, request: RenderRequest, highlights: ?[]const Highlight, ) !Render { if (page >= document.pages or page > std.math.maxInt(c_int)) return error.PageOutOfRange; if (request.dpi == 0 or request.max_dimension == 0 or request.max_dimension > absolute_max_render_dimension) return error.InvalidRenderRequest; const minimum_width: c_int = @intCast(@min( request.minimum_width, @as(u32, @intCast(std.math.maxInt(c_int))), )); const minimum_height: c_int = @intCast(@min( request.minimum_height, @as(u32, @intCast(std.math.maxInt(c_int))), )); const items: []const Highlight = highlights orelse &.{}; if (items.len > c.PARDES_PDF_MAX_RESULT_QUADS) return error.RenderFailed; var raw_layout: c.pardes_pdf_raster_layout = std.mem.zeroes(c.pardes_pdf_raster_layout); if (c.pardes_pdf_measure_render( document.handle, @intCast(page), request.dpi, minimum_width, minimum_height, request.max_dimension, &raw_layout, ) != c.PARDES_PDF_OK) return error.RenderFailed; const layout = try checkedRasterLayout(raw_layout); if (layout.len > max_owned_raster_bytes) return error.PixmapTooLarge; const rgba = try gpa.alloc(u8, layout.len); errdefer gpa.free(rgba); const highlight_ptr: ?[*]const c.pardes_pdf_highlight = if (items.len == 0) null else @ptrCast(items.ptr); if (c.pardes_pdf_render_into( document.handle, @intCast(page), request.dpi, minimum_width, minimum_height, request.max_dimension, highlight_ptr, items.len, rgba.ptr, rgba.len, @intCast(layout.width), @intCast(layout.height), @intCast(layout.stride), ) != c.PARDES_PDF_OK) return error.RenderFailed; return .{ .rgba = rgba, .width = layout.width, .height = layout.height }; } /// Plain UTF-8-ish text projection for one zero-based page. MuPDF owns the /// temporary buffer; callers receive an allocator-owned copy. pub fn pageText(document: *Document, gpa: std.mem.Allocator, page: usize) ![]u8 { if (page >= document.pages or page > std.math.maxInt(c_int)) return error.PageOutOfRange; var text: c.pardes_pdf_text = std.mem.zeroes(c.pardes_pdf_text); if (c.pardes_pdf_page_text(document.handle, @intCast(page), &text) != 0) return error.TextFailed; defer c.pardes_pdf_drop_text(document.handle, text.handle); if (text.len == 0) return gpa.dupe(u8, ""); if (text.data == null) return error.BadText; const bytes: [*]const u8 = @ptrCast(text.data); return gpa.dupe(u8, bytes[0..text.len]); } /// Case-insensitive single-page search. MuPDF's iterative search owns a /// retained reference to the cached structured-text page while running; /// this wrapper copies every borrowed oriented quad before dropping it. /// The C bridge doubles capacity up to 65,536 aggregate quads and returns /// TooManyResults atomically rather than silently truncating beyond it. pub fn search( document: *Document, gpa: std.mem.Allocator, page: usize, needle: []const u8, ) !SearchResults { const page_number = try document.checkedPage(page); if (std.mem.indexOfScalar(u8, needle, 0) != null) return error.InvalidNeedle; const needle_z = try gpa.dupeZ(u8, needle); defer gpa.free(needle_z); var found: c.pardes_pdf_search_result = std.mem.zeroes(c.pardes_pdf_search_result); const status = c.pardes_pdf_search_page(document.handle, page_number, needle_z.ptr, &found); if (status == c.PARDES_PDF_LIMIT_EXCEEDED) return error.TooManyResults; if (status != c.PARDES_PDF_OK) return error.SearchFailed; defer c.pardes_pdf_drop_search_result(document.handle, found.handle); if (found.quad_count != 0 and found.quads == null) return error.BadGeometry; const quads = try gpa.alloc(SearchQuad, found.quad_count); errdefer gpa.free(quads); if (found.quad_count != 0) { const source: [*]const c.pardes_pdf_search_quad = @ptrCast(found.quads); for (quads, source[0..found.quad_count]) |*dest, item| { if (item.hit >= found.hit_count or !validQuad(item.quad)) return error.BadGeometry; dest.* = .{ .quad = item.quad, .hit = item.hit }; } } return .{ .quads = quads, .hit_count = found.hit_count }; } /// Word-snap two normalized page points and return allocator-owned, /// orientation-preserving highlight geometry. pub fn select( document: *Document, gpa: std.mem.Allocator, page: usize, start: Point, end: Point, ) !Selection { const page_number = try document.checkedPage(page); if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint; var selected: c.pardes_pdf_selection = std.mem.zeroes(c.pardes_pdf_selection); const status = c.pardes_pdf_select(document.handle, page_number, start, end, &selected); if (status == c.PARDES_PDF_LIMIT_EXCEEDED) return error.TooManyResults; if (status != c.PARDES_PDF_OK) return error.SelectionFailed; defer c.pardes_pdf_drop_selection(document.handle, selected.handle); if (selected.quad_count != 0 and selected.quads == null) return error.BadGeometry; if (!validPoint(selected.start) or !validPoint(selected.end)) return error.BadGeometry; const quads = try gpa.alloc(Quad, selected.quad_count); errdefer gpa.free(quads); if (selected.quad_count != 0) { const source: [*]const Quad = @ptrCast(selected.quads); for (quads, source[0..selected.quad_count]) |*dest, item| { if (!validQuad(item)) return error.BadGeometry; dest.* = item; } } return .{ .quads = quads, .start = selected.start, .end = selected.end, }; } /// Copy UTF-8 text between normalized page points. For word selection, /// pass the snapped endpoints returned by `select`. MuPDF's temporary /// fz_malloc string is always freed after making the allocator-owned copy. pub fn copySelection( document: *Document, gpa: std.mem.Allocator, page: usize, start: Point, end: Point, ) ![]u8 { const page_number = try document.checkedPage(page); if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint; var text: c.pardes_pdf_owned_text = std.mem.zeroes(c.pardes_pdf_owned_text); if (c.pardes_pdf_copy_selection( document.handle, page_number, start, end, &text, ) != c.PARDES_PDF_OK) return error.TextFailed; defer c.pardes_pdf_drop_owned_text(document.handle, text.handle); if (text.len == 0) return gpa.dupe(u8, ""); if (text.data == null) return error.BadText; const bytes: [*]const u8 = @ptrCast(text.data); return gpa.dupe(u8, bytes[0..text.len]); } fn checkedPage(document: *const Document, page: usize) !c_int { if (page >= document.pages or page > std.math.maxInt(c_int)) return error.PageOutOfRange; return @intCast(page); } }; fn outlineBytes(bytes: []const u8, offset: usize, len: usize) ![]const u8 { if (offset > bytes.len or len > bytes.len - offset) return error.BadOutline; return bytes[offset .. offset + len]; } fn validPoint(point: Point) bool { return std.math.isFinite(point.x) and std.math.isFinite(point.y) and point.x >= 0 and point.x <= 1 and point.y >= 0 and point.y <= 1; } fn validQuad(quad: Quad) bool { return validPoint(quad.ul) and validPoint(quad.ur) and validPoint(quad.ll) and validPoint(quad.lr); } const RasterLayout = struct { width: usize, height: usize, stride: usize, len: usize, }; fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !RasterLayout { if (raw.width < 1 or raw.height < 1 or raw.stride < 1) return error.BadPixmap; const width: usize = @intCast(raw.width); const height: usize = @intCast(raw.height); const stride: usize = @intCast(raw.stride); const expected_stride = std.math.mul(usize, width, 4) catch return error.BadPixmap; if (stride != expected_stride) return error.BadPixmap; const len = std.math.mul(usize, stride, height) catch return error.BadPixmap; if (raw.samples_len != len) return error.BadPixmap; return .{ .width = width, .height = height, .stride = stride, .len = len }; } fn expectOpaque(rgba: []const u8) !void { if (rgba.len % 4 != 0) return error.BadPixmap; var alpha: usize = 3; while (alpha < rgba.len) : (alpha += 4) try std.testing.expectEqual(@as(u8, 0xff), rgba[alpha]); } fn makeOffsetRotatedPdf(gpa: std.mem.Allocator) ![]u8 { const stream = "q 1 0 0 rg 120 220 160 150 re f Q\n"; var bytes: std.ArrayList(u8) = .empty; errdefer bytes.deinit(gpa); var offsets: [5]usize = @splat(0); try bytes.appendSlice(gpa, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); offsets[1] = bytes.items.len; try bytes.appendSlice(gpa, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); offsets[2] = bytes.items.len; try bytes.appendSlice(gpa, "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); offsets[3] = bytes.items.len; try bytes.appendSlice(gpa, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [100 200 300 400] " ++ "/CropBox [120 220 280 370] /Rotate 90 /Resources << >> " ++ "/Contents 4 0 R >>\nendobj\n"); offsets[4] = bytes.items.len; try bytes.print(gpa, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream }); const xref = bytes.items.len; try bytes.appendSlice(gpa, "xref\n0 5\n0000000000 65535 f \n"); for (offsets[1..]) |offset| try bytes.print(gpa, "{d:0>10} 00000 n \n", .{offset}); try bytes.print(gpa, "trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref}); return bytes.toOwnedSlice(gpa); } fn beginPdfObject( bytes: *std.ArrayList(u8), gpa: std.mem.Allocator, offsets: []usize, number: usize, ) !void { offsets[number] = bytes.items.len; try bytes.print(gpa, "{d} 0 obj\n", .{number}); } fn finishGeneratedPdf( bytes: *std.ArrayList(u8), gpa: std.mem.Allocator, offsets: []const usize, ) ![]u8 { const xref = bytes.items.len; try bytes.print(gpa, "xref\n0 {d}\n0000000000 65535 f \n", .{offsets.len}); for (offsets[1..]) |offset| try bytes.print(gpa, "{d:0>10} 00000 n \n", .{offset}); try bytes.print( gpa, "trailer\n<< /Size {d} /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{ offsets.len, xref }, ); return bytes.toOwnedSlice(gpa); } fn makeOutlinePdf(gpa: std.mem.Allocator) ![]u8 { var bytes: std.ArrayList(u8) = .empty; errdefer bytes.deinit(gpa); var offsets: [12]usize = @splat(0); try bytes.appendSlice(gpa, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); try beginPdfObject(&bytes, gpa, &offsets, 1); try bytes.appendSlice(gpa, "<< /Type /Catalog /Pages 2 0 R /Outlines 7 0 R /PageMode /UseOutlines >>\nendobj\n"); try beginPdfObject(&bytes, gpa, &offsets, 2); try bytes.appendSlice(gpa, "<< /Type /Pages /Count 3 /Kids [3 0 R 4 0 R 5 0 R] >>\nendobj\n"); for (3..6) |page| { try beginPdfObject(&bytes, gpa, &offsets, page); try bytes.appendSlice(gpa, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << >> >>\nendobj\n"); } try beginPdfObject(&bytes, gpa, &offsets, 6); try bytes.appendSlice(gpa, "<< >>\nendobj\n"); try beginPdfObject(&bytes, gpa, &offsets, 7); try bytes.appendSlice(gpa, "<< /Type /Outlines /First 8 0 R /Last 11 0 R /Count 4 >>\nendobj\n"); // Destinationless branch with a deliberately missing /Title. try beginPdfObject(&bytes, gpa, &offsets, 8); try bytes.appendSlice(gpa, "<< /Parent 7 0 R /First 9 0 R /Last 9 0 R /Next 10 0 R /Count 1 >>\nendobj\n"); // UTF-16BE "Café 子", with bold/italic + color metadata. try beginPdfObject(&bytes, gpa, &offsets, 9); try bytes.appendSlice(gpa, "<< /Title /Parent 8 0 R " ++ "/Dest [4 0 R /XYZ 12 34 null] /F 3 /C [0.2 0.4 0.6] >>\nendobj\n"); // A present empty title and a destination with only one usable axis. try beginPdfObject(&bytes, gpa, &offsets, 10); try bytes.appendSlice(gpa, "<< /Title () /Parent 7 0 R /Prev 8 0 R /Next 11 0 R " ++ "/Dest [5 0 R /FitH 70] >>\nendobj\n"); try beginPdfObject(&bytes, gpa, &offsets, 11); try bytes.appendSlice(gpa, "<< /Title (External) /Parent 7 0 R /Prev 10 0 R " ++ "/A << /S /URI /URI (https://example.com/manual) >> >>\nendobj\n"); return finishGeneratedPdf(&bytes, gpa, &offsets); } /// TEST-ONLY generated fixture shared with the core integration tests. It is /// public because `pardes.zig` imports this file as a module; production code /// has no caller and the function is dead-stripped. pub fn makeOutlineTestPdf(gpa: std.mem.Allocator) ![]u8 { return makeOutlinePdf(gpa); } /// TEST-ONLY generated PDF with pages but no outline tree. pub fn makeNoOutlineTestPdf(gpa: std.mem.Allocator) ![]u8 { return makeOffsetRotatedPdf(gpa); } fn makeTooDeepOutlinePdf(gpa: std.mem.Allocator) ![]u8 { const levels = c.PARDES_PDF_MAX_OUTLINE_DEPTH + 1; const first_outline_item = 5; const object_count = first_outline_item + levels; const offsets = try gpa.alloc(usize, object_count); defer gpa.free(offsets); @memset(offsets, 0); var bytes: std.ArrayList(u8) = .empty; errdefer bytes.deinit(gpa); try bytes.appendSlice(gpa, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); try beginPdfObject(&bytes, gpa, offsets, 1); try bytes.appendSlice(gpa, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n"); try beginPdfObject(&bytes, gpa, offsets, 2); try bytes.appendSlice(gpa, "<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); try beginPdfObject(&bytes, gpa, offsets, 3); try bytes.appendSlice(gpa, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>\nendobj\n"); try beginPdfObject(&bytes, gpa, offsets, 4); try bytes.print(gpa, "<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count {d} >>\nendobj\n", .{levels}); for (0..levels) |level| { const number = first_outline_item + level; try beginPdfObject(&bytes, gpa, offsets, number); try bytes.print(gpa, "<< /Title (Level {d}) /Parent {d} 0 R", .{ level, if (level == 0) 4 else number - 1, }); if (level + 1 < levels) { try bytes.print(gpa, " /First {d} 0 R /Last {d} 0 R /Count {d}", .{ number + 1, number + 1, levels - level - 1, }); } try bytes.appendSlice(gpa, " >>\nendobj\n"); } return finishGeneratedPdf(&bytes, gpa, offsets); } test "PDF outline is a stable owned DFS view with native destinations" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const fixture = try makeOutlinePdf(std.testing.allocator); defer std.testing.allocator.free(fixture); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture, }); var path_buffer: [256]u8 = undefined; const path = try std.fmt.bufPrint( &path_buffer, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path}, ); var document = try Document.open(path); defer document.deinit(); try std.testing.expectEqual(@as(usize, 3), document.pages); for (0..2) |fail_index| { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index, }); try std.testing.expectError( error.OutOfMemory, document.outline(failing.allocator()), ); } for (0..2) |_| { var outline = try document.outline(std.testing.allocator); defer outline.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 4), outline.entries.len); try std.testing.expectEqualSlices(u8, &.{ 0, 1, 0, 0 }, &.{ outline.entries[0].depth, outline.entries[1].depth, outline.entries[2].depth, outline.entries[3].depth, }); try std.testing.expect(outline.entries[0].title == null); try std.testing.expect(outline.entries[0].is_open); try std.testing.expect(outline.entries[0].destination == .none); try std.testing.expectEqualStrings("Café 子", outline.entries[1].title.?); try std.testing.expect(outline.entries[1].isBold()); try std.testing.expect(outline.entries[1].isItalic()); try std.testing.expectEqual([3]u8{ 51, 102, 153 }, outline.entries[1].color); const child = outline.entries[1].destination.internal; try std.testing.expectEqual(@as(usize, 1), child.page); try std.testing.expect(child.x != null and child.y != null); try std.testing.expectApproxEqAbs(@as(f32, 12), child.x.?, 0.01); try std.testing.expectEqualStrings("", outline.entries[2].title.?); const fitted = outline.entries[2].destination.internal; try std.testing.expectEqual(@as(usize, 2), fitted.page); try std.testing.expect(fitted.x == null); try std.testing.expect(fitted.y != null); try std.testing.expectEqualStrings("External", outline.entries[3].title.?); try std.testing.expectEqualStrings( "https://example.com/manual", outline.entries[3].destination.external, ); } } test "PDF outline absence and hostile depth are atomic and repeatable" { var plain_tmp = std.testing.tmpDir(.{}); defer plain_tmp.cleanup(); const plain_fixture = try makeOffsetRotatedPdf(std.testing.allocator); defer std.testing.allocator.free(plain_fixture); try plain_tmp.dir.writeFile(std.testing.io, .{ .sub_path = "no-outline.pdf", .data = plain_fixture, }); var plain_path_buffer: [256]u8 = undefined; const plain_path = try std.fmt.bufPrint( &plain_path_buffer, ".zig-cache/tmp/{s}/no-outline.pdf", .{plain_tmp.sub_path}, ); var plain_document = try Document.open(plain_path); defer plain_document.deinit(); var absent = try plain_document.outline(std.testing.allocator); defer absent.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 0), absent.entries.len); try std.testing.expectEqual(@as(usize, 0), absent.bytes.len); var deep_tmp = std.testing.tmpDir(.{}); defer deep_tmp.cleanup(); const deep_fixture = try makeTooDeepOutlinePdf(std.testing.allocator); defer std.testing.allocator.free(deep_fixture); try deep_tmp.dir.writeFile(std.testing.io, .{ .sub_path = "too-deep-outline.pdf", .data = deep_fixture, }); var deep_path_buffer: [256]u8 = undefined; const deep_path = try std.fmt.bufPrint( &deep_path_buffer, ".zig-cache/tmp/{s}/too-deep-outline.pdf", .{deep_tmp.sub_path}, ); var deep_document = try Document.open(deep_path); defer deep_document.deinit(); var raw: c.pardes_pdf_outline_result = undefined; @memset(std.mem.asBytes(&raw), 0xa5); try std.testing.expectEqual( c.PARDES_PDF_LIMIT_EXCEEDED, c.pardes_pdf_load_outline(deep_document.handle, &raw), ); try std.testing.expect(raw.handle == null); try std.testing.expect(raw.items == null); try std.testing.expectEqual(@as(usize, 0), raw.item_count); try std.testing.expect(raw.bytes == null); try std.testing.expectEqual(@as(usize, 0), raw.bytes_len); for (0..2) |_| try std.testing.expectError( error.OutlineLimitExceeded, deep_document.outline(std.testing.allocator), ); const size = try deep_document.pageSize(0); try std.testing.expectEqual(@as(f32, 100), size.width); try std.testing.expectEqual(@as(f32, 100), size.height); } test "caller-owned RGBA layout is packed and overflow checked" { const layout = try checkedRasterLayout(.{ .width = 2, .height = 3, .stride = 8, .samples_len = 24, }); try std.testing.expectEqual(@as(usize, 2), layout.width); try std.testing.expectEqual(@as(usize, 3), layout.height); try std.testing.expectEqual(@as(usize, 8), layout.stride); try std.testing.expectEqual(@as(usize, 24), layout.len); try std.testing.expectError(error.BadPixmap, checkedRasterLayout(.{ .width = 2, .height = 3, .stride = 7, .samples_len = 21, })); } test "RGBA allocation and buffer-validation failures leave the document renderable" { var document = try Document.open("docs/design.pdf"); defer document.deinit(); var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0, }); try std.testing.expectError( error.OutOfMemory, document.render(failing.allocator(), 0), ); var raw_layout: c.pardes_pdf_raster_layout = std.mem.zeroes(c.pardes_pdf_raster_layout); try std.testing.expectEqual(c.PARDES_PDF_OK, c.pardes_pdf_measure_render( document.handle, 0, default_render_request.dpi, default_render_request.minimum_width, default_render_request.minimum_height, default_render_request.max_dimension, &raw_layout, )); const layout = try checkedRasterLayout(raw_layout); { const scratch = try std.testing.allocator.alloc(u8, layout.len); defer std.testing.allocator.free(scratch); @memset(scratch, 0xa5); try std.testing.expectEqual(c.PARDES_PDF_ERROR, c.pardes_pdf_render_into( document.handle, 0, default_render_request.dpi, default_render_request.minimum_width, default_render_request.minimum_height, default_render_request.max_dimension, null, 0, scratch.ptr, scratch.len - 1, raw_layout.width, raw_layout.height, raw_layout.stride, )); for (scratch) |byte| try std.testing.expectEqual(@as(u8, 0xa5), byte); } const after = try document.render(std.testing.allocator, 0); defer std.testing.allocator.free(after.rgba); const repeated = try document.render(std.testing.allocator, 0); defer std.testing.allocator.free(repeated.rgba); try std.testing.expectEqualSlices(u8, after.rgba, repeated.rgba); try expectOpaque(after.rgba); try expectOpaque(repeated.rgba); } test "MuPDF search returns normalized oriented quads and word selection text" { var document = try Document.open("docs/design.pdf"); defer document.deinit(); const projected = try document.pageText(std.testing.allocator, 0); defer std.testing.allocator.free(projected); try std.testing.expect(std.ascii.indexOfIgnoreCase(projected, "Pardes") != null); // Exercise transactional cache replacement before searching page zero // again; a failed ownership handoff here tends to surface as a double drop. if (document.pages > 1) { const other_page = try document.pageText(std.testing.allocator, 1); std.testing.allocator.free(other_page); } var found = try document.search(std.testing.allocator, 0, "Pardes"); defer found.deinit(std.testing.allocator); try std.testing.expect(found.hit_count > 0); try std.testing.expect(found.quads.len > 0); for (found.quads) |item| { try std.testing.expect(item.hit < found.hit_count); try std.testing.expect(validQuad(item.quad)); } const first = found.quads[0].quad; var selected = try document.select(std.testing.allocator, 0, first.ul, first.lr); defer selected.deinit(std.testing.allocator); try std.testing.expect(selected.quads.len > 0); try std.testing.expect(validPoint(selected.start)); try std.testing.expect(validPoint(selected.end)); const text = try document.copySelection(std.testing.allocator, 0, selected.start, selected.end); defer std.testing.allocator.free(text); try std.testing.expect(std.ascii.indexOfIgnoreCase(text, "Pardes") != null); } test "highlighted render changes pixels while plain render stays stable" { var document = try Document.open("docs/design.pdf"); defer document.deinit(); const plain_before = try document.render(std.testing.allocator, 0); defer std.testing.allocator.free(plain_before.rgba); const whole_page = Quad{ .ul = .{ .x = 0, .y = 0 }, .ur = .{ .x = 1, .y = 0 }, .ll = .{ .x = 0, .y = 1 }, .lr = .{ .x = 1, .y = 1 }, }; const highlights = [_]Highlight{ Highlight.init(whole_page, .{ 255, 0, 0, 128 }, .custom), }; const marked = try document.renderWithHighlights(std.testing.allocator, 0, &highlights); defer std.testing.allocator.free(marked.rgba); const plain_after = try document.render(std.testing.allocator, 0); defer std.testing.allocator.free(plain_after.rgba); try expectOpaque(plain_before.rgba); try expectOpaque(marked.rgba); try expectOpaque(plain_after.rgba); try std.testing.expectEqual(plain_before.width, marked.width); try std.testing.expectEqual(plain_before.height, marked.height); try std.testing.expect(!std.mem.eql(u8, plain_before.rgba, marked.rgba)); try std.testing.expectEqualSlices(u8, plain_before.rgba, plain_after.rgba); } test "offset crop and rotation keep normalized highlights pixel-aligned" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const fixture = try makeOffsetRotatedPdf(std.testing.allocator); defer std.testing.allocator.free(fixture); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "offset-rotated.pdf", .data = fixture, }); var path_buffer: [256]u8 = undefined; const path = try std.fmt.bufPrint( &path_buffer, ".zig-cache/tmp/{s}/offset-rotated.pdf", .{tmp.sub_path}, ); var document = try Document.open(path); defer document.deinit(); const plain = try document.render(std.testing.allocator, 0); defer std.testing.allocator.free(plain.rgba); // CropBox is 160x150 points and /Rotate 90 swaps its displayed axes. try std.testing.expectEqual(@as(usize, 300), plain.width); try std.testing.expectEqual(@as(usize, 320), plain.height); const kitty = try document.renderAt(std.testing.allocator, 0, .{ .dpi = 96, .max_dimension = 1200, }); defer std.testing.allocator.free(kitty.rgba); try std.testing.expect(kitty.width < plain.width); try std.testing.expect(kitty.height < plain.height); const sdl = try document.renderAt(std.testing.allocator, 0, .{ .dpi = 192, .minimum_width = 640, .minimum_height = 480, .max_dimension = 4096, }); defer std.testing.allocator.free(sdl.rgba); try std.testing.expect(sdl.width >= 640); try std.testing.expect(sdl.height >= 480); try std.testing.expect(sdl.width * sdl.height >= kitty.width * kitty.height * 4); const capped = try document.renderAt(std.testing.allocator, 0, .{ .dpi = 192, .minimum_width = 4096, .minimum_height = 4096, .max_dimension = 512, }); defer std.testing.allocator.free(capped.rgba); try std.testing.expectEqual(@as(usize, 512), @max(capped.width, capped.height)); const left_half = Quad{ .ul = .{ .x = 0, .y = 0 }, .ur = .{ .x = 0.5, .y = 0 }, .ll = .{ .x = 0, .y = 1 }, .lr = .{ .x = 0.5, .y = 1 }, }; const highlights = [_]Highlight{ Highlight.init(left_half, .{ 0, 0, 255, 255 }, .custom), }; const marked = try document.renderWithHighlights(std.testing.allocator, 0, &highlights); defer std.testing.allocator.free(marked.rgba); try std.testing.expectEqual(plain.width, marked.width); try std.testing.expectEqual(plain.height, marked.height); const left = (plain.height / 2 * plain.width + plain.width / 4) * 4; const right = (plain.height / 2 * plain.width + plain.width * 3 / 4) * 4; try std.testing.expect(plain.rgba[left] > 240 and plain.rgba[left + 1] < 10 and plain.rgba[left + 2] < 10); try std.testing.expect(marked.rgba[left] < 10 and 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]); }