diff --git a/ROADMAP.md b/ROADMAP.md index d5f74103d..ea860f0f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,7 +47,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; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — 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; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — 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. - INDD import through indd-utils (#114): File › Open, drop, `file.open`/`file.openBytes` and the CLI open InDesign documents and templates (.indd, .indt) by converting them to IDML in memory and importing that; the converter's warnings join the open result's `warnings`. - 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. @@ -110,7 +110,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 ddf8b0772..3ecc0b77c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -447,7 +447,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, @@ -464,6 +464,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 623c6bb08..38e243825 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -296,3 +296,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 5edde9d1a..2ba0f2f6b 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 ), @@ -399,16 +399,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()?; @@ -421,8 +428,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, @@ -435,11 +441,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, @@ -452,13 +458,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], @@ -508,6 +513,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)); @@ -529,7 +547,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) })?; @@ -616,7 +642,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)? }; @@ -722,7 +750,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}")))?)? }; @@ -833,6 +863,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 944e2bc50..85157908b 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -50,7 +50,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; @@ -66,6 +67,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/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(" 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 ca18b8147..81582808e 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -364,3 +364,109 @@ fn type_para_sets_a_drop_cap_that_survives_idml() { assert_eq!(p.drop_cap_style.as_deref(), Some("Initial")); assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")])); } + +/// 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, crop 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); + 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(); + 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="CropTrim""#), "{attr}"); + assert!(spreads.contains(r#""#), "the trim box"); + + 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(); + 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); + 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}"); +} + +/// 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/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index da2e4f00b..a06df8d24 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -806,6 +806,12 @@ fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Optio v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64()); } plausible(Some(v), upem) +/// `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 @@ -1491,7 +1497,7 @@ impl ScopedFonts<'_> { /// Regular first, then by weight. fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { let v = self.exact_style_traits(family); - match without_format_suffix(family) { + match plain_family(family) { Some(base) if v.is_empty() => self.exact_style_traits(base), _ => v, } @@ -1520,12 +1526,13 @@ 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 { let canonical = self .canonical_family(family) - .or_else(|| without_format_suffix(family).and_then(|base| self.canonical_family(base))) - .unwrap_or_else(|| self.substitute_family(without_format_suffix(family).unwrap_or(family))); + .or_else(|| plain_family(family).and_then(|base| self.canonical_family(base))) + .unwrap_or_else(|| self.substitute_family(plain_family(family).unwrap_or(family))); if let Some(f) = self.find_or_load(&canonical, style) { return f; } @@ -1547,9 +1554,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.canonical_family(family).is_some() || without_format_suffix(family).is_some_and(|base| self.canonical_family(base).is_some()) + self.canonical_family(family).is_some() || plain_family(family).is_some_and(|base| self.canonical_family(base).is_some()) } /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index df66a6c24..d3cbb618d 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -453,6 +453,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/format/src/lib.rs b/crates/format/src/lib.rs index 24feaa332..073e1b4ed 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -264,13 +264,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 33a95dd72..84fd9af8a 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1809,7 +1809,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 { @@ -1848,6 +1850,17 @@ impl<'a> Ex<'a> { .attr("ImportPolicy", "NoAutoImport") .attr("ExportPolicy", "NoAutoExport"), ); + if is_pdf { + // `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", names::pdf_crop_out(asset.pdf_crop)) + .attr("TransparentBackground", "true"), + ); + } el } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 2607d1fb5..a983992f4 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -365,7 +365,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, @@ -1223,9 +1223,7 @@ impl<'r> Importer<'r> { 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(self.composite_names.get(fam).cloned().unwrap_or_else(|| fam.to_string())); - } + a.font_family = self.composite_names.get(fam).cloned().or_else(|| names::font_family_in(fam)); } a.font_style = e.prop("FontStyle"); a.size = e.num("PointSize"); @@ -2416,7 +2414,11 @@ 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); + // 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, @@ -2819,6 +2821,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..f33c53ae5 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -35,6 +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 (`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 cdf8294ce..3e3acfcfe 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)] } @@ -81,6 +101,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 d37bdb16d..b980671b4 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1290,7 +1290,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:?}"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 97569aab6..5ade69ac7 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -449,8 +449,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. @@ -1197,7 +1197,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)); @@ -1212,7 +1213,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 @@ -1221,7 +1223,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", @@ -1326,7 +1329,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 => { @@ -1335,10 +1342,26 @@ 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)); + 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 => {} } return; } @@ -1572,10 +1595,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 9d9c8810f..896056b4b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -232,10 +232,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 8bff48fe1..e815936b0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -762,20 +762,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(); } @@ -1053,10 +1059,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()) } @@ -1076,26 +1087,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 @@ -1496,13 +1579,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 a85cdb7ef..8caf68288 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1378,6 +1378,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"), @@ -1385,7 +1387,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 6ba54c96f..f29d271ab 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -613,6 +613,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 大小:"]),