mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
This commit is contained in:
@@ -7238,4 +7238,36 @@ trailer << /Root 1 0 R >>
|
|||||||
assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph");
|
assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting
|
||||||
|
/// but draws no pixels itself.
|
||||||
|
#[test]
|
||||||
|
fn test_clip_only_text_raster_preserves_clipping_without_painting() {
|
||||||
|
let pdf_bytes = |content: &str| {
|
||||||
|
Arc::new(
|
||||||
|
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 200 100] /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()
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let render = |content: &str| {
|
||||||
|
let mut r = PageRenderer::new(pdf_bytes(content), RenderConfig::default());
|
||||||
|
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||||
|
assert!(p.error.is_none(), "{content}: {:?}", p.error);
|
||||||
|
p
|
||||||
|
};
|
||||||
|
let white = [255, 255, 255, 255];
|
||||||
|
|
||||||
|
// The text alone paints nothing.
|
||||||
|
let p = render("BT /F1 40 Tf 7 Tr 20 60 Td (Hi) Tj ET");
|
||||||
|
assert!(p.rgba.as_chunks::<4>().0.iter().all(|c| *c == white), "7 Tr alone leaves the page blank");
|
||||||
|
|
||||||
|
// A fill afterwards shows only through the text-shaped clip.
|
||||||
|
let p = render("q BT /F1 40 Tf 7 Tr 20 60 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f Q");
|
||||||
|
let pixels = p.rgba.as_chunks::<4>().0;
|
||||||
|
assert!(pixels.contains(&[0, 0, 255, 255]), "fill shows through the 7 Tr clip");
|
||||||
|
assert!(pixels.contains(&white), "the clip is text-shaped, not the whole page");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1871,6 +1871,84 @@ 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<String> =
|
||||||
|
(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 <</ActualText (Hello World)>> 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]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 <</ActualText (Accessible heading)>> 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`).
|
/// One page per `(encoding, content)`, in Helvetica (`/F1`).
|
||||||
fn encoded_pages(pages: &[(lopdf::Object, &[u8])]) -> Pdf {
|
fn encoded_pages(pages: &[(lopdf::Object, &[u8])]) -> Pdf {
|
||||||
use lopdf::{Document, Object, Stream, dictionary};
|
use lopdf::{Document, Object, Stream, dictionary};
|
||||||
|
|||||||
Vendored
+11
@@ -51,3 +51,14 @@ black-point normalization is unchanged. `lab_bradford_tests` checks independent
|
|||||||
D50/equal-energy neutral and chromatic oracles, D65 identity, and direct/Indexed
|
D50/equal-energy neutral and chromatic oracles, D65 identity, and direct/Indexed
|
||||||
consistency (including an asymmetric chromatic Range), and zero/overflowing
|
consistency (including an asymmetric chromatic Range), and zero/overflowing
|
||||||
cone-response fallback.
|
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).
|
||||||
|
|||||||
+10
@@ -126,6 +126,16 @@ pub(crate) fn show_glyph<'a>(
|
|||||||
}
|
}
|
||||||
TextRenderingMode::Clip => {
|
TextRenderingMode::Clip => {
|
||||||
clip_glyph(ctx, glyph, glyph_transform);
|
clip_glyph(ctx, glyph, glyph_transform);
|
||||||
|
// 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::Clip,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
TextRenderingMode::FillAndClip => {
|
TextRenderingMode::FillAndClip => {
|
||||||
clip_glyph(ctx, glyph, glyph_transform);
|
clip_glyph(ctx, glyph, glyph_transform);
|
||||||
|
|||||||
Vendored
+2
@@ -284,6 +284,8 @@ pub enum GlyphDrawMode {
|
|||||||
Fill,
|
Fill,
|
||||||
/// Draw using a stroke.
|
/// Draw using a stroke.
|
||||||
Stroke(StrokeProps),
|
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 text (for text extraction but not visual rendering).
|
||||||
Invisible,
|
Invisible,
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1738,8 +1738,8 @@ impl<'a> Device<'a> for Renderer {
|
|||||||
GlyphDrawMode::Stroke(s) => {
|
GlyphDrawMode::Stroke(s) => {
|
||||||
Self::stroke_glyph(self, glyph, transform, glyph_transform, paint, s);
|
Self::stroke_glyph(self, glyph, transform, glyph_transform, paint, s);
|
||||||
}
|
}
|
||||||
GlyphDrawMode::Invisible => {
|
GlyphDrawMode::Invisible | GlyphDrawMode::Clip => {
|
||||||
// Don't render invisible text for visual output
|
// PdfCraft patch: neither mode paints; clipping was installed by the interpreter.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user