From 00a1b1cd3ebedfc090d5bc361b406adf5160c9a1 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:12:14 +0100 Subject: [PATCH] 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. --- crates/io/src/pdf_import.rs | 98 +++++++++++++++++++++++++++++--- crates/io/tests/it/pdf_import.rs | 30 ++++++++-- 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 82233b2abc..10d88beaf8 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -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 = 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, IoError> { +fn object_layers( + page: &pdfium_render::prelude::PdfPage, + opts: &ImportOptions, + frame: &PageFrame, + ctl: &Interrupt, + budget: &mut ObjectBudget, +) -> Result<(Vec, 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 { #[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. diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs index 9bee105b0c..f659eb5166 100644 --- a/crates/io/tests/it/pdf_import.rs +++ b/crates/io/tests/it/pdf_import.rs @@ -51,12 +51,15 @@ fn three_page_pdf() -> Vec { "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 { 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] {