mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
PDF: use PDFium's page bounding box; bound editable objects
- Map editable objects through the page's effective bounding box (media box clipped to the crop box) rather than the raw CropBox: an oversized CropBox no longer offsets text or paths from the rendered page. Covered by a fourth fixture page with CropBox (-50, -50, 250, 150) around a 200 x 100 MediaBox. - Add a document-wide editable-object budget (20,000 layers, 1,000,000 path knots). A page that exceeds it keeps its raster, warns that its editable objects were omitted, and the unit test covers the accounting.
This commit is contained in:
1 parent
4f585f704b
commit
00a1b1cd3e
2 files changed
+117
-11
No files matched your search
@@ -103,6 +103,8 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -
|
||||
budget >> 20
|
||||
)));
|
||||
}
|
||||
let mut object_budget = ObjectBudget::default();
|
||||
let mut object_warnings: Vec<String> = Vec::new();
|
||||
let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8);
|
||||
doc.resolution_dpi = dpi as f32;
|
||||
let mut x = 0u32;
|
||||
@@ -155,14 +157,18 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -
|
||||
let mut children = vec![layer];
|
||||
if opts.pdf_text || opts.pdf_vectors {
|
||||
let frame = PageFrame { geometry: *geometry, dpi, x, rgba: &rgba, width, height };
|
||||
children.extend(object_layers(&page, opts, &frame, ctl)?);
|
||||
let (layers, truncated) = object_layers(&page, opts, &frame, ctl, &mut object_budget)?;
|
||||
children.extend(layers);
|
||||
if truncated {
|
||||
object_warnings.push(format!("PDF page {}: editable text/vector layers omitted at the import limit (the raster page is kept)", page_index + 1));
|
||||
}
|
||||
}
|
||||
let group = Group { children, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None };
|
||||
doc.layers.push(Layer::new(format!("Page {}", page_index + 1), LayerContent::Group(group)));
|
||||
x += width + GAP;
|
||||
ctl.progress((index + 1) as f32 / count as f32);
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let mut warnings = object_warnings;
|
||||
if first > 1 || last < total || count < page_count {
|
||||
let shown_last = first + count - 1;
|
||||
let reason = if count < page_count { " (the page limit is 16)" } else { "" };
|
||||
@@ -171,6 +177,39 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -
|
||||
Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false })
|
||||
}
|
||||
|
||||
/// Document-wide bound on editable objects. Text layers and path knots are allocated outside the
|
||||
/// pixel-memory estimate, so a pathological PDF could otherwise exhaust memory.
|
||||
const MAX_OBJECT_LAYERS: usize = 20_000;
|
||||
const MAX_PATH_KNOTS: usize = 1_000_000;
|
||||
|
||||
/// Tracks the editable objects created so far, document-wide.
|
||||
#[derive(Default)]
|
||||
struct ObjectBudget {
|
||||
layers: usize,
|
||||
knots: usize,
|
||||
}
|
||||
|
||||
impl ObjectBudget {
|
||||
/// Claims one editable layer; false once the document-wide limit is reached.
|
||||
fn claim_layer(&mut self) -> bool {
|
||||
if self.layers >= MAX_OBJECT_LAYERS {
|
||||
false
|
||||
} else {
|
||||
self.layers += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims `count` path knots; false if that would pass the document-wide limit.
|
||||
fn claim_knots(&mut self, count: usize) -> bool {
|
||||
if self.knots.saturating_add(count) > MAX_PATH_KNOTS {
|
||||
false
|
||||
} else {
|
||||
self.knots += count;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A page's visible area and rotation, in PDF points.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PageGeometry {
|
||||
@@ -198,7 +237,9 @@ fn page_geometry(page: &pdfium_render::prelude::PdfPage, page_number: usize) ->
|
||||
return Err(IoError::Unsupported(format!("PDF page {page_number} has no area")));
|
||||
}
|
||||
let rotation = page.rotation().map(|r| r.as_degrees() as u16).unwrap_or(0);
|
||||
let (origin_x, origin_y, crop_w, crop_h) = match page.boundaries().crop().or_else(|_| page.boundaries().media()) {
|
||||
// The page's effective box, as PDFium renders it: the bounding box (media box clipped to the
|
||||
// crop box), not the raw crop box, which may extend past the media box.
|
||||
let (origin_x, origin_y, crop_w, crop_h) = match page.boundaries().bounding().or_else(|_| page.boundaries().crop()).or_else(|_| page.boundaries().media()) {
|
||||
Ok(boundary) => {
|
||||
let r = boundary.bounds;
|
||||
(f64::from(r.left().value), f64::from(r.bottom().value), f64::from(r.right().value - r.left().value), f64::from(r.top().value - r.bottom().value))
|
||||
@@ -335,12 +376,23 @@ impl PageFrame<'_> {
|
||||
|
||||
/// Editable text and shape layers for one rendered page, in document pixels.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, frame: &PageFrame, ctl: &Interrupt) -> Result<Vec<Layer>, IoError> {
|
||||
fn object_layers(
|
||||
page: &pdfium_render::prelude::PdfPage,
|
||||
opts: &ImportOptions,
|
||||
frame: &PageFrame,
|
||||
ctl: &Interrupt,
|
||||
budget: &mut ObjectBudget,
|
||||
) -> Result<(Vec<Layer>, bool), IoError> {
|
||||
use pdfium_render::prelude::*;
|
||||
|
||||
let objects = page.objects();
|
||||
let mut layers = Vec::new();
|
||||
for object in objects.iter() {
|
||||
let mut truncated = false;
|
||||
'objects: for object in objects.iter() {
|
||||
if budget.layers >= MAX_OBJECT_LAYERS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
if ctl.check().is_err() {
|
||||
return Err(IoError::Cancelled);
|
||||
}
|
||||
@@ -372,6 +424,10 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f
|
||||
if !text.is_visible() {
|
||||
layer.visible = false;
|
||||
}
|
||||
if !budget.claim_layer() {
|
||||
truncated = true;
|
||||
break 'objects;
|
||||
}
|
||||
layers.push(layer);
|
||||
}
|
||||
PdfPageObject::Path(path) if opts.pdf_vectors => {
|
||||
@@ -396,10 +452,18 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f
|
||||
subpaths.push(Subpath { closed, knots: std::mem::take(&mut knots), op: PathOp::Combine });
|
||||
closed = false;
|
||||
}
|
||||
if !budget.claim_knots(1) {
|
||||
truncated = true;
|
||||
break 'objects;
|
||||
}
|
||||
let (px, py) = point();
|
||||
knots.push(Knot::corner(px, py));
|
||||
}
|
||||
PdfPathSegmentType::LineTo => {
|
||||
if !budget.claim_knots(1) {
|
||||
truncated = true;
|
||||
break 'objects;
|
||||
}
|
||||
let (px, py) = point();
|
||||
knots.push(Knot::corner(px, py));
|
||||
}
|
||||
@@ -412,6 +476,10 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f
|
||||
if let Some(prev) = knots.last_mut() {
|
||||
prev.out_ctrl = Point::new(c1x, c1y);
|
||||
}
|
||||
if !budget.claim_knots(1) {
|
||||
truncated = true;
|
||||
break 'objects;
|
||||
}
|
||||
knots.push(Knot::smooth(Point::new(ex, ey), Point::new(c2x, c2y), Point::new(ex, ey)));
|
||||
bezier.clear();
|
||||
}
|
||||
@@ -431,6 +499,10 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f
|
||||
continue;
|
||||
}
|
||||
let stroke = stroked.then(|| ShapeStroke { paint: Fill::Solid(Color::BLACK), width: 1.0, ..Default::default() });
|
||||
if !budget.claim_layer() {
|
||||
truncated = true;
|
||||
break 'objects;
|
||||
}
|
||||
layers.push(Layer::new(
|
||||
"Path",
|
||||
LayerContent::Shape(ShapeLayer {
|
||||
@@ -444,7 +516,7 @@ fn object_layers(page: &pdfium_render::prelude::PdfPage, opts: &ImportOptions, f
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(layers)
|
||||
Ok((layers, truncated))
|
||||
}
|
||||
|
||||
/// The highest DPI not above `requested` that keeps every page and the artboard strip within
|
||||
@@ -503,7 +575,7 @@ fn bind() -> Result<pdfium_render::prelude::Pdfium, IoError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PageFrame, PageGeometry};
|
||||
use super::{MAX_OBJECT_LAYERS, MAX_PATH_KNOTS, ObjectBudget, PageFrame, PageGeometry};
|
||||
|
||||
fn geometry(rotation: u16) -> PageGeometry {
|
||||
PageGeometry { width_pt: 595.0, height_pt: 842.0, origin_x: 0.0, origin_y: 0.0, crop_w: 595.0, crop_h: 842.0, rotation }
|
||||
@@ -522,6 +594,18 @@ mod tests {
|
||||
assert_eq!(frame(300.0, 2480).to_doc(60.0, 420.0), (2730.0, 1758.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_budget_stops_at_its_limits() {
|
||||
let mut budget = ObjectBudget::default();
|
||||
for _ in 0..MAX_OBJECT_LAYERS {
|
||||
assert!(budget.claim_layer());
|
||||
}
|
||||
assert!(!budget.claim_layer());
|
||||
let mut knots = ObjectBudget::default();
|
||||
assert!(knots.claim_knots(MAX_PATH_KNOTS));
|
||||
assert!(!knots.claim_knots(1));
|
||||
assert!(!knots.claim_knots(usize::MAX));
|
||||
}
|
||||
#[test]
|
||||
fn crop_box_offsets_and_rotations_map_like_the_raster() {
|
||||
// A 200 x 100 pt crop box at (10, 20), displayed rotated 90 degrees clockwise: 100 x 200.
|
||||
|
||||
@@ -51,12 +51,15 @@ fn three_page_pdf() -> Vec<u8> {
|
||||
"Font" => dictionary! { "F1" => font_id },
|
||||
});
|
||||
let pages = [
|
||||
("1 0 0 rg BT /F1 48 Tf 20 50 Td (Red) Tj ET", [0.0f32, 0.0, PAGE_W as f32, PAGE_H as f32], None),
|
||||
("0 0 1 rg q 2 0 0 2 10 5 cm 10 10 20 10 re f Q 0 1 0 rg BT /F1 48 Tf 20 20 Td (Green) Tj ET", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None),
|
||||
("0 0 1 rg 20 30 40 40 re f 0 0.5 0 rg BT /F1 24 Tf 40 60 Td (Rot) Tj ET", [10.0, 20.0, 210.0, 120.0], Some(90i64)),
|
||||
("1 0 0 rg BT /F1 48 Tf 20 50 Td (Red) Tj ET", [0.0f32, 0.0, PAGE_W as f32, PAGE_H as f32], None, None),
|
||||
("0 0 1 rg q 2 0 0 2 10 5 cm 10 10 20 10 re f Q 0 1 0 rg BT /F1 48 Tf 20 20 Td (Green) Tj ET", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None, None),
|
||||
("0 0 1 rg 20 30 40 40 re f 0 0.5 0 rg BT /F1 24 Tf 40 60 Td (Rot) Tj ET", [10.0, 20.0, 210.0, 120.0], Some(90i64), None),
|
||||
// A CropBox larger than the MediaBox: the rendered page and the mapping use the
|
||||
// intersection, so the rectangle must not be offset by the crop box's negative origin.
|
||||
("0 0.5 0 rg 20 30 40 40 re f", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None, Some([-50.0f32, -50.0, 250.0, 150.0])),
|
||||
];
|
||||
let mut kids = Vec::new();
|
||||
for (content, [left, bottom, right, top], rotate) in pages {
|
||||
for (content, [left, bottom, right, top], rotate, crop) in pages {
|
||||
let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec()));
|
||||
let mut page = dictionary! {
|
||||
"Type" => "Page",
|
||||
@@ -68,6 +71,9 @@ fn three_page_pdf() -> Vec<u8> {
|
||||
if let Some(rotate) = rotate {
|
||||
page.set("Rotate", rotate);
|
||||
}
|
||||
if let Some([left, bottom, right, top]) = crop {
|
||||
page.set("CropBox", Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)]));
|
||||
}
|
||||
let page_id = pdf.add_object(page);
|
||||
kids.push(Object::Reference(page_id));
|
||||
}
|
||||
@@ -139,6 +145,22 @@ fn check(document: &Document, dpi: f32) {
|
||||
assert!(m[0].abs() < 0.01 && (m[1] - 1.0).abs() < 0.01 && (m[2] + 1.0).abs() < 0.01 && m[3].abs() < 0.01, "page 3 text rotation is {m:?}");
|
||||
assert!(m[4] > page_three_x, "page 3 text x misses the page offset: {} <= {page_three_x}", m[4]);
|
||||
assert!(text.color.c[1] > 0.3 && text.color.c[0] < 0.2 && text.color.c[2] < 0.2, "page 3 text should be green, got {:?}", text.color.c);
|
||||
|
||||
// Page 4: a CropBox (-50, -50, 250, 150) around a 200 x 100 MediaBox must not offset the rect.
|
||||
let shape = shape_layer(page_children(document, 4));
|
||||
let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) {
|
||||
min_x = min_x.min(knot.anchor.x);
|
||||
max_x = max_x.max(knot.anchor.x);
|
||||
min_y = min_y.min(knot.anchor.y);
|
||||
max_y = max_y.max(knot.anchor.y);
|
||||
}
|
||||
// Page 3 is rotated 90 degrees, so it is 100 pt wide on the strip, not 200.
|
||||
let page_four_x = 2.0 * (PAGE_W * scale).round() + (100.0 * scale).round() + 3.0 * GAP;
|
||||
assert!((min_x - (page_four_x + 20.0 * scale)).abs() < 3.0, "oversized-crop path min x is {min_x}, page 4 starts at {page_four_x} ({dpi} dpi)");
|
||||
assert!((max_x - (page_four_x + 60.0 * scale)).abs() < 3.0, "oversized-crop path max x is {max_x} ({dpi} dpi)");
|
||||
assert!((min_y - 30.0 * scale).abs() < 3.0, "oversized-crop path min y is {min_y} ({dpi} dpi)");
|
||||
assert!((max_y - 70.0 * scale).abs() < 3.0, "oversized-crop path max y is {max_y} ({dpi} dpi)");
|
||||
}
|
||||
|
||||
fn page_children(document: &Document, page: usize) -> &[Layer] {
|
||||
|
||||
Reference in new issue
Block a user