M2: bound image resampling buffers before allocation (#592)

Check source, destination, convolution and filter-plan sizes before allocation. Preserve original image geometry when a resize exceeds the policy, while allowing valid wide sources to shrink.

Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Streetblock 2026-10-10 00:53:56 +02:00 committed by GitHub
parent 04f6b1a634
commit 1f0466fcae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 538 additions and 167 deletions

1
Cargo.lock generated
View File

@ -2285,6 +2285,7 @@ dependencies = [
"hayro-interpret",
"image",
"kurbo 0.13.1",
"log",
"pic-scale",
"vello_cpu 0.0.8",
]

View File

@ -186,6 +186,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, image resampling bounds):** Image resampling checks source buffers, finite transforms, target RGBA sizes, intermediate scratch dimensions and estimated filter work before planning or allocating; wide valid sources may shrink before backend side limits. Oversized or failed resampling retains the original pixels and placement when the source fits the backend; invalid sources, unrepresentable fallbacks and failed staging/output reserves are skipped with a logged warning. Small synthetic metadata regressions and an anisotropically transformed image cover the guards and fallback. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, Windows Auto):** Neutral launcher locales (`C`, `C.UTF-8`, `POSIX`) no longer select English ahead of the Windows preferred display-language list. Explicit supported locale overrides retain their priority. Regression tests use existing catalogs and cover neutral/mixed overrides, unsupported first display tags and multiple preferred Windows languages. Catalogs and other platforms are unchanged; overall effort estimate remains about 30–35%.

View File

