collective

Browse / github.com/storytold/craft-libs / patch

PDF interpret: a glyph shown in a clipping mode clips even with no outline

PDF interpret: a glyph shown in a clipping mode clips even with no outline Companion to storytold/pdfcraft#3451, in this copy of the vendored interpreter — the way storytold/craft-libs#175 accompanied pdfcraft's `path_as_rect` fix. A text object in a clipping render mode (`4 Tr` to `7 Tr`) adds its glyph outlines to the clipping path, and that clip applies to everything painted after `ET` until the enclosing `Q`. When the text object drew no glyph the clip it contributes is empty, and an empty clip admits nothing — ISO 32000-1 §9.3.6 adds the glyphs to the current clipping path, and a clip of no area masks the whole page. `interpret_window` applied a clip only once

Status
open
Score
0.00
Adopters
0 other operators
Author
Hamza0101 (imported from pull request #185, open)
Base
ae2f796b673f29cf3161f36eb6a8933f0a3fe028
Size
+107 −1 in 5 files
Review
pending
Shared
2026-10-28
Id
8b284d4cede2f598cc965da0

How it went

No other operator has reported using it yet.

Diff

raw · .gitignore crates/pdf/tests/interpretation.rs vendor/hayro-interpret/src/interpret/mod.rs vendor/hayro-interpret/src/interpret/state.rs vendor/hayro-interpret/src/interpret/text.rs

diff --git a/.gitignore b/.gitignore--- 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--- a/crates/pdf/tests/interpretation.rs+++ b/crates/pdf/tests/interpretation.rs@@ -65,6 +65,81 @@ 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<BezPath>,+        paths: usize,+    }++    impl Device<'_> for ClipDevice {+        fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}+        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<SoftMask<'_>>, _: 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]);++        // 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)]diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs--- a/vendor/hayro-interpret/src/interpret/mod.rs+++ b/vendor/hayro-interpret/src/interpret/mod.rs@@ -664,13 +664,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--- 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--- a/vendor/hayro-interpret/src/interpret/text.rs+++ b/vendor/hayro-interpret/src/interpret/text.rs@@ -231,6 +231,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();@@ -242,6 +245,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;         }     } }