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
@@ -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/@@ -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)]@@ -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;@@ -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, } } }@@ -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; } } }