diff options
Diffstat (limited to 'src/pdf.zig')
| -rw-r--r-- | src/pdf.zig | 303 |
1 files changed, 240 insertions, 63 deletions
diff --git a/src/pdf.zig b/src/pdf.zig index 2a674172..c1faafc5 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -268,36 +268,111 @@ pub fn tintRgba(rgba: []u8, mode: TintMode, colors: TintColors) !void { 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 => { - for (pixels) |*pixel| { - 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]; - pixel[0] = target[0]; - pixel[1] = target[1]; - pixel[2] = target[2]; - } + pixel[0] = target[0]; + pixel[1] = target[1]; + pixel[2] = target[2]; }, .filtered => { - for (pixels) |*pixel| { - 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]; - 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); - } + 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 { @@ -403,6 +478,60 @@ test "optimized PDF tint matches scalar rule across deterministic broad samples" } } +// 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()); @@ -740,62 +869,107 @@ pub const Document = struct { return document.renderInternal(gpa, page, request, highlights); } - fn renderInternal( + /// 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, - 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; + ) !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), - request.dpi, - minimum_width, - minimum_height, - request.max_dimension, + 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; + } - const rgba = try gpa.alloc(u8, layout.len); - errdefer gpa.free(rgba); + /// Rasterize into `rgba`, which MUST be exactly `raster.len` bytes from a + /// matching `measureRenderAt` with the same 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, + highlights: []const Highlight, + rgba: []u8, + ) !void { + if (rgba.len != raster.len) 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 (items.len == 0) null else @ptrCast(items.ptr); + if (highlights.len == 0) null else @ptrCast(highlights.ptr); if (c.pardes_pdf_render_into( document.handle, @intCast(page), - request.dpi, - minimum_width, - minimum_height, - request.max_dimension, + bounded.dpi, + bounded.minimum_width, + bounded.minimum_height, + bounded.max_dimension, highlight_ptr, - items.len, + highlights.len, rgba.ptr, rgba.len, - @intCast(layout.width), - @intCast(layout.height), - @intCast(layout.stride), + @intCast(raster.width), + @intCast(raster.height), + @intCast(raster.stride), ) != c.PARDES_PDF_OK) return error.RenderFailed; - return .{ .rgba = rgba, .width = layout.width, .height = layout.height }; + } + + 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, 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 @@ -955,14 +1129,17 @@ fn validQuad(quad: Quad) bool { validPoint(quad.ll) and validPoint(quad.lr); } -const RasterLayout = struct { +/// The exact shape of the raster a render request produces. `len` is the +/// packed RGBA byte count `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, }; -fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !RasterLayout { +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); |
