diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 2ce2d986..8096888b 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -1457,6 +1457,21 @@ trailer << /Root 1 0 R >> assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45"); assert_eq!(pretty_date("yesterday"), "yesterday"); } + + #[test] + fn jspdf_high_precision_mediabox_does_not_overflow_dimensions() { + let pdf = b"%PDF-1.3 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 3874.9606299212600788 5493.5433070866147318] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(std::sync::Arc::new(pdf.to_vec()), None).expect("opens"); + assert_eq!(info.pages.len(), 1); + let p = &info.pages[0]; + assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width); + assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height); + } } /// "1.7" from the parser's version name ("Pdf17", "V1_7", …). diff --git a/vendor/README.md b/vendor/README.md index eb708e41..636f6029 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -12,7 +12,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | 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-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) | (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` | +| 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) | (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) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11 | Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`. diff --git a/vendor/hayro-syntax/src/object/number.rs b/vendor/hayro-syntax/src/object/number.rs index a2847cd7..bd37b19c 100644 --- a/vendor/hayro-syntax/src/object/number.rs +++ b/vendor/hayro-syntax/src/object/number.rs @@ -125,26 +125,30 @@ fn read_inner(r: &mut Reader<'_>) -> Option { _ => false, }; + // PdfCraft patch: high-precision float mantissa overflow (jsPDF double-precision coordinates). + // Digits that don't fit in `mantissa` are dropped: past the dot they are only precision; + // before it, each one scales the value by ten (`extra_int_digits`). let mut mantissa: u64 = 0; let mut has_dot = false; let mut decimal_shift: u32 = 0; let mut has_digits = false; + let mut extra_int_digits: u32 = 0; loop { match r.peek_byte() { Some(b'0'..=b'9') => { - let d = r.read_byte().unwrap(); - mantissa = mantissa - // Using `saturating` would arguably be better here, but - // profiling showed that it seems to be more expensive, at least - // on ARM. Since such large numbers shouldn't appear anyway, - // it doesn't really matter a lot what mode we use. - .wrapping_mul(10) - .wrapping_add((d - b'0') as u64); + let d = (r.read_byte().unwrap() - b'0') as u64; has_digits = true; - if has_dot { - decimal_shift += 1; + if let Some(next) = mantissa.checked_mul(10).and_then(|m| m.checked_add(d)) { + mantissa = next; + if has_dot { + decimal_shift += 1; + } + } else if !has_dot { + extra_int_digits = extra_int_digits.saturating_add(1); } + // If has_dot and adding the digit would overflow u64: + // Safely ignore excess trailing fractional digits (do not add, do not increment decimal_shift). } Some(b'.') if !has_dot => { r.forward(); @@ -174,15 +178,34 @@ fn read_inner(r: &mut Reader<'_>) -> Option { return None; } + // A value whose integer part overflowed `u64` is a real, scaled by the digits dropped and + // kept finite. + let scaled = |value: f64| -> f64 { + let value = if extra_int_digits > 0 { value * powi_f64(10.0, extra_int_digits) } else { value }; + if value.is_finite() { value } else { f64::MAX } + }; + + if !has_dot && extra_int_digits > 0 { + let value = scaled(mantissa as f64); + return Some(Number(InternalNumber::Real(if negative { -value } else { value }))); + } + if !has_dot { - let value = if negative { - (mantissa as i64).wrapping_neg() + let value = if mantissa <= i64::MAX as u64 { + let i = mantissa as i64; + if negative { -i } else { i } + } else if negative && mantissa == (i64::MIN as u64) { + i64::MIN } else { - mantissa as i64 + let mut val = mantissa as f64; + if negative { + val = -val; + } + return Some(Number(InternalNumber::Real(val))); }; Some(Number(InternalNumber::Integer(value))) } else { - let mut value = mantissa as f64; + let mut value = scaled(mantissa as f64); if decimal_shift > 0 { if decimal_shift < POWERS_OF_10.len() as u32 { @@ -537,4 +560,37 @@ mod tests { 4294966260 ); } + + #[test] + fn high_precision_float_does_not_overflow_mantissa() { + let num1 = Reader::new("3874.9606299212600788".as_bytes()) + .read_without_context::() + .unwrap(); + assert!((num1 - 3874.96062992126).abs() < 1e-6, "got {num1}"); + + let num2 = Reader::new("5493.5433070866147318".as_bytes()) + .read_without_context::() + .unwrap(); + assert!((num2 - 5493.543307086615).abs() < 1e-6, "got {num2}"); + + // Verify extreme fractional precision (50 decimal places) truncates safely + let num3 = Reader::new("0.12345678901234567890123456789012345678901234567890".as_bytes()) + .read_without_context::() + .unwrap(); + assert!((num3 - 0.12345678901234568).abs() < 1e-15, "got {num3}"); + + // Integer digits past `u64` scale the value instead of being dropped. + let num4 = Reader::new("123456789012345678901.5".as_bytes()) + .read_without_context::() + .unwrap(); + assert!((num4 / 1.2345678901234568e20 - 1.0).abs() < 1e-12, "got {num4}"); + let num5 = Reader::new("-123456789012345678901".as_bytes()) + .read_without_context::() + .unwrap(); + assert!((num5 / -1.2345678901234568e20 - 1.0).abs() < 1e-12, "got {num5}"); + // Hundreds of digits stay finite. + let huge = "9".repeat(400); + let num6 = Reader::new(huge.as_bytes()).read_without_context::().unwrap(); + assert!(num6.is_finite() && num6 > 1e300, "got {num6}"); + } }