mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
fix(render): clip extracted text to the page CropBox (#2670)
This commit is contained in:
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user