//! 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, }; /// Rewrite packed straight-alpha RGBA in place without allocating. /// /// Source luminance is Rec. 601's 77/150/29 integer approximation. `full` /// linearly maps source black to theme foreground and source white to theme /// background. `filtered` starts with that same themed target, then adds 3/4 /// of each source channel's signed distance from source luminance, clamped to /// a byte. Thus grayscale is identical in both modes while filtered color /// retains the source hue/chroma. Alpha is never touched. pub fn tintRgba(rgba: []u8, mode: TintMode, colors: TintColors) !void { if (rgba.len % 4 != 0) return error.InvalidRgbaLength; if (mode == .disabled) return; var at: usize = 0; while (at < rgba.len) : (at += 4) { const source = rgba[at..][0..3]; const luminance: u8 = @intCast((@as(u32, source[0]) * 77 + @as(u32, source[1]) * 150 + @as(u32, source[2]) * 29 + 128) >> 8); for (0..3) |channel| { const target: i32 = @intCast((@as(u32, colors.foreground[channel]) * (255 - @as(u32, luminance)) + @as(u32, colors.background[channel]) * @as(u32, luminance) + 127) / 255); const chroma = if (mode == .filtered) @divTrunc( (@as(i32, source[channel]) - @as(i32, luminance)) * 3, 4, ) else 0; rgba[at + channel] = @intCast(std.math.clamp(target + chroma, 0, 255)); } } } test "PDF tint mode cycle is exact" { try std.testing.expectEqual(TintMode.filtered, TintMode.disabled.next()); try std.testing.expectEqual(TintMode.full, TintMode.filtered.next()); try std.testing.expectEqual(TintMode.disabled, TintMode.full.next()); } test "full PDF tint maps black and white to dark and light theme endpoints" { const dark: TintColors = .{ .background = .{ 0x12, 0x12, 0x12 }, .foreground = .{ 0x94, 0x94, 0x94 }, }; var dark_pixels = [_]u8{ 0, 0, 0, 17, 255, 255, 255, 231 }; try tintRgba(&dark_pixels, .full, dark); try std.testing.expectEqualSlices(u8, &.{ 0x94, 0x94, 0x94, 17 }, dark_pixels[0..4]); try std.testing.expectEqualSlices(u8, &.{ 0x12, 0x12, 0x12, 231 }, dark_pixels[4..8]); const light: TintColors = .{ .background = .{ 0xea, 0xff, 0xff }, .foreground = .{ 0x00, 0x00, 0x00 }, }; var light_pixels = [_]u8{ 0, 0, 0, 0, 255, 255, 255, 255 }; try tintRgba(&light_pixels, .full, light); try std.testing.expectEqualSlices(u8, &.{ 0x00, 0x00, 0x00, 0 }, light_pixels[0..4]); try std.testing.expectEqualSlices(u8, &.{ 0xea, 0xff, 0xff, 255 }, light_pixels[4..8]); } test "filtered PDF tint preserves fixed-point source chroma and alpha" { const colors: TintColors = .{ .background = .{ 20, 40, 60 }, .foreground = .{ 220, 200, 180 }, }; var full = [_]u8{ 255, 0, 0, 37 }; var filtered = full; try tintRgba(&full, .full, colors); try tintRgba(&filtered, .filtered, colors); // Red has integer luma 77. The themed target is {160,152,144}; adding // 3/4 of its {178,-77,-77} chroma residual yields this clamped result. try std.testing.expectEqualSlices(u8, &.{ 160, 152, 144, 37 }, &full); try std.testing.expectEqualSlices(u8, &.{ 255, 95, 87, 37 }, &filtered); var gray_full = [_]u8{ 128, 128, 128, 91 }; var gray_filtered = gray_full; try tintRgba(&gray_full, .full, colors); try tintRgba(&gray_filtered, .filtered, colors); try std.testing.expectEqualSlices(u8, &gray_full, &gray_filtered); try std.testing.expectEqual(@as(u8, 91), gray_filtered[3]); } test "disabled PDF tint is byte-identical and malformed RGBA is rejected unchanged" { const colors: TintColors = .{ .background = .{ 1, 2, 3 }, .foreground = .{ 4, 5, 6 }, }; var pixels = [_]u8{ 9, 80, 170, 0, 250, 33, 71, 199 }; const before = pixels; try tintRgba(&pixels, .disabled, colors); try std.testing.expectEqualSlices(u8, &before, &pixels); var malformed = [_]u8{ 11, 22, 33 }; const malformed_before = malformed; try std.testing.expectError(error.InvalidRgbaLength, tintRgba(&malformed, .full, colors)); try std.testing.expectEqualSlices(u8, &malformed_before, &malformed); } pub const PageSize = struct { width: f32, height: f32, }; 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; } }; 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; } /// 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 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); } 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]); }