* hayro-syntax: fix float mantissa overflow on high-precision coordinates (#199) jsPDF outputs double-precision floating-point coordinates with 16+ decimal places in /MediaBox. In hayro-syntax, read_inner accumulated float mantissa digits into u64 using wrapping arithmetic. Integer mantissas exceeding u64::MAX wrapped modulo 2^64, severely corrupting page dimensions (e.g., 3874.96 pt became 185.61 pt, rendering as a warped ribbon). Accumulation now uses checked arithmetic: trailing fractional digits exceeding u64 capacity are safely ignored without incrementing decimal_shift. This preserves full IEEE-754 f64 precision while preventing overflow for numbers with arbitrary numbers of decimal places. Includes: - Unit test in hayro-syntax with reported values and a 50-digit fraction. - Synthetic PDF regression test in pdfcraft-render verifying inspect(). - Vendored patch (6) documentation in vendor/README.md. Fixes #199 * Never crash: mesh shadings can't exhaust memory or run for minutes `cargo xtask fuzz` (with the dash, JBIG2 and decompression fixes in place) found a page whose Coons patch shading (type 6), a 147-byte stream with 1-bit coordinates decoded onto +-40000, took the renderer past 3 GB. hayro-interpret samples mesh shadings (types 4-7) pixel by pixel over each triangle's device-space bounding box into a map, with nothing but the u16 cast bounding it: up to 65536 x 65536 entries for one triangle. A synthetic single patch passed 6 GB in six seconds; ten thousand triangles that each cover half a 1000 x 1000 page ran for minutes. Two rounds of review of earlier versions found more: every patch was cut into 722 triangles (about 150 KB) before anything limited them; a lattice mesh with `/VerticesPerRow 0` pushed empty rows forever, and one with 4294967295 read the whole stream into one row; a mesh painted a thousand times was triangulated a thousand times; and the first caps cut content without a word. Vendored hayro-interpret patch (18): - `ShadingPattern::encode_within(area)`: the vendored hayro renderer passes the area it will actually draw (the path's bounding box on the canvas, which it already computes) plus a pixel for texels at a fractional edge, and triangles are sampled only inside it, so the map holds at most that area's pixels. - `TriangleSampler`: at most 8 visits per pixel of that area plus 4 per triangle in it. A triangle's bounding box is about twice its area and a sub-pixel triangle's is at most 2 x 2 pixels, so tiling meshes and small dense ones (a patch in a few pixels is 722 triangles) still shade completely; overlapping ones stop. - Coons and tensor-product patches are triangulated one at a time. - Meshes hold at most `MAX_MESH_PATCHES` (2^16; a 200 x 200 gradient mesh is 40 thousand) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, and a lattice needs two (ISO 32000-2 8.7.4.5.6) to 2^19 vertices per row. - A page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25; the 200 x 200 mesh is 29 million) mesh triangles in all, reset per page like the existing paint budget. - Each of these cuts logs a warning. `encode()` keeps its signature; with no area it samples within a fixed 2^24 visits. Synthetic regression tests in render: - `mesh_shadings_far_off_the_page_render`: a Coons patch and a triangle mesh decoded onto +-40000 render and the rest of the page draws; a patch on the page, the part of the huge one that covers it, and a patch filling a 4 x 4 pt rectangle as a pattern are all shaded. - `many_overlapping_mesh_triangles_finish`: 10,000 triangles each covering half of a 1000 x 1000 page finish, and are drawn. - `mesh_shadings_with_many_patches_or_no_rows_finish`: 40,000 patches beside the page, and a lattice with `/VerticesPerRow 0`. Red on main (out of memory, killed at 3.9 GB). Each part is pinned by a mutation: no visit budget stalls past 60 s; no clip leaves the covering patch unshaded; no per-triangle allowance leaves the small patch unshaded; triangulating all patches first runs out of memory (killed at 5.8 GB); no rows check runs out of memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Never crash: decompression bombs can't exhaust the renderer's memory The renderer's parser (vendored hayro-syntax) decoded Flate, LZW and RunLength streams with no output limit. A 2 MB page whose content stream inflates to 2 GB, or whose 8 x 8 image does, took `pdfcraft-cli render` and `text` past 3 GB in under a second, and a few more MB of file scale it further. On the desktop that is the first-page render of an emailed PDF, and the allocation-failure abort takes every open document with it. Our own layers already bound single streams (lopdf's load-time decoding at 256 MiB, cos at 1 GiB), so `info` stays at a few MB; the renderer path didn't. Random mutation almost never makes a high-ratio stream, so `cargo xtask fuzz` doesn't find this; found by hand while triaging the fuzz run. Vendored hayro-syntax patch (6): - `decode_limit` gives each filter an output limit, and Flate (both flate2 and the fallback inflater), LZW and RunLength never write past it (the buffer can still grow to under twice it): `MAX_DECODED_STREAM`, 256 MiB, lopdf's figure; or, for the last filter of an image whose size, bits per component and components are known, twice its pixel bytes (a PNG predictor tag byte per byte at most), capped at 1 GiB. Earlier filters in a chain produce compressed data and get the general limit. - `Stream::decoded_within(max)` for callers that budget. - A page's `/Contents` array shares one `MAX_DECODED_STREAM` (naming one bomb twenty times decoded 5 GB), reserving exactly what it appends. Vendored hayro-interpret patch (18): - The content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within `MAX_PAGE_CONTENT`, 512 MiB, reset per page like the existing paint budget. Forms nest fifty deep and each level kept its content while the next ran: a form painting itself around a 256 MiB stream would have held over 12 GB. Each level gives its bytes back when it is dropped, so content painted again and again keeps drawing, and a stream without filters (borrowed from the file) costs nothing. - Type 0 sampled functions read every sample into a 4-byte value and then a table entry: 256 MiB of 1-bit samples asked for over 8 GB. They now take at most 2^21 table points and 2^23 values (the largest real tables, DeviceN tint transforms such as 33^4 points x 3 outputs, are 1.2 million and 3.6 million), counted with checked arithmetic, and read no further than `/Size` asks. `/Size 0` no longer underflows building the default `/Encode`. Three rounds of independent review found the aggregate cases, buffer doubling past the limit, a chain/predictor shape the first image limit would have truncated, the Type 0 amplifier, and a first, cumulative budget that would have dropped legitimately repeated content, and a first Type 0 cap low enough to refuse real tint transforms; all fixed here. Measured in a release build (render; OS peak commit): - content stream inflating to 2 GB: 389 MB, 0.3 s - 8 x 8 image inflating to 2 GB: 4 MB, 0.2 s - /Contents naming it twenty times: 516 MB, 0.3 s - a form painting itself around it: 644 MB, 0.4 s Synthetic regression tests in render (flate2 is a new dev-dependency there, already in the workspace): - `decompression_bombs_stop_at_the_stream_limit`: 288 MiB bombs through flate2, the fallback inflater (a stored block with a wrong length check, which flate2 refuses and the fallback accepts), LZW and RunLength each decode exactly 256 MiB, and at most 128 bytes as an 8 x 8 image. - `page_contents_and_nested_forms_share_one_budget`: three /Contents references decode one stream's worth; a self-painting form finishes. - `content_painted_again_and_again_keeps_drawing`: a 2 MiB form painted 300 times draws the 300th time. - `sampled_functions_with_billions_of_samples_are_refused`. Ten mutations each turn them red: no limit at all; no limit in flate2, the fallback, LZW, RunLength or for images; an unshared /Contents limit; no page budget; no refund on drop; no Type 0 sample cap. (Taking a form out of the budget no longer compiles: its content is the budget guard.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M2: normalize JPEG2000 samples without overflowing Use bounded integer sample scaling for 1-32-bit JPX images and reject empty dimensions before chunking. Cover public decoding and continued page rendering with synthetic regressions. Co-Authored-By: OpenAI Codex <codex@openai.com> * Never crash: decompression cut at its limit is logged, not silent; vendor README renumbered after #575 --------- Co-authored-by: James Locke <336218001+jcl-bit@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Streetblock <github@streetblock.de> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
Vendored crates
Permissively licensed crates we carry small patches for. Each directory keeps the upstream
licence files unchanged. Every patch is marked PdfCraft patch: in the source, is covered by a
PdfCraft test, and should be proposed upstream. Remove the vendored copy once upstream releases
the fix. Vendoring copyleft code is never allowed (plan/adr/0001).
| Crate | Version | Licence | Patches | Test |
|---|---|---|---|---|
| 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 |
(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 |
| 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 | (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) | (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), 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.
Text extraction: hayro-interpret exposes OutlineGlyph::is_vertical from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126).