@ -1493,6 +1493,147 @@ mod tests {
use super::*;
// Metadata-only: the test budget is 128 pixels and no image buffer is allocated.
#[test]
fn image_resampling_rejects_anisotropic_target_growth() {
assert_eq!(hayro::image_resampling_size(16, 1, 1, 16, 0.5, 32.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 32.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 1, 4, 64, 0.5, 32.0, 128), None);
}
#[test]
fn image_resampling_bounds_intermediate_before_planning() {
// Source and destination are each 128 pixels, but their crossed dimensions are 256.
assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 0.5, 2.0, 128), None);
}
#[test]
fn image_resampling_rejects_invalid_sources_and_scales() {
assert_eq!(hayro::image_resampling_size(4, 4, 3, 47, 0.5, 0.5, 128), None);
assert_eq!(hayro::image_resampling_size(4, 4, 3, 49, 0.5, 0.5, 128), None);
for scale in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0] {
assert_eq!(hayro::image_resampling_size(4, 4, 3, 48, 0.5, scale, 128), None);
}
}
#[test]
fn image_resampling_keeps_valid_sizes_and_exact_limits() {
assert_eq!(hayro::image_resampling_size(16, 8, 1, 128, 0.5, 0.5, 128), Some((8, 4)));
assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 8.0, 128), Some((8, 8)));
assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 1.0, 1.0, 128), Some((16, 8)));
assert_eq!(hayro::image_resampling_size(0, 8, 1, 0, 1.0, 1.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 9, 1, 144, 1.0, 1.0, 128), None);
assert_eq!(hayro::image_resampling_size(65_536, 1, 1, 65_536, 1.0, 1.0, u64::MAX), None);
for channels in [1, 3, 4] {
assert_eq!(hayro::image_resampling_size(65_536, 1, channels, 65_536 * channels, 1.0 / 4096.0, 1.0, 65_536), Some((16, 1)));
}
assert_eq!(hayro::image_resampling_size(65_535, 1, 1, 65_535, 1.0, 1.0, 65_535), Some((65_535, 1)));
assert_eq!(hayro::image_resampling_size((1 << 20) + 1, 1, 1, (1 << 20) + 1, 1.0 / 4096.0, 1.0, 1 << 28), None);
}
#[test]
fn image_resampling_padded_backend_dimensions_stay_checked() {
// A Type 3 image's two-pixel frame may reach u16::MAX exactly, never wrap to zero.
assert_eq!(hayro::image_resampling_size(65_531 + 4, 1 + 4, 4, 65_535 * 5 * 4, 1.0, 1.0, 65_535 * 5), Some((65_535, 5)));
assert_eq!(hayro::image_resampling_size(65_531 + 4, 1 + 4, 4, 65_535 * 5 * 4, 1.0, 1.0, 65_535 * 5 - 1), None);
assert_eq!(hayro::image_resampling_size(65_532 + 4, 1 + 4, 4, 65_536 * 5 * 4, 1.0, 1.0, 1 << 28), None);
}
fn strip_image_pdf(width: u32, body: &str, space: &str, encoded: &str, alpha: Option<&str>) -> Vec<u8> {
let (mask_ref, mask_obj) = match alpha {
Some(data) => (
"/SMask 6 0 R",
format!(
"6 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{data}>\nendstream endobj\n",
data.len() + 1
),
),
None => ("", String::new()),
};
format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 4] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{body}endstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height 1 /ColorSpace /{space} /BitsPerComponent 8 {mask_ref} /Filter /ASCIIHexDecode /Length {} >> stream\n{encoded}>\nendstream endobj\n\
{mask_obj}trailer << /Root 1 0 R >>\n%%EOF", body.len(), encoded.len() + 1,
).into_bytes()
}
// Safe on the original renderer too: 192 KiB RGB / 64 KiB gray sources shrink to 16 pixels.
#[test]
fn image_resampling_wide_sources_shrink_before_backend_side_limits() {
for (space, encoded, expected) in
[("DeviceRGB", "ff0000".repeat(65_536), [255, 0, 0, 255]), ("DeviceGray", "7f".repeat(65_536), [127, 127, 127, 255])]
{
let pdf = strip_image_pdf(65_536, "q 16 0 0 1 2 2 cm /Im0 Do Q\n", space, &encoded, None);
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{space}: {:?}", page.error);
assert_eq!((page.width, page.height), (20, 4));
let pixel = |x: usize| &page.rgba[(20 + x) * 4..][..4];
assert_eq!(pixel(1), &[255, 255, 255, 255], "{space}: left placement");
assert_eq!(pixel(3), &expected, "{space}: source image was not dropped");
assert_eq!(pixel(17), &expected, "{space}: right placement");
assert_eq!(pixel(19), &[255, 255, 255, 255], "{space}: right placement");
}
}
#[test]
fn image_resampling_wide_transparent_sources_still_shrink() {
let data = "ff0000".repeat(65_536);
let alpha = "7f".repeat(65_536);
let pdf = strip_image_pdf(65_536, "q 16 0 0 1 2 2 cm /Im0 Do Q\n", "DeviceRGB", &data, Some(&alpha));
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (20, 4));
assert_eq!(&page.rgba[(20 + 3) * 4..][..4], &[255, 128, 128, 255]);
}
#[test]
fn image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement() {
let data = "ff0000".repeat(16);
let alpha = "7f".repeat(8);
let pdf = strip_image_pdf(16, "q 16 0 0 1 2 2 cm /Im0 Do Q\n", "DeviceRGB", &data, Some(&alpha));
let pdf = String::from_utf8(pdf)
.unwrap()
.replace("6 0 obj << /Type /XObject /Subtype /Image /Width 16", "6 0 obj << /Type /XObject /Subtype /Image /Width 8");
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (20, 4));
let pixel = |x: usize| &page.rgba[(20 + x) * 4..][..4];
assert_eq!(pixel(1), &[255, 255, 255, 255]);
assert_eq!(pixel(3), &[255, 128, 128, 255]);
assert_eq!(pixel(17), &[255, 128, 128, 255]);
assert_eq!(pixel(19), &[255, 255, 255, 255]);
}
// GREEN-only integration: the original renderer would attempt GiB buffers. The source is
// only 96 KiB, and the fixed guard rejects the target before the resampling plan/allocation.
#[test]
fn resampling_fallback_preserves_original_geometry_and_pixels() {
let data = "ff0000".repeat(32_767);
let body = "q 16383.5 0 0 1000000000 2 2 cm /Im0 Do Q\n";
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{body}endstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 32767 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{data}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
body.len(),
data.len() + 1,
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (20, 20));
let pixel = |x: usize, y: usize| &page.rgba[(y * 20 + x) * 4..][..4];
assert_eq!(pixel(0, 5), &[255, 255, 255, 255]);
assert_eq!(pixel(3, 5), &[255, 0, 0, 255]);
assert_eq!(pixel(19, 5), &[255, 0, 0, 255]);
}
const ONE_PAGE: &[u8] = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj

