diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index e6b6e7ea2..f7b1f89fc 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1340,6 +1340,106 @@ trailer << /Root 1 0 R >> assert_eq!(&page.rgba[((20 * 40 + 2) * 4)..][..4], &[255, 255, 255, 255], "beyond the butt cap"); } + /// Render a 40 × 40 pt page at 1:1 on its own thread, with `/GS1` (from `gs1`) in its + /// resources, and fail instead of waiting when the renderer stalls. + fn render_with_gs1(content: &str, gs1: &str) -> RenderedPage { + let pdf = format!( + "%PDF-1.7 +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 40 40] /Contents 4 0 R /Resources << /ExtGState << /GS1 {gs1} >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer"); + assert!(page.error.is_none(), "{:?}", page.error); + page + } + + /// The pixel at device (x, y), y down, of a 40-pixel-wide page. + fn px40(page: &RenderedPage, x: usize, y: usize) -> &[u8] { + &page.rgba[((y * 40 + x) * 4)..][..4] + } + + /// From `cargo xtask fuzz`: an ExtGState `/D [[11] 0]` on a line that starts at x = + /// 92234775807 cut it into billions of dashes, and stroke expansion allocated more than + /// 5 GB. A tiny dash on an ordinary line does the same (`[0.000001] 0 d` on 20 pt passed + /// 10 GB), and so does a long pattern on many short subpaths, because every subpath restarts + /// it. Vendored hayro patch: a stroke whose dash pattern could cut it into more than + /// `MAX_DASHES_PER_STROKE` pieces is drawn solid. + #[test] + fn dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render() { + let red = [255, 0, 0, 255]; + // The fuzzed shape. A line that far out isn't drawn even undashed (its coordinates are + // beyond the rasterizer's range), so this case only proves the page finishes and the + // content after the stroke is drawn. + let page = render_with_gs1("1 0 0 RG 4 w /GS1 gs 92234775807 20 m 30 20 l S 0 0 1 rg 0 0 4 4 re f", "<< /D [[11] 0] >>"); + assert_eq!(px40(&page, 1, 38), &[0, 0, 255, 255], "the square after the stroke is drawn"); + for (what, content, gs1) in [ + ("a tiny dash on an ordinary line", "1 0 0 RG 4 w [0.000001] 0 d 10 20 m 30 20 l S", "<< >>"), + ("a tiny dash from an ExtGState", "1 0 0 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[0.000001] 0] >>"), + ( + "a tiny dash under a shrinking matrix", + "1 0 0 RG 0.0000001 0 0 0.0000001 0 0 cm 40000000 w [1] 0 d 100000000 200000000 m 300000000 200000000 l S", + "<< >>", + ), + ] { + let page = render_with_gs1(content, gs1); + // Device row 20 is user y 20; the stroke covers x 10 to 30. + for x in [12, 20, 28] { + assert_eq!(px40(&page, x, 20), &red, "{what}: ({x}, 20) is drawn, solid"); + } + assert_eq!(px40(&page, 35, 20), &[255, 255, 255, 255], "{what}: beyond the end of the line"); + } + // A long pattern on many short subpaths: 9,999 tiny dashes, then a long one, restarted on + // each of 10,000 subpaths 0.01 pt long, is 100 million pieces from a 300 KB stream. + let mut many = String::from("1 0 0 RG 4 w ["); + many.push_str(&"0.000001 ".repeat(9_999)); + many.push_str("1000] 0 d "); + many.push_str(&"10 20 m 10.01 20 l ".repeat(10_000)); + many.push_str("S 0 0 1 rg 0 0 4 4 re f"); + let page = render_with_gs1(&many, "<< >>"); + assert_eq!(px40(&page, 1, 38), &[0, 0, 255, 255], "a pattern restarted on many subpaths finishes"); + // A fine pattern below the cap still dashes: 200,000 pieces of 0.0001 pt cover about + // half of each pixel, so the line is neither solid red nor missing. + let page = render_with_gs1("1 0 0 RG 4 w [0.0001] 0 d 10 20 m 30 20 l S", "<< >>"); + let p = px40(&page, 20, 20); + assert!(p[0] == 255 && (40..=215).contains(&p[1]), "a fine dash below the cap is drawn dashed: {p:?}"); + // An ordinary dash pattern still dashes: butt-capped 4 pt dashes from x 0. + let page = render_with_gs1("1 0 0 RG 4 w [4 4] 0 d 0 20 m 40 20 l S", "<< >>"); + assert_eq!(px40(&page, 2, 20), &red, "inside the first dash"); + assert_eq!(px40(&page, 6, 20), &[255, 255, 255, 255], "inside the first gap"); + assert_eq!(px40(&page, 10, 20), &red, "inside the second dash"); + } + + /// From `cargo xtask fuzz` triage: a dash array whose entries sum to less than zero made + /// kurbo's search for the starting dash loop for ever (`[-1 -1] 0 d` never finished). + /// ISO 32000-2 §8.4.3.6 requires nonnegative entries. Vendored hayro patch: a pattern with a + /// negative entry, or a period that isn't positive, is drawn solid. + #[test] + fn invalid_dash_patterns_terminate_and_are_drawn_solid() { + for (what, content, gs1) in [ + ("[-1 -1]", "1 0 0 RG 4 w [-1 -1] 0 d 10 20 m 30 20 l S", "<< >>"), + ("[-1] with a phase", "1 0 0 RG 4 w [-1] 5 d 10 20 m 30 20 l S", "<< >>"), + ("[-2 -3] from an ExtGState", "1 0 0 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[-2 -3] 0] >>"), + ("[-1 3], a negative entry in a positive period", "1 0 0 RG 4 w [-1 3] 0 d 10 20 m 30 20 l S", "<< >>"), + ] { + let page = render_with_gs1(content, gs1); + for x in [12, 20, 28] { + assert_eq!(px40(&page, x, 20), &[255, 0, 0, 255], "{what}: ({x}, 20) is drawn, solid"); + } + } + } + /// From the nightly `cargo xtask fuzz`: a Type 3 glyph that shows several glyphs of its own /// font. The nesting cap bounds the depth but not the breadth: eight glyphs per glyph, sixteen /// deep, is 8^16 paints. Vendored hayro-interpret patch: nested paints (forms, Type 3 glyphs, diff --git a/vendor/README.md b/vendor/README.md index 9225714ef..f9c8d07d1 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` | (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` | -| 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) | (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` | +| 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) | (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` | | 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) | (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 | | 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); (2) predictor row widths use saturating arithmetic | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating` | | 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 | (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` | diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 3bf5ccec7..ebf3cdc57 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -23,6 +23,76 @@ use vello_cpu::{ /// 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: 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 +/// 10 GB. Measured in release builds: a stroke cut into 250,000 pieces renders in about 16 ms and +/// 75 MB at its peak (a million took 80 ms and 290 MB). Dotted and dashed lines in real documents +/// stay far below this. +const MAX_DASHES_PER_STROKE: f64 = 250_000.0; + +/// PdfCraft patch: the dash pattern to stroke `path` with. Empty (a solid line) when the pattern +/// is invalid, or when it could cut the path into more than [`MAX_DASHES_PER_STROKE`] pieces. +/// ISO 32000-2 §8.4.3.6 requires nonnegative entries that are not all zero; a pattern whose +/// period isn't positive (`[-1 -1] 0 d`) made kurbo's search for the starting dash loop for ever. +fn usable_dash_pattern(path: &BezPath, dashes: &[f32]) -> Vec { + if dashes.iter().any(|d| !d.is_finite() || *d < 0.0) { + return Vec::new(); + } + let sum: f64 = dashes.iter().map(|d| f64::from(*d)).sum(); + // kurbo repeats an odd-length pattern once, as SVG does, so a period covers it twice. + let (period, per_period) = if dashes.len() % 2 == 1 { + (2.0 * sum, dashes.len().saturating_mul(2)) + } else { + (sum, dashes.len()) + }; + if !period.is_finite() || period <= 0.0 { + return Vec::new(); + } + let (length, restarts) = control_polygon_length(path); + // kurbo restarts the pattern at every subpath and after every `ClosePath`, so each restart + // can begin up to a whole period of pieces however short its segment is. + let pieces = (length / period + restarts as f64) * per_period as f64; + if !pieces.is_finite() || pieces > MAX_DASHES_PER_STROKE { + return Vec::new(); + } + dashes.iter().map(|d| f64::from(*d)).collect() +} + +/// PdfCraft patch: the length of `path`'s control polygon, which a curve never exceeds, and how +/// many times kurbo restarts a dash pattern along it (each `MoveTo` and `ClosePath`). +fn control_polygon_length(path: &BezPath) -> (f64, usize) { + let (mut length, mut restarts) = (0.0, 0usize); + let (mut start, mut last) = (Point::ORIGIN, Point::ORIGIN); + for el in path.elements() { + match *el { + kurbo::PathEl::MoveTo(p) => { + (start, last) = (p, p); + restarts = restarts.saturating_add(1); + } + kurbo::PathEl::LineTo(p) => { + length += last.distance(p); + last = p; + } + kurbo::PathEl::QuadTo(c, p) => { + length += last.distance(c) + c.distance(p); + last = p; + } + kurbo::PathEl::CurveTo(c1, c2, p) => { + length += last.distance(c1) + c1.distance(c2) + c2.distance(p); + last = p; + } + kurbo::PathEl::ClosePath => { + length += last.distance(start); + last = start; + restarts = restarts.saturating_add(1); + } + } + } + (length, restarts) +} + pub(crate) struct Renderer { pub(crate) ctx: RenderContext, pub(crate) inside_pattern: bool, @@ -61,7 +131,7 @@ impl Renderer { } } - fn set_stroke_properties(&mut self, stroke_props: &StrokeProps, is_text: bool) { + fn set_stroke_properties(&mut self, stroke_props: &StrokeProps, is_text: bool, path: &BezPath) { let threshold = if is_text { 0.25 } else { 1.0 }; // Best-effort attempt to ensure a line width of at least 1.0, as required by the PDF @@ -91,8 +161,13 @@ impl Renderer { miter_limit: stroke_props.miter_limit as f64, start_cap: stroke_props.line_cap, end_cap: stroke_props.line_cap, - dash_pattern: stroke_props.dash_array.iter().map(|n| *n as f64).collect(), - dash_offset: stroke_props.dash_offset as f64, + // PdfCraft patch: see `usable_dash_pattern`. + dash_pattern: usable_dash_pattern(path, &stroke_props.dash_array).into(), + dash_offset: if stroke_props.dash_offset.is_finite() { + stroke_props.dash_offset as f64 + } else { + 0.0 + }, }; self.ctx.set_stroke(stroke); @@ -598,7 +673,7 @@ impl Renderer { is_text: bool, ) { self.ctx.set_transform(transform); - self.set_stroke_properties(stroke_props, is_text); + self.set_stroke_properties(stroke_props, is_text, path); let clip_path = self.set_paint(paint, path, true); if let Some(clip_path) = clip_path.as_ref() {