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 ++++++++++++++++++++++++++++++++++--------------------- src/pdf_bridge.c | 197 ++++++++++++++++++++++++++++++++----------------- src/pdf_bridge.h | 28 ++++--- 3 files changed, 284 insertions(+), 159 deletions(-) (limited to 'src') 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)); diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c index b880cd21..202ef4f7 100644 --- a/src/pdf_bridge.c +++ b/src/pdf_bridge.c @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -310,23 +311,107 @@ pardes_pdf_get_page_size( return PARDES_PDF_OK; } +static void +pardes_pdf_render_geometry( + pardes_pdf_document *document, + int page_number, + int dpi, + int minimum_width, + int minimum_height, + int max_dimension, + fz_matrix *ctm_out, + fz_irect *bbox_out) +{ + fz_rect transformed; + fz_rect bounds; + float page_width; + float page_height; + float longest; + float scale; + + pardes_pdf_cache_page(document, page_number, 0); + bounds = document->cached_bounds; + page_width = bounds.x1 - bounds.x0; + page_height = bounds.y1 - bounds.y0; + longest = fmaxf(page_width, page_height); + if (!(longest > 0.0f)) + fz_throw(document->ctx, FZ_ERROR_FORMAT, "PDF page has empty bounds"); + scale = (float)dpi / 72.0f; + if (minimum_width > 0) + scale = fmaxf(scale, (float)minimum_width / page_width); + if (minimum_height > 0) + scale = fmaxf(scale, (float)minimum_height / page_height); + if (longest * scale > (float)max_dimension) + scale = (float)max_dimension / longest; + + /* + * Keep this identical to fz_new_pixmap_from_page/display_list: transform + * non-zero crop boxes to the origin, transform the bounds, then round the + * complete rectangle once. The same CTM also aligns highlight geometry. + */ + *ctm_out = fz_transform_page(bounds, scale * 72.0f, 0.0f); + transformed = fz_transform_rect(bounds, *ctm_out); + *bbox_out = fz_round_rect(transformed); + if (fz_irect_width(*bbox_out) < 1 || fz_irect_height(*bbox_out) < 1) + fz_throw(document->ctx, FZ_ERROR_FORMAT, "PDF page rendered empty"); +} + int -pardes_pdf_render( +pardes_pdf_measure_render( pardes_pdf_document *document, int page_number, int dpi, int minimum_width, int minimum_height, int max_dimension, - pardes_pdf_pixmap *out) + pardes_pdf_raster_layout *out) { - return pardes_pdf_render_with_highlights( - document, page_number, dpi, minimum_width, minimum_height, - max_dimension, NULL, 0, out); + fz_context *ctx; + fz_matrix ctm; + fz_irect bbox; + int width = 0; + int height = 0; + int stride = 0; + size_t samples_len = 0; + + if (out == NULL) + return PARDES_PDF_ERROR; + memset(out, 0, sizeof(*out)); + if (document == NULL || page_number < 0 || + page_number >= document->page_count || dpi < 1 || + minimum_width < 0 || minimum_height < 0 || max_dimension < 1) + return PARDES_PDF_ERROR; + + ctx = document->ctx; + fz_try(ctx) + { + pardes_pdf_render_geometry(document, page_number, dpi, + minimum_width, minimum_height, max_dimension, &ctm, &bbox); + width = fz_irect_width(bbox); + height = fz_irect_height(bbox); + if (width > INT_MAX / 4) + fz_throw(ctx, FZ_ERROR_LIMIT, "PDF raster row is too large"); + stride = width * 4; + if ((size_t)height > SIZE_MAX / (size_t)stride) + fz_throw(ctx, FZ_ERROR_LIMIT, "PDF raster is too large"); + samples_len = (size_t)stride * (size_t)height; + } + fz_catch(ctx) + { + fz_report_error(ctx); + memset(out, 0, sizeof(*out)); + return PARDES_PDF_ERROR; + } + + out->width = width; + out->height = height; + out->stride = stride; + out->samples_len = samples_len; + return PARDES_PDF_OK; } int -pardes_pdf_render_with_highlights( +pardes_pdf_render_into( pardes_pdf_document *document, int page_number, int dpi, @@ -335,75 +420,69 @@ pardes_pdf_render_with_highlights( int max_dimension, const pardes_pdf_highlight *highlights, size_t highlight_count, - pardes_pdf_pixmap *out) + unsigned char *samples, + size_t samples_len, + int width, + int height, + int stride) { fz_context *ctx; fz_pixmap *pixmap = NULL; fz_device *device = NULL; fz_path *path = NULL; fz_matrix ctm; - fz_rect bounds; - float page_width; - float page_height; - float longest; - float scale; + fz_irect bbox; size_t i; - if (document == NULL || out == NULL || page_number < 0 || + if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || (highlight_count != 0 && highlights == NULL) || - highlight_count > PARDES_PDF_MAX_RESULT_QUADS) + highlight_count > PARDES_PDF_MAX_RESULT_QUADS || samples == NULL || + width < 1 || height < 1 || stride < 1 || width > INT_MAX / 4 || + stride != width * 4 || + (size_t)height > SIZE_MAX / (size_t)stride || + samples_len != (size_t)stride * (size_t)height) return PARDES_PDF_ERROR; - memset(out, 0, sizeof(*out)); ctx = document->ctx; fz_var(pixmap); fz_var(device); fz_var(path); fz_try(ctx) { - pardes_pdf_cache_page(document, page_number, 0); - bounds = document->cached_bounds; - page_width = bounds.x1 - bounds.x0; - page_height = bounds.y1 - bounds.y0; - longest = fmaxf(page_width, page_height); - if (!(longest > 0.0f)) - fz_throw(ctx, FZ_ERROR_FORMAT, "PDF page has empty bounds"); - scale = (float)dpi / 72.0f; - if (minimum_width > 0) - scale = fmaxf(scale, (float)minimum_width / page_width); - if (minimum_height > 0) - scale = fmaxf(scale, (float)minimum_height / page_height); - if (longest * scale > (float)max_dimension) - scale = (float)max_dimension / longest; - /* - * Unlike a bare scale, fz_transform_page translates non-zero crop - * boxes to the pixmap origin and rounds both dimensions coherently. - * The overlay draw device receives this exact CTM, so normalized page - * geometry and page pixels stay aligned for offset and rotated pages. - */ - ctm = fz_transform_page(bounds, scale * 72.0f, 0.0f); - - /* - * alpha=0 is intentional. MuPDF clears this RGB pixmap to opaque - * white before drawing. Asking for alpha=1 instead produces - * premultiplied transparent samples, while Pardes consumes straight - * RGBA after the Zig-side RGB expansion. - */ + pardes_pdf_render_geometry(document, page_number, dpi, + minimum_width, minimum_height, max_dimension, &ctm, &bbox); + if (fz_irect_width(bbox) != width || + fz_irect_height(bbox) != height) + fz_throw(ctx, FZ_ERROR_ARGUMENT, + "PDF raster layout changed between measure and render"); + + /* External samples are never marked FZ_PIXMAP_FLAG_FREE_SAMPLES. */ + pixmap = fz_new_pixmap_with_bbox_and_data( + ctx, fz_device_rgb(ctx), bbox, NULL, 1, samples); + if (fz_pixmap_samples(ctx, pixmap) != samples || + fz_pixmap_width(ctx, pixmap) != width || + fz_pixmap_height(ctx, pixmap) != height || + fz_pixmap_stride(ctx, pixmap) != stride || + fz_pixmap_components(ctx, pixmap) != 4) + fz_throw(ctx, FZ_ERROR_FORMAT, + "MuPDF wrapped an unexpected RGBA layout"); + fz_clear_pixmap_with_value(ctx, pixmap, 0xFF); + if (document->cached_display_list != NULL || document->display_list_candidate_page_number == page_number) { pardes_pdf_cache_display_list(document); - pixmap = fz_new_pixmap_from_display_list( - ctx, document->cached_display_list, ctm, - fz_device_rgb(ctx), 0); + device = fz_new_draw_device(ctx, ctm, pixmap); + fz_run_display_list(ctx, document->cached_display_list, device, + fz_identity, fz_infinite_rect, NULL); } else { - pixmap = fz_new_pixmap_from_page( - ctx, document->cached_page, ctm, fz_device_rgb(ctx), 0); + device = fz_new_draw_device(ctx, ctm, pixmap); + fz_run_page(ctx, document->cached_page, device, fz_identity, NULL); } - if (fz_pixmap_width(ctx, pixmap) < 1 || - fz_pixmap_height(ctx, pixmap) < 1) - fz_throw(ctx, FZ_ERROR_FORMAT, "PDF page rendered empty"); + fz_close_device(ctx, device); + fz_drop_device(ctx, device); + device = NULL; if (highlight_count != 0) { device = fz_new_draw_device(ctx, ctm, pixmap); @@ -445,33 +524,21 @@ pardes_pdf_render_with_highlights( { fz_drop_path(ctx, path); fz_drop_device(ctx, device); + /* Drops only the wrapper: caller-owned samples remain untouched. */ + fz_drop_pixmap(ctx, pixmap); } fz_catch(ctx) { fz_report_error(ctx); - fz_drop_pixmap(ctx, pixmap); return PARDES_PDF_ERROR; } if (document->cached_display_list == NULL) document->display_list_candidate_page_number = page_number; - out->handle = pixmap; - out->samples = fz_pixmap_samples(ctx, pixmap); - out->width = fz_pixmap_width(ctx, pixmap); - out->height = fz_pixmap_height(ctx, pixmap); - out->stride = fz_pixmap_stride(ctx, pixmap); - out->components = fz_pixmap_components(ctx, pixmap); return PARDES_PDF_OK; } -void -pardes_pdf_drop_pixmap(pardes_pdf_document *document, void *pixmap) -{ - if (document != NULL && pixmap != NULL) - fz_drop_pixmap(document->ctx, (fz_pixmap *)pixmap); -} - int pardes_pdf_page_text( pardes_pdf_document *document, diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index cc706c33..17323a29 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -14,14 +14,12 @@ extern "C" { */ typedef struct pardes_pdf_document pardes_pdf_document; -typedef struct pardes_pdf_pixmap { - void *handle; - const unsigned char *samples; +typedef struct pardes_pdf_raster_layout { int width; int height; int stride; - int components; -} pardes_pdf_pixmap; + size_t samples_len; +} pardes_pdf_raster_layout; typedef struct pardes_pdf_page_size { float width; @@ -120,18 +118,25 @@ int pardes_pdf_get_page_size( /* * Start at dpi, raise the uniform scale until both optional minimum pixel * dimensions are met, then clamp the page's longest side to max_dimension. + * Measuring may populate the page cache, but never advances adaptive display + * list promotion; only a successful render_into does that. */ -int pardes_pdf_render( +int pardes_pdf_measure_render( pardes_pdf_document *document, int page, int dpi, int minimum_width, int minimum_height, int max_dimension, - pardes_pdf_pixmap *out + pardes_pdf_raster_layout *out ); -int pardes_pdf_render_with_highlights( +/* + * Rasterize directly into an exactly-sized caller-owned packed RGBA buffer. + * MuPDF wraps but never owns or frees samples. Every successful pixel is + * opaque, including the caller-supplied highlights composited onto the page. + */ +int pardes_pdf_render_into( pardes_pdf_document *document, int page, int dpi, @@ -140,9 +145,12 @@ int pardes_pdf_render_with_highlights( int max_dimension, const pardes_pdf_highlight *highlights, size_t highlight_count, - pardes_pdf_pixmap *out + unsigned char *samples, + size_t samples_len, + int width, + int height, + int stride ); -void pardes_pdf_drop_pixmap(pardes_pdf_document *document, void *pixmap); int pardes_pdf_page_text( pardes_pdf_document *document, -- cgit v1.3