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;
         }
     }
 }
