H: place a chosen PDF page (Image Import Options)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 20:57:38 -04:00
co-authored by Claude Opus 5.5
parent 1b778945af
commit 175aaf28fd
14 changed files with 124 additions and 30 deletions
+1
View File
@@ -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.
+1
View File
@@ -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(),
+7
View File
@@ -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)]
+23 -5
View File
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<Value> {
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<Value> {
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)));
+36
View File
@@ -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);
}
}
+4 -1
View File
@@ -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
}
+1
View File
@@ -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(),
+1 -1
View File
@@ -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() })
}
+1 -1
View File
@@ -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)),
+18 -17
View File
@@ -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<usize, Arc<Vec<u8>>>,
bad: HashMap<(usize, u32), Arc<Vec<u8>>>,
}
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
@@ -41,7 +42,7 @@ fn with<R>(f: impl FnOnce(&mut Cache) -> R) -> R {
f(g.get_or_insert_with(Cache::default))
}
fn lookup(key: (usize, u8)) -> Option<Arc<Pixmap>> {
fn lookup(key: (usize, u32, u8)) -> Option<Arc<Pixmap>> {
with(|c| {
c.clock += 1;
let clock = c.clock;
@@ -52,17 +53,17 @@ fn lookup(key: (usize, u8)) -> Option<Arc<Pixmap>> {
})
}
fn store(bytes: &Arc<Vec<u8>>, level: u8, pm: Arc<Pixmap>) {
fn store(bytes: &Arc<Vec<u8>>, page: u32, level: u8, pm: Arc<Pixmap>) {
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<Vec<u8>>, level: u8, pm: Arc<Pixmap>) {
}
/// Full-resolution decode of `bytes` (cached).
pub fn decoded(bytes: &Arc<Vec<u8>>) -> Option<Arc<Pixmap>> {
pub fn decoded(bytes: &Arc<Vec<u8>>, page: u32) -> Option<Arc<Pixmap>> {
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<Vec<u8>>) -> Option<Arc<Pixmap>> {
/// 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<Vec<u8>>, width_pt: f64, on_screen: f64) -> Option<Arc<Pixmap>> {
let full = decoded(bytes)?;
pub fn mip(bytes: &Arc<Vec<u8>>, page: u32, width_pt: f64, on_screen: f64) -> Option<Arc<Pixmap>> {
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
}
};
+8 -2
View File
@@ -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<Pixmap> {
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<Pixmap> {
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(),
+9
View File
@@ -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<Value, String> {
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");
+11
View File
@@ -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),
};
+3 -3
View File
@@ -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 |