fix(render): clip extracted text to the page CropBox (#2670)

This commit is contained in:
jonathanwxh-cell
2026-10-16 18:51:55 +08:00
parent f0c9901c39
commit 644f7ab685
2 changed files with 120 additions and 2 deletions
+36
View File
@@ -243,4 +243,40 @@ mod page_argument_tests {
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
}
#[test]
fn text_clips_glyphs_outside_the_crop_box() {
let dir = PageDir::new();
let path = dir.0.join("cropped.pdf");
let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET";
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(o);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
std::fs::write(&path, pdf).unwrap();
for args in [vec![], vec!["--page", "1"]] {
let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}");
}
}
}
+84 -2
View File
@@ -358,6 +358,8 @@ struct TextDevice {
actual: Option<(usize, String, Vec<Drawn>)>,
/// Every `/ActualText` replacement made on the page.
replaced: Vec<Replaced>,
/// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`).
page: Rect,
filter: TextFilter,
excluded_depth: Option<usize>,
actual_filtered: bool,
@@ -431,6 +433,7 @@ impl TextDevice {
depth: 0,
actual: None,
replaced: Vec::new(),
page: Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY),
filter: TextFilter::default(),
excluded_depth: None,
actual_filtered: false,
@@ -619,7 +622,12 @@ impl<'a> Device<'a> for TextDevice {
let b = corners.iter().fold(Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| {
Rect::new(b.x0.min(p.x), b.y0.min(p.y), b.x1.max(p.x), b.y1.max(p.y))
});
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 {
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite())
|| b.width() > 10_000.0
|| b.height() > 10_000.0
|| b.x0.max(self.page.x0) >= b.x1.min(self.page.x1)
|| b.y0.max(self.page.y0) >= b.y1.min(self.page.y1)
{
return;
}
let length = dx.hypot(dy);
@@ -677,8 +685,10 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret
// as in Acrobat, and match what other extractors report.
let settings = settings.clone();
let initial = p.initial_transform(true).to_kurbo();
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings);
let c = p.intersected_crop_box();
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings);
let mut dev = TextDevice::new();
dev.page = initial.transform_rect_bbox(Rect::new(c.x0, c.y0, c.x1, c.y1));
dev.filter = filter;
interpret_page(p, &mut ctx, &mut dev);
// A replacement the content never closed ends with the page.
@@ -2646,4 +2656,76 @@ mod tests {
assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode");
assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only");
}
#[test]
fn text_outside_the_crop_box_is_clipped() {
use lopdf::{Document, Object, Stream, dictionary};
let ops = [
"BT /F1 12 Tf 1 0 0 1 100 200 Tm (Inside crop) Tj ET",
"BT /F1 12 Tf 1 0 0 1 100 150 Tm /Span <</ActualText (ActualIn)>> BDC (in) Tj EMC ET",
"BT /F1 12 Tf 1 0 0 1 5 200 Tm (LeftOut) Tj ET",
"BT /F1 12 Tf 1 0 0 1 360 200 Tm (RightOut) Tj ET",
"BT /F1 12 Tf 1 0 0 1 100 20 Tm (BelowOut) Tj ET",
"BT /F1 12 Tf 1 0 0 1 100 370 Tm (AboveOut) Tj ET",
"BT /F1 12 Tf 1 0 0 1 100 20 Tm /Span <</ActualText (ActualOut)>> BDC (out) Tj EMC ET",
"BT /F1 12 Tf 1 0 0 1 46 100 Tm (A) Tj ET",
]
.join("\n");
for rotate in [0, 90, 180, 270] {
for unit in [1, 2] {
let mut doc = Document::with_version("1.7");
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
let pages = doc.new_object_id();
let page = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages,
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
"CropBox" => vec![Object::Integer(50), Object::Integer(60), Object::Integer(350), Object::Integer(340)],
"Rotate" => rotate,
"UserUnit" => unit,
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
"Contents" => content,
});
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
doc.trailer.set("Root", catalog);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
assert_eq!(text.plain_text(), "Inside crop\nActualIn\nA", "rotate={rotate} unit={unit}");
assert!(!text.find("Inside").is_empty(), "rotate={rotate} unit={unit}");
assert!(!text.find("ActualIn").is_empty(), "rotate={rotate} unit={unit}");
for hidden in ["LeftOut", "RightOut", "BelowOut", "AboveOut", "ActualOut"] {
assert!(text.find(hidden).is_empty(), "{hidden} must be clipped at rotate={rotate} unit={unit}");
}
}
}
// A CropBox that is zero-area or disjoint from the MediaBox leaves an empty visible region,
// even when a glyph straddles the clamped intersection point.
for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] {
let mut doc = Document::with_version("1.7");
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
let pages = doc.new_object_id();
let page = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages,
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
"CropBox" => crop.map(Object::Integer).to_vec(),
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
"Contents" => content,
});
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
doc.trailer.set("Root", catalog);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text());
}
}
}