Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion crates/rofd-render/src/cairo_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1614,7 +1614,15 @@ fn decode_seal_raster(
))
})?;
let mut decoder_limits = Limits::default();
decoder_limits.max_alloc = Some(limits.max_decoded_image_bytes.min(max_raster_bytes));
// Two different budgets meet here. `max_decoded_image_bytes` and
// `max_raster_bytes` both bound the finished RGBA8 raster, which is only known
// after decoding, so the image crate's allocation budget (which also covers the
// decoder's own intermediate buffers) must be wider than the tighter of the two
// — the RGBA8 check below stays the authority. Three times the tighter budget:
// a decoder holds its own buffer (up to 8 bytes per pixel for 16-bit channels)
// and the RGBA8 conversion at the same time.
let raster_budget = limits.max_decoded_image_bytes.min(max_raster_bytes);
decoder_limits.max_alloc = Some(raster_budget.saturating_mul(3));
reader.limits(decoder_limits);
let decoded = reader.decode().map_err(|error| {
invalid_display_list(format!("seal picture could not be decoded: {error}"))
Expand Down
9 changes: 8 additions & 1 deletion crates/rofd-render/src/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,14 @@ fn decode_pixels(
let mut decoder_limits = Limits::default();
decoder_limits.max_image_width = Some(width);
decoder_limits.max_image_height = Some(height);
decoder_limits.max_alloc = Some(limits.max_decoded_image_bytes);
// `Limits::max_alloc` is a running budget for every allocation the decoder
// makes, its own intermediate buffers included, while
// `max_decoded_image_bytes` bounds the finished RGBA8 buffer, which
// `validate_dimensions` has already checked. Allow three times that budget: a
// decoder holds its own buffer (up to 8 bytes per pixel for 16-bit channels)
// and the RGBA8 conversion (4 bytes per pixel) at the same time, so an image
// that exactly fills the byte budget must still decode.
decoder_limits.max_alloc = Some(limits.max_decoded_image_bytes.saturating_mul(3));
reader.limits(decoder_limits);
let decoded = reader
.decode()
Expand Down
30 changes: 30 additions & 0 deletions crates/rofd-render/tests/cairo_stamp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ use zip::{write::SimpleFileOptions, ZipWriter};
/// A 3x2 RGBA PNG with six distinct pixels.
const PNG: &[u8] = include_bytes!("fixtures/images/asymmetric-rgba.png");

/// A 6000x6000 16-bit RGBA PNG. Its finished RGBA8 raster is 137 MiB, well inside
/// the default document limits (400 MiB decoded bytes, 100 M pixels), but the
/// decoder holds the 274 MiB source buffer and the RGBA8 conversion at the same
/// time, which the old single-budget accounting rejected ("Memory limit exceeded")
/// before those limits were ever consulted - so the seal was silently dropped.
const LARGE_PNG: &[u8] = include_bytes!("fixtures/images/seal-16bit-boundary.png");

fn der(tag: u8, content: &[u8]) -> Vec<u8> {
let mut out = vec![tag];
if content.len() < 0x80 {
Expand Down Expand Up @@ -193,6 +200,29 @@ fn ofd_stamp_renders_the_mini_document_page() {
assert_eq!(pixel(&mut surface, 14, 8), [255, 255, 255, 255]);
}

#[test]
fn a_large_seal_raster_within_the_document_limits_is_not_called_over_budget() {
// The stamp raster decode must not report a budget error for a picture that
// fits the document's decoded-image limits: the image crate's allocation
// budget covers its own intermediate buffers on top of the finished RGBA8
// raster, so budgeting it with the raster size alone rejects pictures that
// are well inside the limits. A broken picture still leaves the page blank
// (see the next test); this one distinguishes a decode failure from a budget
// failure by pixel colour.
let mut surface = render(
r#"<ofd:StampAnnot PageRef="10" ID="01" Boundary="2 2 6 4"/>"#,
"png",
LARGE_PNG,
);
let inside = pixel(&mut surface, 4, 4);
assert_eq!(inside[3], 255, "stamp pixel stayed transparent: {inside:?}");
assert_eq!(
(&inside[0], &inside[1], &inside[2]),
(&0x20, &0x40, &0x60),
"stamp pixel is not the fixture's colour: {inside:?}"
);
}

#[test]
fn broken_or_unsupported_stamps_are_skipped_silently() {
let mut surface = render(
Expand Down
30 changes: 30 additions & 0 deletions crates/rofd-render/tests/fixtures/images/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,33 @@ b98b78b351b1511a0b9c5a953334bf2e801f3bf8c6922173d4590613bdb9f9a8 asymmetric-rgb
86367e4786fe0911e54a3c7567e1cbd1f68557de199b1f72ffcf43b184c27982 asymmetric-rgb.gif
e6b956ad3682993147602bda9ff31c4a6755ebf7cf5b4e0f9015069f240e93ec asymmetric-rgb.tiff
```

`seal-16bit-boundary.png` is a programmatically generated 6000 by 6000 16-bit
RGBA test image, filled with the single opaque colour `#204060` at 16-bit
precision (every 16-bit channel is the 8-bit value repeated). It contains no
third-party artwork or other licensed material. It was generated with CPython
3.14 in a few lines (a zero filter byte per row, `zlib.compress` at level 9, and
`zlib.crc32` for the PNG chunks), with no image library involved:

```text
python3 - <<'EOF'
import struct, zlib
w = h = 6000
raw = bytearray()
for _ in range(h):
raw.append(0)
raw.extend(b'\x20\x20\x40\x40\x60\x60\xff\xff' * w)
chunk = lambda t, d: struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t + d) & 0xffffffff)
open('seal-16bit-boundary.png', 'wb').write(
b'\x89PNG\r\n\x1a\n'
+ chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 16, 6, 0, 0, 0))
+ chunk(b'IDAT', zlib.compress(bytes(raw), 9))
+ chunk(b'IEND', b''))
EOF
```

SHA-256 checksums:

```text
264bfb51e016bb80c612db7dbab8d6070b91e5b4b4b0f8f7f8a2012842496c55 seal-16bit-boundary.png
```
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading