Merge #362 update: placed PDFs cropped to their bounding box render only when drawn or exported

This commit is contained in:
aiongg
2026-10-11 03:01:04 +00:00
9 changed files with 121 additions and 13 deletions
+14 -3
View File
@@ -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"
}
}
+1 -1
View File
@@ -211,7 +211,7 @@ pub fn specs() -> Vec<CommandSpec> {
// 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));
+5 -2
View File
@@ -40,7 +40,8 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
};
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<designcraft_doc::
continue;
}
// The whole page at 300 ppi (the graphic may show only a box of it).
let shown = a.shown_box();
let shown = designcraft_render::shown_box(&a);
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t));
let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
@@ -559,6 +560,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
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.
+33 -3
View File
@@ -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<AssetId> = 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<AssetId, (f64, f64)> = 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;
+1 -1
View File
@@ -1954,7 +1954,7 @@ fn clipping_path(s: &mut Session, p: &Value) -> Result<Value> {
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,
};
+2 -1
View File
@@ -401,7 +401,8 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
}
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);
}
+2 -1
View File
@@ -57,7 +57,8 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
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<String> = vec!["lpr".into()];
+29
View File
@@ -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:?}");
}
+34 -1
View File
@@ -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<Vec<u8>>, 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<Vec<u8>>, page: u32, crop: designcraft_doc::PdfCrop) -> Option<[f64; 4]> {
static CACHE: std::sync::Mutex<Option<ContentBoxes>> = 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)?;