From 90eed6a89c66d6243ea43e799113409244308563 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 23:29:50 -0300 Subject: render MuPDF pages directly into owned RGBA --- src/pdf.zig | 218 +++++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 134 insertions(+), 84 deletions(-) (limited to 'src/pdf.zig') diff --git a/src/pdf.zig b/src/pdf.zig index a1c05681..28f976e4 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -166,8 +166,7 @@ pub const Document = struct { return .{ .width = size.width, .height = size.height }; } - /// Render one zero-based page, bounded before allocation. MuPDF supplies - /// row-strided RGB; Pardes' attachment boundary is packed straight RGBA. + /// 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); } @@ -227,62 +226,42 @@ pub const Document = struct { request.minimum_height, @as(u32, @intCast(std.math.maxInt(c_int))), )); - var pixmap: c.pardes_pdf_pixmap = std.mem.zeroes(c.pardes_pdf_pixmap); - const status = if (highlights) |items| - if (items.len == 0) - c.pardes_pdf_render( - document.handle, - @intCast(page), - request.dpi, - minimum_width, - minimum_height, - request.max_dimension, - &pixmap, - ) - else - c.pardes_pdf_render_with_highlights( - document.handle, - @intCast(page), - request.dpi, - minimum_width, - minimum_height, - request.max_dimension, - @ptrCast(items.ptr), - items.len, - &pixmap, - ) - else - c.pardes_pdf_render( - document.handle, - @intCast(page), - request.dpi, - minimum_width, - minimum_height, - request.max_dimension, - &pixmap, - ); - if (status != c.PARDES_PDF_OK) return error.RenderFailed; - defer c.pardes_pdf_drop_pixmap(document.handle, pixmap.handle); - - if (pixmap.samples == null or pixmap.width < 1 or pixmap.height < 1 or - pixmap.stride < 1 or pixmap.components != 3) - return error.BadPixmap; - const width: usize = @intCast(pixmap.width); - const height: usize = @intCast(pixmap.height); - const stride: usize = @intCast(pixmap.stride); - if (width > std.math.maxInt(usize) / 4 or - height > std.math.maxInt(usize) / (width * 4) or - height > std.math.maxInt(usize) / stride or - stride < width * 3) - return error.BadPixmap; - - const rgba_len = width * height * 4; - if (rgba_len > max_owned_raster_bytes) return error.PixmapTooLarge; - const rgba = try gpa.alloc(u8, rgba_len); + 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 samples: [*]const u8 = @ptrCast(pixmap.samples); - try expandRgb(rgba, samples[0 .. stride * height], width, height, stride); - return .{ .rgba = rgba, .width = width, .height = height }; + 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 @@ -428,26 +407,33 @@ fn validQuad(quad: Quad) bool { validPoint(quad.ll) and validPoint(quad.lr); } -fn expandRgb( - rgba: []u8, - rgb: []const u8, +const RasterLayout = struct { width: usize, height: usize, stride: usize, -) !void { - if (rgba.len != width * height * 4 or stride < width * 3 or - rgb.len < stride * height) + 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; - for (0..height) |y| { - const src = rgb[y * stride ..][0 .. width * 3]; - const dst = rgba[y * width * 4 ..][0 .. width * 4]; - for (0..width) |x| { - dst[x * 4 + 0] = src[x * 3 + 0]; - dst[x * 4 + 1] = src[x * 3 + 1]; - dst[x * 4 + 2] = src[x * 3 + 2]; - dst[x * 4 + 3] = 0xff; - } - } + 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 { @@ -476,17 +462,78 @@ fn makeOffsetRotatedPdf(gpa: std.mem.Allocator) ![]u8 { return bytes.toOwnedSlice(gpa); } -test "RGB pixmap expansion respects row stride and writes opaque RGBA" { - const rgb = [_]u8{ - 1, 2, 3, 4, 5, 6, 99, 99, - 7, 8, 9, 10, 11, 12, 88, 88, - }; - var rgba: [16]u8 = undefined; - try expandRgb(&rgba, &rgb, 2, 2, 8); - try std.testing.expectEqualSlices(u8, &.{ - 1, 2, 3, 255, 4, 5, 6, 255, - 7, 8, 9, 255, 10, 11, 12, 255, - }, &rgba); +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" { @@ -544,6 +591,9 @@ test "highlighted render changes pixels while plain render stays stable" { 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)); -- cgit v1.3