From 580db95c7140b98eaa4cc2679adf6e7f0f3150a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:15:11 +0000 Subject: [PATCH 1/4] IDML: font names with the $ID/ prefix resolve AppliedFont values like "$ID/Arial" import as the plain family, in paragraph and character styles, text ranges and composite font entries. The font database also looks a family up without the prefix, so documents that already hold one draw in the named font. --- crates/fonts/src/fontdb.rs | 19 +++++++++++++----- crates/fonts/src/lib.rs | 14 +++++++++++++ crates/idml/src/import.rs | 12 ++---------- crates/idml/src/names.rs | 9 +++++++++ crates/idml/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++++- 5 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..279379ca6 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -712,6 +712,14 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s }) } +/// `family` without what layout apps add to a family name, if it has any: InDesign's `$ID/` +/// prefix (`$ID/Arial` → `Arial`) and the font-format suffix appended when a family is installed +/// in several formats (`Minion Pro (OTF)` → `Minion Pro`). +fn plain_family(family: &str) -> Option<&str> { + let unprefixed = family.trim_start().strip_prefix("$ID/").map(str::trim).filter(|f| !f.is_empty()); + without_format_suffix(unprefixed.unwrap_or(family)).or(unprefixed) +} + /// `family` without the font-format suffix layout apps append when a family is installed in /// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one. fn without_format_suffix(family: &str) -> Option<&str> { @@ -1228,7 +1236,7 @@ impl ScopedFonts<'_> { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { let v = self.exact_styles(family); - match without_format_suffix(family) { + match plain_family(family) { Some(base) if v.is_empty() => self.exact_styles(base), _ => v, } @@ -1255,9 +1263,10 @@ impl ScopedFonts<'_> { /// Resolve a family + style to a face, falling back to the closest style of the family, then to /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. A - /// family with a format suffix (`Minion Pro (OTF)`) that isn't found is looked up without it. + /// family with InDesign's `$ID/` prefix or a format suffix (`Minion Pro (OTF)`) that isn't + /// found is looked up without them. pub fn face(&self, family: &str, style: &str) -> Arc { - if let Some(f) = self.find_or_load(family, style).or_else(|| without_format_suffix(family).and_then(|base| self.find_or_load(base, style))) { + if let Some(f) = self.find_or_load(family, style).or_else(|| plain_family(family).and_then(|base| self.find_or_load(base, style))) { return f; } self.find(FALLBACK_FAMILY, style) @@ -1278,9 +1287,9 @@ impl ScopedFonts<'_> { } /// Is `family` available (the document's, loaded, or installed on the system), as named or - /// without a format suffix (see [`ScopedFonts::face`])? + /// without an `$ID/` prefix or a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { - self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base)) + self.has_exact_family(family) || plain_family(family).is_some_and(|base| self.has_exact_family(base)) } fn has_exact_family(&self, family: &str) -> bool { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c1d744780..70ec8cf19 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -437,6 +437,20 @@ mod tests { assert!(!db.has_family("DC Test Suffix")); } + /// IDML files from InDesign can name a family with its `$ID/` prefix (`$ID/Arial`). + #[test] + fn an_indesign_id_prefix_names_the_plain_family() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let prefixed = format!("$ID/{DEFAULT_FAMILY}"); + assert_eq!(db.face(&prefixed, "Bold").family, DEFAULT_FAMILY); + assert!(db.has_family(&prefixed)); + assert_eq!(db.styles(&prefixed), db.styles(DEFAULT_FAMILY)); + assert!(db.has_family(&format!("$ID/{DEFAULT_FAMILY} (OTF)"))); + assert!(!db.has_family("$ID/")); + assert!(!db.has_family("$ID/DC No Such Family")); + } + #[test] fn outlines_and_metrics() { let db = FontDb::global(); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d85d9ee02..5276f74cb 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -291,7 +291,7 @@ impl<'r> Importer<'r> { .map(|c| designcraft_doc::cjk::CompositeFontEntry { name: c.get("Name").unwrap_or("").into(), characters: c.get("CustomCharacters").unwrap_or("").into(), - family: c.prop("AppliedFont").unwrap_or_default(), + family: c.prop("AppliedFont").as_deref().and_then(names::font_family_in).unwrap_or_default(), style: c.get("FontStyle").unwrap_or("Regular").trim_start_matches("$ID/").into(), relative_size: c.num("RelativeSize").unwrap_or(100.0) / 100.0, horizontal_scale: c.num("HorizontalScale").unwrap_or(100.0) / 100.0, @@ -1064,15 +1064,7 @@ impl<'r> Importer<'r> { } fn char_attrs(&mut self, e: &El) -> CharAttrs { - let mut a = CharAttrs::default(); - if let Some(f) = e.prop("AppliedFont") { - let f = f.trim(); - // Some writers append the style after a tab. - let fam = f.split('\t').next().unwrap_or(f); - if !fam.is_empty() && fam != "$ID/" { - a.font_family = Some(fam.to_string()); - } - } + let mut a = CharAttrs { font_family: e.prop("AppliedFont").as_deref().and_then(names::font_family_in), ..Default::default() }; a.font_style = e.prop("FontStyle"); a.size = e.num("PointSize"); a.leading = e.prop("Leading").and_then(|l| names::leading_in(l.trim())); diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 1df0f56d5..bba20370b 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -81,6 +81,15 @@ pub fn unescape_id(s: &str) -> String { s.replace("%3a", ":").replace("%3A", ":").replace("%25", "%") } +/// An `AppliedFont` value → font family. InDesign can write the family with its `$ID/` prefix +/// (`$ID/Arial`), and `$ID/` alone for no font; some writers append the style after a tab. +pub fn font_family_in(v: &str) -> Option { + let v = v.trim(); + let family = v.split('\t').next().unwrap_or(v); + let family = family.strip_prefix("$ID/").unwrap_or(family).trim(); + (!family.is_empty()).then(|| family.to_string()) +} + /// Built-in style names: (ours, IDML `Name`). pub const PARA_BUILTINS: &[(&str, &str)] = &[("[No Paragraph Style]", "$ID/[No paragraph style]"), ("[Basic Paragraph]", "$ID/NormalParagraphStyle")]; pub const CHAR_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[No character style]")]; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7b747587a..ce98f6e8c 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -838,7 +838,7 @@ fn cjk_composite_fonts_and_custom_kinsoku_are_document_resources() { let map = DESIGNMAP.replace("", r#" Source Serif 4 - Source Sans 3 + $ID/Source Sans 3 "#); let story = STORY.replace("Source Serif 4", ">$ID/Source Serif 4"); + let story = STORY.replace( + r#""#, + r#"$ID/Source Serif 4"#, + ); + let bytes = zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", &story), + ]); + let d = import_idml_with(&bytes, &|_| None).unwrap(); + let fonts: Vec<&str> = d.styles.paragraph.iter().filter_map(|s| s.chars.font_family.as_deref()).collect(); + assert!(!fonts.is_empty() && fonts.iter().all(|f| *f == "Source Serif 4"), "{fonts:?}"); + let (sid, story) = d.stories.iter().next().unwrap(); + let bold = story.runs().find(|(r, _)| story.text[r.clone()].starts_with("bold")).unwrap().1; + assert_eq!(bold.over.font_family.as_deref(), Some("Source Serif 4")); + // The bundled family draws the text (an unknown family would fall back to Source Sans 3). + let cs = designcraft_compose::compose_story(&d, *sid, &Default::default()); + let first_para = story.text.find('\n').unwrap(); + let families: Vec<&str> = cs + .frames + .iter() + .flat_map(|f| &f.lines) + .flat_map(|l| &l.glyphs) + .filter(|g| g.visible && g.len > 0 && g.byte < first_para) + .map(|g| g.face.get().family.as_str()) + .collect(); + assert!(!families.is_empty() && families.iter().all(|f| *f == "Source Serif 4"), "{families:?}"); +} From 0f11a4599f502895ce25276bc8a551b7c722995a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:15:19 +0000 Subject: [PATCH 2/4] IDML: placed PDFs keep their page and crop Export writes a PDFAttribute with the page number and PDFCrop="CropPDF": the graphic bounds already span the page's crop box, so the frame clips to the chosen trim, bleed or media box as before. Import reads the page number back instead of always showing page 1. A placed PDF page that the PDF does not have (a hostile PageNumber, or any document holding one) is left out of PDF export with a warning instead of failing the whole export. --- crates/engine/src/tests_idml.rs | 61 +++++++++++++++++++++++++++++++++ crates/idml/src/export.rs | 11 ++++++ crates/idml/src/import.rs | 8 ++++- crates/idml/src/lib.rs | 3 ++ crates/pdf/src/export.rs | 28 ++++++++++----- 5 files changed, 101 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index cc40768ea..ae466e134 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -251,3 +251,64 @@ fn named_lists_round_trip_through_idml() { assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps")); assert_eq!(st.paras[0].para.start_at, Some(Some(5))); } + +/// IDML says which page of a placed PDF shows and which box its graphic spans, so InDesign opens +/// it at the same size and position; importing it back gives the same page and geometry. +#[test] +fn placed_pdf_page_and_crop_round_trip_through_idml() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + // Page 2, cropped to its trim box: the frame is smaller than the page with its bleed. + let place = json!({"base64": b64, "name": "a.pdf", "pdfPage": 2, "pdfCrop": "trim", "width": 315, "x": 20, "y": 30}); + let r = t.execute("file.place", &place).unwrap(); + let d = t.doc().unwrap().doc.clone(); + let item = d.item(ItemId(r["id"].as_u64().unwrap())).unwrap(); + let (bounds, g) = (item.bounds(), item.graphic().unwrap().clone()); + assert_eq!(d.assets[&g.asset].page, 1); + + let bytes = designcraft_idml::export_idml(&d); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + let mut spreads = String::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + if f.name().starts_with("Spreads/") { + std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); + } + } + let attr = spreads.split("").next()).expect("a PDFAttribute"); + assert!(attr.contains(r#"PageNumber="2""#) && attr.contains(r#"PDFCrop="CropPDF""#), "{attr}"); + + let back = designcraft_idml::import_idml(&bytes).unwrap(); + let bi = back.spreads.iter().flat_map(|sp| sp.items.iter()).find(|i| i.graphic().is_some()).unwrap(); + let bg = bi.graphic().unwrap(); + assert_eq!(back.assets[&bg.asset].page, 1); + let bb = bi.bounds(); + assert!([bb.x0 - bounds.x0, bb.y0 - bounds.y0, bb.x1 - bounds.x1, bb.y1 - bounds.y1].iter().all(|v| v.abs() < 1e-3), "{bb:?} vs {bounds:?}"); + assert!((bg.size.0 - g.size.0).abs() < 1e-3 && (bg.size.1 - g.size.1).abs() < 1e-3); + let (a, b) = ((bi.xf * bg.xf).as_coeffs(), (item.xf * g.xf).as_coeffs()); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3), "{a:?} vs {b:?}"); + + // A page the PDF doesn't have imports, and the PDF export leaves it out with a warning. + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + let mut body = String::new(); + std::io::Read::read_to_string(&mut f, &mut body).unwrap(); + out.start_file(f.name(), zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut out, body.replace(r#"PageNumber="2""#, r#"PageNumber="4294967295""#).as_bytes()).unwrap(); + } + let hostile = designcraft_idml::import_idml(&out.finish().unwrap().into_inner()).unwrap(); + assert_eq!(hostile.assets.values().map(|a| a.page).collect::>(), [u32::MAX - 1]); + t.edit(|doc, _| { + *doc = hostile; + Ok(()) + }) + .unwrap(); + let r = t.execute("file.exportPdf", &json!({})).unwrap(); + assert!(r["warnings"].to_string().contains("has no page 4294967295"), "{r}"); +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..00683e747 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1802,6 +1802,17 @@ impl<'a> Ex<'a> { .attr("ImportPolicy", "NoAutoImport") .attr("ExportPolicy", "NoAutoExport"), ); + if is_pdf { + // The graphic spans the page's crop box (the page as it shows): InDesign's "Crop to: + // Crop". Without `PDFAttribute` InDesign crops the page to its visible content, which + // changes the graphic's size and so its scale and position. + el.push( + El::new("PDFAttribute") + .attr("PageNumber", asset.page.saturating_add(1)) + .attr("PDFCrop", "CropPDF") + .attr("TransparentBackground", "true"), + ); + } el } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 5276f74cb..d639ee128 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2220,7 +2220,8 @@ impl<'r> Importer<'r> { uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into()); let id = AssetId(self.alloc()); let link_path = uri.filter(|p| p.contains('/') || p.contains('\\')); - self.assets.insert(id, Arc::new(Asset { page: 0, id, name, mime, link: link_path, data: Arc::new(data), pixels })); + let page = pdf_page_in(g); + self.assets.insert(id, Arc::new(Asset { page, id, name, mime, link: link_path, data: Arc::new(data), pixels })); Content::Graphic(designcraft_doc::Graphic { asset: id, size, @@ -2531,6 +2532,11 @@ fn path_of(pg: &El) -> PathData { PathData::new(subs) } +/// The page a placed PDF shows (0-based), from its `PDFAttribute` (`PageNumber` is 1-based). +fn pdf_page_in(g: &El) -> u32 { + g.find("PDFAttribute").and_then(|a| a.get("PageNumber")).and_then(|v| v.trim().parse::().ok()).map_or(0, |n| n.saturating_sub(1)) +} + /// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`). pub(crate) fn uri_to_path(uri: &str) -> String { let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri); diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index 71d688255..fc282e509 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -35,6 +35,9 @@ //! - Mixed inks, gradient stop opacity and gradient feathers, effects other than drop shadow and //! basic feather, EPS/PDF/AI placed graphics (imported as images when the data is available), //! clipping paths, compound-path fill rules. +//! - Placed PDFs: the page (`PDFAttribute` `PageNumber`) round-trips and export writes +//! `PDFCrop="CropPDF"`, the box DesignCraft draws. On import, a `PDFCrop` naming another box +//! (trim, bleed, media, content) is not mapped: the page's crop box fills `GraphicBounds`. //! - Unknown elements are ignored; nothing is preserved opaquely for round-trip yet. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d92fc242b..05ac80adf 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -448,8 +448,8 @@ pub(crate) struct Exporter<'a> { images_by_content: ImageContentCache, #[cfg(test)] image_conversions: usize, - /// Placed PDFs (embedded as vector pages). - pdfs: HashMap>, + /// Placed PDFs (embedded as vector pages) and their page counts. + pdfs: HashMap>, /// The PDF version written; placed PDFs must not be newer. version: PdfVersion, /// Placed PDFs too new to embed in this version ("name is PDF 2.0"): the export fails. @@ -1175,7 +1175,8 @@ impl Exporter<'_> { /// VectorCraft and most tools write 1.7. PDF 1.7 changed nothing in how a page draws, so a 1.7 /// file goes in as 1.6 (its header relabelled, as `qpdf --force-version=1.6` does). A newer /// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]). - fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option { + /// Returns the document with its page count. + fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<(krilla::pdf::PdfDocument, usize)> { use hayro_syntax::PdfVersion as V; let Ok(pdf) = krilla::pdf::Pdf::new(asset.data.clone()) else { self.warn(format!("{}: can't read the placed PDF", asset.name)); @@ -1190,7 +1191,8 @@ impl Exporter<'_> { }; let have = pdf.version(); if have <= max { - return Some(krilla::pdf::PdfDocument::new(Arc::new(pdf))); + let pages = pdf.pages().len(); + return Some((krilla::pdf::PdfDocument::new(Arc::new(pdf)), pages)); } if have == V::Pdf17 && max == V::Pdf16 @@ -1199,7 +1201,8 @@ impl Exporter<'_> { && pdf.version() <= max { self.warn(format!("{}: a PDF 1.7 placed in a PDF 1.6 file, embedded as PDF 1.6", asset.name)); - return Some(krilla::pdf::PdfDocument::new(Arc::new(pdf))); + let pages = pdf.pages().len(); + return Some((krilla::pdf::PdfDocument::new(Arc::new(pdf)), pages)); } let v = match have { V::Pdf17 => "PDF 1.7", @@ -1314,10 +1317,17 @@ impl Exporter<'_> { p } }; - if let Some(placed) = placed { - s.push_transform(&tf(g.xf)); - s.draw_pdf_page(&placed, size, asset.page as usize); - s.pop(); + match placed { + // krilla would fail the whole export at the end on a page the PDF doesn't have. + Some((_, pages)) if asset.page as usize >= pages => { + self.warn(format!("{}: the placed PDF has no page {}", asset.name, u64::from(asset.page) + 1)); + } + Some((placed, _)) => { + s.push_transform(&tf(g.xf)); + s.draw_pdf_page(&placed, size, asset.page as usize); + s.pop(); + } + None => {} } return; } From 7d5af8961cbd469e49de822ad4b48969a7039908 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:15:19 +0000 Subject: [PATCH 3/4] Package: the packaged IDML links its images instead of embedding them The images are already copied to the package's Links folder, and the IDML links point there; it no longer also embeds a copy of each one. --- ROADMAP.md | 2 +- crates/engine/src/cmd/package.rs | 39 +++++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 86a435456..b04bff8e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. -- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). +- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). Placed PDFs keep their page (`PDFAttribute`), font names with the `$ID/` prefix resolve, and the IDML in a package links the images in its Links folder without embedding them. - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 468667510..ae5ace4d0 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -197,7 +197,7 @@ pub fn specs() -> Vec { "Package…", ["File"], None, - "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy, an optional PDF and a report → {dir, files, report}", + "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}", has_doc, package ), @@ -394,7 +394,9 @@ fn package(s: &mut Session, p: &Value) -> Result { .collect(); if p.get("idml").and_then(Value::as_bool).unwrap_or(true) { let path = package_output_path(&dir, &title, "idml")?; - root.write(&path, &designcraft_idml::export_idml(&packed))?; + // The images are in Links/ beside it: link them there, without embedded copies. + let opts = designcraft_idml::ExportOptions { embed_images: false }; + root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?; files.push(path); } if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { @@ -464,7 +466,7 @@ mod tests { let mut opened = Session::new(); opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap(); let asset = opened.doc().unwrap().doc.assets.values().next().unwrap(); - assert_eq!(*asset.data, png, "{extension}: preserve embedded pixels"); + assert_eq!(*asset.data, png, "{extension}: the packaged pixels"); assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file"); let links = opened.execute("links.list", &json!({})).unwrap(); assert_eq!(links[0]["status"], "ok", "{extension}"); @@ -472,6 +474,37 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// The package's IDML links its images to the files in the package's Links folder and embeds + /// no copies of them. + #[test] + fn package_idml_links_the_links_folder_and_embeds_nothing() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-idml-links-{}-{nonce}", std::process::id())); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "LinkedPackage", "width": 72, "height": 72})).unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "my photo.png", "x": 5, "y": 5, "width": 16})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); + let idml = std::fs::read(dir.join("LinkedPackage.idml")).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(idml.as_slice())).unwrap(); + let mut spreads = String::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + if f.name().starts_with("Spreads/") { + std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); + } + } + let linked = dir.join("Links").join("my photo.png"); + let uri = format!("file:{}", linked.to_string_lossy().replace(' ', "%20")); + assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}"); + assert!(!spreads.contains(" Date: Sat, 10 Oct 2026 15:52:40 +0000 Subject: [PATCH 4/4] IDML: a placed PDF keeps its crop choice The placed PDF's asset stores its Crop to choice (crop, art, trim, bleed, media, or the content bounds with visible or all layers) and where that box sits on the page. The graphic spans the box: the screen, PDF export, rasterised exports and clipping-path tracing draw only that part of the page, so a larger frame doesn't reveal the bleed. IDML export writes the choice as PDFCrop, with GraphicBounds spanning the box. Import reads PDFCrop back; the engine finds the box on the PDF page after import (and after relink or update) and maps it onto GraphicBounds, or gives a graphic without bounds the box's size. Documents saved without a choice load with the crop box. The Place PDF dialog offers the content bounds too, and links.list reports the choice. --- ROADMAP.md | 2 +- apps/designcraft-cli/src/perf.rs | 3 +- crates/doc/src/lib.rs | 64 ++++++++- crates/doc/src/tests.rs | 15 ++ crates/engine/src/cmd/book.rs | 4 +- crates/engine/src/cmd/datamerge/fill.rs | 2 +- crates/engine/src/cmd/export.rs | 17 +-- crates/engine/src/cmd/file.rs | 126 ++++++++++++++--- crates/engine/src/cmd/interchange.rs | 49 ++++++- crates/engine/src/cmd/links.rs | 8 +- crates/engine/src/cmd/mathexpr.rs | 6 +- crates/engine/src/cmd/media.rs | 7 +- crates/engine/src/cmd/object.rs | 5 + crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/sample.rs | 2 +- crates/engine/src/tests_idml.rs | 53 ++++++- crates/epub/src/lib.rs | 3 +- crates/format/src/lib.rs | 3 +- crates/idml/src/export.rs | 12 +- crates/idml/src/import.rs | 5 +- crates/idml/src/lib.rs | 10 +- crates/idml/src/names.rs | 20 +++ crates/pdf/src/export.rs | 20 ++- crates/pdf/src/pdfx.rs | 2 +- crates/pdf/src/tests.rs | 3 +- .../support/image_content_cache_contract.rs | 2 +- crates/render/src/lib.rs | 132 ++++++++++++++---- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/i18n.rs | 20 +++ 29 files changed, 502 insertions(+), 99 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b04bff8e2..a53591ed9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,7 +78,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. - Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. - Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. -- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. +- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|art|trim|bleed|media|contentVisible|contentAll}`, Place PDF dialog): the graphic spans that page box, the document stores the choice, and IDML keeps it as `PDFCrop`. - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). - Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows. - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 7749f6443..4b542a946 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -183,13 +183,12 @@ pub fn synthetic(spec: &Spec) -> Result { d.assets.insert( id, Arc::new(Asset { - page: 0, id, name: format!("art-{k}.png"), mime: "image/png".into(), - link: None, data: Arc::new(data), pixels: Some((aw, ah)), + ..Default::default() }), ); id diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4b69e86d..2e28e62f7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -431,7 +431,7 @@ pub const LAYER_COLORS: &[(&str, [u8; 3])] = &[ ]; /// An embedded file (placed image / PDF). Bytes are stored next to the JSON in the native format. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Asset { pub id: AssetId, @@ -448,6 +448,68 @@ pub struct Asset { /// The page shown from a multi-page PDF (0-based; Image Import Options). #[serde(default, skip_serializing_if = "is_zero")] pub page: u32, + /// Image Import Options › Crop to: the page box a placed PDF shows. + #[serde(default, skip_serializing_if = "PdfCrop::is_crop")] + 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pdf_box: Option<[f64; 4]>, +} + +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")?; + // 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) + } +} + +/// Image Import Options › Crop to: which box of a PDF page a placed PDF shows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PdfCrop { + /// The crop box: the page as PDF readers show it. + #[default] + Crop, + Art, + Trim, + Bleed, + Media, + /// The bounds of what the page draws, with the layers the PDF shows by default. + ContentVisible, + /// The bounds of what the page draws, with every layer on. + ContentAll, +} + +impl PdfCrop { + pub const ALL: [PdfCrop; 7] = + [PdfCrop::ContentVisible, PdfCrop::ContentAll, PdfCrop::Crop, PdfCrop::Art, PdfCrop::Trim, PdfCrop::Bleed, PdfCrop::Media]; + + /// The name commands and files use (`crop`, `trim`, `contentVisible`…). + pub fn name(self) -> &'static str { + match self { + PdfCrop::Crop => "crop", + PdfCrop::Art => "art", + PdfCrop::Trim => "trim", + PdfCrop::Bleed => "bleed", + PdfCrop::Media => "media", + PdfCrop::ContentVisible => "contentVisible", + PdfCrop::ContentAll => "contentAll", + } + } + + pub fn from_name(name: &str) -> Option { + PdfCrop::ALL.into_iter().find(|c| c.name() == name) + } + + fn is_crop(&self) -> bool { + *self == PdfCrop::Crop + } } fn is_zero(v: &u32) -> bool { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 9ee28a139..e4199037b 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -164,3 +164,18 @@ fn next_id_near_the_top_is_rejected() { d.next_id = crate::MAX_NEXT_ID; d.check().unwrap(); } + +/// A placed PDF saved before its crop choice was stored loads with the crop box (the whole page), +/// and that default isn't written out. +#[test] +fn placed_pdf_without_a_crop_choice_loads_with_the_crop_box() { + let a: Asset = serde_json::from_str(r#"{"id": 7, "name": "a.pdf", "mime": "application/pdf", "page": 1}"#).unwrap(); + assert_eq!((a.pdf_crop, a.pdf_box, a.shown_box()), (PdfCrop::Crop, None, None)); + let json = serde_json::to_string(&a).unwrap(); + assert!(!json.contains("pdfCrop") && !json.contains("pdfBox"), "{json}"); + let trim = Asset { pdf_crop: PdfCrop::Trim, pdf_box: Some([0.1, 0.1, 0.9, 0.9]), ..a }; + let back: Asset = serde_json::from_str(&serde_json::to_string(&trim).unwrap()).unwrap(); + assert_eq!((back.pdf_crop, back.shown_box()), (PdfCrop::Trim, Some([0.1, 0.1, 0.9, 0.9]))); + // A box with no area shows the whole page. + assert_eq!(Asset { pdf_box: Some([0.5, 0.5, 0.5, 0.9]), ..back }.shown_box(), None); +} diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 9631dac2b..7b2fbe426 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -45,7 +45,9 @@ fn load(path: &str) -> Result { vec![] }; if path.to_lowercase().ends_with(".idml") { - return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + let mut d = designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + super::interchange::resolve_pdf_crops(&mut d); + return Ok(d); } super::file::from_bytes(&bytes) } diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index deb0948bc..5628aef2d 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -367,7 +367,7 @@ fn place_image( link, data: Arc::new(bytes), pixels: Some((pw, ph)), - page: 0, + ..Default::default() }), ); let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] }; diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f47863851..503293eb2 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -249,13 +249,12 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp out.assets.insert( aid, std::sync::Arc::new(Asset { - page: 0, id: aid, name: format!("flattened-{}.png", aid.0), mime: "image/png".into(), - link: None, data: std::sync::Arc::new(img.to_png()), pixels: Some((w, h)), + ..Default::default() }), ); let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); @@ -415,13 +414,12 @@ fn rasterize_where( out.assets.insert( aid, std::sync::Arc::new(Asset { - page: 0, id: aid, name: format!("effects-{}.png", id.0), mime: "image/png".into(), - link: None, data: std::sync::Arc::new(img.to_png()), pixels: Some((w, h)), + ..Default::default() }), ); let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); @@ -461,20 +459,23 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}", has_doc, file_place ), @@ -315,16 +315,23 @@ fn file_place(s: &mut Session, p: &Value) -> Result { }; // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64)); - // Image Import Options › Crop to: the frame shows that box of the PDF page. - let crop_box = match str_param(p, "pdfCrop") { - Some(k) if designcraft_render::is_pdf(&bytes) => { - if !["crop", "trim", "bleed", "art", "media"].contains(&k) { - return Err(bad("file.place", format!("unknown pdfCrop `{k}` (crop, trim, bleed, art, media)"))); - } - designcraft_render::pdf_page_box(&bytes, pdf_page as usize, k).filter(|b| b.2 > 0.0 && b.3 > 0.0) + // Image Import Options › Crop to: the graphic spans that box of the PDF page. + let is_pdf = designcraft_render::is_pdf(&bytes); + let pdf_crop = match str_param(p, "pdfCrop") { + Some(k) if is_pdf => PdfCrop::from_name(k) + .ok_or_else(|| bad("file.place", format!("unknown pdfCrop `{k}` (crop, art, trim, bleed, media, contentVisible, contentAll)")))?, + _ => PdfCrop::Crop, + }; + let mime = designcraft_render::image_mime(&bytes).to_string(); + let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; + // The graphic's natural size: the box in points (the whole page when the box can't be found, + // and then the document says so). + let (bw, bh) = match crop_pdf(&mut asset) { + Some(size) => size, + None => { + asset.pdf_crop = PdfCrop::Crop; + (nw, nh) } - Some(_) => None, - None => None, }; let target_frame = super::id_param(p, "frame").or_else(|| { let st = s.active()?; @@ -337,8 +344,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let want_w = p.get("width").and_then(Value::as_f64); s.edit(|d, sel| { let aid = AssetId(d.alloc()); - let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) })); + d.assets.insert(aid, Arc::new(Asset { id: aid, ..asset })); // The width the whole page (or image) gets; the height follows the proportions. let w = match want_w { Some(w) => w, @@ -351,11 +357,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; let r = it.inner_bounds(); // Fill frame proportionally. - let k = (r.width() / nw).max(r.height() / nh); - let (gw, gh) = (nw * k, nh * k); + let k = (r.width() / bw).max(r.height() / bh); + let (gw, gh) = (bw * k, bh * k); it.content = Content::Graphic(Graphic { asset: aid, - size: (nw, nh), + size: (bw, bh), xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k), auto_fit: designcraft_doc::Fitting::FillProportionally, fit_align: 4, @@ -368,13 +374,12 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0)); let id = ItemId(d.alloc()); let k = w / nw; - let (bx, by, bw, bh) = crop_box.unwrap_or((0.0, 0.0, nw, nh)); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x, y, x + bw * k, y + bh * k))); it.object_style = d.styles.default_graphic_frame.clone(); it.content = Content::Graphic(Graphic { asset: aid, - size: (nw, nh), - xf: Affine::translate((x - bx * k, y - by * k)) * Affine::scale(k), + size: (bw, bh), + xf: Affine::translate((x, y)) * Affine::scale(k), auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], @@ -424,6 +429,19 @@ pub fn base64_decode(s: &str) -> Vec { out } +/// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in +/// points. The crop box is the whole page and has none; nor has a page that can't be read, which +/// keeps its crop choice (a missing link relinked later gets its box then). +pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { + a.pdf_box = None; + if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&a.data) { + return None; + } + let (frac, size) = designcraft_render::pdf_crop_box(&a.data, a.page as usize, a.pdf_crop)?; + a.pdf_box = Some(frac); + Some(size) +} + pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { if let Some(b) = str_param(p, "base64") { return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)); @@ -445,7 +463,15 @@ fn place_load(s: &mut Session, p: &Value) -> Result { let mime = designcraft_render::image_mime(&bytes).to_string(); d.assets.insert( aid, - Arc::new(Asset { page: 0, id: aid, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }), + Arc::new(Asset { + id: aid, + name: name.clone(), + mime, + link: link.clone(), + data: Arc::new(bytes), + pixels: Some((pw, ph)), + ..Default::default() + }), ); Ok(aid) })?; @@ -532,7 +558,9 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { const ID: &str = "file.place"; let src = if name.to_lowercase().ends_with(".idml") { - designcraft_idml::import_idml(bytes).map_err(|e| bad(ID, e.to_string()))? + let mut d = designcraft_idml::import_idml(bytes).map_err(|e| bad(ID, e.to_string()))?; + super::interchange::resolve_pdf_crops(&mut d); + d } else { from_bytes(bytes)? }; @@ -638,7 +666,9 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { { let mut d = if path.to_ascii_lowercase().ends_with(".idml") { let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))? + let mut d = designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))?; + super::interchange::resolve_pdf_crops(&mut d); + d } else { from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)? }; @@ -689,6 +719,58 @@ mod pdf_crop_tests { assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); } + + /// Placing with `trim` stores the choice; the graphic spans the trim box and draws only it, + /// even in a frame larger than the graphic. + #[test] + fn a_pdf_placed_with_its_trim_box_stores_and_draws_that_box() { + // Letter with a 9 pt bleed: black in the bleed's top-left corner and a black square inside. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); + for rect in [[-9, -9, 0, 0], [10, 10, 30, 30]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + } + let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); + + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + // `width` is the whole page's (630 pt with its bleed): half size. + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 315, "x": 100, "y": 100})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = t.doc().unwrap().doc.clone(); + let it = d.item(id).unwrap(); + let g = it.graphic().unwrap().clone(); + let a = &d.assets[&g.asset]; + assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Trim); + let b = a.shown_box().unwrap(); + let want = [9.0 / 630.0, 9.0 / 810.0, 621.0 / 630.0, 801.0 / 810.0]; + assert!(b.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-4), "{b:?}"); + assert!((g.size.0 - 612.0).abs() < 1e-3 && (g.size.1 - 792.0).abs() < 1e-3, "{:?}", g.size); + let shown = (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + let fb = it.bounds(); + for (x, y) in [(shown.x0, 100.0), (shown.y0, 100.0), (shown.x1, 406.0), (shown.y1, 496.0), (fb.x1, 406.0), (fb.y1, 496.0)] { + assert!((x - y).abs() < 1e-3, "{shown:?} {fb:?}"); + } + + // A frame larger than the graphic still shows only the trim box. + t.edit(|d, _| { + d.item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(90.0, 90.0, 416.0, 506.0)); + Ok(()) + }) + .unwrap(); + let d = t.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &t.cache, 0, 1.0, false, &Default::default()).unwrap(); + let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 60; + // The page sits at 95.5: its bleed corner (95.5–100) is cut off, the square (105–115) shows + // where it is on the page. + assert!(!dark(97, 97), "the bleed is outside the trim box"); + assert!(!dark(103, 103), "the page is offset by its bleed"); + assert!(dark(110, 110), "the square inside the trim box"); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 8a65ec913..fb73bd2c8 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -49,7 +49,8 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { None } }; - let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?; + let mut document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?; + resolve_pdf_crops(&mut document); #[cfg(not(target_arch = "wasm32"))] let document = { let mut document = document; @@ -65,6 +66,52 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { Ok(document) } +/// 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. +pub(crate) fn resolve_pdf_crops(d: &mut Document) { + use std::collections::HashMap; + use std::sync::Arc; + + use designcraft_doc::{AssetId, Content, Item, PdfCrop}; + let mut sizes: HashMap = HashMap::new(); + for a in d.assets.values_mut() { + if a.pdf_crop == PdfCrop::Crop || a.pdf_box.is_some() { + continue; + } + let a = Arc::make_mut(a); + if let Some(size) = super::file::crop_pdf(a) { + sizes.insert(a.id, size); + } + } + let no_size = |i: &Item| matches!(&i.content, Content::Graphic(g) if !(g.size.0 > 0.0 && g.size.1 > 0.0) && sizes.contains_key(&g.asset)); + fn fix(it: &mut Arc, sizes: &HashMap, no_size: &dyn Fn(&Item) -> bool) { + let mut any = false; + it.walk(&mut |i| any |= no_size(i)); + if !any { + return; + } + let it = Arc::make_mut(it); + if let Content::Graphic(g) = &mut it.content + && let Some(size) = sizes.get(&g.asset) + && !(g.size.0 > 0.0 && g.size.1 > 0.0) + { + g.size = *size; + } + for k in it.children_mut().into_iter().flatten() { + fix(k, sizes, no_size); + } + } + if sizes.is_empty() { + return; + } + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + for it in &mut Arc::make_mut(sp).items { + fix(it, &sizes, &no_size); + } + } +} + #[cfg(not(target_arch = "wasm32"))] const MAX_LINK_PATH_BYTES: usize = 128 * 1024; diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c05c6dad9..1137a303d 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -15,7 +15,7 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, uses: [{id, page, ppi}]}]", has_doc, |s, _| { + cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}]", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -136,7 +136,8 @@ fn list(d: &Document) -> Vec { json!({"id": id.0, "page": page, "ppi": ppi}) }) .collect(); - json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "uses": uses}) + let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name()); + json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses}) }) .collect() } @@ -166,6 +167,8 @@ fn relink(s: &mut Session, p: &Value) -> Result { a.pixels = Some(px); a.name = name.clone(); a.link = Some(path.clone()); + // A placed PDF keeps its crop choice; its box is found on the new file. + super::file::crop_pdf(a); Ok(json!({"name": name})) }) } @@ -229,6 +232,7 @@ fn update(s: &mut Session, p: &Value) -> Result { let a = Arc::make_mut(a); a.data = Arc::new(bytes.clone()); a.pixels = Some(*px); + super::file::crop_pdf(a); } } Ok(json!({"updated": n})) diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index 791a0d704..9f1e3806c 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -14,10 +14,8 @@ use crate::{Result, Session}; fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item { let (svg, (w, h)) = crate::math::to_svg(latex, size); let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { page: 0, id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), link: None, data: Arc::new(svg), pixels: None }), - ); + d.assets + .insert(aid, Arc::new(Asset { id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), data: Arc::new(svg), ..Default::default() })); let id = ItemId(d.alloc()); let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index b80089585..66f1c2899 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -26,7 +26,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let mime = mime.to_string(); s.edit(|d, sel| { let aid = AssetId(d.alloc()); - d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: None })); + d.assets.insert(aid, Arc::new(Asset { id: aid, name, mime, link, data: Arc::new(bytes), ..Default::default() })); let id = match target { Some(fid) => { let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; @@ -92,7 +92,10 @@ pub fn specs() -> Vec { Some(Some((bytes, name, link, px))) => { let aid = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px) })); + d.assets.insert( + aid, + Arc::new(Asset { id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px), ..Default::default() }), + ); Some(Some(aid)) } Some(None) => Some(None), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 17c44c27a..d40eb468a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1611,6 +1611,11 @@ fn clipping_path(s: &mut Session, p: &Value) -> Result { let Some(g) = it.graphic() else { continue }; 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() { + Some(frac) => designcraft_render::crop_pixmap(&px, frac).unwrap_or(px), + None => px, + }; let (pw, ph) = (px.width() as usize, px.height() as usize); // A grid of at most 400 cells on the long side. let step = (pw.max(ph) as f64 / 400.0).max(1.0); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 028971e97..cb75e553f 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1407,13 +1407,13 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { d.assets.insert( aid, std::sync::Arc::new(designcraft_doc::Asset { - page: 0, id: aid, name, mime, link, data: std::sync::Arc::new(bytes), pixels: Some((pw, ph)), + ..Default::default() }), ); let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 15a05d084..2d9a54717 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -67,7 +67,7 @@ fn image(d: &mut Document, name: &str, w: u32, h: u32, hue: f32) -> AssetId { let data = art_png(w, h, hue); d.assets.insert( id, - Arc::new(Asset { page: 0, id, name: name.into(), mime: "image/png".into(), link: None, data: Arc::new(data), pixels: Some((w, h)) }), + Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), data: Arc::new(data), pixels: Some((w, h)), ..Default::default() }), ); id } diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index ae466e134..e0fa0e2d4 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -253,7 +253,7 @@ fn named_lists_round_trip_through_idml() { } /// IDML says which page of a placed PDF shows and which box its graphic spans, so InDesign opens -/// it at the same size and position; importing it back gives the same page and geometry. +/// it at the same size and position; importing it back gives the same page, crop and geometry. #[test] fn placed_pdf_page_and_crop_round_trip_through_idml() { let mut s = Session::new(); @@ -269,6 +269,7 @@ fn placed_pdf_page_and_crop_round_trip_through_idml() { let item = d.item(ItemId(r["id"].as_u64().unwrap())).unwrap(); let (bounds, g) = (item.bounds(), item.graphic().unwrap().clone()); assert_eq!(d.assets[&g.asset].page, 1); + assert_eq!(d.assets[&g.asset].pdf_crop, designcraft_doc::PdfCrop::Trim); let bytes = designcraft_idml::export_idml(&d); let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); @@ -280,12 +281,16 @@ fn placed_pdf_page_and_crop_round_trip_through_idml() { } } let attr = spreads.split("").next()).expect("a PDFAttribute"); - assert!(attr.contains(r#"PageNumber="2""#) && attr.contains(r#"PDFCrop="CropPDF""#), "{attr}"); + assert!(attr.contains(r#"PageNumber="2""#) && attr.contains(r#"PDFCrop="CropTrim""#), "{attr}"); + assert!(spreads.contains(r#""#), "the trim box"); - let back = designcraft_idml::import_idml(&bytes).unwrap(); + let back = crate::cmd::interchange::import(&bytes, None).unwrap(); let bi = back.spreads.iter().flat_map(|sp| sp.items.iter()).find(|i| i.graphic().is_some()).unwrap(); let bg = bi.graphic().unwrap(); - assert_eq!(back.assets[&bg.asset].page, 1); + let ba = &back.assets[&bg.asset]; + assert_eq!((ba.page, ba.pdf_crop), (1, designcraft_doc::PdfCrop::Trim)); + let (want, got) = (d.assets[&g.asset].shown_box().unwrap(), ba.shown_box().expect("the trim box found on the page")); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?} vs {want:?}"); let bb = bi.bounds(); assert!([bb.x0 - bounds.x0, bb.y0 - bounds.y0, bb.x1 - bounds.x1, bb.y1 - bounds.y1].iter().all(|v| v.abs() < 1e-3), "{bb:?} vs {bounds:?}"); assert!((bg.size.0 - g.size.0).abs() < 1e-3 && (bg.size.1 - g.size.1).abs() < 1e-3); @@ -312,3 +317,43 @@ fn placed_pdf_page_and_crop_round_trip_through_idml() { let r = t.execute("file.exportPdf", &json!({})).unwrap(); assert!(r["warnings"].to_string().contains("has no page 4294967295"), "{r}"); } + +/// An IDML placed PDF cropped to its media box, without `GraphicBounds`: the graphic takes the +/// media box (the page with its 9 pt bleed). +#[test] +fn idml_placed_pdf_cropped_to_its_media_box_gets_media_box_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).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": "trim", "width": 630, "x": 0, "y": 0})).unwrap(); + let bytes = designcraft_idml::export_idml(&t.doc().unwrap().doc); + + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let mut edited = false; + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + let mut body = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut body).unwrap(); + if f.name().starts_with("Spreads/") { + let text = String::from_utf8(body).unwrap().replace(r#"PDFCrop="CropTrim""#, r#"PDFCrop="CropMedia""#); + let start = text.find("").unwrap() + 2; + body = format!("{}{}", &text[..start], &text[end..]).into_bytes(); + edited = true; + } + out.start_file(f.name(), zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut out, &body).unwrap(); + } + assert!(edited); + let back = crate::cmd::interchange::import(&out.finish().unwrap().into_inner(), None).unwrap(); + let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap(); + let a = &back.assets[&g.asset]; + assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Media); + assert!((g.size.0 - 630.0).abs() < 1e-3 && (g.size.1 - 810.0).abs() < 1e-3, "{:?}", g.size); + 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:?}"); +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 643f59341..7ae14c715 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -832,13 +832,12 @@ mod tests { d.assets.insert( aid, Arc::new(Asset { - page: 0, id: aid, name: "Cover & Notes".into(), mime: "image/png; profile=screen".into(), - link: None, data: Arc::new(vec![1]), pixels: Some((1, 1)), + ..Default::default() }), ); let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f80ad0a65..3bbe8b344 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -214,13 +214,12 @@ mod tests { d.assets.insert( aid, Arc::new(Asset { - page: 0, id: aid, name: "x.png".into(), mime: "image/png".into(), - link: None, data: Arc::new(vec![1, 2, 3, 4]), pixels: Some((1, 1)), + ..Default::default() }), ); let bytes = save(&d).unwrap(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 00683e747..189e21c79 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1763,7 +1763,9 @@ impl<'a> Ex<'a> { fn image_el(&mut self, asset: &designcraft_doc::Asset, g: &designcraft_doc::Graphic) -> El { let is_pdf = asset.mime == "application/pdf"; let mut el = El::new(if is_pdf { "PDF" } else { "Image" }).attr("Self", self.fresh()); - if let Some((pw, ph)) = asset.pixels + // A PDF is vector: its page size in `pixels` isn't a resolution, and a cropped graphic + // spans less than the page. + if let Some((pw, ph)) = asset.pixels.filter(|_| !is_pdf) && g.size.0 > 0.0 && g.size.1 > 0.0 { @@ -1803,13 +1805,13 @@ impl<'a> Ex<'a> { .attr("ExportPolicy", "NoAutoExport"), ); if is_pdf { - // The graphic spans the page's crop box (the page as it shows): InDesign's "Crop to: - // Crop". Without `PDFAttribute` InDesign crops the page to its visible content, which - // changes the graphic's size and so its scale and position. + // `GraphicBounds` span the chosen box, as graphic space does. Without `PDFAttribute` + // the page would be cropped to its visible content, which changes the graphic's size + // and so its scale and position. el.push( El::new("PDFAttribute") .attr("PageNumber", asset.page.saturating_add(1)) - .attr("PDFCrop", "CropPDF") + .attr("PDFCrop", names::pdf_crop_out(asset.pdf_crop)) .attr("TransparentBackground", "true"), ); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d639ee128..3535ef024 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2221,7 +2221,10 @@ impl<'r> Importer<'r> { let id = AssetId(self.alloc()); let link_path = uri.filter(|p| p.contains('/') || p.contains('\\')); let page = pdf_page_in(g); - self.assets.insert(id, Arc::new(Asset { page, id, name, mime, link: link_path, data: Arc::new(data), pixels })); + // Where the box sits on the page needs the PDF parsed: the engine finds it after import. + let pdf_crop = g.find("PDFAttribute").and_then(|a| a.get("PDFCrop")).map_or(designcraft_doc::PdfCrop::Crop, names::pdf_crop_in); + self.assets + .insert(id, Arc::new(Asset { page, id, name, mime, link: link_path, data: Arc::new(data), pixels, pdf_crop, ..Default::default() })); Content::Graphic(designcraft_doc::Graphic { asset: id, size, diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index fc282e509..f33c53ae5 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -35,9 +35,13 @@ //! - Mixed inks, gradient stop opacity and gradient feathers, effects other than drop shadow and //! basic feather, EPS/PDF/AI placed graphics (imported as images when the data is available), //! clipping paths, compound-path fill rules. -//! - Placed PDFs: the page (`PDFAttribute` `PageNumber`) round-trips and export writes -//! `PDFCrop="CropPDF"`, the box DesignCraft draws. On import, a `PDFCrop` naming another box -//! (trim, bleed, media, content) is not mapped: the page's crop box fills `GraphicBounds`. +//! - Placed PDFs: the page (`PageNumber`) and crop choice (`PDFCrop`) round-trip, and +//! `GraphicBounds` span the chosen box. Where that box sits on the page needs the PDF parsed, +//! which this crate doesn't do: the engine finds it after import (`file.openIdml`), and when the +//! PDF's data isn't available the page's crop box fills `GraphicBounds`. The content crops +//! (`CropContentVisibleLayers`, `CropContentAllLayers`) are the bounds of the page's +//! non-transparent pixels on a render about 2048 pixels on its long side, so they can differ +//! from a vector bounding box by a fraction of a point. //! - Unknown elements are ignored; nothing is preserved opaquely for round-trip yet. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index bba20370b..80a1fad6c 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -44,6 +44,26 @@ pub fn anchor_rel_in(s: &str) -> designcraft_doc::anchored::AnchorRelative { } } +/// `PDFAttribute` `PDFCrop` for a placed PDF's crop choice. +pub fn pdf_crop_out(c: designcraft_doc::PdfCrop) -> &'static str { + use designcraft_doc::PdfCrop as C; + match c { + C::Crop => "CropPDF", + C::Art => "CropArt", + C::Trim => "CropTrim", + C::Bleed => "CropBleed", + C::Media => "CropMedia", + C::ContentVisible => "CropContentVisibleLayers", + C::ContentAll => "CropContentAllLayers", + } +} + +/// A placed PDF's crop choice from `PDFCrop` (the crop box when unknown). +pub fn pdf_crop_in(s: &str) -> designcraft_doc::PdfCrop { + use designcraft_doc::PdfCrop as C; + C::ALL.into_iter().find(|c| pdf_crop_out(*c) == s).unwrap_or(C::Crop) +} + pub fn anchor_out(i: u8) -> &'static str { ANCHORS[(i as usize).min(8)] } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 05ac80adf..95a0a6569 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1308,7 +1308,11 @@ impl Exporter<'_> { if let Some(asset) = doc.assets.get(&g.asset) && asset.data.starts_with(b"%PDF") { - let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return }; + // Cropped to a box: the whole page, placed so that the box spans graphic space. + let shown = asset.shown_box(); + let [l, t, r, b] = shown.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 Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.max(1e-3) as f32) else { return }; let placed = match self.pdfs.get(&g.asset) { Some(p) => p.clone(), None => { @@ -1324,8 +1328,17 @@ impl Exporter<'_> { } Some((placed, _)) => { s.push_transform(&tf(g.xf)); + let clip = shown.and_then(|_| to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1))); + if let Some(clip) = &clip { + s.push_clip_path(clip, &FillRule::NonZero); + } + s.push_transform(&tf(Affine::translate((-l * page_w, -t * page_h)))); s.draw_pdf_page(&placed, size, asset.page as usize); s.pop(); + if clip.is_some() { + s.pop(); + } + s.pop(); } None => {} } @@ -1561,10 +1574,7 @@ mod image_content_cache_tests { } fn asset(doc: &mut Document, bytes: Vec, name: &str) -> AssetId { let id = AssetId(doc.alloc()); - doc.assets.insert( - id, - Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), link: None, data: Arc::new(bytes), pixels: None, page: 0 }), - ); + doc.assets.insert(id, Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), data: Arc::new(bytes), ..Default::default() })); id } fn exporter<'a>(doc: &'a Document, cache: &'a Cache, opts: &'a PdfOptions) -> Exporter<'a> { diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 3218ffc76..1a5b8d978 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -468,7 +468,7 @@ mod tests { let asset = AssetId(d.alloc()); let mime = designcraft_images::mime(&data).into(); let pixels = designcraft_images::pixel_size(&data); - d.assets.insert(asset, Arc::new(Asset { id: asset, name: name.into(), mime, link: None, data: data.into(), pixels, page: 0 })); + d.assets.insert(asset, Arc::new(Asset { id: asset, name: name.into(), mime, data: data.into(), pixels, ..Default::default() })); let lid = d.default_layer(); let id = ItemId(d.alloc()); let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..59985de20 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -220,10 +220,9 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) { id: asset, name: "scan.tif".into(), mime, - link: None, data: data.into(), pixels: Some(px), - page: 0, + ..Default::default() }), ); let lid = d.default_layer(); diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 6a4ac91f1..a921f90b5 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -23,7 +23,7 @@ fn place(d: &mut Document, data: Vec, x: f64) { fn place_named(d: &mut Document, data: Vec, x: f64, name: &str, mime: &str, page: u32) { let aid = AssetId(d.alloc()); - d.assets.insert(aid, Arc::new(Asset { id: aid, name: name.into(), mime: mime.into(), link: None, data: Arc::new(data), pixels: None, page })); + d.assets.insert(aid, Arc::new(Asset { id: aid, name: name.into(), mime: mime.into(), data: Arc::new(data), page, ..Default::default() })); let id = ItemId(d.alloc()); let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0))); item.stroke.weight = 0.0; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c1b288def..07c0fe443 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -751,20 +751,26 @@ impl Renderer { let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen }; 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() }; - let Some(pm) = images::mip(&data, asset.page, g.size.0, on_screen) else { return }; + // 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 (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 }; let pm = match PLATE.with(|p| p.get()) { Some(plate) => plate_pixmap(&pm, plate), None => pm, }; - let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1); + // Only where the page is: a box reaching past it (a media box larger than the crop box) + // shows nothing there. + let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1).intersect(page); ctx.set_transform(f.view * xf * g.xf); - let sx = g.size.0 / pm.width().max(1) as f64; - let sy = g.size.1 / pm.height().max(1) as f64; + let sx = page_w / pm.width().max(1) as f64; + let sy = page_h / pm.height().max(1) as f64; ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium), }); - ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.set_paint_transform(Affine::translate((page.x0, page.y0)) * Affine::scale_non_uniform(sx, sy)); ctx.fill_rect(&rect); ctx.reset_paint_transform(); } @@ -1042,10 +1048,15 @@ pub fn pdf_page_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> { Some((w as f64, h as f64)) } -/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off. -pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String]) -> Option> { +/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off; +/// only the `shown` part of it ([`designcraft_doc::Asset::shown_box`]) when given. +pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Option> { let data = if hidden.is_empty() { bytes.to_vec() } else { pdf_hide_layers(bytes, hidden).unwrap_or_else(|| bytes.to_vec()) }; let pm = render_pdf_page(&data, page, max_side)?; + let pm = match shown { + Some(frac) => crop_pixmap(&pm, frac)?, + None => pm, + }; let pixels: Vec = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(); Some(Rendered { width: pm.width() as u32, height: pm.height() as u32, pixels }.to_png()) } @@ -1065,26 +1076,98 @@ pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option Option<(f64, f64, f64, f64)> { +/// The part of PDF page `page` that `crop` shows: its left, top, right and bottom edges as +/// fractions of the page as rendered (its visible crop box, rotated as it displays), and its size +/// in points. A box the page doesn't define is its crop box, as PDF readers do. The content boxes +/// are the bounds of what the page draws, found on a render about 2048 pixels on its long side +/// (the whole page when it draws nothing). +pub fn pdf_crop_box(bytes: &[u8], page: usize, crop: designcraft_doc::PdfCrop) -> Option<([f64; 4], (f64, f64))> { + use designcraft_doc::PdfCrop; use hayro::hayro_syntax::object::{Rect as PRect, dict::keys}; + const WHOLE: [f64; 4] = [0.0, 0.0, 1.0, 1.0]; let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; let p = pdf.pages().get(page)?; - let visible = p.intersected_crop_box(); - let key: &[u8] = match kind { - "trim" => keys::TRIM_BOX, - "bleed" => keys::BLEED_BOX, - "art" => keys::ART_BOX, - "media" => return Some(rel(visible, p.media_box())), - _ => return Some(rel(visible, visible)), - }; - let b = p.raw().get::(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible); - fn rel(v: PRect, b: PRect) -> (f64, f64, f64, f64) { - (b.x0 - v.x0, v.y1 - b.y1, b.width(), b.height()) + let (w, h) = p.render_dimensions(); + let (w, h) = (f64::from(w), f64::from(h)); + if !(w > 0.0 && h > 0.0) { + return None; } - Some(rel(visible, b)) + let frac = match crop { + PdfCrop::Crop => WHOLE, + PdfCrop::ContentVisible => content_bounds(bytes, page).unwrap_or(WHOLE), + PdfCrop::ContentAll => { + let all_on = pdf_hide_layers(bytes, &[]); + content_bounds(all_on.as_deref().unwrap_or(bytes), page).unwrap_or(WHOLE) + } + PdfCrop::Art | PdfCrop::Trim | PdfCrop::Bleed | PdfCrop::Media => { + let media = p.media_box(); + let b = match crop { + PdfCrop::Media => media, + _ => { + let key: &[u8] = match crop { + PdfCrop::Trim => keys::TRIM_BOX, + PdfCrop::Bleed => keys::BLEED_BOX, + _ => keys::ART_BOX, + }; + p.raw().get::(key).map(|b| b.intersect(media)).unwrap_or_else(|| p.intersected_crop_box()) + } + }; + // Into rendered page space (y down, rotated, origin at the crop box). + let r = Affine::new(p.initial_transform(true).as_coeffs()).transform_rect_bbox(Rect::new(b.x0, b.y0, b.x1, b.y1)); + [r.x0 / w, r.y0 / h, r.x1 / w, r.y1 / h] + } + }; + let size = ((frac[2] - frac[0]) * w, (frac[3] - frac[1]) * h); + (frac.iter().all(|v| v.is_finite()) && size.0 > 0.0 && size.1 > 0.0).then_some((frac, size)) +} + +/// 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)?; + let (pw, ph) = (usize::from(pm.width()), usize::from(pm.height())); + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0); + for (i, px) in pm.data().iter().enumerate() { + if px.a > 0 { + let (x, y) = (i % pw.max(1), i / pw.max(1)); + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x + 1); + y1 = y1.max(y + 1); + } + } + (x0 < x1 && y0 < y1 && pw > 0 && ph > 0).then(|| [x0 as f64 / pw as f64, y0 as f64 / ph as f64, x1 as f64 / pw as f64, y1 as f64 / ph as f64]) +} + +/// The part of `pm` inside `frac` (left, top, right, bottom as fractions of it); what lies outside +/// `pm` is transparent. +pub fn crop_pixmap(pm: &Pixmap, frac: [f64; 4]) -> Option { + let (pw, ph) = (f64::from(pm.width()), f64::from(pm.height())); + let [l, t, r, b] = [frac[0] * pw, frac[1] * ph, frac[2] * pw, frac[3] * ph].map(f64::round); + let (w, h) = (r - l, b - t); + if !(w >= 1.0 && h >= 1.0 && w <= f64::from(u16::MAX) && h <= f64::from(u16::MAX)) { + return None; + } + let (w, h) = (w as u16, h as u16); + let mut out = Pixmap::new(w, h); + let src = pm.data(); + let dst = out.data_mut(); + for y in 0..usize::from(h) { + let sy = y as f64 + t; + if sy < 0.0 || sy >= ph { + continue; + } + for x in 0..usize::from(w) { + let sx = x as f64 + l; + if sx < 0.0 || sx >= pw { + continue; + } + let (Some(p), Some(q)) = (src.get(sy as usize * usize::from(pm.width()) + sx as usize), dst.get_mut(y * usize::from(w) + x)) else { + continue; + }; + *q = *p; + } + } + Some(out) } /// Separations Preview on a rendered image: `plate` 0–3 shows that process plate (C, M, Y, K) as @@ -1429,13 +1512,12 @@ mod tests { d.assets.insert( aid, Arc::new(designcraft_doc::Asset { - page: 0, id: aid, name: "red.png".into(), mime: "image/png".into(), - link: None, data: Arc::new(png), pixels: Some((4, 4)), + ..Default::default() }), ); let id = designcraft_doc::ItemId(d.alloc()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..d18f05a20 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1408,6 +1408,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:")); let cur = d.s("crop"); let label = |v: &str| match v { + "contentVisible" => crate::i18n::tr(&app.ui.language, "Bounding Box (Visible Layers Only)"), + "contentAll" => crate::i18n::tr(&app.ui.language, "Bounding Box (All Layers)"), "trim" => crate::i18n::tr(&app.ui.language, "Trim"), "bleed" => crate::i18n::tr(&app.ui.language, "Bleed"), "art" => crate::i18n::tr(&app.ui.language, "Art"), @@ -1415,7 +1417,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { _ => crate::i18n::tr(&app.ui.language, "Crop"), }; egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { - for v in ["crop", "trim", "bleed", "art", "media"] { + for v in designcraft_doc::PdfCrop::ALL.map(designcraft_doc::PdfCrop::name) { if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { d.fields.insert("crop".into(), json!(v)); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..870a6fa25 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -576,6 +576,26 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Trim", ["Trim", "Trim", "Trim", "Trim", "裁切"]), + ( + "Bounding Box (Visible Layers Only)", + [ + "Begrenzungsrahmen (nur sichtbare Ebenen)", + "Cadre de sélection (calques visibles uniquement)", + "Cuadro delimitador (solo capas visibles)", + "バウンディングボックス(表示レイヤーのみ)", + "边界框(仅可见图层)", + ], + ), + ( + "Bounding Box (All Layers)", + [ + "Begrenzungsrahmen (alle Ebenen)", + "Cadre de sélection (tous les calques)", + "Cuadro delimitador (todas las capas)", + "バウンディングボックス(すべてのレイヤー)", + "边界框(所有图层)", + ], + ), ("Type:", ["Type:", "Type:", "Type:", "Type:", "类型:"]), ("Typical", ["Typical", "Typical", "Typical", "Typical", "典型"]), ("UI Size:", ["UI Size:", "UI Size:", "UI Size:", "UI Size:", "UI 大小:"]),