diff --git a/.gitignore b/.gitignore index c725b3c4b..3c2518872 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ plan/ plans/ /target +# Also a sub-crate's own build directory: `crates/pdf/read-only-contract/target/` is not covered +# by the anchored `/target` above, and 803 of its files reached a commit once (#185 review). +target/ .DS_Store /.claude/ .idea/ diff --git a/crates/pdf/tests/interpretation.rs b/crates/pdf/tests/interpretation.rs index 9433c0426..124746aaf 100644 --- a/crates/pdf/tests/interpretation.rs +++ b/crates/pdf/tests/interpretation.rs @@ -65,6 +65,88 @@ mod device { execute(&mut fresh).unwrap(); assert_eq!(fresh.paths, 1); } + + /// Records the clips the interpreter applies, alongside the paths painted under them. + #[derive(Default)] + struct ClipDevice { + clips: Vec, + paths: usize, + } + + impl Device<'_> for ClipDevice { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) { + self.paths += 1; + } + fn push_clip_path(&mut self, clip_path: &ClipPath) { + self.clips.push(clip_path.path.clone()); + } + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + fn clips_of(content: &str) -> ClipDevice { + let bytes = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let pdf = craft_pdf::open_slice(bytes.as_bytes(), None).unwrap(); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + let mut context = Context::new( + page.initial_transform(true).to_kurbo(), + Rect::new(0.0, 0.0, 40.0, 20.0), + &cache, + page.xref(), + InterpreterSettings::default(), + ); + let mut device = ClipDevice::default(); + craft_pdf::interpret_page(page, &mut context, &mut device).unwrap(); + device + } + + /// A glyph shown in a clipping render mode adds its outline to the clipping path, and that + /// clip applies to everything painted after `ET`. When the glyphs shown have no outline -- a + /// space, say -- the clip is empty and nothing paints through it. The interpreter applied a + /// clip only once a glyph had contributed an outline, so such a text object clipped nothing + /// and what followed painted unmasked. + /// + /// A text object that shows no glyph at all is a different case: there the clipping path is + /// unchanged, as readers leave it. + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn a_glyph_shown_in_a_clipping_mode_clips_even_with_no_outline() { + // `7 Tr` is clip-only, and the glyph shown has no outline: the clip is empty. + let empty = clips_of("q BT 7 Tr /F1 12 Tf 4 4 Td ( ) Tj ET 1 0 0 rg 0 0 40 20 re f Q"); + assert_eq!(empty.clips.len(), 1, "the empty clip is applied"); + assert!(empty.clips[0].is_empty(), "and it clips everything away: {:?}", empty.clips[0]); + + // Visible glyphs in the same mode clip to their outlines, as they always did: one clip, not + // empty, and the fill still painted beneath it (noted in review). + let visible = clips_of("q BT 7 Tr /F1 12 Tf 4 4 Td (H) Tj ET 1 0 0 rg 0 0 40 20 re f Q"); + assert_eq!(visible.clips.len(), 1, "the glyph outline is applied as the clip"); + assert!(!visible.clips[0].is_empty(), "and it holds the outline"); + assert_eq!(visible.paths, 1); + + // A clipping mode with no glyph shown at all leaves the clipping path alone. + let none = clips_of("q BT 7 Tr /F1 12 Tf 4 4 Td ET 1 0 0 rg 0 0 40 20 re f Q"); + assert!(none.clips.is_empty(), "nothing was shown, so nothing clips"); + assert_eq!(none.paths, 1); + + // No text object: the fill is unclipped, as it always was. + let plain = clips_of("q 1 0 0 rg 0 0 40 20 re f Q"); + assert!(plain.clips.is_empty(), "nothing clips the fill"); + assert_eq!(plain.paths, 1); + + // A painting mode never clips, outline or not. + let painting = clips_of("q BT 0 Tr /F1 12 Tf 4 4 Td ( ) Tj ET 1 0 0 rg 0 0 40 20 re f Q"); + assert!(painting.clips.is_empty(), "a painting text mode never clips"); + assert_eq!(painting.paths, 1); + } } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] @@ -119,6 +201,37 @@ fn caller_decode_policy_survives_open_fallback() { assert_eq!(normal.decode_limits(), craft_pdf::syntax::DecodeLimits::default()); } +/// What the clip does to the page, rendered: the device test above shows the clip the interpreter +/// sets, this shows the pixels it leaves (noted in review). +#[cfg(all(feature = "raster", not(target_arch = "wasm32")))] +mod rendered { + use craft_pdf::raster::{self, Options, RenderCache}; + + /// The share of a 40 x 20 pt page painted red (the fill), after `text` and then the fill. + fn red_share(text: &str) -> f64 { + let content = format!("q {text} 1 0 0 rg 0 0 40 20 re f Q"); + let bytes = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let pdf = craft_pdf::open_slice(bytes.as_bytes(), None).unwrap(); + let page = raster::render(&pdf.pages()[0], &RenderCache::new(), &Default::default(), &Options { background: [255; 4], ..Default::default() }) + .unwrap(); + let pixels = page.bytes().as_chunks::<4>().0; + let red = pixels.iter().filter(|p| p[0] > 200 && p[1] < 80 && p[2] < 80).count(); + red as f64 / pixels.len() as f64 + } + + #[test] + fn an_empty_text_clip_hides_what_follows_and_a_visible_one_shows_its_glyphs() { + assert!(red_share("") > 0.99, "no text object: the fill covers the page"); + assert_eq!(red_share("BT 7 Tr /F1 12 Tf 4 4 Td ( ) Tj ET"), 0.0, "an outline-less glyph in 7 Tr: nothing paints"); + let through = red_share("BT 7 Tr /F1 12 Tf 4 4 Td (H) Tj ET"); + assert!(through > 0.02 && through < 0.5, "visible glyphs in 7 Tr: the fill shows through the H only ({through})"); + assert!(red_share("BT 7 Tr /F1 12 Tf 4 4 Td ET") > 0.99, "no glyph shown: the clip is unchanged"); + } +} + /// A path reaches the device as a rectangle only when it is one. A five-point open polyline whose /// points reach every corner of its own bounding box (VectorCraft #2920, a zigzag in an `.ai` /// file) used to be drawn as that box. diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 1e9c0b80b..022c4d1bc 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -709,13 +709,24 @@ fn interpret_window<'a>( .next() .is_some(); - if has_outline { + // PdfCraft patch: a glyph shown in a clipping mode that contributes no outline + // still clips -- the clip is empty, so nothing paints through it, as a glyph with + // an outline clips to its shape. Applying nothing instead left what followed + // unmasked. A text object that shows no glyph at all is not this case and leaves + // the clipping path alone, and a glyph whose outline is unavailable (Type 3) + // leaves the real clip unknown, so both stay unclipped. + let state = &context.get().text_state; + let empty_clip = state.clip_shown && !state.clip_unsupported; + + if has_outline || empty_clip { let clip_path = context.get().ctm * context.get().text_state.clip_paths.clone(); context.push_clip_path(clip_path, FillRule::NonZero, device); } context.get_mut().text_state.clip_paths.truncate(0); + context.get_mut().text_state.clip_shown = false; + context.get_mut().text_state.clip_unsupported = false; } TypedInstruction::TextFont(t) => { let name = t.0; diff --git a/vendor/hayro-interpret/src/interpret/state.rs b/vendor/hayro-interpret/src/interpret/state.rs index a146d2660..d6fd263aa 100644 --- a/vendor/hayro-interpret/src/interpret/state.rs +++ b/vendor/hayro-interpret/src/interpret/state.rs @@ -149,6 +149,15 @@ pub(crate) struct TextState<'a> { // When setting the text rendering mode to `clip`, the glyphs should instead be collected // as paths and then applied as 1 single clip path. This field stores those clip paths. pub(crate) clip_paths: BezPath, + /// PdfCraft patch: whether a glyph was shown in a clipping render mode (4 to 7) in this text + /// object, so `ET` knows to apply the clip even when none of them contributed an outline: the + /// clip is then empty and nothing paints through it. A text object that shows no glyph at all + /// leaves the clipping path alone, which is what readers do with it. + pub(crate) clip_shown: bool, + /// PdfCraft patch: whether a glyph in a clipping mode could not be turned into an outline + /// (a Type 3 glyph, which `clip_glyph` does not implement). The real clip is then unknown, so + /// `ET` leaves what follows unclipped rather than hiding it. + pub(crate) clip_unsupported: bool, } impl<'a> TextState<'a> { @@ -245,6 +254,8 @@ impl Default for TextState<'_> { text_line_matrix: Affine::IDENTITY, rise: 0.0, clip_paths: BezPath::default(), + clip_shown: false, + clip_unsupported: false, } } } diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index f9f68b1e6..fecf66c04 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -243,6 +243,9 @@ fn show_glyph<'a>( } pub(crate) fn clip_glyph(context: &mut Context<'_>, glyph: &Glyph<'_>, transform: Affine) { + // PdfCraft patch: this glyph is shown in a clipping mode, so the text object clips at `ET` + // whether or not the glyph has an outline to clip to. + context.get_mut().text_state.clip_shown = true; match glyph { Glyph::Outline(o) => { let outline = transform * o.outline(); @@ -254,6 +257,9 @@ pub(crate) fn clip_glyph(context: &mut Context<'_>, glyph: &Glyph<'_>, transform } Glyph::Type3(_) => { warn!("text rendering mode clip not implemented for shape glyphs"); + // PdfCraft patch: the clip this glyph belongs to cannot be built, so `ET` leaves what + // follows unclipped rather than applying an empty clip and hiding it. + context.get_mut().text_state.clip_unsupported = true; } } }