From d7b4d576a1a048ea63570c7ee074d31b43479d67 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 08:16:58 +0800 Subject: [PATCH 1/2] fix(render): emit clip-only text (7 Tr) to devices for text extraction (#3109) --- crates/render/src/text.rs | 64 ++++++++++++++++++++ vendor/hayro-interpret/src/interpret/text.rs | 10 +++ 2 files changed, 74 insertions(+) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index b767e2831..e163b204b 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1740,6 +1740,70 @@ mod tests { } } + /// Issue #3109: text drawn in clipping mode (`7 Tr`) sets the clip for a subsequent fill, + /// shading or image without painting the glyphs directly, and must still reach `TextDevice` + /// with the same boxes and search ranges as modes 0–6. + #[test] + fn clipping_mode_text_is_extracted_and_searchable_like_other_rendering_modes() { + let fill_through = " 0 0 1 rg 0 0 300 300 re f"; + let mut pages: Vec = + (0..=7).map(|mode| format!("q BT /F1 24 Tf {mode} Tr 72 150 Td () Tj [(Hello) -200 (World)] TJ ET{fill_through} Q")).collect(); + // Page 8: /ActualText wrapping 7 Tr clip-only text. + pages.push(format!("q /Span <> BDC BT /F1 24 Tf 7 Tr 72 150 Td (Glyphs) Tj ET EMC{fill_through} Q")); + // Page 9: 0 Tr underpainting followed by 7 Tr at the same position (outline/gradient overlay) deduplicates. + pages.push(format!("BT /F1 24 Tf 0 Tr 72 150 Td (Hello World) Tj ET q BT /F1 24 Tf 7 Tr 72 150 Td (Hello World) Tj ET{fill_through} Q")); + // Page 10: 90° turned 7 Tr text. + pages.push(format!("q BT /F1 24 Tf 7 Tr 0 1 -1 0 150 100 Tm (Hello World) Tj ET{fill_through} Q")); + // Page 11: 7 Tr without any fill-through (visually blank, still extractable). + pages.push("BT /F1 24 Tf 7 Tr 72 150 Td (Hello World) Tj ET".into()); + + let refs: Vec<&str> = pages.iter().map(String::as_str).collect(); + let pdf = marked_pages(&refs); + let control = marked_text(&pdf, 0); + assert_eq!(control.plain_text(), "Hello World"); + let control_rects = control.line_rects(0..5); + assert_eq!(control_rects.len(), 1); + for mode in 0..=7 { + let text = marked_text(&pdf, mode); + assert_eq!(text.plain_text(), "Hello World", "mode {mode} Tr"); + assert_eq!(text.find("hello"), vec![0..5], "mode {mode} Tr"); + assert_eq!(text.find_opts("Hello", true, true), vec![0..5], "mode {mode} Tr"); + assert_eq!( + text.find_with(|chars| chars.starts_with(&['H', 'e', 'l', 'l', 'o']).then_some(0..5).into_iter().collect()), + vec![0..5], + "mode {mode} Tr" + ); + assert_eq!(text.line_rects(0..5), control_rects, "mode {mode} Tr"); + assert_eq!(text.glyphs, control.glyphs, "mode {mode} Tr"); + } + let actual = marked_text(&pdf, 8); + assert_eq!(actual.plain_text(), "Hello World"); + assert_eq!(actual.find("Hello"), vec![0..1]); + + let deduped = marked_text(&pdf, 9); + assert_eq!(deduped.plain_text(), "Hello World"); + + let turned = marked_text(&pdf, 10); + assert_eq!(turned.plain_text(), "Hello World"); + assert_eq!(turned.find("World"), vec![6..11]); + + let unfilled = marked_text(&pdf, 11); + assert_eq!(unfilled.plain_text(), "Hello World"); + + // Visual check: page 7 (`7 Tr` + blue fill-through) paints blue only inside the clipped + // glyph outlines, whereas page 11 (`7 Tr` alone) paints no ink at all. + let mut renderer = crate::PageRenderer::new(std::sync::Arc::new(pdf.data().as_ref().to_vec()), crate::RenderConfig::default()); + let filled_px = renderer.render(crate::RenderRequest { page: 7, kind: crate::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(filled_px.error.is_none()); + let blue_pixels = filled_px.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 32 && p[1] < 32 && p[2] > 200).count(); + let white_pixels = filled_px.rgba.as_chunks::<4>().0.iter().filter(|p| p[..3] == [255, 255, 255]).count(); + assert!(blue_pixels > 50 && white_pixels > 80_000, "blue={blue_pixels}, white={white_pixels}"); + + let unfilled_px = renderer.render(crate::RenderRequest { page: 11, kind: crate::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(unfilled_px.error.is_none()); + assert!(unfilled_px.rgba.as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255])); + } + /// One page per `(encoding, content)`, in Helvetica (`/F1`). fn encoded_pages(pages: &[(lopdf::Object, &[u8])]) -> Pdf { use lopdf::{Document, Object, Stream, dictionary}; diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index ddec0400e..8b80b4bdb 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -126,6 +126,16 @@ pub(crate) fn show_glyph<'a>( } TextRenderingMode::Clip => { clip_glyph(ctx, glyph, glyph_transform); + // PdfCraft patch: still call draw_glyph for clip-only text (`7 Tr`), as mode 3 does, + // so text extraction, search and search-redaction see text that is filled through its + // clipping path without rendering devices drawing it directly. + device.draw_glyph( + glyph, + ctx.get().ctm, + glyph_transform, + &get_paint(ctx, false), + &GlyphDrawMode::Invisible, + ); } TextRenderingMode::FillAndClip => { clip_glyph(ctx, glyph, glyph_transform); From ff5a2fc9eb9e50a395ee1025210d5812ee99d867 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Sun, 18 Oct 2026 22:00:21 +0800 Subject: [PATCH 2/2] M2: retain clipping text in filtered extraction (#3116) --- ROADMAP.md | 1 + crates/render/src/text.rs | 14 ++++++++++++++ docs/roadmap.md | 2 ++ vendor/README.md | 11 +++++++++++ vendor/hayro-interpret/src/interpret/text.rs | 6 +++--- vendor/hayro-interpret/src/types.rs | 2 ++ vendor/hayro/src/renderer.rs | 4 ++-- 7 files changed, 35 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c895ed2c3..eede1bb98 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -160,6 +160,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-18 (M2, #3116):** Distinguish clipping-only text from invisible OCR so image-filtered extraction retains clipped headings and ActualText. Vendor changes and regression coverage are documented; estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. - **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index e163b204b..7dbe3cebb 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1804,6 +1804,20 @@ mod tests { assert!(unfilled_px.rgba.as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255])); } + #[test] + fn clipping_mode_filter_keeps_titles_and_excludes_ocr() { + let pdf = marked_pages(&[ + "q BT /F1 24 Tf 7 Tr 30 200 Td (Clipped heading) Tj ET 0 0 1 rg 0 0 300 300 re f Q BT /F1 12 Tf 3 Tr 30 100 Td (Hidden OCR) Tj ET", + "q /Span <> BDC BT /F1 24 Tf 7 Tr 30 200 Td (Clipped heading) Tj ET EMC 0 0 1 rg 0 0 300 300 re f Q", + ]); + assert!(marked_text(&pdf, 0).plain_text().contains("Hidden OCR")); + for (page, expected) in [(0, "Clipped heading"), (1, "Accessible heading")] { + let text = extract_page_filtered(&pdf, page, &InterpreterSettings::default(), TextFilter { images: true, ..Default::default() }).unwrap(); + assert_eq!(text.plain_text(), expected); + assert!(!text.find("heading").is_empty()); + } + } + /// One page per `(encoding, content)`, in Helvetica (`/F1`). fn encoded_pages(pages: &[(lopdf::Object, &[u8])]) -> Pdf { use lopdf::{Document, Object, Stream, dictionary}; diff --git a/docs/roadmap.md b/docs/roadmap.md index 2c65496b6..16528303b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **2026-10-18:** M2 / #3116: clipping-only glyphs stay extractable with the image filter; invisible OCR remains excluded. Estimates unchanged. + > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) diff --git a/vendor/README.md b/vendor/README.md index 62be4cad9..06e592e47 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -52,3 +52,14 @@ black-point normalization is unchanged. `lab_bradford_tests` checks independent D50/equal-energy neutral and chromatic oracles, D65 identity, and direct/Indexed consistency (including an asymmetric chromatic Range), and zero/overflowing cone-response fallback. + +## Clipping-only text reporting (#3109 / #3116) + +`hayro-interpret/src/interpret/text.rs` reports mode 7 glyphs after installing their +clip, and `hayro-interpret/src/types.rs` distinguishes `GlyphDrawMode::Clip` from +invisible OCR text. `hayro/src/renderer.rs` does not paint either mode directly. +This preserves clipping-only titles when `TextFilter.images` excludes invisible OCR. +Coverage: `text::tests::clipping_mode_filter_keeps_titles_and_excludes_ocr` (plain +and ActualText filtered extraction), and +`text::tests::clipping_mode_text_is_extracted_and_searchable_like_other_rendering_modes` +(extraction, search, glyph geometry, clip-through pixels and no-paint pixels). diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index 8b80b4bdb..cd5e147f9 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -126,15 +126,15 @@ pub(crate) fn show_glyph<'a>( } TextRenderingMode::Clip => { clip_glyph(ctx, glyph, glyph_transform); - // PdfCraft patch: still call draw_glyph for clip-only text (`7 Tr`), as mode 3 does, - // so text extraction, search and search-redaction see text that is filled through its + // PdfCraft patch: report clipping text separately from invisible OCR text. + // Extraction filters must retain text that is filled through its // clipping path without rendering devices drawing it directly. device.draw_glyph( glyph, ctx.get().ctm, glyph_transform, &get_paint(ctx, false), - &GlyphDrawMode::Invisible, + &GlyphDrawMode::Clip, ); } TextRenderingMode::FillAndClip => { diff --git a/vendor/hayro-interpret/src/types.rs b/vendor/hayro-interpret/src/types.rs index 64d2b849b..685ed3e99 100644 --- a/vendor/hayro-interpret/src/types.rs +++ b/vendor/hayro-interpret/src/types.rs @@ -284,6 +284,8 @@ pub enum GlyphDrawMode { Fill, /// Draw using a stroke. Stroke(StrokeProps), + /// PdfCraft patch: text that shapes a clip; report it without painting a glyph. + Clip, /// Invisible text (for text extraction but not visual rendering). Invisible, } diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 0619b4c6d..dee2f983f 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1686,8 +1686,8 @@ impl<'a> Device<'a> for Renderer { GlyphDrawMode::Stroke(s) => { Self::stroke_glyph(self, glyph, transform, glyph_transform, paint, s); } - GlyphDrawMode::Invisible => { - // Don't render invisible text for visual output + GlyphDrawMode::Invisible | GlyphDrawMode::Clip => { + // PdfCraft patch: neither mode paints; clipping was installed by the interpreter. } } }