2
vendor/README.md vendored

File diff suppressed because one or more lines are too long

View File

@ -60,6 +60,10 @@ default-features = false
[dependencies.kurbo]
version = "0.13.0"
# PdfCraft patch: report image resampling fallbacks through the application's logger.
[dependencies.log]
version = "0.4"
[dependencies.pic-scale]
version = "0.7.9"
features = [

View File

@ -16,6 +16,8 @@ image = { workspace = true, features = ["png"] }
bytemuck = { workspace = true, features = ["bytemuck_derive"] }
vello_cpu = { workspace = true }
pic-scale = { workspace = true }
# PdfCraft patch: report image resampling fallbacks through the application's logger.
log = { workspace = true }
[dev-dependencies]
log = { workspace = true }

View File

@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode};
mod renderer;
/// PdfCraft patch: exported for its regression test.
pub use renderer::tiling_cell_scale;
pub use renderer::{image_resampling_size, tiling_cell_scale};
/// A cache used by the renderer.
///

View File

@ -11,6 +11,7 @@ use pic_scale::{
ImageSize, ImageStore, ImageStoreMut, PicScaleError, Resampling, ResamplingFunction, Scaler,
};
use std::collections::HashMap;
use std::mem::size_of;
use std::rc::Rc;
use std::sync::Arc;
use vello_cpu::color::palette::css::BLACK;
@ -20,10 +21,161 @@ use vello_cpu::{
Image, ImageSource, Mask, PaintType, Pixmap, RenderContext, RenderSettings, peniko,
};
/// PdfCraft patch: largest image drawn, in pixels (2^28 ≈ 268 MP, well above real page images).
const MAX_IMAGE_PIXELS: u64 = 1 << 28;
/// PdfCraft patch: source axes allowed into CatmullRom planning. Unlike the final pixmap,
/// sources need not fit u16: a 65536-pixel strip can safely shrink to a 16-pixel image.
/// This resource cap follows the existing CCITT column limit; the filter-work estimate below
/// additionally bounds tables/scratch before entering the opaque PicScale planner.
const MAX_RESAMPLING_SOURCE_SIDE: u32 = 1 << 20;
const MAX_RESAMPLING_PLAN_BYTES: usize = 128 << 20;
/// PdfCraft patch: source, intermediate and RGBA byte lengths, independent of backend sides.
fn source_byte_len(width: u32, height: u32, channels: usize, max_pixels: u64) -> Option<usize> {
if width == 0 || height == 0 || !matches!(channels, 1 | 3 | 4) {
return None;
}
let pixels = u64::from(width).checked_mul(u64::from(height))?;
if pixels > max_pixels {
return None;
}
let pixels = usize::try_from(pixels).ok()?;
let rgba_len = pixels.checked_mul(4)?;
if rgba_len > isize::MAX as usize {
return None;
}
pixels.checked_mul(channels)
}
/// PdfCraft patch: only final pixmaps/padded glyphs need to fit the backend's u16 sides.
fn image_byte_len(width: u32, height: u32, channels: usize, max_pixels: u64) -> Option<usize> {
if width > u32::from(u16::MAX) || height > u32::from(u16::MAX) {
return None;
}
source_byte_len(width, height, channels, max_pixels)
}
/// PdfCraft patch: conservative CatmullRom planning work for one axis in PicScale 0.7.12.
/// Its four-tap kernel grows on downscaling. Include float/32-aligned i16 weights, temporary
/// float/f64/usize tap arrays and two copies of the bounds. This is a size estimate, not a
/// fallible reserve inside PicScale or a bound on total renderer memory.
fn filter_plan_bytes(source: u32, target: u32) -> Option<usize> {
if source == target {
return Some(0);
}
let (source, target) = (usize::try_from(source).ok()?, usize::try_from(target).ok()?);
if target == 0 {
return None;
}
let taps = source.checked_mul(4)?.div_ceil(target).max(4);
let aligned_taps = taps.div_ceil(32).checked_mul(32)?;
let float_weights = taps.checked_mul(target)?.checked_mul(size_of::<f32>())?;
let fixed_weights = aligned_taps
.checked_mul(target)?
.checked_mul(size_of::<i16>())?;
let tap_scratch = taps.checked_mul(size_of::<f32>() + size_of::<f64>() + size_of::<usize>())?;
let bounds = target.checked_mul(4)?.checked_mul(size_of::<usize>())?;
float_weights
.checked_add(fixed_weights)?
.checked_add(tap_scratch)?
.checked_add(bounds)
}
/// PdfCraft patch: alpha-aware RGBA plans also need source-sized premultiplication scratch.
/// Bound the combined scratch before planning, including a conservative crossed intermediate
/// (or four rows for a short target) when both convolution axes change.
fn resampling_scratch_byte_len(
source: (u32, u32),
target: (u32, u32),
channels: usize,
max_pixels: u64,
) -> Option<usize> {
if source == target {
return Some(0);
}
let alpha = if channels == 4 {
source_byte_len(source.0, source.1, channels, max_pixels)?
} else {
0
};
let filter = if source.0 != target.0 && source.1 != target.1 {
source_byte_len(source.0, target.1.max(4), channels, max_pixels)?
} else {
0
};
let len = alpha.checked_add(filter)?;
let limit = max_pixels.saturating_mul(4).min(isize::MAX as u64);
if u64::try_from(len).ok()? > limit {
return None;
}
Some(len)
}
/// PdfCraft patch: validate output, crossed intermediate and filter-work bounds before planning.
/// Source width × target height also bounds PicScale's smaller single-threaded four-row scratch.
fn resampling_byte_len(
source: (u32, u32),
target: (u32, u32),
channels: usize,
data_len: usize,
max_pixels: u64,
) -> Option<usize> {
if source_byte_len(source.0, source.1, channels, max_pixels)? != data_len {
return None;
}
let target_len = image_byte_len(target.0, target.1, channels, max_pixels)?;
resampling_scratch_byte_len(source, target, channels, max_pixels)?;
source_byte_len(source.0, target.1, channels, max_pixels)?;
if source.0 > MAX_RESAMPLING_SOURCE_SIDE || source.1 > MAX_RESAMPLING_SOURCE_SIDE {
return None;
}
let plan_bytes = filter_plan_bytes(source.0, target.0)?
.checked_add(filter_plan_bytes(source.1, target.1)?)?;
if plan_bytes > MAX_RESAMPLING_PLAN_BYTES {
return None;
}
Some(target_len)
}
/// PdfCraft patch: image dimensions selected before allocating resampling buffers.
/// Exported for PdfCraft's small, metadata-only regression tests. `None` rejects invalid
/// sources/scales or an over-budget target/intermediate; the caller may retain a valid source.
#[doc(hidden)]
pub fn image_resampling_size(
width: u32,
height: u32,
channels: usize,
data_len: usize,
x_scale: f32,
y_scale: f32,
max_pixels: u64,
) -> Option<(u32, u32)> {
if !x_scale.is_finite() || !y_scale.is_finite() || x_scale <= 0.0 || y_scale <= 0.0 {
return None;
}
let target = if x_scale < 1.0 || y_scale < 1.0 {
let side = |size: u32, scale: f32| {
(size as f32 * scale)
.ceil()
.max(1.0)
.min((u16::MAX / 2) as f32) as u32
};
(side(width, x_scale), side(height, y_scale))
} else {
(width, height)
};
resampling_byte_len((width, height), target, channels, data_len, max_pixels)?;
Some(target)
}
/// PdfCraft patch: allocation failures in image conversion/resampling keep the renderer alive.
fn image_buffer(len: usize) -> Option<Vec<u8>> {
let mut out = Vec::new();
out.try_reserve_exact(len).ok()?;
Some(out)
}
/// PdfCraft patch: most pieces one dashed stroke may be cut into. Stroke expansion keeps every
/// dash, so a pattern that is tiny next to its path asks for billions: a fuzzed `/D [[11] 0]` on
/// a line from x = 92234775807 allocated over 5 GB, and `[0.000001] 0 d` on a 20 pt line over
@ -174,6 +326,20 @@ impl Renderer {
}
fn draw_image_with_alpha_mask(&mut self, image_data: ImageData, alpha_data: LumaData) {
// PdfCraft patch: mismatched masks need a dummy RGB staging image. Validate and
// reserve it before creating/pushing any mask, so failure skips the whole image.
let Some(len) = source_byte_len(alpha_data.width, alpha_data.height, 3, MAX_IMAGE_PIXELS)
else {
log::warn!(
"Image alpha-mask staging dimensions exceeded the buffer limit; image skipped"
);
return;
};
let Some(mut dummy_rgb) = image_buffer(len) else {
log::warn!("Image alpha-mask staging buffer could not be allocated; image skipped");
return;
};
dummy_rgb.resize(len, 0);
let mask = {
let transform = *self.ctx.transform()
* Affine::scale_non_uniform(
@ -196,7 +362,7 @@ impl Renderer {
};
let mut mask_pix = Pixmap::new(self.ctx.width(), self.ctx.height());
let rgb_data = ImageData::Rgb(RgbData {
data: vec![0; alpha_data.width as usize * alpha_data.height as usize * 3],
data: dummy_rgb,
width: alpha_data.width,
height: alpha_data.height,
interpolate: alpha_data.interpolate,
@ -228,8 +394,8 @@ impl Renderer {
new_width: u32,
new_height: u32,
pixel_format: ImagePixelFormat,
) -> Vec<u8> {
match pixel_format {
) -> (Vec<u8>, u32, u32) {
let resized = match pixel_format {
ImagePixelFormat::Luma => self.resize_image_data_impl::<1>(
data,
src_width,
@ -260,6 +426,15 @@ impl Renderer {
scaler.plan_rgba_resampling(source_size, target_size, true)
},
),
};
match resized {
Ok(out) => (out, new_width, new_height),
Err(original) => {
log::warn!(
"Image resampling failed or exceeded its buffer limit; falling back to source data"
);
(original, src_width, src_height)
}
}
}
@ -275,213 +450,259 @@ impl Renderer {
ImageSize,
ImageSize,
) -> Result<Arc<Resampling<u8, N>>, PicScaleError>,
) -> Vec<u8> {
let source_size = ImageSize::new(src_width as usize, src_height as usize);
let target_size = ImageSize::new(new_width as usize, new_height as usize);
let src = ImageStore::<u8, N>::from_slice(&data, src_width as usize, src_height as usize)
.unwrap();
let mut out = vec![0; new_width as usize * new_height as usize * N];
let mut dst =
ImageStoreMut::<u8, N>::from_slice(&mut out, new_width as usize, new_height as usize)
.unwrap();
let plan = plan(&self.scaler, source_size, target_size).unwrap();
plan.resample(&src, &mut dst).unwrap();
out
) -> Result<Vec<u8>, Vec<u8>> {
// PdfCraft patch: validate before constructing a plan or allocating its output/scratch.
// Returning the owned input preserves both its pixels and dimensions on any failure.
let resized = (|| {
let len = resampling_byte_len(
(src_width, src_height),
(new_width, new_height),
N,
data.len(),
MAX_IMAGE_PIXELS,
)?;
let source_size = ImageSize::new(src_width as usize, src_height as usize);
let target_size = ImageSize::new(new_width as usize, new_height as usize);
let src = ImageStore::<u8, N>::from_slice(&data, source_size.width, source_size.height)
.ok()?;
let plan = plan(&self.scaler, source_size, target_size).ok()?;
let scratch_len = plan.scratch_size();
let scratch_bound = resampling_scratch_byte_len(
(src_width, src_height),
(new_width, new_height),
N,
MAX_IMAGE_PIXELS,
)?;
if scratch_len > scratch_bound {
return None;
}
let mut out = image_buffer(len)?;
out.resize(len, 0);
let mut dst =
ImageStoreMut::<u8, N>::from_slice(&mut out, target_size.width, target_size.height)
.ok()?;
let mut scratch = image_buffer(scratch_len)?;
scratch.resize(scratch_len, 0);
plan.resample_with_scratch(&src, &mut dst, &mut scratch)
.ok()?;
Some(out)
})();
match resized {
Some(out) => Ok(out),
None => Err(data),
}
}
fn draw_image(&mut self, image_data: ImageData, alpha_data: Option<LumaData>) {
let cur_transform = *self.ctx.transform();
let mut additional_transform = Affine::IDENTITY;
let (x_scale, y_scale) = {
let (x, y) = x_y_advances(&cur_transform);
(x.length() as f32, y.length() as f32)
};
let mut img_width = image_data.width();
let mut img_height = image_data.height();
// PdfCraft patch: skip images with absurd dimensions (a fuzzed inline image claimed
// /W 4294967295 over four bytes of data; resampling it never finished).
if img_width == 0 || img_height == 0 || u64::from(img_width) * u64::from(img_height) > MAX_IMAGE_PIXELS {
// PdfCraft patch: do not send non-finite or collapsed geometry to the rasterizer.
if !cur_transform.as_coeffs().iter().all(|v| v.is_finite())
|| !x_scale.is_finite()
|| !y_scale.is_finite()
|| x_scale <= 0.0
|| y_scale <= 0.0
{
log::warn!("Image has an invalid transform and was skipped");
return;
}
let (source_width, source_height) = (image_data.width(), image_data.height());
let channels = match &image_data {
ImageData::Luma(_) => 1,
ImageData::Rgb(_) => 3,
};
let data_len = match &image_data {
ImageData::Luma(a) => a.data.len(),
ImageData::Rgb(a) => a.data.len(),
};
if source_byte_len(source_width, source_height, channels, MAX_IMAGE_PIXELS)
!= Some(data_len)
{
log::warn!("Image has invalid or over-limit source dimensions/data and was skipped");
return;
}
let interpolate = image_data.interpolate();
if let Some(a) = &alpha_data
&& (a.width != img_width || a.height != img_height || a.interpolate != interpolate)
{
return self.draw_image_with_alpha_mask(image_data, alpha_data.unwrap());
if let Some(a) = &alpha_data {
if source_byte_len(a.width, a.height, 1, MAX_IMAGE_PIXELS) != Some(a.data.len()) {
log::warn!("Image has invalid or over-limit alpha dimensions/data and was skipped");
return;
}
if a.width != source_width || a.height != source_height || a.interpolate != interpolate
{
if let Some(alpha) = alpha_data {
self.draw_image_with_alpha_mask(image_data, alpha);
}
return;
}
}
let mut quality = if interpolate {
ImageQuality::Medium
} else {
ImageQuality::Low
};
let has_alpha = alpha_data.is_some();
let mut may_have_transparency = has_alpha || self.force_images_may_have_transparency();
let needs_resize = x_scale < 1.0 || y_scale < 1.0;
let (new_width, new_height) = if needs_resize {
let w = (img_width as f32 * x_scale)
.ceil()
.max(1.0)
.min((u16::MAX / 2) as f32) as u32;
let h = (img_height as f32 * y_scale)
.ceil()
.max(1.0)
.min((u16::MAX / 2) as f32) as u32;
if self.in_type3_glyph {
quality = ImageQuality::High;
let requested_resize = x_scale < 1.0 || y_scale < 1.0;
let (new_width, new_height) = match image_resampling_size(
source_width,
source_height,
channels,
data_len,
x_scale,
y_scale,
MAX_IMAGE_PIXELS,
) {
Some(target) => target,
None => {
if image_byte_len(source_width, source_height, 4, MAX_IMAGE_PIXELS).is_none() {
log::warn!(
"Image resampling exceeded its buffer limit and the original resolution cannot fit the backend; image skipped"
);
return;
}
log::warn!(
"Image resampling exceeded its buffer limit; retaining the original resolution"
);
(source_width, source_height)
}
(w, h)
} else {
(img_width, img_height)
};
if requested_resize && self.in_type3_glyph {
quality = ImageQuality::High;
}
let needs_resize = (new_width, new_height) != (source_width, source_height);
// For luma images without alpha, we can resize as single-channel and
// expand to RGBA afterwards, which is ~4x faster.
let mut rgba_data = if matches!(&image_data, ImageData::Luma(_)) && !has_alpha {
// We cannot lift this up due to borrowing issues.
let ImageData::Luma(luma) = image_data else {
unreachable!()
};
let luma_data = if !needs_resize {
luma.data
} else {
let resized = self.resize_image_data(
luma.data,
img_width,
img_height,
new_width,
new_height,
ImagePixelFormat::Luma,
);
additional_transform = Affine::scale_non_uniform(
img_width as f64 / new_width as f64,
img_height as f64 / new_height as f64,
);
img_width = new_width;
img_height = new_height;
resized
};
luma_data
.iter()
.flat_map(|g| [*g, *g, *g, 255])
.collect::<Vec<_>>()
} else if matches!(&image_data, ImageData::Rgb(_)) && !has_alpha && needs_resize {
let ImageData::Rgb(rgb) = image_data else {
unreachable!()
};
let resized = self.resize_image_data(
rgb.data,
img_width,
img_height,
// Preserve the single-channel/RGB fast paths. With alpha, expand directly to RGBA;
// every source and alpha length was validated before zipping or allocating.
let (data, format) = match (image_data, alpha_data) {
(ImageData::Luma(luma), None) => (luma.data, ImagePixelFormat::Luma),
(ImageData::Rgb(rgb), None) => (rgb.data, ImagePixelFormat::Rgb),
(image, Some(alpha)) => {
let Some(len) = source_byte_len(source_width, source_height, 4, MAX_IMAGE_PIXELS)
else {
return;
};
let Some(mut rgba) = image_buffer(len) else {
log::warn!(
"Image RGBA buffer could not be allocated and the image was skipped"
);
return;
};
match image {
ImageData::Luma(luma) => {
rgba.extend(
luma.data
.iter()
.zip(alpha.data)
.flat_map(|(g, a)| [*g, *g, *g, a]),
);
}
ImageData::Rgb(rgb) => {
rgba.extend(
rgb.data
.as_chunks::<3>()
.0
.iter()
.zip(alpha.data)
.flat_map(|([r, g, b], a)| [*r, *g, *b, a]),
);
}
}
(rgba, ImagePixelFormat::Rgba)
}
};
let (data, mut img_width, mut img_height) = if needs_resize {
self.resize_image_data(
data,
source_width,
source_height,
new_width,
new_height,
ImagePixelFormat::Rgb,
);
additional_transform = Affine::scale_non_uniform(
img_width as f64 / new_width as f64,
img_height as f64 / new_height as f64,
);
img_width = new_width;
img_height = new_height;
let mut out = Vec::with_capacity((img_width * img_height) as usize * 4);
for px in resized.chunks_exact(3) {
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
out
format,
)
} else {
let (rgb_data, alpha_data) = match image_data {
ImageData::Rgb(rgb) => (rgb.data, alpha_data.map(|a| a.data)),
ImageData::Luma(luma) => {
let rgb = luma
.data
.iter()
.flat_map(|g| [*g, *g, *g])
.collect::<Vec<_>>();
(rgb, alpha_data.map(|a| a.data))
}
};
let rgba_data = match alpha_data {
None => rgb_data
.chunks_exact(3)
.flat_map(|rgb| [rgb[0], rgb[1], rgb[2], 255])
.collect::<Vec<_>>(),
Some(alpha) => rgb_data
.chunks_exact(3)
.zip(alpha)
.flat_map(|(rgb, a)| [rgb[0], rgb[1], rgb[2], a])
.collect::<Vec<_>>(),
};
if !needs_resize {
rgba_data
} else {
let resized = self.resize_image_data(
rgba_data,
img_width,
img_height,
new_width,
new_height,
ImagePixelFormat::Rgba,
);
additional_transform = Affine::scale_non_uniform(
img_width as f64 / new_width as f64,
img_height as f64 / new_height as f64,
);
img_width = new_width;
img_height = new_height;
resized
}
(data, source_width, source_height)
};
if image_byte_len(img_width, img_height, 4, MAX_IMAGE_PIXELS).is_none() {
log::warn!(
"Image resampling failed and the original resolution cannot fit the backend; image skipped"
);
return;
}
let mut additional_transform = Affine::scale_non_uniform(
source_width as f64 / img_width as f64,
source_height as f64 / img_height as f64,
);
let mut rgba_data = if matches!(format, ImagePixelFormat::Rgba) {
data
} else {
let Some(len) = image_byte_len(img_width, img_height, 4, MAX_IMAGE_PIXELS) else {
return;
};
let Some(mut rgba) = image_buffer(len) else {
log::warn!("Image RGBA buffer could not be allocated and the image was skipped");
return;
};
match format {
ImagePixelFormat::Luma => rgba.extend(data.iter().flat_map(|g| [*g, *g, *g, 255])),
ImagePixelFormat::Rgb => rgba.extend(
data.as_chunks::<3>()
.0
.iter()
.flat_map(|[r, g, b]| [*r, *g, *b, 255]),
),
ImagePixelFormat::Rgba => {} // Handled above.
}
rgba
};
if has_alpha {
let (chunks, _) = rgba_data.as_chunks_mut::<4>();
for chunk in chunks {
*chunk = AlphaColor::from_rgba8(chunk[0], chunk[1], chunk[2], chunk[3])
let [r, g, b, a] = *chunk;
*chunk = AlphaColor::from_rgba8(r, g, b, a)
.premultiply()
.to_rgba8()
.to_u8_array();
}
}
// The problem is that by default, when applying a bilinear or bicubic scaling, we will
// sample pixels using an extend (pad/reflect/repeat). For glyphs, this is undesirable
// as the glyphs will look very bold. Therefore, for glyphs it is more desirable to sample
// a transparent pixel when reaching the border. Thus, we wrap glyphs in a transparent frame
// of pixel width 2.
// Type 3 glyphs get a transparent two-pixel frame for interpolation. Check the padded
// RGBA allocation and u16 dimensions too, before narrowing or growing either side.
if self.in_type3_glyph {
let mut padded_image = vec![];
padded_image.extend(vec![0; (4 * img_width as usize + 16) * 2]);
let (Some(width), Some(height)) = (img_width.checked_add(4), img_height.checked_add(4))
else {
return;
};
let Some(len) = image_byte_len(width, height, 4, MAX_IMAGE_PIXELS) else {
log::warn!(
"Image glyph padding exceeded its buffer limit and the image was skipped"
);
return;
};
let Some(mut padded) = image_buffer(len) else {
log::warn!("Image glyph padding could not be allocated and the image was skipped");
return;
};
let row_len = width as usize * 4; // Checked RGBA length above bounds every row.
padded.resize(row_len * 2, 0);
for row in rgba_data.chunks_exact(img_width as usize * 4) {
padded_image.extend([0; 8]);
padded_image.extend(row);
padded_image.extend([0; 8]);
padded.extend([0; 8]);
padded.extend_from_slice(row);
padded.extend([0; 8]);
}
padded_image.extend(vec![0; (4 * img_width as usize + 16) * 2]);
img_width += 4;
img_height += 4;
padded.resize(len, 0);
(img_width, img_height) = (width, height);
additional_transform *= Affine::translate((-2.0, -2.0));
may_have_transparency = true;
rgba_data = padded_image;
rgba_data = padded;
}
let pixmap = Pixmap::from_parts_with_opacity(
bytemuck::cast_vec(rgba_data),
img_width as u16,
img_height as u16,
may_have_transparency,
);
self.draw_pixmap(
Arc::new(pixmap),
quality,