diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 53b2c8b0..96ccb8c3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -505,7 +505,8 @@ pub struct Asset { pub pdf_crop: PdfCrop, /// Where the `pdf_crop` box sits on the page as rendered (its visible crop box, rotated as it /// displays): left, top, right, bottom as fractions of the page's width and height. Graphic - /// space (0,0)-(w,h) spans this box. `None`: the whole page. + /// space (0,0)-(w,h) spans this box. `None`: the whole page, or a Bounding Box crop not + /// measured yet (`designcraft_render::shown_box` measures it when the graphic is drawn). #[serde(default, skip_serializing_if = "Option::is_none")] pub pdf_box: Option<[f64; 4]>, } @@ -513,11 +514,21 @@ pub struct Asset { impl Asset { /// The part of the page a placed PDF shows, when [`Asset::pdf_box`] holds a usable box. pub fn shown_box(&self) -> Option<[f64; 4]> { - let b = self.pdf_box.filter(|_| self.mime == "application/pdf")?; + self.shown(self.pdf_box?) + } + + /// `b` as the part of the page this asset shows: a usable box of a placed PDF. + pub fn shown(&self, b: [f64; 4]) -> Option<[f64; 4]> { // A box under 1/10000 of the page, or far outside it, would blow the page up to sizes // nothing can draw. let usable = b.iter().all(|v| v.is_finite() && v.abs() <= 1e3) && b[2] - b[0] >= 1e-4 && b[3] - b[1] >= 1e-4; - usable.then_some(b) + (usable && self.mime == "application/pdf").then_some(b) + } + + /// A Bounding Box crop ([`PdfCrop::ContentVisible`], [`PdfCrop::ContentAll`]) whose box isn't + /// stored yet: finding it needs the page rendered. + pub fn content_box_pending(&self) -> bool { + self.pdf_box.is_none() && matches!(self.pdf_crop, PdfCrop::ContentVisible | PdfCrop::ContentAll) && self.mime == "application/pdf" } } diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 2ba0f2f6..849796a2 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -211,7 +211,7 @@ pub fn specs() -> Vec { // The chapter's own fonts, for this export (skipped font files are logged). let _fonts = super::file::load_document_fonts(&mut d, path).0; let cache = designcraft_compose::Cache::new(); - let r = designcraft_pdf::export_pdf_with_report(&d, &cache, &designcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?; + let r = designcraft_pdf::export_pdf_with_report(&super::interchange::with_pdf_boxes(&d), &cache, &designcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?; let n = designcraft_render::pdf_page_count(&r.bytes).unwrap_or(0); let sizes: Vec<(f32, f32)> = (0..n).filter_map(|i| designcraft_render::pdf_page_size(&r.bytes, i)).map(|(w, h)| (w as f32, h as f32)).collect(); parts.push((r.bytes, sizes)); diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index acadf2bf..5b9e4789 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -40,7 +40,8 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result { }; let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None }; let st = s.doc()?; - let r = designcraft_pdf::export_booklet(&st.doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let r = designcraft_pdf::export_booklet(&super::interchange::with_pdf_boxes(&st.doc), &s.cache, &opts) + .map_err(|e| EngineError::Other(e.to_string()))?; match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] @@ -505,7 +506,7 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option Result { let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache); let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); + let boxed = super::interchange::with_pdf_boxes(doc); + let doc: &designcraft_doc::Document = &boxed; let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let mut r = r; // Transparency Blend Space: the page group of pages with transparency. diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index a3c37915..2c5524b6 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -75,15 +75,29 @@ pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Doc /// Placed PDFs read from IDML name their crop (`PDFCrop`): find where each box sits on its page, /// which the IDML reader can't (it doesn't parse PDFs). The box spans the graphic's -/// `GraphicBounds`; a graphic without bounds takes the box's size. +/// `GraphicBounds`; a graphic without bounds takes the box's size. Bounding Box crops render the +/// page to find the box, so they wait for drawing or export ([`designcraft_render::shown_box`], +/// [`with_pdf_boxes`]) unless a graphic needs the box's size now. pub(crate) fn resolve_pdf_crops(d: &mut Document) { - use std::collections::HashMap; + use std::collections::{HashMap, HashSet}; use std::sync::Arc; use designcraft_doc::{AssetId, Content, Item, PdfCrop}; + let mut no_bounds: HashSet = HashSet::new(); + for sp in d.spreads.iter().chain(&d.parents) { + for it in &sp.items { + it.walk(&mut |i| { + if let Content::Graphic(g) = &i.content + && !(g.size.0 > 0.0 && g.size.1 > 0.0) + { + no_bounds.insert(g.asset); + } + }); + } + } let mut sizes: HashMap = HashMap::new(); for a in d.assets.values_mut() { - if a.pdf_crop == PdfCrop::Crop || a.pdf_box.is_some() { + if a.pdf_crop == PdfCrop::Crop || a.pdf_box.is_some() || (a.content_box_pending() && !no_bounds.contains(&a.id)) { continue; } let a = Arc::make_mut(a); @@ -119,6 +133,22 @@ pub(crate) fn resolve_pdf_crops(d: &mut Document) { } } +/// `d` with the boxes of its Bounding Box crops measured ([`resolve_pdf_crops`] leaves them for +/// later), for writers that don't render: PDF export places the page by the box. +pub(crate) fn with_pdf_boxes(d: &Document) -> std::borrow::Cow<'_, Document> { + if !d.assets.values().any(|a| a.content_box_pending()) { + return std::borrow::Cow::Borrowed(d); + } + let mut out = d.clone(); + for a in out.assets.values_mut() { + if a.content_box_pending() { + // No usable box: the whole page. + std::sync::Arc::make_mut(a).pdf_box = designcraft_render::shown_box(a); + } + } + std::borrow::Cow::Owned(out) +} + #[cfg(not(target_arch = "wasm32"))] const MAX_LINK_PATH_BYTES: usize = 128 * 1024; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 0190fd3b..c5ee322d 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1954,7 +1954,7 @@ fn clipping_path(s: &mut Session, p: &Value) -> Result { let Some(asset) = d.assets.get(&g.asset) else { continue }; let Some(px) = designcraft_render::decode_pixmap_page(&asset.data, asset.page) else { continue }; // A placed PDF cropped to a box: trace that box, which graphic space spans. - let px = match asset.shown_box() { + let px = match designcraft_render::shown_box(asset) { Some(frac) => designcraft_render::crop_pixmap(&px, frac).unwrap_or(px), None => px, }; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index b4706492..f0ad9fd4 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -401,7 +401,8 @@ fn package(s: &mut Session, p: &Value) -> Result { } if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { let path = package_output_path(&dir, &title, "pdf")?; - let bytes = designcraft_pdf::export_pdf(&packed, &s.cache, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?; + let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &Default::default()) + .map_err(|e| EngineError::Other(e.to_string()))?; root.write(&path, &bytes)?; files.push(path); } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 724feb2b..cfb953a1 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -57,7 +57,8 @@ fn print(s: &mut Session, p: &Value) -> Result { let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999); let printer = str_param(p, "printer").map(str::to_string); let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); - let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let pdf = + designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(d), &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let mut file = secure_print_file()?; let file_path = file.path().to_path_buf(); let mut cmd: Vec = vec!["lpr".into()]; diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 81582808..6d7c7bf5 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -470,3 +470,32 @@ fn idml_placed_pdf_cropped_to_its_media_box_gets_media_box_bounds() { let b = a.shown_box().unwrap_or([0.0, 0.0, 1.0, 1.0]); assert!(b.iter().zip([0.0, 0.0, 1.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-6), "{b:?}"); } + +/// A Bounding Box crop needs the page rendered to find its box. IDML import leaves that until +/// the graphic is drawn or exported (its `GraphicBounds` give its size), so opening a document +/// doesn't render every placed PDF; drawing and PDF export find the same box placing it did. +#[test] +fn idml_bounding_box_crop_is_measured_when_drawn_not_on_import() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [100, 200, 300, 260], "content": "unassigned"})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "contentVisible", "width": 200, "x": 20, "y": 30})).unwrap(); + let placed = t.doc().unwrap().doc.assets.values().next().unwrap().shown_box().expect("placing measures the box"); + assert!(placed[0] > 0.1 && placed[2] < 0.6, "the drawn rectangle, not the page: {placed:?}"); + + let bytes = designcraft_idml::export_idml(&t.doc().unwrap().doc); + let back = crate::cmd::interchange::import(&bytes, None).unwrap(); + let a = back.assets.values().next().unwrap(); + assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::ContentVisible); + assert!(a.content_box_pending(), "import didn't render the page: {:?}", a.pdf_box); + let close = |b: [f64; 4]| b.iter().zip(placed).all(|(x, y)| (x - y).abs() < 1e-9); + let drawn = designcraft_render::shown_box(a).expect("drawing measures the box"); + assert!(close(drawn), "{drawn:?} vs {placed:?}"); + let boxed = crate::cmd::interchange::with_pdf_boxes(&back); + let exported = boxed.assets.values().next().unwrap().shown_box().expect("PDF export gets the box"); + assert!(close(exported), "{exported:?} vs {placed:?}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4521af2d..1aaa6e34 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -768,7 +768,7 @@ impl Renderer { let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone()); let data = if !hidden.is_empty() && is_pdf(&asset.data) { pdf_layers::layered(&asset.data, &hidden) } else { asset.data.clone() }; // A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space. - let [l, t, r, b] = asset.shown_box().unwrap_or([0.0, 0.0, 1.0, 1.0]); + let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]); let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t)); let page = Rect::new(-l * page_w, -t * page_h, (1.0 - l) * page_w, (1.0 - t) * page_h); let Some(pm) = images::mip(&data, asset.page, page_w, on_screen) else { return }; @@ -1137,6 +1137,39 @@ pub fn pdf_crop_box(bytes: &[u8], page: usize, crop: designcraft_doc::PdfCrop) - (frac.iter().all(|v| v.is_finite()) && size.0 > 0.0 && size.1 > 0.0).then_some((frac, size)) } +/// The part of the page a placed PDF shows ([`designcraft_doc::Asset::shown_box`]). A Bounding +/// Box crop that import left unmeasured is measured here, once per file, page and crop: that +/// renders the page, which only drawing or exporting the graphic needs. +pub fn shown_box(asset: &designcraft_doc::Asset) -> Option<[f64; 4]> { + if !asset.content_box_pending() { + return asset.shown_box(); + } + asset.shown(content_box(&asset.data, asset.page, asset.pdf_crop)?) +} + +type ContentBoxes = HashMap<(usize, u32, designcraft_doc::PdfCrop), (std::sync::Weak>, Option<[f64; 4]>)>; + +/// [`pdf_crop_box`]'s box, cached by the file's allocation. The weak reference keeps that address +/// from naming another file while the entry lives. +fn content_box(bytes: &Arc>, page: u32, crop: designcraft_doc::PdfCrop) -> Option<[f64; 4]> { + static CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + let key = (Arc::as_ptr(bytes) as usize, page, crop); + let cached = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.get(&key)).and_then(|(file, b)| { + let same = file.upgrade().is_some_and(|f| Arc::ptr_eq(&f, bytes)); + same.then_some(*b) + }); + if let Some(b) = cached { + return b; + } + // Render outside the lock: other graphics keep drawing meanwhile. + let b = pdf_crop_box(bytes, page as usize, crop).map(|(frac, _)| frac); + let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let cache = cache.get_or_insert_with(HashMap::new); + cache.retain(|_, (file, _)| file.strong_count() > 0); + cache.insert(key, (Arc::downgrade(bytes), b)); + b +} + /// The bounds of the non-transparent pixels of PDF page `page`, as fractions of the page. fn content_bounds(bytes: &[u8], page: usize) -> Option<[f64; 4]> { let pm = render_pdf_page(bytes, page, 2048)?;