From 1f0466fcaeba154d51fd958938d6cba00e1d484f Mon Sep 17 00:00:00 2001 From: Streetblock Date: Sat, 10 Oct 2026 00:53:56 +0200 Subject: [PATCH] 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 Co-authored-by: Brandon Thomas --- Cargo.lock | 1 + ROADMAP.md | 2 + crates/render/src/raster.rs | 141 +++++++++ vendor/README.md | 2 +- vendor/hayro/Cargo.toml | 4 + vendor/hayro/Cargo.toml.orig | 2 + vendor/hayro/src/lib.rs | 2 +- vendor/hayro/src/renderer.rs | 551 ++++++++++++++++++++++++----------- 8 files changed, 538 insertions(+), 167 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5da3f9f..1d7ab14c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2285,6 +2285,7 @@ dependencies = [ "hayro-interpret", "image", "kurbo 0.13.1", + "log", "pic-scale", "vello_cpu 0.0.8", ] diff --git a/ROADMAP.md b/ROADMAP.md index 80a4f931..a00e8933 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%. diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index bce9fefb..71dad3d0 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -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 { + 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 diff --git a/vendor/README.md b/vendor/README.md index baa1f23c..584d9206 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,7 +9,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices` | diff --git a/vendor/hayro/Cargo.toml b/vendor/hayro/Cargo.toml index cb05624d..86360dca 100644 --- a/vendor/hayro/Cargo.toml +++ b/vendor/hayro/Cargo.toml @@ -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 = [ diff --git a/vendor/hayro/Cargo.toml.orig b/vendor/hayro/Cargo.toml.orig index 1e60e6c0..36bdd8bb 100644 --- a/vendor/hayro/Cargo.toml.orig +++ b/vendor/hayro/Cargo.toml.orig @@ -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 } diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 7acc4851..3f702ee4 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -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. /// diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 89982c48..e23b77bc 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -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 { + 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 { + 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 { + 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::())?; + let fixed_weights = aligned_taps + .checked_mul(target)? + .checked_mul(size_of::())?; + let tap_scratch = taps.checked_mul(size_of::() + size_of::() + size_of::())?; + let bounds = target.checked_mul(4)?.checked_mul(size_of::())?; + 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 { + 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 { + 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> { + 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 { - match pixel_format { + ) -> (Vec, 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>, PicScaleError>, - ) -> Vec { - 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::::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::::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> { + // 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::::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::::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) { 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::>() - } 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::>(); - (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::>(), - Some(alpha) => rgb_data - .chunks_exact(3) - .zip(alpha) - .flat_map(|(rgb, a)| [rgb[0], rgb[1], rgb[2], a]) - .collect::>(), - }; - - 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,