L: books — continuous numbering, style sync, merged PDF (+ panel)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 03:49:25 -04:00
1 parent adb054df46
commit 60a8fefe37
12 files changed
+370 -9

No files matched your search

+1
View File
@@ -81,6 +81,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- 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.
+2
View File
@@ -93,6 +93,8 @@ fn services() -> Services {
let d = rfd::FileDialog::new();
let d = if purpose == "swatches" {
d.add_filter("Swatch Exchange (ASE)", &["ase"])
} else if purpose == "book" {
d.add_filter("Book", &["dcbook"])
} else if purpose == "xml" {
d.add_filter("XML", &["xml"])
} else if purpose == "library" {
+252
View File
@@ -0,0 +1,252 @@
//! Books (File › New / Open Book): an ordered list of documents with continuous page numbers,
//! styles synchronised from a style source, and one PDF for the whole book. Saved as `.dcbook`.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, ok, str_param};
use crate::{EngineError, Result, Session};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Book {
#[serde(skip)]
pub path: String,
pub documents: Vec<String>,
/// Index of the style source document.
pub style_source: usize,
}
fn has_book(s: &Session) -> std::result::Result<(), String> {
if s.book.is_some() { Ok(()) } else { Err("no book open".into()) }
}
#[cfg(not(target_arch = "wasm32"))]
fn save(b: &Book) -> Result<()> {
let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?;
std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path)))
}
#[cfg(target_arch = "wasm32")]
fn save(_: &Book) -> Result<()> {
Err(EngineError::Other("books need a file system".into()))
}
fn load(path: &str) -> Result<designcraft_doc::Document> {
#[cfg(not(target_arch = "wasm32"))]
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
#[cfg(target_arch = "wasm32")]
let bytes: Vec<u8> = {
let _ = path;
vec![]
};
if path.to_lowercase().ends_with(".idml") {
return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
}
super::file::from_bytes(&bytes)
}
fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
return std::fs::write(path, super::file::to_bytes(d)).map_err(|e| EngineError::Other(format!("{path}: {e}")));
#[cfg(target_arch = "wasm32")]
{
let _ = (path, d);
Err(EngineError::Other("books need a file system".into()))
}
}
/// Make page numbering of `d` start at `n` (the first section).
fn start_at(d: &mut designcraft_doc::Document, n: u32) {
if !d.sections.iter().any(|x| x.start == 0) {
d.sections.insert(
0,
designcraft_doc::Section {
start: 0,
start_number: None,
style: Default::default(),
prefix: String::new(),
marker: String::new(),
include_prefix: false,
},
);
}
if let Some(first) = d.sections.iter_mut().find(|x| x.start == 0) {
first.start_number = Some(n.max(1));
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "book.new", "New Book", ["File", "New"], None, "{path} — an empty book file (.dcbook)", always, |s, p| {
let path = str_param(p, "path").ok_or_else(|| bad("book.new", "`path` required"))?.to_string();
let b = Book { path, ..Default::default() };
save(&b)?;
s.book = Some(b);
ok()
}),
cmd!(noundo "book.open", "Open Book", [], None, "{path} → {documents}", always, |s, p| {
let path = str_param(p, "path").ok_or_else(|| bad("book.open", "`path` required"))?.to_string();
#[cfg(not(target_arch = "wasm32"))]
let text = std::fs::read_to_string(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
#[cfg(target_arch = "wasm32")]
let text = String::new();
let mut b: Book = serde_json::from_str(&text).map_err(|e| bad("book.open", format!("not a book: {e}")))?;
b.path = path;
let n = b.documents.len();
s.book = Some(b);
Ok(json!({"documents": n}))
}),
cmd!(noundo "book.close", "Close Book", [], None, "{}", has_book, |s, _| {
s.book = None;
ok()
}),
cmd!(noundo "book.add", "Add Document", [], None, "{path, at?: index}", has_book, |s, p| {
let path = str_param(p, "path").ok_or_else(|| bad("book.add", "`path` required"))?.to_string();
load(&path)?;
let b = s.book.as_mut().expect("enabled");
let at = p.get("at").and_then(Value::as_u64).map_or(b.documents.len(), |i| (i as usize).min(b.documents.len()));
b.documents.insert(at, path);
save(b)?;
ok()
}),
cmd!(noundo "book.remove", "Remove Document", [], None, "{index}", has_book, |s, p| {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("book.remove", "`index` required"))? as usize;
let b = s.book.as_mut().expect("enabled");
if i >= b.documents.len() {
return Err(bad("book.remove", format!("no document {i}")));
}
b.documents.remove(i);
if b.style_source >= b.documents.len() {
b.style_source = 0;
}
save(b)?;
ok()
}),
cmd!(noundo "book.styleSource", "Style Source", [], None, "{index}", has_book, |s, p| {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = s.book.as_mut().expect("enabled");
if i >= b.documents.len() {
return Err(bad("book.styleSource", format!("no document {i}")));
}
b.style_source = i;
save(b)?;
ok()
}),
cmd!(query "book.list", "Book", [], None, "{} → {path, styleSource, documents: [{path, pages, firstPage}]}", has_book, |s, _| {
let b = s.book.as_ref().expect("enabled");
let mut first = 1;
let mut docs = Vec::new();
for p in &b.documents {
let pages = load(p).map(|d| d.page_count()).unwrap_or(0);
docs.push(json!({"path": p, "pages": pages, "firstPage": first}));
first += pages;
}
Ok(json!({"path": b.path, "styleSource": b.style_source, "documents": docs}))
}),
cmd!(noundo "book.paginate", "Update Numbering", [], None, "{} — each document starts numbering where the previous one ended (files are saved)", has_book, |s, _| {
let b = s.book.clone().expect("enabled");
let mut next = 1u32;
for p in &b.documents {
let mut d = load(p)?;
start_at(&mut d, next);
next += d.page_count() as u32;
store(p, &d)?;
}
Ok(json!({"pages": next - 1}))
}),
cmd!(noundo "book.syncStyles", "Synchronize Book", [], None, "{} — paragraph and character styles and swatches of the style source go into every document (by name)", has_book, |s, _| {
let b = s.book.clone().expect("enabled");
let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
let src = load(src_path)?;
let mut n = 0;
for (i, p) in b.documents.iter().enumerate() {
if i == b.style_source {
continue;
}
let mut d = load(p)?;
{
let st = d.styles_mut();
for ps in &src.styles.paragraph {
st.paragraph.retain(|x| x.name != ps.name);
st.paragraph.push(ps.clone());
}
for cs in &src.styles.character {
st.character.retain(|x| x.name != cs.name);
st.character.push(cs.clone());
}
}
for sw in &src.swatches {
match d.swatches.iter_mut().find(|w| w.name == sw.name) {
Some(w) if !w.locked => *w = sw.clone(),
Some(_) => {}
None => d.swatches.push(sw.clone()),
}
}
store(p, &d)?;
n += 1;
}
Ok(json!({"synced": n}))
}),
cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| {
let b = s.book.clone().expect("enabled");
let mut parts = Vec::new();
for path in &b.documents {
let d = load(path)?;
let cache = designcraft_compose::Cache::new();
let r = designcraft_pdf::export_pdf_with_report(&d, &cache, &designcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
let n = designcraft_render::pdf_page_count(&r.bytes).unwrap_or(0);
let sizes: Vec<(f32, f32)> = (0..n).filter_map(|i| designcraft_render::pdf_page_size(&r.bytes, i)).map(|(w, h)| (w as f32, h as f32)).collect();
parts.push((r.bytes, sizes));
}
let pages: usize = parts.iter().map(|p| p.1.len()).sum();
let bytes = designcraft_pdf::merge_pdfs(&parts, None).map_err(|e| EngineError::Other(e.to_string()))?;
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
}
None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})),
}
}),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn book_paginates_syncs_and_exports() {
let dir = std::env::temp_dir().join(format!("dc-book-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = |n: &str| dir.join(n).to_string_lossy().to_string();
let mut s = Session::new();
for (name, pages, style) in [("a.designcraft", 3, "Chapter Head"), ("b.designcraft", 2, "Other")] {
s.execute("file.new", &json!({"pages": pages})).unwrap();
s.execute("style.paragraph.create", &json!({"name": style, "chars": {"size": 20}})).unwrap();
s.execute("file.saveAs", &json!({"path": p(name)})).unwrap();
}
s.execute("book.new", &json!({"path": p("book.dcbook")})).unwrap();
s.execute("book.add", &json!({"path": p("a.designcraft")})).unwrap();
s.execute("book.add", &json!({"path": p("b.designcraft")})).unwrap();
let l = s.execute("book.list", &json!({})).unwrap();
assert_eq!(l["documents"][1]["firstPage"], 4);
assert_eq!(s.execute("book.paginate", &json!({})).unwrap()["pages"], 5);
let b = super::load(&p("b.designcraft")).unwrap();
assert_eq!(b.page_name(0), "4", "continues after the first document");
assert_eq!(s.execute("book.syncStyles", &json!({})).unwrap()["synced"], 1);
assert!(super::load(&p("b.designcraft")).unwrap().styles.para("Chapter Head").is_some());
let r = s.execute("book.exportPdf", &json!({})).unwrap();
assert_eq!(r["pages"], 5);
let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap());
assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(5));
// A fresh session reopens the book.
let mut t = Session::new();
assert_eq!(t.execute("book.open", &json!({"path": p("book.dcbook")})).unwrap()["documents"], 2);
std::fs::remove_dir_all(&dir).ok();
}
}
+2
View File
@@ -3,6 +3,7 @@
mod anchored;
pub mod anchors;
mod articles;
pub mod book;
mod captions;
mod conditions;
mod datamerge;
@@ -146,6 +147,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(anchors::specs());
v.extend(articles::specs());
v.extend(paths::specs());
v.extend(book::specs());
v.extend(captions::specs());
v.extend(conditions::specs());
v.extend(library::specs());
+7 -4
View File
@@ -5,7 +5,7 @@ use designcraft_doc::{Content, Document, Item, ItemId, ParaFormat, Story, StoryI
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, ids_param, ok, str_param};
use crate::{EngineError, Result, Session};
use crate::{Result, Session};
const COLORS: [[u8; 3]; 6] = [[230, 120, 40], [60, 140, 220], [70, 170, 90], [200, 70, 160], [150, 110, 220], [200, 170, 40]];
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"text": text, "bytes": text.len()})),
@@ -280,9 +280,12 @@ fn import_xml(s: &mut Session, p: &Value) -> Result<Value> {
(Some(t), _) => t.to_string(),
(None, Some(path)) => {
#[cfg(not(target_arch = "wasm32"))]
let t = std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
#[cfg(target_arch = "wasm32")]
let t = String::new();
let t = {
let _ = path;
String::new()
};
t
}
_ => return Err(bad(ID, "`path` or `text` required")),
+3
View File
@@ -201,6 +201,8 @@ pub struct Session {
pub(crate) text_drag: bool,
/// The open Object Library (File › New / Open Library).
pub library: Option<cmd::library::Library>,
/// The open book (File › Open Book).
pub book: Option<cmd::book::Book>,
/// Content Collector conveyor: collected objects as snippets (name, bytes).
pub conveyor: Vec<(String, Vec<u8>)>,
}
@@ -229,6 +231,7 @@ impl Session {
text_drag: false,
library: None,
conveyor: Vec::new(),
book: None,
untitled: 0,
}
}
+29
View File
@@ -204,6 +204,35 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
Ok(ExportReport { bytes, pages: sheets.len(), warnings })
}
/// One PDF from several (a book's documents), every page carried over as vectors; each part
/// comes with its pages' sizes (points).
pub fn merge_pdfs(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> Result<Vec<u8>> {
let mut pdf = krilla::Document::new_with(krilla::SerializeSettings::default());
let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into());
if let Some(t) = title {
meta = meta.title(t.to_string());
}
pdf.set_metadata(meta);
let mut pages = 0;
for (bytes, sizes) in parts {
let src = krilla::pdf::Pdf::new(Arc::new(bytes.clone())).map_err(|e| PdfError::Write(format!("{e:?}")))?;
let doc = krilla::pdf::PdfDocument::new(Arc::new(src));
for (i, &(w, h)) in sizes.iter().enumerate() {
let size = Size::from_wh(w.max(1.0), h.max(1.0)).ok_or(PdfError::NoPages)?;
let mut page = pdf.start_page_with(PageSettings::new(size));
let mut s = page.surface();
s.draw_pdf_page(&doc, size, i);
s.finish();
page.finish();
pages += 1;
}
}
if pages == 0 {
return Err(PdfError::NoPages);
}
pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))
}
/// File › Print Booklet: how pages pair up on printer spreads.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BookletKind {
+1 -1
View File
@@ -30,7 +30,7 @@ mod links;
mod marks;
mod text;
pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report};
pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs};
/// PDF standard to target.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+2
View File
@@ -37,6 +37,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("bookmarks", "Bookmarks", "panel-bookmarks"),
("articles", "Articles", "panel-articles"),
("tags", "Tags", "panel-tags"),
("book", "Book", "panel-library"),
];
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
@@ -179,6 +180,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
"bookmarks" => panels::interactive::bookmarks(app, ui),
"articles" => panels::interactive::articles(app, ui),
"tags" => panels::interactive::tags(app, ui),
"book" => panels::library::book(app, ui),
"table" => panels::table::show(app, ui),
_ => panels::properties::info_panel(app, ui),
}
+1
View File
@@ -514,6 +514,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}",
"ui:window.panel|Articles|{\"panel\": \"articles\"}",
"ui:window.panel|Tags|{\"panel\": \"tags\"}",
"ui:window.panel|Book|{\"panel\": \"book\"}",
"<",
">Utilities",
"cmd:data.merge",
+66
View File
@@ -66,3 +66,69 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
}
/// Book panel: the open book's documents with their page ranges; numbering, synchronising and
/// exporting the whole book.
pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
if ui.button("New Book…").clicked()
&& let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.dcbook"))
&& let Err(e) = app.run("book.new", json!({"path": path}))
{
app.status(format!("Book: {e}"));
}
if ui.button("Open…").clicked()
&& let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("book"))
&& let Err(e) = app.run("book.open", json!({"path": path}))
{
app.status(format!("Book: {e}"));
}
});
let Ok(info) = app.session.execute("book.list", &json!({})) else {
ui.label(egui::RichText::new("No book open.").size(11.0).color(t.text_dim));
return;
};
let source = info["styleSource"].as_u64().unwrap_or(0) as usize;
ui.separator();
for (i, d) in info["documents"].as_array().cloned().unwrap_or_default().iter().enumerate() {
let path = d["path"].as_str().unwrap_or("").to_string();
let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let (first, pages) = (d["firstPage"].as_u64().unwrap_or(1), d["pages"].as_u64().unwrap_or(0));
ui.horizontal(|ui| {
if ui.selectable_label(i == source, if i == source { "◆" } else { "◇" }).on_hover_text("Style source").clicked() {
let _ = app.run("book.styleSource", json!({"index": i}));
}
if ui.selectable_label(false, &name).on_hover_text("Open").clicked() {
let _ = app.run("file.open", json!({"path": path}));
}
ui.label(egui::RichText::new(format!("{first}–{}", first + pages.saturating_sub(1))).size(10.5).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("Remove").clicked() {
let _ = app.run("book.remove", json!({"index": i}));
}
});
});
}
ui.separator();
ui.horizontal_wrapped(|ui| {
if ui.button("Add Document…").clicked()
&& let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open"))
&& let Err(e) = app.run("book.add", json!({"path": path}))
{
app.status(format!("Book: {e}"));
}
if ui.button("Update Numbering").clicked() {
let _ = app.run("book.paginate", json!({}));
}
if ui.button("Synchronize").clicked() {
let _ = app.run("book.syncStyles", json!({}));
}
if ui.button("Export PDF…").clicked()
&& let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.pdf"))
&& let Err(e) = app.run("book.exportPdf", json!({"path": path}))
{
app.status(format!("Book: {e}"));
}
});
}
+4 -4
View File
@@ -5,13 +5,13 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
<!-- SUMMARY -->
**Weighted breadth parity: 92%** over 275 features.
**Weighted breadth parity: 93%** over 275 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 92 | 4 | 1 | 97% |
| P2 | 76 | 33 | 20 | 23 | 57% |
| P2 | 76 | 34 | 20 | 22 | 58% |
| Area | Parity |
|---|---|
@@ -27,7 +27,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Typography | 98% |
| Styles | 97% |
| Tables | 92% |
| Long documents | 88% |
| Long documents | 96% |
| Interactivity & digital | 75% |
| Output & production | 80% |
| XML & automation | 83% |
@@ -262,7 +262,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Tables | Drag to move rows/columns | P2 | M |
| Long documents | Table of contents | P1 | D |
| Long documents | Index | P2 | D |
| Long documents | Books | P2 | M |
| Long documents | Books | P2 | D |
| Long documents | Numbered lists across stories | P1 | D |
| Long documents | Cross-references | P2 | D |
| Long documents | Running headers via variables | P1 | D |