diff --git a/ROADMAP.md b/ROADMAP.md index 6e8f6e26..f4ea0843 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- 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. - View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen. - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 48402278..f0745063 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -183,6 +183,7 @@ pub fn synthetic(spec: &Spec) -> Document { d.assets.insert( id, Arc::new(Asset { + page: 0, id, name: format!("art-{k}.png"), mime: "image/png".into(), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6a220c7e..43709a9c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -249,6 +249,13 @@ pub struct Asset { /// Pixel size for raster images. #[serde(default, skip_serializing_if = "Option::is_none")] pub pixels: Option<(u32, u32)>, + /// The page shown from a multi-page PDF (0-based; Image Import Options). + #[serde(default, skip_serializing_if = "is_zero")] + pub page: u32, +} + +fn is_zero(v: &u32) -> bool { + *v == 0 } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bcab61ad..616f0ae3 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) 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?}", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) 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?}", has_doc, file_place ), @@ -203,7 +203,23 @@ fn file_place(s: &mut Session, p: &Value) -> Result { if designcraft_textimport::is_text_file(&name) { return super::place_text::place_text(s, p, &name, &bytes); } - let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?; + // Image Import Options: which page of a PDF (1-based). + let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { + Some(n) if designcraft_render::is_pdf(&bytes) => { + let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); + if n == 0 || n as usize > count { + return Err(bad("file.place", format!("the PDF has {count} page(s)"))); + } + n as u32 - 1 + } + _ => 0, + }; + let (pw, ph) = match pdf_page { + 0 => designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?, + n => designcraft_render::pdf_page_size(&bytes, n as usize) + .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) + .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, + }; // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. let (nw, nh) = (pw as f64, ph as f64); let target_frame = super::id_param(p, "frame").or_else(|| { @@ -218,7 +234,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { 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 { id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) })); + d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) })); let (w, h) = match want_w { Some(w) => (w, w * nh / nw), None => { @@ -316,8 +332,10 @@ fn place_load(s: &mut Session, p: &Value) -> Result { let aid = s.edit(|d, _| { let aid = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets - .insert(aid, Arc::new(Asset { id: aid, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) })); + 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)) }), + ); Ok(aid) })?; s.loaded = Some((aid, (pw as f64, ph as f64))); diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index cfd07223..f967339e 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -290,3 +290,39 @@ mod reflow_tests { assert_eq!(s.doc().unwrap().doc.page_count(), 1); } } + +#[cfg(test)] +mod pdf_page_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn place_a_chosen_pdf_page() { + let dir = std::env::temp_dir().join(format!("dc-pdf-page-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let src = dir.join("two.pdf"); + // Page 1 white, page 2 a different size with a black square. + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false, "width": 200, "height": 100})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [2], "width": 300, "height": 150})).unwrap(); + let id = s.execute("frame.create", &json!({"spread": 1, "rect": [0, 0, 300, 150]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + s.execute("file.exportPdf", &json!({"path": src.to_string_lossy()})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + assert!(s.execute("file.place", &json!({"path": src.to_string_lossy(), "pdfPage": 3})).is_err()); + s.execute("file.place", &json!({"path": src.to_string_lossy(), "pdfPage": 2, "x": 100, "y": 100})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let it = d.spreads[0].items.last().unwrap(); + let b = it.bounds(); + assert!((b.width() - 300.0).abs() < 1.0 && (b.height() - 150.0).abs() < 1.0, "page 2's size: {b:?}"); + assert_eq!(d.assets.values().next().unwrap().page, 1); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + assert!(img.pixel(250, 170)[0] < 60, "page 2 (black) shows: {:?}", img.pixel(250, 170)); + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + assert!(String::from_utf8_lossy(&pdf).contains("/Subtype /Form") || String::from_utf8_lossy(&pdf).contains("/Subtype/Form")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index fea90bcb..6062724a 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -65,7 +65,10 @@ fn para_style(name: &str, based: Option<&str>, para: ParaAttrs, chars: CharAttrs fn image(d: &mut Document, name: &str, w: u32, h: u32, hue: f32) -> AssetId { let id = AssetId(d.alloc()); let data = art_png(w, h, hue); - d.assets.insert(id, Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), link: None, data: Arc::new(data), pixels: Some((w, h)) })); + 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)) }), + ); id } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 749c3cb7..22814b01 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -161,6 +161,7 @@ mod tests { d.assets.insert( aid, Arc::new(Asset { + page: 0, id: aid, name: "x.png".into(), mime: "image/png".into(), diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index e0c09adb..2699e73a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1851,7 +1851,7 @@ 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 { id, name, mime, link: link_path, data: Arc::new(data), pixels })); + self.assets.insert(id, Arc::new(Asset { page: 0, id, name, mime, link: link_path, data: Arc::new(data), pixels })); Content::Graphic(designcraft_doc::Graphic { asset: id, size, xf: gxf * Affine::translate((l, t)), auto_fit: Default::default() }) } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 358981a1..da79a6c2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -747,7 +747,7 @@ impl Exporter<'_> { match doc { Some(doc) => { s.push_transform(&tf(g.xf)); - s.draw_pdf_page(&doc, size, 0); + s.draw_pdf_page(&doc, size, asset.page as usize); s.pop(); } None => self.warn(format!("{}: can't read the placed PDF", asset.name)), diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 89760710..117a0fa1 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex}; use vello_cpu::Pixmap; -use crate::{decode_pixmap, halve}; +use crate::{decode_pixmap_page, halve}; /// Decoded pixels kept (bytes). const BUDGET: usize = 768 << 20; @@ -23,11 +23,12 @@ struct Entry { #[derive(Default)] struct Cache { - map: HashMap<(usize, u8), Entry>, + /// (bytes address, PDF page, mip level). + map: HashMap<(usize, u32, u8), Entry>, bytes: usize, clock: u64, /// Assets that failed to decode (don't retry every frame). - bad: HashMap>>, + bad: HashMap<(usize, u32), Arc>>, } static CACHE: Mutex> = Mutex::new(None); @@ -41,7 +42,7 @@ fn with(f: impl FnOnce(&mut Cache) -> R) -> R { f(g.get_or_insert_with(Cache::default)) } -fn lookup(key: (usize, u8)) -> Option> { +fn lookup(key: (usize, u32, u8)) -> Option> { with(|c| { c.clock += 1; let clock = c.clock; @@ -52,17 +53,17 @@ fn lookup(key: (usize, u8)) -> Option> { }) } -fn store(bytes: &Arc>, level: u8, pm: Arc) { +fn store(bytes: &Arc>, page: u32, level: u8, pm: Arc) { with(|c| { c.clock += 1; - let key = (Arc::as_ptr(bytes) as usize, level); + let key = (Arc::as_ptr(bytes) as usize, page, level); let n = size_of(&pm); if let Some(old) = c.map.insert(key, Entry { _bytes: bytes.clone(), pm, stamp: c.clock }) { c.bytes -= size_of(&old.pm); } c.bytes += n; if c.bytes > BUDGET { - let mut v: Vec<(u64, (usize, u8))> = c.map.iter().map(|(k, e)| (e.stamp, *k)).collect(); + let mut v: Vec<(u64, (usize, u32, u8))> = c.map.iter().map(|(k, e)| (e.stamp, *k)).collect(); v.sort_unstable(); for (_, k) in v { if c.bytes <= BUDGET * 3 / 4 { @@ -80,23 +81,23 @@ fn store(bytes: &Arc>, level: u8, pm: Arc) { } /// Full-resolution decode of `bytes` (cached). -pub fn decoded(bytes: &Arc>) -> Option> { +pub fn decoded(bytes: &Arc>, page: u32) -> Option> { let id = Arc::as_ptr(bytes) as usize; - if let Some(pm) = lookup((id, 0)) { + if let Some(pm) = lookup((id, page, 0)) { return Some(pm); } - if with(|c| c.bad.contains_key(&id)) { + if with(|c| c.bad.contains_key(&(id, page))) { return None; } // Decode outside the lock: other threads keep drawing cached images meanwhile. - match decode_pixmap(bytes) { + match decode_pixmap_page(bytes, page) { Some(pm) => { let pm = Arc::new(pm); - store(bytes, 0, pm.clone()); + store(bytes, page, 0, pm.clone()); Some(pm) } None => { - with(|c| c.bad.insert(id, bytes.clone())); + with(|c| c.bad.insert((id, page), bytes.clone())); None } } @@ -104,19 +105,19 @@ pub fn decoded(bytes: &Arc>) -> Option> { /// The mip level of `bytes` closest above `on_screen` device pixels per point for a graphic /// `width_pt` points wide. -pub fn mip(bytes: &Arc>, width_pt: f64, on_screen: f64) -> Option> { - let full = decoded(bytes)?; +pub fn mip(bytes: &Arc>, page: u32, width_pt: f64, on_screen: f64) -> Option> { + let full = decoded(bytes, page)?; let ppt = full.width() as f64 / width_pt.max(1e-6); let id = Arc::as_ptr(bytes) as usize; let mut pm = full; let mut level = 0u8; while ppt / (1u64 << (level + 1)) as f64 >= on_screen * 1.2 && pm.width() > 64 && pm.height() > 64 && level < 8 { level += 1; - pm = match lookup((id, level)) { + pm = match lookup((id, page, level)) { Some(p) => p, None => { let half = Arc::new(halve(&pm)); - store(bytes, level, half.clone()); + store(bytes, page, level, half.clone()); half } }; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 643b5f67..bf90003b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -589,7 +589,7 @@ impl Renderer { // Pick a mip level close to the on-screen size (Typical: a 72 ppi proxy). let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen }; - let Some(pm) = images::mip(&asset.data, g.size.0, on_screen) else { return }; + let Some(pm) = images::mip(&asset.data, asset.page, g.size.0, on_screen) else { return }; let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1); ctx.set_transform(f.view * xf * g.xf); let sx = g.size.0 / pm.width().max(1) as f64; @@ -696,8 +696,13 @@ pub fn blend_mode(b: DcBlend) -> BlendMode { /// Decode encoded image bytes into a premultiplied pixmap. pub fn decode_pixmap(bytes: &[u8]) -> Option { + decode_pixmap_page(bytes, 0) +} + +/// [`decode_pixmap`] for page `page` of a PDF (other formats have one page). +pub fn decode_pixmap_page(bytes: &[u8], page: u32) -> Option { if is_pdf(bytes) { - return render_pdf_page(bytes, 0, 3000); + return render_pdf_page(bytes, page as usize, 3000); } if designcraft_images::is_svg(bytes) { let (px, w, h) = designcraft_images::render_svg(bytes, 3000)?; @@ -933,6 +938,7 @@ mod tests { d.assets.insert( aid, Arc::new(designcraft_doc::Asset { + page: 0, id: aid, name: "red.png".into(), mime: "image/png".into(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1bc5dc34..d7a7354b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -558,6 +558,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "polygonSettings" => "Polygon Settings", "preferences" => "Preferences", "print" => "Print", + "pdfImport" => "Place PDF", "keyboardShortcuts" => "Keyboard Shortcuts", "colorPicker" => "Color Picker", id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { @@ -697,6 +698,13 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorPicker" => color_picker(ui, &mut d), "preferences" => preferences(ui, &mut d), "print" => print_dialog(ui, &mut d), + "pdfImport" => { + ui.label(egui::RichText::new(d.s("path")).size(11.0)); + ui.horizontal(|ui| { + ui.label(format!("Page (1–{}):", d.n("pages").unwrap_or(1.0))); + text_field(ui, &mut d, "page", 60.0); + }); + } "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), "polygonSettings" => { egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { @@ -858,6 +866,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) } + "pdfImport" => app.run("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64})), "print" => { let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) }; let printer = d.s("printer"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f2ced097..281cda02 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -354,6 +354,17 @@ impl DesignApp { let cmd = if purpose == "place" { "file.place" } else { "file.open" }; if let Some(pick) = self.services.pick_open.as_mut() { return match pick(purpose) { + // Image Import Options: a multi-page PDF asks which page. + Some(path) if purpose == "place" && path.to_ascii_lowercase().ends_with(".pdf") => { + let pages = + self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); + if pages > 1 { + self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages}))); + Ok(Value::Null) + } else { + self.run(cmd, json!({"path": path})) + } + } Some(path) => self.run(cmd, json!({"path": path})), None => Ok(Value::Null), }; diff --git a/docs/parity.md b/docs/parity.md index cf123038..1f29229a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 65 | 25 | 7 | 80% | +| P1 | 97 | 65 | 26 | 6 | 80% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -22,7 +22,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Transform | 93% | | Fill, stroke, colour | 81% | | Effects & transparency | 50% | -| Placing & links | 78% | +| Placing & links | 81% | | Type & text frames | 85% | | Typography | 85% | | Styles | 92% | @@ -177,7 +177,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Placing & links | Relink to folder / extension | P1 | D | | Placing & links | Copy Links To / Package | P1 | D | | Placing & links | Captions | P2 | M | -| Placing & links | Image import options | P1 | M | +| Placing & links | Image import options | P1 | P | | Placing & links | Linked text/stories | P2 | M | | Placing & links | Display performance | P0 | D | | Placing & links | Video/audio | P2 | M |