diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/pardes.zig | 101 | ||||
| -rw-r--r-- | src/pdf.zig | 107 | ||||
| -rw-r--r-- | src/pdf_bridge.c | 25 | ||||
| -rw-r--r-- | src/pdf_bridge.h | 10 |
4 files changed, 221 insertions, 22 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index bc56f966..f32eb93f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -40,6 +40,39 @@ pub const lsp = @import("lsp/lsp.zig"); pub const Platform = enum { tty, gui, web }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); +/// Native PDF quality is a shell property, but the core owns MuPDF and the +/// RGBA cache. Kitty favors wire bandwidth; SDL favors physical-pixel text +/// quality and asks the renderer to cover either fit axis without upscaling. +pub const PdfRasterPolicy = struct { + dpi: u16, + max_dimension: u16, + match_viewport: bool, +}; + +pub const kitty_pdf_raster_policy: PdfRasterPolicy = .{ + .dpi = 96, + .max_dimension = 1200, + .match_viewport = false, +}; +pub const sdl_pdf_raster_policy: PdfRasterPolicy = .{ + .dpi = 192, + .max_dimension = 4096, + .match_viewport = true, +}; +pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) { + .tty => kitty_pdf_raster_policy, + .gui => sdl_pdf_raster_policy, + .web => kitty_pdf_raster_policy, +}; + +test "SDL PDF raster policy is materially denser than Kitty" { + try std.testing.expect(sdl_pdf_raster_policy.dpi >= kitty_pdf_raster_policy.dpi * 2); + try std.testing.expect(sdl_pdf_raster_policy.max_dimension > + kitty_pdf_raster_policy.max_dimension * 3); + try std.testing.expect(!kitty_pdf_raster_policy.match_viewport); + try std.testing.expect(sdl_pdf_raster_policy.match_viewport); +} + // The capacities and the two heights that are STRUCTURE, not taste: the // fixed-size pane/column arrays, and the fact that the topbar and a tag are // one row each (nothing here works at any other value). The layout numbers @@ -108,6 +141,23 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { const first_place = first.images[0].?; try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4); try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit); + const request = p.pdfRenderRequest(pane) orelse return error.MissingPdfRenderRequest; + try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi); + try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension); + try std.testing.expectEqual(request, pane.pdf.?.render_request); + try std.testing.expect(@max(first_place.iw, first_place.ih) <= request.max_dimension); + if (pdf_raster_policy.match_viewport) { + const viewport = p.pdfViewport(pane) orelse return error.MissingPdfViewport; + try std.testing.expectEqual(viewport.pixel_w, request.minimum_width); + try std.testing.expectEqual(viewport.pixel_h, request.minimum_height); + try std.testing.expect(first_place.iw >= viewport.pixel_w or + @max(first_place.iw, first_place.ih) == request.max_dimension); + try std.testing.expect(first_place.ih >= viewport.pixel_h or + @max(first_place.iw, first_place.ih) == request.max_dimension); + } else { + try std.testing.expectEqual(@as(u32, 0), request.minimum_width); + try std.testing.expectEqual(@as(u32, 0), request.minimum_height); + } // The default reading view moves one visible cell row without replacing // the page pixels. Fit/pan are placement state, not texture identity. @@ -426,7 +476,9 @@ test "PDF native mouse selection, Look, and highlights share page geometry" { try std.testing.expect(searched.images[0].?.native.revision != selected_revision); // Fit and viewport changes can crop a hit which was already revealed. - // Both rearm the placement-only reveal without forcing new page pixels. + // Fit remains placement-only. SDL's physical-viewport quality request + // changes with a resize and therefore replaces pixels; Kitty's fixed, + // bandwidth-conscious request retains them. const revision_before_geometry_change = searched.images[0].?.native.revision; pv.search_reveal_pending = false; p.togglePdfFit(pane); @@ -453,10 +505,14 @@ test "PDF native mouse selection, Look, and highlights share page geometry" { try std.testing.expect(pv.search_reveal_pending); _ = frame.reset(.retain_capacity); const geometry_changed = try p.render(frame.allocator()); - try std.testing.expectEqual( - revision_before_geometry_change, - geometry_changed.images[0].?.native.revision, - ); + if (pdf_raster_policy.match_viewport) + try std.testing.expect(geometry_changed.images[0].?.native.revision != + revision_before_geometry_change) + else + try std.testing.expectEqual( + revision_before_geometry_change, + geometry_changed.images[0].?.native.revision, + ); p.update(.{ .resize = .{ .cols = p.screen_w, .rows = p.screen_h, @@ -1756,6 +1812,11 @@ const PdfView = if (pdf_enabled) struct { iw: usize = 0, ih: usize = 0, render_tried: bool = false, + /// Last quality request attempted, successful or not. A viewport change + /// gets one transactional retry; a capped/failed request does not spin on + /// every frame. Fit and pan are intentionally absent from this key. + render_request: pdf_impl.RenderRequest = .{}, + render_request_valid: bool = false, render_revision: u32 = 0, text: []u8 = &.{}, text_tried: bool = false, @@ -7644,6 +7705,7 @@ pub const Pardes = struct { if (pv.rgba.len > 0) p.gpa.free(pv.rgba); pv.rgba = &.{}; pv.render_tried = false; + pv.render_request_valid = false; } fn dropPdfSearchResults(p: *Pardes, pv: *PdfView) void { @@ -7860,6 +7922,17 @@ pub const Pardes = struct { }; } + fn pdfRenderRequest(p: *const Pardes, pane: *const Pane) ?pdf_impl.RenderRequest { + if (comptime !pdf_enabled) return null; + const viewport = p.pdfViewport(pane) orelse return null; + return .{ + .dpi = pdf_raster_policy.dpi, + .minimum_width = if (pdf_raster_policy.match_viewport) viewport.pixel_w else 0, + .minimum_height = if (pdf_raster_policy.match_viewport) viewport.pixel_h else 0, + .max_dimension = pdf_raster_policy.max_dimension, + }; + } + fn pdfGeometry(p: *const Pardes, pane: *const Pane) ?image.NativeGeometry { if (comptime !pdf_enabled) return null; const pv = pane.pdf orelse return null; @@ -9569,21 +9642,27 @@ pub const Pardes = struct { } } - /// Render the current PDF page once and attach its stable RGBA until page - /// navigation invalidates it. MuPDF rendering is bounded to a 1600-pixel - /// longest side in pdf.zig, independent of malicious page dimensions. + /// Render the current PDF page once per backend-quality request and attach + /// its stable RGBA until pixels or physical viewport quality change. + /// Fit/pan stay placement-only, while every request retains its backend's + /// hard longest-side allocation ceiling. fn drawPdf(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) bool { if (comptime !pdf_enabled) return false; const pv = &(pane.pdf orelse return false); - if (!pv.render_tried) { + const request = p.pdfRenderRequest(pane) orelse return false; + if (!pv.render_tried or !pv.render_request_valid or + !pv.render_request.eql(request)) + { pv.render_tried = true; + pv.render_request = request; + pv.render_request_valid = true; p.resolvePdfSearch(pv); const rendered = render: { const highlights = p.pdfHighlights(pv) catch break :render null; const fresh = if (highlights.len > 0) - pv.document.renderWithHighlights(p.gpa, pv.page, highlights) catch break :render null + pv.document.renderWithHighlightsAt(p.gpa, pv.page, request, highlights) catch break :render null else - pv.document.render(p.gpa, pv.page) catch break :render null; + pv.document.renderAt(p.gpa, pv.page, request) catch break :render null; break :render fresh; }; if (rendered) |fresh| { diff --git a/src/pdf.zig b/src/pdf.zig index 48b15cf1..b06c85e8 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -7,13 +7,31 @@ const c = @cImport({ @cInclude("pdf_bridge.h"); }); -pub const max_render_dimension = 1600; +/// 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 @@ -135,7 +153,16 @@ pub const Document = struct { /// Render one zero-based page, bounded before allocation. MuPDF supplies /// row-strided RGB; Pardes' attachment boundary is packed straight RGBA. pub fn render(document: *Document, gpa: std.mem.Allocator, page: usize) !Render { - return document.renderInternal(gpa, page, null); + 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 @@ -146,31 +173,64 @@ pub const Document = struct { page: usize, highlights: []const Highlight, ) !Render { - return document.renderInternal(gpa, page, highlights); + 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))), + )); 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), - max_render_dimension, + request.dpi, + minimum_width, + minimum_height, + request.max_dimension, &pixmap, ) else c.pardes_pdf_render_with_highlights( document.handle, @intCast(page), - max_render_dimension, + request.dpi, + minimum_width, + minimum_height, + request.max_dimension, @ptrCast(items.ptr), items.len, &pixmap, @@ -179,7 +239,10 @@ pub const Document = struct { c.pardes_pdf_render( document.handle, @intCast(page), - max_render_dimension, + request.dpi, + minimum_width, + minimum_height, + request.max_dimension, &pixmap, ); if (status != c.PARDES_PDF_OK) return error.RenderFailed; @@ -197,7 +260,9 @@ pub const Document = struct { stride < width * 3) return error.BadPixmap; - const rgba = try gpa.alloc(u8, width * height * 4); + const rgba_len = width * height * 4; + if (rgba_len > max_owned_raster_bytes) return error.PixmapTooLarge; + const rgba = try gpa.alloc(u8, rgba_len); errdefer gpa.free(rgba); const samples: [*]const u8 = @ptrCast(pixmap.samples); try expandRgb(rgba, samples[0 .. stride * height], width, height, stride); @@ -493,6 +558,34 @@ test "offset crop and rotation keep normalized highlights pixel-aligned" { 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 }, diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c index a26fea55..320919b1 100644 --- a/src/pdf_bridge.c +++ b/src/pdf_bridge.c @@ -234,17 +234,24 @@ int pardes_pdf_render( pardes_pdf_document *document, int page_number, + int dpi, + int minimum_width, + int minimum_height, int max_dimension, pardes_pdf_pixmap *out) { return pardes_pdf_render_with_highlights( - document, page_number, max_dimension, NULL, 0, out); + document, page_number, dpi, minimum_width, minimum_height, + max_dimension, NULL, 0, out); } int pardes_pdf_render_with_highlights( pardes_pdf_document *document, int page_number, + int dpi, + int minimum_width, + int minimum_height, int max_dimension, const pardes_pdf_highlight *highlights, size_t highlight_count, @@ -256,12 +263,15 @@ pardes_pdf_render_with_highlights( fz_path *path = NULL; fz_matrix ctm; fz_rect bounds; + float page_width; + float page_height; float longest; - float scale = 2.0f; /* 144 dpi; PDF user space is 72 dpi. */ + float scale; size_t i; if (document == NULL || out == NULL || page_number < 0 || - page_number >= document->page_count || max_dimension < 1 || + 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) return PARDES_PDF_ERROR; @@ -275,9 +285,16 @@ pardes_pdf_render_with_highlights( { pardes_pdf_cache_page(document, page_number, 0); bounds = document->cached_bounds; - longest = fmaxf(bounds.x1 - bounds.x0, bounds.y1 - bounds.y0); + 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; /* diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index 85f477d0..77b3dc58 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -105,9 +105,16 @@ enum { pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count); void pardes_pdf_close(pardes_pdf_document *document); +/* + * 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. + */ int pardes_pdf_render( pardes_pdf_document *document, int page, + int dpi, + int minimum_width, + int minimum_height, int max_dimension, pardes_pdf_pixmap *out ); @@ -115,6 +122,9 @@ int pardes_pdf_render( int pardes_pdf_render_with_highlights( pardes_pdf_document *document, int page, + int dpi, + int minimum_width, + int minimum_height, int max_dimension, const pardes_pdf_highlight *highlights, size_t highlight_count, |
