From faf23fc74b0c09c66647c2004b6798021084add7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:46:35 -0400 Subject: [PATCH] H: place a page of an IDML/DesignCraft document as a group Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + apps/designcraft-web/src/web.rs | 23 ++++++++- apps/designcraft/src/main.rs | 22 ++++++++- crates/engine/src/cmd/file.rs | 85 ++++++++++++++++++++++++++++++++- docs/parity.md | 6 +-- 5 files changed, 130 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 512a15e30..0c084bf2f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,6 +70,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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. - 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. +- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fc7f2d4ed..c0466b691 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -5,8 +5,27 @@ use designcraft_ui_egui::{DesignApp, Inbox, Services}; use wasm_bindgen::JsCast as _; const DOC_EXTS: &[&str] = &["designcraft", "idml"]; -const IMAGE_EXTS: &[&str] = - &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "txt", "docx", "rtf", "md", "xlsx"]; +const IMAGE_EXTS: &[&str] = &[ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "tif", + "tiff", + "bmp", + "psd", + "svg", + "pdf", + "ai", + "txt", + "docx", + "rtf", + "md", + "xlsx", + "idml", + "designcraft", +]; const CANVAS_ID: &str = "designcraft_canvas"; const LOADING_ID: &str = "designcraft_loading"; diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 222511ec5..408ff59af 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -98,7 +98,27 @@ fn services() -> Services { } else if purpose == "place" { d.add_filter( "Graphics and text", - &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "txt", "docx", "rtf", "md", "xlsx"], + &[ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "tif", + "tiff", + "bmp", + "psd", + "svg", + "pdf", + "ai", + "txt", + "docx", + "rtf", + "md", + "xlsx", + "idml", + "designcraft", + ], ) .add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai"]) .add_filter("Text (Word, RTF, plain, Excel)", &["docx", "rtf", "txt", "md", "xlsx"]) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4567269f4..ae78c2ff8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -92,7 +92,7 @@ pub fn specs() -> 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} — 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|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}", has_doc, file_place ), @@ -214,6 +214,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result { s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) }); return ok(); }; + // A page of another layout (IDML or DesignCraft): its objects, as one group. + let lname = name.to_lowercase(); + if lname.ends_with(".idml") || lname.ends_with(".designcraft") { + return place_layout_page(s, p, &name, &bytes); + } // Text files (plain, Word, RTF) flow into frames. if designcraft_textimport::is_text_file(&name) { return super::place_text::place_text(s, p, &name, &bytes); @@ -449,6 +454,56 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { } } +/// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`, +/// 1-based) grouped, top-left at x/y (default: the source position on the first page). +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()))? + } else { + from_bytes(bytes)? + }; + let n = src.page_count(); + let page = p.get("layoutPage").and_then(Value::as_u64).unwrap_or(1) as usize; + if page == 0 || page > n { + return Err(bad(ID, format!("the document has {n} page(s)"))); + } + let (si, pi) = src.page_loc(page - 1).ok_or_else(|| bad(ID, "no such page"))?; + let sp = &src.spreads[si]; + let pr = sp.pages[pi].bounds(); + let ids: Vec = sp.items.iter().filter(|it| !it.hidden && sp.page_at_x(it.bounds().center().x) == Some(pi)).map(|it| it.id).collect(); + if ids.is_empty() { + return Err(bad(ID, format!("page {page} has no objects"))); + } + let to = super::spread_param(p, "spread"); + let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), + _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), + }; + let placed = s.edit(|d, sel| { + for sw in &src.swatches { + if d.swatch(&sw.name).is_none() { + d.swatches.push(sw.clone()); + } + } + let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); + let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); + if !missing_p.is_empty() || !missing_c.is_empty() { + let st = d.styles_mut(); + st.paragraph.extend(missing_p); + st.character.extend(missing_c); + } + let new = super::object::duplicate_from(d, &src, &ids, to, off)?; + *sel = Selection::items(new.clone()); + Ok(new) + })?; + if placed.len() > 1 { + let g = s.execute("object.group", &json!({"ids": placed.iter().map(|i| i.0).collect::>()}))?; + return Ok(json!({"id": g["id"], "items": placed.len()})); + } + Ok(json!({"id": placed[0].0, "items": 1})) +} + pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { let (bytes, _, _) = read_source(p)?; let src = from_bytes(&bytes)?; @@ -559,3 +614,31 @@ mod pdf_crop_tests { assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); } } + +#[cfg(test)] +mod layout_place_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn place_a_page_of_an_idml_file_as_a_group() { + let mut a = Session::new(); + a.execute("file.new", &json!({"pages": 2})).unwrap(); + a.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "text", "text": "From page one", "caret": false})).unwrap(); + a.execute("frame.create", &json!({"rect": [300, 400, 350, 450]})).unwrap(); + let idml = designcraft_idml::export_idml(&a.doc().unwrap().doc); + let b64 = super::base64_encode(&idml); + let mut b = Session::new(); + b.execute("file.new", &json!({})).unwrap(); + let r = b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); + assert_eq!(r["items"], 2); + let d = &b.doc().unwrap().doc; + let g = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap(); + assert_eq!(g.children().len(), 2, "one group"); + let bb = g.bounds(); + assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.y0 - 100.0).abs() < 1e-6, "page top-left at 0,0: {bb:?}"); + assert!(d.stories.values().any(|st| st.text == "From page one"), "stories come along"); + assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); + } +} diff --git a/docs/parity.md b/docs/parity.md index 70d1f12e0..6c3f881f3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 92 | 4 | 1 | 97% | -| P2 | 76 | 23 | 13 | 40 | 39% | +| P2 | 76 | 23 | 14 | 39 | 39% | | Area | Parity | |---|---| @@ -22,7 +22,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Transform | 100% | | Fill, stroke, colour | 95% | | Effects & transparency | 71% | -| Placing & links | 85% | +| Placing & links | 87% | | Type & text frames | 94% | | Typography | 98% | | Styles | 97% | @@ -170,7 +170,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Placing & links | Place into frame / by click or drag | P0 | D | | Placing & links | Raster formats | P0 | D | | Placing & links | Vector/page formats (PDF/AI/SVG/EPS place) | P0 | P | -| Placing & links | Placing INDD/IDML pages | P2 | M | +| Placing & links | Placing INDD/IDML pages | P2 | P | | Placing & links | Text import (TXT/DOCX/RTF) | P0 | D | | Placing & links | Links panel | P0 | D | | Placing & links | Effective/actual PPI | P0 | D |