mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
L: books — continuous numbering, style sync, merged PDF (+ panel)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
adb054df46
commit
60a8fefe37
12 files changed
+370
-9
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user