mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M2.6: skip annotation appearances whose box collapses to a line or point (#271)
ISO 32000-2 §12.5.5 maps an appearance onto its annotation by transforming /BBox with /Matrix and scaling the resulting box onto /Rect. When /BBox has zero width or height, or /Matrix has a zero row or column, that box is a line or a point, and the vendored interpreter divided the /Rect size by zero. The infinite or NaN matrix then reached the device with every path, clip and glyph the appearance drew (4 of 4 drawing calls in the test). hayro's rasteriser happens to drop such geometry, so pages looked right, but nothing guaranteed it; AGENTS.md §4 asks for division by zero and NaN to be guarded where they arise. Clipped to a collapsed box the appearance shows nothing (MuPDF and Poppler draw nothing for these fixtures either), so the appearance is now skipped, as is one whose mapping overflows. The mapping of valid appearances is unchanged. Test: raster::tests::degenerate_annotation_appearances_are_skipped. A recording device sees non-finite geometry on main for a zero-width, zero-height and point /BBox and for a /Matrix with a zero row or column; none after. Each page renders blank; a valid appearance still fills its /Rect and nothing outside it. Vendored patch (12) in vendor/README.md. Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
3 files changed
+118
-1
No files matched your search
@@ -670,6 +670,110 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream");
|
||||
}
|
||||
|
||||
/// An appearance whose /BBox is a line or a point, or whose /Matrix collapses it to one, has
|
||||
/// no mapping onto the annotation's /Rect: the scale (/Rect size over transformed box size)
|
||||
/// divided by zero, and the infinite or NaN matrix reached the device with everything the
|
||||
/// appearance drew. Vendored hayro-interpret patch: such appearances are skipped. Clipped to
|
||||
/// their box they show nothing, and MuPDF and Poppler draw nothing for them either.
|
||||
#[test]
|
||||
fn degenerate_annotation_appearances_are_skipped() {
|
||||
use hayro::hayro_interpret::font::Glyph;
|
||||
use hayro::hayro_interpret::hayro_syntax::Pdf;
|
||||
use hayro::hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page,
|
||||
};
|
||||
use kurbo::{Affine, BezPath, Rect};
|
||||
|
||||
/// Counts the geometry that reaches a device, and how much of it is not finite.
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
drawn: usize,
|
||||
non_finite: usize,
|
||||
}
|
||||
impl Geometry {
|
||||
fn see(&mut self, finite: bool) {
|
||||
self.drawn += 1;
|
||||
self.non_finite += usize::from(!finite);
|
||||
}
|
||||
}
|
||||
impl<'a> Device<'a> for Geometry {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'a>, _: &PathDrawMode) {
|
||||
self.see(path.is_finite() && transform.is_finite());
|
||||
}
|
||||
fn push_clip_path(&mut self, clip: &ClipPath) {
|
||||
self.see(clip.path.is_finite());
|
||||
}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {
|
||||
self.see(transform.is_finite() && glyph_transform.is_finite());
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'a, '_>, transform: Affine) {
|
||||
self.see(transform.is_finite());
|
||||
}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
|
||||
let pdf = |form: &str| {
|
||||
let content = "1 0 0 rg 0 0 20 20 re f BT /F1 12 Tf 2 5 Td (Hi) Tj ET";
|
||||
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 100 100] /Annots [4 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Square /Rect [10 10 60 40] /AP << /N 5 0 R >> >> endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form {form} /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len()
|
||||
)
|
||||
};
|
||||
let geometry = |bytes: &[u8]| {
|
||||
let parsed = Pdf::new(Arc::new(bytes.to_vec())).expect("parses");
|
||||
let page = &parsed.pages()[0];
|
||||
let cache = InterpreterCache::new();
|
||||
let initial = page.initial_transform(true).to_kurbo();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 100.0, 100.0), &cache, page.xref(), InterpreterSettings::default());
|
||||
let mut device = Geometry::default();
|
||||
interpret_page(page, &mut ctx, &mut device);
|
||||
device
|
||||
};
|
||||
let render = |bytes: Vec<u8>| {
|
||||
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })
|
||||
};
|
||||
for (case, form) in [
|
||||
("zero-width /BBox", "/BBox [0 0 0 20]"),
|
||||
("zero-height /BBox", "/BBox [0 0 20 0]"),
|
||||
("point /BBox", "/BBox [5 5 5 5]"),
|
||||
("/Matrix with a zero column", "/BBox [0 0 20 20] /Matrix [0 0 0 1 0 0]"),
|
||||
("/Matrix with a zero row", "/BBox [0 0 20 20] /Matrix [1 0 0 0 0 0]"),
|
||||
] {
|
||||
let bytes = pdf(form).into_bytes();
|
||||
let device = geometry(&bytes);
|
||||
assert_eq!(device.non_finite, 0, "{case}: {} of {} drawing calls were not finite", device.non_finite, device.drawn);
|
||||
let p = render(bytes);
|
||||
assert!(p.error.is_none(), "{case}: {:?}", p.error);
|
||||
assert!(p.rgba.as_chunks::<4>().0.iter().all(|c| *c == [255, 255, 255, 255]), "{case}: nothing is drawn");
|
||||
}
|
||||
|
||||
// A valid appearance still reaches the device, and fills its /Rect and only its /Rect.
|
||||
let valid = pdf("/BBox [0 0 20 20]").into_bytes();
|
||||
let device = geometry(&valid);
|
||||
assert!(device.drawn >= 4 && device.non_finite == 0, "{} drawn, {} non-finite", device.drawn, device.non_finite);
|
||||
let p = render(valid);
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
|
||||
assert_eq!(px(11, 61), vec![255, 0, 0, 255]);
|
||||
assert_eq!(px(58, 88), vec![255, 0, 0, 255]);
|
||||
assert_eq!(px(61, 75), vec![255, 255, 255, 255], "right of /Rect");
|
||||
assert_eq!(px(30, 58), vec![255, 255, 255, 255], "above /Rect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_comments_keeps_fields() {
|
||||
let pdf = b"%PDF-1.7
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ 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`) | (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` |
|
||||
| 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) | (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` |
|
||||
|
||||
| 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-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 |
|
||||
|
||||
+13
@@ -220,6 +220,19 @@ pub fn interpret_page<'a>(
|
||||
annot_rect.y0 - transformed_rect.y0,
|
||||
]);
|
||||
|
||||
// PdfCraft patch: a /BBox or /Matrix that collapses the appearance box to a line
|
||||
// or a point leaves nothing to map onto /Rect: the scales above divide by zero,
|
||||
// and the infinite or NaN matrix reached the device with everything the
|
||||
// appearance draws. Clipped to such a box the appearance shows nothing, so skip
|
||||
// it, as when the mapping overflows.
|
||||
if !(transformed_rect.is_finite()
|
||||
&& transformed_rect.width() > 0.0
|
||||
&& transformed_rect.height() > 0.0
|
||||
&& affine.is_finite())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3) Matrix shall be concatenated with A to form a matrix
|
||||
// AA that maps from the appearance’s coordinate system to
|
||||
// the annotation’s rectangle in default user space.
|
||||
|
||||
Reference in new issue
Block a user