//! 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"); }); const allocation_header_size = 16; var bridge_allocator: std.mem.Allocator = undefined; var bridge_started = false; comptime { if (allocation_header_size < @sizeOf(usize)) @compileError("PDF allocation header is too small"); } fn bridgeAlloc(user: ?*anyopaque, size_arg: usize) callconv(.c) ?*anyopaque { const user_ptr = user orelse return null; const allocator: *std.mem.Allocator = @ptrCast(@alignCast(user_ptr)); const size = @max(size_arg, 1); const total = std.math.add(usize, allocation_header_size, size) catch return null; const bytes = allocator.alignedAlloc(u8, .@"16", total) catch return null; const header: *align(16) usize = @ptrCast(bytes.ptr); header.* = total; return @ptrCast(bytes.ptr + allocation_header_size); } fn bridgeFree(user: ?*anyopaque, pointer: ?*anyopaque) callconv(.c) void { const data = pointer orelse return; const user_ptr = user orelse @panic("PDF allocator callback has no allocator"); const allocator: *std.mem.Allocator = @ptrCast(@alignCast(user_ptr)); const data_bytes: [*]u8 = @ptrCast(data); const base: [*]align(16) u8 = @ptrCast(@alignCast(data_bytes - allocation_header_size)); const header: *align(16) usize = @ptrCast(base); allocator.free(base[0..header.*]); } fn bridgeRealloc( user: ?*anyopaque, pointer: ?*anyopaque, size_arg: usize, ) callconv(.c) ?*anyopaque { if (pointer == null) return bridgeAlloc(user, size_arg); if (size_arg == 0) { bridgeFree(user, pointer); return null; } const user_ptr = user orelse return null; const allocator: *std.mem.Allocator = @ptrCast(@alignCast(user_ptr)); const total = std.math.add(usize, allocation_header_size, size_arg) catch return null; const data_bytes: [*]u8 = @ptrCast(pointer.?); const base: [*]align(16) u8 = @ptrCast(@alignCast(data_bytes - allocation_header_size)); const header: *align(16) usize = @ptrCast(base); const resized = allocator.realloc(base[0..header.*], total) catch return null; const resized_header: *align(16) usize = @ptrCast(resized.ptr); resized_header.* = total; return @ptrCast(resized.ptr + allocation_header_size); } /// Installs the allocator used by all subsequently opened PDF documents and /// by MuPDF itself. Call once before opening any documents. pub fn start(allocator: std.mem.Allocator) void { if (bridge_started) @panic("PDF allocator is already started"); bridge_allocator = allocator; const descriptor = c.pardes_pdf_allocator{ .user = &bridge_allocator, .alloc = bridgeAlloc, .realloc = bridgeRealloc, .free = bridgeFree, }; if (c.pardes_pdf_set_allocator(&descriptor) != c.PARDES_PDF_OK) @panic("cannot start PDF allocator while documents are live"); bridge_started = true; } /// Restores libc and MuPDF's default allocator. Every Document must have been /// destroyed first so no allocation can outlive its allocator domain. pub fn stop() void { if (!bridge_started) @panic("PDF allocator is not started"); if (c.pardes_pdf_set_allocator(null) != c.PARDES_PDF_OK) @panic("cannot stop PDF allocator while documents are live"); bridge_started = false; bridge_allocator = undefined; } test "PDF C allocator callbacks preserve and free exact allocations" { var allocator = std.testing.allocator; var live: ?*anyopaque = bridgeAlloc(&allocator, 4) orelse return error.OutOfMemory; defer if (live) |pointer| bridgeFree(&allocator, pointer); const original: [*]u8 = @ptrCast(live.?); @memcpy(original[0..4], "data"); live = bridgeRealloc(&allocator, live, 32) orelse return error.OutOfMemory; const grown: [*]u8 = @ptrCast(live.?); try std.testing.expectEqualSlices(u8, "data", grown[0..4]); try std.testing.expect(bridgeAlloc( &allocator, std.math.maxInt(usize), ) == null); const zero = bridgeAlloc(&allocator, 0) orelse return error.OutOfMemory; bridgeFree(&allocator, zero); try std.testing.expect(bridgeRealloc(&allocator, live, 0) == null); live = null; } test "custom allocator owns a complete PDF document lifecycle" { start(std.testing.allocator); defer stop(); var document = try Document.open("docs/design.pdf"); defer document.deinit(); const raster = try document.render(std.testing.allocator, 0); defer std.testing.allocator.free(raster.rgba); try std.testing.expect(raster.rgba.len > 0); } /// 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; /// Maximum native outline rows accepted atomically from one document. pub const max_outline_items: usize = c.PARDES_PDF_MAX_OUTLINE_ITEMS; 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 => tintPixels(pixels, .full, &targets), .filtered => tintPixels(pixels, .filtered, &targets), } } /// The whole rule for ONE pixel, and the reason the memo below is exact: the /// transform reads nothing but this packed word, and alpha rides through /// untouched, so the word is a complete cache key. inline fn tintPixel(word: u32, comptime mode: TintMode, targets: *const [256][3]u8) u32 { var pixel: [4]u8 = @bitCast(word); 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]; switch (mode) { .disabled => comptime unreachable, .full => { pixel[0] = target[0]; pixel[1] = target[1]; pixel[2] = target[2]; }, .filtered => { 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); }, } return @bitCast(pixel); } /// Alpha-less packed tint for every grayscale level. See tintWord: this one /// table serves BOTH modes, which is why it takes no mode. fn grayTintTable(targets: *const [256][3]u8) [256]u32 { var table: [256]u32 = undefined; for (&table, targets) |*word, target| word.* = @bitCast([4]u8{ target[0], target[1], target[2], 0 }); return table; } const tint_alpha_mask: u32 = @bitCast([4]u8{ 0, 0, 0, 0xff }); /// One pixel, with the two shortcuts that make a full-page tint affordable. /// /// Rec. 601's integer weights sum to exactly 256, so for r == g == b the /// luminance dot product is the channel value itself, and `filtered`'s chroma /// residual is then exactly zero — both modes collapse to the themed target /// for that level. A text page's raster is overwhelmingly grayscale, so this /// arm carries almost every pixel at one table load. Colour falls through to /// the general rule, unchanged. inline fn tintWord( word: u32, comptime mode: TintMode, targets: *const [256][3]u8, gray: *const [256]u32, ) u32 { const pixel: [4]u8 = @bitCast(word); if (pixel[0] == pixel[1] and pixel[1] == pixel[2]) return gray[pixel[0]] | (word & tint_alpha_mask); return tintPixel(word, mode, targets); } /// Pixels per run test. Sixteen is four SSE2 registers: one branch says /// "this whole span repeats the previous pixel", which is the shape of the /// paper margins and of any flat fill. const tint_run_pixels = 16; /// A rasterized page is mostly flat — paper, then runs of one ink value — and /// the transform reads nothing but the packed word, so a one-entry memo tested /// a span at a time turns those runs into one comparison and one store. That, /// plus the grayscale collapse above, is what lets a fling afford to tint a /// whole freshly rasterized page inside a single frame. Colour-photographic /// content pays the span comparison and nothing else; the arithmetic is /// byte-for-byte the scalar rule in every arm. fn tintPixels(pixels: [][4]u8, comptime mode: TintMode, targets: *const [256][3]u8) void { const gray = grayTintTable(targets); const Run = @Vector(tint_run_pixels, u32); var memo_key: u32 = undefined; var memo_value: u32 = undefined; var memo_valid = false; var at: usize = 0; while (at + tint_run_pixels <= pixels.len) : (at += tint_run_pixels) { const span = pixels[at..][0..tint_run_pixels]; if (memo_valid) { const words: Run = @bitCast(span.*); if (@reduce(.And, words == @as(Run, @splat(memo_key)))) { span.* = @bitCast(@as(Run, @splat(memo_value))); continue; } } for (span) |*pixel| { memo_key = @bitCast(pixel.*); memo_value = tintWord(memo_key, mode, targets, &gray); pixel.* = @bitCast(memo_value); } memo_valid = true; } while (at < pixels.len) : (at += 1) { const pixel = &pixels[at]; const word: u32 = @bitCast(pixel.*); pixel.* = @bitCast(tintWord(word, mode, targets, &gray)); } } 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); } } } // The memo, its tint_run_pixels span test, and the grayscale collapse only // engage on repeated or achromatic pixels, and the span loop leaves a tail on // any pixel count that is not a multiple of tint_run_pixels. None of that is // visible to the random corpus above, so drive flat runs, run boundaries, and // every remainder against the reference rule directly. test "optimized PDF tint matches scalar rule across runs, boundaries, and every tail" { const modes = [_]TintMode{ .full, .filtered }; const colors: TintColors = .{ .background = .{ 0x08, 0x12, 0x2a }, .foreground = .{ 0xbd, 0xa4, 0x71 }, }; // Paper, ink, antialiased edge, saturated colour, and a transparent pixel // that shares its RGB with an opaque one — the memo key must include alpha // or that pair would tint from one cached word. const palette = [_][4]u8{ .{ 0xff, 0xff, 0xff, 0xff }, .{ 0x00, 0x00, 0x00, 0xff }, .{ 0x7f, 0x80, 0x81, 0xff }, .{ 0xff, 0x00, 0x00, 0xff }, .{ 0xff, 0xff, 0xff, 0x00 }, }; var state: u64 = 0x7061_7264_6573_5254; var pixels: [37][4]u8 = undefined; for (0..96) |round| { // Alternate long flat runs against pixel-by-pixel churn so the block // fast path, the per-pixel memo, and the miss path all run. var run_left: usize = 0; var current: [4]u8 = palette[round % palette.len]; for (&pixels) |*pixel| { if (run_left == 0) { run_left = 1 + (tintTestByte(&state) % 9); current = if (tintTestByte(&state) & 3 == 0) .{ tintTestByte(&state), tintTestByte(&state), tintTestByte(&state), tintTestByte(&state) } else palette[tintTestByte(&state) % palette.len]; } run_left -= 1; pixel.* = current; } for (0..pixels.len + 1) |count| { const source = std.mem.sliceAsBytes(pixels[0..count]); for (modes) |mode| { var expected: [pixels.len * 4]u8 = undefined; var actual: [pixels.len * 4]u8 = undefined; @memcpy(expected[0..source.len], source); @memcpy(actual[0..source.len], source); try tintRgbaReference(expected[0..source.len], mode, colors); try tintRgba(actual[0..source.len], mode, colors); try std.testing.expectEqualSlices(u8, expected[0..source.len], actual[0..source.len]); } } } } 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 Link = struct { page: usize, quad: Quad, /// Owned independently of the document's current-page cache. External /// destination slices point into this same allocation. uri: []u8, destination: OutlineDestination, pub fn deinit(link: *Link, gpa: std.mem.Allocator) void { gpa.free(link.uri); link.* = undefined; } }; 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; } pub fn openBytes(bytes: []const u8) !Document { var page_count: c_int = 0; const handle = c.pardes_pdf_open_memory(bytes.ptr, bytes.len, &page_count) orelse return error.OpenFailed; return .{ .handle = handle, .pages = @intCast(page_count) }; } /// Link annotations are loaded only for the pointed page, separately from /// text/raster work. Returned URIs survive cache eviction and page changes. pub fn linkAt(document: *Document, gpa: std.mem.Allocator, page: usize, point: Point) !?Link { const page_number = try document.checkedPage(page); if (!validPoint(point)) return null; var raw: c.pardes_pdf_link = std.mem.zeroes(c.pardes_pdf_link); if (c.pardes_pdf_link_at(document.handle, page_number, point, &raw) != c.PARDES_PDF_OK) return error.LinkFailed; if (raw.uri == null or raw.uri_len == 0) return null; if (raw.uri_len > c.PARDES_PDF_MAX_LINK_URI_BYTES or !validQuad(raw.quad)) return error.BadLink; const borrowed: [*]const u8 = @ptrCast(raw.uri); const text = borrowed[0..raw.uri_len]; if (!std.unicode.utf8ValidateSlice(text) or std.mem.trim(u8, text, " ").len == 0 or std.ascii.eqlIgnoreCase(text, "file://")) return null; for (text) |byte| if (byte < ' ') return null; const uri = try gpa.dupe(u8, text); errdefer gpa.free(uri); const destination: OutlineDestination = switch (raw.destination_kind) { c.PARDES_PDF_OUTLINE_DESTINATION_INTERNAL => blk: { if (raw.page < 0 or @as(usize, @intCast(raw.page)) >= document.pages or raw.has_x > 1 or raw.has_y > 1 or (raw.has_x != 0 and !std.math.isFinite(raw.x)) or (raw.has_y != 0 and !std.math.isFinite(raw.y))) return error.BadLink; break :blk .{ .internal = .{ .page = @intCast(raw.page), .x = if (raw.has_x != 0) raw.x else null, .y = if (raw.has_y != 0) raw.y else null, } }; }, c.PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL => .{ .external = uri }, else => return error.BadLink, }; return .{ .page = page, .quad = raw.quad, .uri = uri, .destination = destination }; } pub fn copyRectangle(document: *Document, gpa: std.mem.Allocator, page: usize, quad: Quad) ![]u8 { const page_number = try document.checkedPage(page); if (!validQuad(quad)) return error.InvalidPoint; var text: c.pardes_pdf_owned_text = std.mem.zeroes(c.pardes_pdf_owned_text); if (c.pardes_pdf_copy_rectangle(document.handle, page_number, quad, &text) != c.PARDES_PDF_OK) return error.SelectionFailed; defer c.pardes_pdf_drop_owned_text(document.handle, text.handle); if (text.len == 0) return &.{}; if (text.data == null) return error.BadText; const source: [*]const u8 = @ptrCast(text.data); return gpa.dupe(u8, source[0..text.len]); } /// 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); } /// Exactly the raster this request will produce, without producing it. /// Split out of the render so a caller holding a retired buffer of the /// same length can hand it straight back instead of making the allocator /// fetch (and later return) fresh pages for every page it flies past. pub fn measureRenderAt( document: *Document, page: usize, request: RenderRequest, ) !Raster { const bounded = try boundedRequest(document, page, request); 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), bounded.dpi, bounded.minimum_width, bounded.minimum_height, bounded.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; return layout; } /// Rasterize `band` of a page into `rgba`, which MUST be exactly /// `band.len` bytes for a `band` taken from the same `measureRenderAt` /// shape and request. On any failure the buffer's contents are unspecified /// and it still belongs to the caller. pub fn renderIntoAt( document: *Document, page: usize, request: RenderRequest, raster: Raster, band: Raster.Band, highlights: []const Highlight, rgba: []u8, ) !void { if (rgba.len != band.len) return error.BadPixmap; if (band.height == 0 or band.y + band.height > raster.height) return error.BadPixmap; const bounded = try boundedRequest(document, page, request); if (highlights.len > c.PARDES_PDF_MAX_RESULT_QUADS) return error.RenderFailed; const highlight_ptr: ?[*]const c.pardes_pdf_highlight = if (highlights.len == 0) null else @ptrCast(highlights.ptr); if (c.pardes_pdf_render_into( document.handle, @intCast(page), bounded.dpi, bounded.minimum_width, bounded.minimum_height, bounded.max_dimension, highlight_ptr, highlights.len, rgba.ptr, rgba.len, @intCast(raster.width), @intCast(raster.height), @intCast(raster.stride), @intCast(band.y), @intCast(band.height), ) != c.PARDES_PDF_OK) return error.RenderFailed; } const BoundedRequest = struct { dpi: c_int, minimum_width: c_int, minimum_height: c_int, max_dimension: c_int, }; fn boundedRequest( document: *Document, page: usize, request: RenderRequest, ) !BoundedRequest { 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; return .{ .dpi = request.dpi, .minimum_width = @intCast(@min( request.minimum_width, @as(u32, @intCast(std.math.maxInt(c_int))), )), .minimum_height = @intCast(@min( request.minimum_height, @as(u32, @intCast(std.math.maxInt(c_int))), )), .max_dimension = request.max_dimension, }; } fn renderInternal( document: *Document, gpa: std.mem.Allocator, page: usize, request: RenderRequest, highlights: ?[]const Highlight, ) !Render { const raster = try document.measureRenderAt(page, request); const rgba = try gpa.alloc(u8, raster.len); errdefer gpa.free(rgba); try document.renderIntoAt( page, request, raster, raster.wholePage(), highlights orelse &.{}, rgba, ); return .{ .rgba = rgba, .width = raster.width, .height = raster.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, selection_start: Point, selection_end: Point, ) !Selection { const page_number = try document.checkedPage(page); if (!validPoint(selection_start) or !validPoint(selection_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, selection_start, selection_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, selection_start: Point, selection_end: Point, ) ![]u8 { const page_number = try document.checkedPage(page); if (!validPoint(selection_start) or !validPoint(selection_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, selection_start, selection_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); } /// The exact shape of the raster a render request produces. `len` is the /// packed RGBA byte count a FULL-page `renderIntoAt` demands, so a caller can /// match a retired buffer against it before deciding to allocate. pub const Raster = struct { width: usize, height: usize, stride: usize, len: usize, /// The rows a reader can actually see are usually a fraction of a page: /// flinging through a manual, a page is on screen for one frame showing a /// couple of hundred of its twelve hundred rows. `Band` is that fraction, /// and rendering one is the same rendering clipped — see the bridge's /// comment and the test that proves the rows are identical. pub const Band = struct { y: usize, height: usize, /// bytes of a `y`/`height` band at this raster's stride len: usize, }; /// The whole page as a band, which is what a reader at rest gets. pub fn wholePage(raster: Raster) Band { return .{ .y = 0, .height = raster.height, .len = raster.len }; } /// Clamp a wanted row range to the page and return it as a band. A range /// that misses the page entirely comes back as its nearest single row /// rather than as an error: the caller is describing a viewport, and a /// viewport that has just left a page is not a malformed request. pub fn band(raster: Raster, first_row: usize, rows: usize) Band { const y = @min(first_row, raster.height - 1); const height = @min(@max(rows, 1), raster.height - y); return .{ .y = y, .height = height, .len = raster.stride * height }; } }; fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !Raster { 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]); } const generated_pdf_fragment_max = 256; const PdfBytes = struct { storage: ?[]u8 = null, len: usize = 0, fn appendSlice(bytes: *PdfBytes, source: []const u8) !void { const end = std.math.add(usize, bytes.len, source.len) catch return error.GeneratedPdfTooLarge; if (bytes.storage) |storage| { if (end > storage.len) return error.GeneratedPdfTooLarge; @memcpy(storage[bytes.len..end], source); } bytes.len = end; } fn print(bytes: *PdfBytes, comptime format: []const u8, args: anytype) !void { var fragment: [generated_pdf_fragment_max]u8 = undefined; const rendered = std.fmt.bufPrint(&fragment, format, args) catch return error.GeneratedPdfFragmentTooLong; try bytes.appendSlice(rendered); } }; fn allocateGeneratedPdf( gpa: std.mem.Allocator, offsets: []usize, context: anytype, comptime emit: anytype, ) ![]u8 { @memset(offsets, 0); var counter: PdfBytes = .{}; try emit(&counter, offsets, context); const result = try gpa.alloc(u8, counter.len); errdefer gpa.free(result); @memset(offsets, 0); var bytes: PdfBytes = .{ .storage = result }; try emit(&bytes, offsets, context); std.debug.assert(bytes.len == result.len); return result; } fn beginPdfObject(bytes: *PdfBytes, offsets: []usize, number: usize) !void { offsets[number] = bytes.len; try bytes.print("{d} 0 obj\n", .{number}); } fn finishGeneratedPdf(bytes: *PdfBytes, offsets: []const usize) !void { const xref = bytes.len; try bytes.print("xref\n0 {d}\n0000000000 65535 f \n", .{offsets.len}); for (offsets[1..]) |offset| try bytes.print("{d:0>10} 00000 n \n", .{offset}); try bytes.print( "trailer\n<< /Size {d} /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{ offsets.len, xref }, ); } fn emitOffsetRotatedPdf(bytes: *PdfBytes, offsets: []usize, stream: []const u8) !void { try bytes.appendSlice("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); try beginPdfObject(bytes, offsets, 1); try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); try beginPdfObject(bytes, offsets, 2); try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); try beginPdfObject(bytes, offsets, 3); try bytes.appendSlice("<< /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"); try beginPdfObject(bytes, offsets, 4); try bytes.print("<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream }); try finishGeneratedPdf(bytes, offsets); } fn makeOffsetRotatedPdf(gpa: std.mem.Allocator) ![]u8 { const stream = "q 1 0 0 rg 120 220 160 150 re f Q\n"; var offsets: [5]usize = undefined; return allocateGeneratedPdf(gpa, &offsets, stream, emitOffsetRotatedPdf); } fn emitOutlinePdf(bytes: *PdfBytes, offsets: []usize, _: void) !void { try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); try beginPdfObject(bytes, offsets, 1); try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R /Outlines 7 0 R /PageMode /UseOutlines >>\nendobj\n"); try beginPdfObject(bytes, offsets, 2); try bytes.appendSlice("<< /Type /Pages /Count 3 /Kids [3 0 R 4 0 R 5 0 R] >>\nendobj\n"); for (3..6) |page| { try beginPdfObject(bytes, offsets, page); try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << >> >>\nendobj\n"); } try beginPdfObject(bytes, offsets, 6); try bytes.appendSlice("<< >>\nendobj\n"); try beginPdfObject(bytes, offsets, 7); try bytes.appendSlice("<< /Type /Outlines /First 8 0 R /Last 11 0 R /Count 4 >>\nendobj\n"); // Destinationless branch with a deliberately missing /Title. try beginPdfObject(bytes, offsets, 8); try bytes.appendSlice("<< /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, offsets, 9); try bytes.appendSlice("<< /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, offsets, 10); try bytes.appendSlice("<< /Title () /Parent 7 0 R /Prev 8 0 R /Next 11 0 R " ++ "/Dest [5 0 R /FitH 70] >>\nendobj\n"); try beginPdfObject(bytes, offsets, 11); try bytes.appendSlice("<< /Title (External) /Parent 7 0 R /Prev 10 0 R " ++ "/A << /S /URI /URI (https://example.com/manual) >> >>\nendobj\n"); try finishGeneratedPdf(bytes, offsets); } fn makeOutlinePdf(gpa: std.mem.Allocator) ![]u8 { var offsets: [12]usize = undefined; return allocateGeneratedPdf(gpa, &offsets, {}, emitOutlinePdf); } /// 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 emitLinksPdf(bytes: *PdfBytes, offsets: []usize, _: void) !void { const stream = "BT /F1 6 Tf 20 240 Td (target.txt) Tj ET\n" ++ "BT /F1 6 Tf 20 120 Td (https://example.com/same) Tj ET\n" ++ "BT /F1 6 Tf 20 60 Td (label) Tj ET\n"; try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); try beginPdfObject(bytes, offsets, 1); try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); try beginPdfObject(bytes, offsets, 2); try bytes.appendSlice("<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >>\nendobj\n"); try beginPdfObject(bytes, offsets, 3); try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R] >>\nendobj\n"); try beginPdfObject(bytes, offsets, 4); try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << >> >>\nendobj\n"); try beginPdfObject(bytes, offsets, 5); try bytes.appendSlice("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); try beginPdfObject(bytes, offsets, 6); try bytes.print("<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream }); try beginPdfObject(bytes, offsets, 7); try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 230 120 260] /Border [0 0 0] /A << /S /URI /URI (https://example.com/embedded) >> >>\nendobj\n"); try beginPdfObject(bytes, offsets, 8); try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 170 120 200] /Border [0 0 0] /Dest [4 0 R /XYZ 12 34 null] >>\nendobj\n"); try beginPdfObject(bytes, offsets, 9); try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 110 190 140] /Border [0 0 0] /A << /S /URI /URI (https://example.com/same) >> >>\nendobj\n"); try beginPdfObject(bytes, offsets, 10); try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 50 120 80] /Border [0 0 0] /A << /S /URI /URI (https://example.com/only) >> >>\nendobj\n"); try finishGeneratedPdf(bytes, offsets); } /// TEST-ONLY: external, blank internal, identical-label URI, and link-only URI. pub fn makeLinksTestPdf(gpa: std.mem.Allocator) ![]u8 { var offsets: [11]usize = undefined; return allocateGeneratedPdf(gpa, &offsets, {}, emitLinksPdf); } test "PDF links own URIs across page eviction and preserve internal coordinates" { const gpa = std.testing.allocator; start(gpa); defer stop(); const fixture = try makeLinksTestPdf(gpa); defer gpa.free(fixture); var document = try Document.openBytes(fixture); defer document.deinit(); var external = (try document.linkAt(gpa, 0, .{ .x = 0.3, .y = 0.18 })) orelse return error.NoLink; defer external.deinit(gpa); try std.testing.expectEqualStrings("https://example.com/embedded", external.uri); try std.testing.expectEqualStrings(external.uri, external.destination.external); try std.testing.expectApproxEqAbs(@as(f32, 0.1), external.quad.ul.x, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 40.0 / 300.0), external.quad.ul.y, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 0.6), external.quad.lr.x, 0.0001); const label = try document.copyRectangle(gpa, 0, external.quad); defer gpa.free(label); try std.testing.expectEqualStrings("target.txt", std.mem.trim(u8, label, "\r\n ")); var internal = (try document.linkAt(gpa, 0, .{ .x = 0.3, .y = 0.38 })) orelse return error.NoLink; defer internal.deinit(gpa); try std.testing.expectEqual(@as(usize, 1), internal.destination.internal.page); try std.testing.expectApproxEqAbs(@as(f32, 12), internal.destination.internal.x.?, 0.001); try std.testing.expectApproxEqAbs(@as(f32, 266), internal.destination.internal.y.?, 0.001); const blank = try document.copyRectangle(gpa, 0, internal.quad); defer gpa.free(blank); try std.testing.expectEqualStrings("", blank); try std.testing.expect(try document.linkAt(gpa, 1, .{ .x = 0.3, .y = 0.18 }) == null); try std.testing.expectEqualStrings("https://example.com/embedded", external.uri); var again = (try document.linkAt(gpa, 0, .{ .x = 0.3, .y = 0.18 })) orelse return error.NoLink; defer again.deinit(gpa); try std.testing.expectEqualStrings(external.uri, again.uri); var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); try std.testing.expectError(error.OutOfMemory, document.linkAt(failing.allocator(), 0, .{ .x = 0.3, .y = 0.18 })); try std.testing.expectError(error.PageOutOfRange, document.linkAt(gpa, 2, .{ .x = 0.3, .y = 0.18 })); for ([_]Point{ .{ .x = -0.1, .y = 0.18 }, .{ .x = 0.3, .y = 1.1 }, .{ .x = std.math.nan(f32), .y = 0.18 } }) |point| try std.testing.expect(try document.linkAt(gpa, 0, point) == null); try std.testing.expect(try document.linkAt(gpa, 0, .{ .x = 0.99, .y = 0.99 }) == null); } fn emitRotatedLinkPdf(bytes: *PdfBytes, offsets: []usize, action: []const u8) !void { try bytes.appendSlice("%PDF-1.7\n"); try beginPdfObject(bytes, offsets, 1); try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); try beginPdfObject(bytes, offsets, 2); try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); try beginPdfObject(bytes, offsets, 3); try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [100 200 300 400] /CropBox [120 220 280 370] /Rotate 90 /Resources << >> /Annots [4 0 R] >>\nendobj\n"); try beginPdfObject(bytes, offsets, 4); try bytes.print("<< /Type /Annot /Subtype /Link /Rect [120 220 160 270] /Border [0 0 0] {s} >>\nendobj\n", .{action}); try finishGeneratedPdf(bytes, offsets); } test "PDF links normalize cropped rotated annotations and ignore unsupported actions" { const gpa = std.testing.allocator; start(gpa); defer stop(); const actions = [_][]const u8{ "/A << /S /URI /URI (https://example.com/rotated) >>", "/A << /S /URI /URI () >>", "/A << /S /JavaScript /JS (ignored) >>", "/Dest /missing_destination", }; for (actions, 0..) |action, index| { var offsets: [5]usize = undefined; const fixture = try allocateGeneratedPdf(gpa, &offsets, action, emitRotatedLinkPdf); defer gpa.free(fixture); var document = try Document.openBytes(fixture); defer document.deinit(); var found = try document.linkAt(gpa, 0, .{ .x = 0.1, .y = 0.1 }); defer if (found) |*item| item.deinit(gpa); if (index != 0) { try std.testing.expect(found == null); continue; } const link = found orelse return error.NoRotatedLink; try std.testing.expectApproxEqAbs(@as(f32, 0), link.quad.ul.x, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 0), link.quad.ul.y, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 50.0 / 150.0), link.quad.lr.x, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 40.0 / 160.0), link.quad.lr.y, 0.0001); try std.testing.expect(try document.linkAt(gpa, 0, .{ .x = 0.7, .y = 0.7 }) == null); } } fn emitTooDeepOutlinePdf(bytes: *PdfBytes, offsets: []usize, levels: usize) !void { const first_outline_item = 5; try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); try beginPdfObject(bytes, offsets, 1); try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n"); try beginPdfObject(bytes, offsets, 2); try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); try beginPdfObject(bytes, offsets, 3); try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>\nendobj\n"); try beginPdfObject(bytes, offsets, 4); try bytes.print("<< /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, offsets, number); try bytes.print("<< /Title (Level {d}) /Parent {d} 0 R", .{ level, if (level == 0) 4 else number - 1, }); if (level + 1 < levels) { try bytes.print(" /First {d} 0 R /Last {d} 0 R /Count {d}", .{ number + 1, number + 1, levels - level - 1, }); } try bytes.appendSlice(" >>\nendobj\n"); } try finishGeneratedPdf(bytes, offsets); } fn makeTooDeepOutlinePdf(gpa: std.mem.Allocator) ![]u8 { const levels: usize = c.PARDES_PDF_MAX_OUTLINE_DEPTH + 1; const object_count = 5 + levels; const offsets = try gpa.alloc(usize, object_count); defer gpa.free(offsets); return allocateGeneratedPdf(gpa, offsets, levels, emitTooDeepOutlinePdf); } fn emitWideOutlinePdf(bytes: *PdfBytes, offsets: []usize, items: usize) !void { const first_outline_item = 5; try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); try beginPdfObject(bytes, offsets, 1); try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n"); try beginPdfObject(bytes, offsets, 2); try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); try beginPdfObject(bytes, offsets, 3); try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>\nendobj\n"); try beginPdfObject(bytes, offsets, 4); try bytes.print("<< /Type /Outlines /First 5 0 R /Last {d} 0 R /Count {d} >>\nendobj\n", .{ first_outline_item + items - 1, items, }); for (0..items) |ordinal| { const number = first_outline_item + ordinal; try beginPdfObject(bytes, offsets, number); try bytes.print("<< /Title (Section {d}) /Parent 4 0 R /Dest [3 0 R /Fit]", .{ordinal}); if (ordinal != 0) try bytes.print(" /Prev {d} 0 R", .{number - 1}); if (ordinal + 1 < items) try bytes.print(" /Next {d} 0 R", .{number + 1}); try bytes.appendSlice(" >>\nendobj\n"); } try finishGeneratedPdf(bytes, offsets); } fn makeWideOutlinePdf(gpa: std.mem.Allocator, items: usize) ![]u8 { if (items == 0) return error.EmptyOutlineFixture; const object_count = try std.math.add(usize, 5, items); const offsets = try gpa.alloc(usize, object_count); defer gpa.free(offsets); return allocateGeneratedPdf(gpa, offsets, items, emitWideOutlinePdf); } 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 accepts manual-sized trees and rejects item overflow atomically" { const manual_items = 4105; try std.testing.expect(max_outline_items >= manual_items); var manual_tmp = std.testing.tmpDir(.{}); defer manual_tmp.cleanup(); const manual_fixture = try makeWideOutlinePdf(std.testing.allocator, manual_items); defer std.testing.allocator.free(manual_fixture); try manual_tmp.dir.writeFile(std.testing.io, .{ .sub_path = "manual-outline.pdf", .data = manual_fixture, }); var manual_path_buffer: [256]u8 = undefined; const manual_path = try std.fmt.bufPrint( &manual_path_buffer, ".zig-cache/tmp/{s}/manual-outline.pdf", .{manual_tmp.sub_path}, ); var manual_document = try Document.open(manual_path); defer manual_document.deinit(); var outline = try manual_document.outline(std.testing.allocator); defer outline.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, manual_items), outline.entries.len); try std.testing.expectEqualStrings("Section 0", outline.entries[0].title.?); try std.testing.expectEqualStrings("Section 4104", outline.entries[manual_items - 1].title.?); try std.testing.expectEqual(@as(usize, 0), outline.entries[manual_items - 1].destination.internal.page); var overflow_tmp = std.testing.tmpDir(.{}); defer overflow_tmp.cleanup(); const overflow_fixture = try makeWideOutlinePdf(std.testing.allocator, max_outline_items + 1); defer std.testing.allocator.free(overflow_fixture); try overflow_tmp.dir.writeFile(std.testing.io, .{ .sub_path = "overflow-outline.pdf", .data = overflow_fixture, }); var overflow_path_buffer: [256]u8 = undefined; const overflow_path = try std.fmt.bufPrint( &overflow_path_buffer, ".zig-cache/tmp/{s}/overflow-outline.pdf", .{overflow_tmp.sub_path}, ); var overflow_document = try Document.open(overflow_path); defer overflow_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(overflow_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); } 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, 0, raw_layout.height, )); 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 "a band's rows are the full page's rows, and highlights land in them" { // THE claim the fast-scroll path rests on: rendering rows [y, y+h) of a // page is the same rendering, clipped — not a different one. If MuPDF ever // let the pixmap's bbox change antialiasing, hinting or subpixel placement, // a fling would show pixels a reader could tell apart from a rested view, // and this test is what refuses to let that happen quietly. var document = try Document.open("docs/design.pdf"); defer document.deinit(); const request: RenderRequest = .{ .dpi = 96, .max_dimension = 1200 }; const raster = try document.measureRenderAt(0, request); const whole = try std.testing.allocator.alloc(u8, raster.len); defer std.testing.allocator.free(whole); try document.renderIntoAt(0, request, raster, raster.wholePage(), &.{}, whole); // Odd offsets and an odd height on purpose: a band that starts on a row // MuPDF would never choose itself is exactly where an off-by-one in the // bbox arithmetic would show up. const cases = [_]struct { y: usize, h: usize }{ .{ .y = 0, .h = 1 }, .{ .y = 1, .h = 37 }, .{ .y = raster.height / 3, .h = 101 }, .{ .y = raster.height / 2, .h = raster.height / 2 }, .{ .y = raster.height - 1, .h = 1 }, }; for (cases) |case| { const band = raster.band(case.y, case.h); const rows = try std.testing.allocator.alloc(u8, band.len); defer std.testing.allocator.free(rows); try document.renderIntoAt(0, request, raster, band, &.{}, rows); const from = band.y * raster.stride; try std.testing.expectEqualSlices(u8, whole[from..][0..band.len], rows); try expectOpaque(rows); } // A baked highlight is drawn under the same CTM, so it has to land on the // same rows through a band as it does through a whole page. const highlights = [_]Highlight{.{ .quad = .{ .ul = .{ .x = 0.1, .y = 0.4 }, .ur = .{ .x = 0.9, .y = 0.4 }, .ll = .{ .x = 0.1, .y = 0.6 }, .lr = .{ .x = 0.9, .y = 0.6 }, }, .kind = .search, .rgba = .{ 0x30, 0x80, 0xf0, 0x80 }, }}; const marked_whole = try std.testing.allocator.alloc(u8, raster.len); defer std.testing.allocator.free(marked_whole); try document.renderIntoAt(0, request, raster, raster.wholePage(), &highlights, marked_whole); const marked_band = raster.band(raster.height / 3, raster.height / 3); const marked_rows = try std.testing.allocator.alloc(u8, marked_band.len); defer std.testing.allocator.free(marked_rows); try document.renderIntoAt(0, request, raster, marked_band, &highlights, marked_rows); const marked_from = marked_band.y * raster.stride; try std.testing.expectEqualSlices( u8, marked_whole[marked_from..][0..marked_band.len], marked_rows, ); // ...and the highlight really is in the band that was compared, or the // comparison above would be two identical plain renders agreeing. try std.testing.expect(!std.mem.eql( u8, whole[marked_from..][0..marked_band.len], marked_rows, )); // A band request that misses the page is a viewport that has left it, not // a malformed call: it clamps instead of failing. const past_end = raster.band(raster.height + 500, 64); try std.testing.expect(past_end.y < raster.height); try std.testing.expectEqual(@as(usize, 1), past_end.height); } 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); // The SIZE comes off the page object rather than a loaded page (see // pardes_pdf_get_page_size), and this is the fixture that would catch it // being a different answer: an offset CropBox plus /Rotate 90, where a // MediaBox reading, an untransformed cropbox or a missed rotation all give // plausible-looking wrong numbers. The rendered raster above is what // fz_bound_page produces, so the strip's layout has to agree with it. const size = try document.pageSize(0); try std.testing.expectEqual(@as(f32, 150), size.width); try std.testing.expectEqual(@as(f32, 160), size.height); try std.testing.expectApproxEqAbs( @as(f32, @floatFromInt(plain.width)) / @as(f32, @floatFromInt(plain.height)), size.width / size.height, 0.001, ); 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]); }