diff --git a/ASSETS.md b/ASSETS.md index 97efe1cf0..3fd8493fd 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -29,9 +29,9 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `assets/fonts/SourceSerif4-Semibold.ttf` | Frank Grießhammer / Adobe (released as open source) | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Open-source typeface | | `docs/images/page-2.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/page-3.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | +| `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | -| `docs/images/ui-typing.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | | `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | | `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | diff --git a/Cargo.lock b/Cargo.lock index 74fb4ff20..c8c97afa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,6 +899,7 @@ dependencies = [ "designcraft-color", "designcraft-compose", "designcraft-doc", + "designcraft-epub", "designcraft-fonts", "designcraft-format", "designcraft-geom", @@ -917,6 +918,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "designcraft-epub" +version = "0.1.0" +dependencies = [ + "designcraft-color", + "designcraft-doc", + "zip", +] + [[package]] name = "designcraft-fonts" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0b1d112da..50356bb76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ designcraft-images = { path = "crates/images" } designcraft-render = { path = "crates/render" } designcraft-pdf = { path = "crates/pdf" } designcraft-idml = { path = "crates/idml" } +designcraft-epub = { path = "crates/epub" } designcraft-format = { path = "crates/format" } designcraft-tools = { path = "crates/tools" } designcraft-engine = { path = "crates/engine" } diff --git a/README.md b/README.md index 2f3b7de29..cb0f78ee5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Every page above was laid out by DesignCraft from code (`crates/engine/src/sampl ### Why DesignCraft - **Familiar.** InDesign's layout, tools, menus, panels and shortcuts: spreads and parent pages, frames and threaded stories, the Control panel, paragraph and character styles, swatches, text wrap… you already know how to use it. -- **Beautiful type.** A Knuth–Plass paragraph composer (plus single-line), hyphenation, justification, columns, baseline grid, keeps, tabs, rules and shading — and identical line breaks on screen and in export. +- **Beautiful type.** A Knuth–Plass paragraph composer (plus single-line), dictionary hyphenation (public-domain Moby word list + our own trained patterns), word/letter/glyph-scaling justification, keeps, optical margin alignment, columns, baseline grid, tabs, rules and shading — identical line breaks on screen and in PDF. - **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo snapshots, cached composition. - **Open.** A documented native format, IDML import/export, PNG export, and PDF on the roadmap — no subscription, no licence server, no telemetry. - **Agent-native.** Every menu item, tool gesture, panel control and dialog is drivable over a JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit documents like a designer. diff --git a/ROADMAP.md b/ROADMAP.md index dbedbd3a8..e9e374a28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,14 +11,16 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document dialog, ⌘K palette, 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. -- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). -- ~100 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. +- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. +- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). +- Data Merge (CSV or JSON rows, `<>` placeholders), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. +- ~170 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. - MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input. - Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads. -**Next (in order):** tables · TOC/index/footnotes · PDF/X-4 output intent, bookmarks, tagged PDF · Links panel + relink · EPUB · column-break / default-tab fixes. +**Next (in order):** tables · TOC, text variables, footnotes · PDF/X-4 output intent, tagged PDF · Links panel + relink · UI for Data Merge/spelling/hyperlinks. ## Milestones @@ -31,11 +33,11 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope | M4 | Color & effects | started | | M5 | Graphics & links | started | | M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) | -| M7 | Long documents (sections, TOC, index, footnotes, books) | | +| M7 | Long documents (sections, TOC, index, footnotes, books) | started (sections, hyperlinks, bookmarks) | | M8 | Tables | | | M9 | Performance (MT composition, tiles) | | -| M10 | Find/Change, spelling, Preflight | | -| M11 | Layout power features (liquid/alternate layouts, data merge) | | -| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | | +| M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) | +| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge, step & repeat, snippets) | +| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) | | M13 | Automation (scripts, batch) | | | M14 | 1.0 polish & packaging | | diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 2be0afc9c..fef9d7335 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -1,7 +1,7 @@ //! Headless DesignCraft. //! //! ```text -//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml] [--pdf-options JSON] [--all-pages DIR] +//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR] //! designcraft-cli commands # list every command (JSON) //! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md) //! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document @@ -120,7 +120,10 @@ fn run(args: &[String]) -> Result<(), String> { } "--export" => { let out = val()?; - if out.ends_with(".pdf") { + if out.ends_with(".epub") { + let r = s.execute("file.exportEpub", &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} bytes)", r["bytes"]); + } else if out.ends_with(".pdf") { let mut p = pdf_opts.clone(); p["path"] = json!(out); let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?; diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 91988eb32..582dd2848 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -135,6 +135,9 @@ impl Document { include_prefix: false, }], assets: BTreeMap::new(), + hyperlinks: vec![], + bookmarks: vec![], + user_words: vec![], next_id: 0, }; let lid = LayerId(d.alloc()); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 337e5cb08..e8daf17b2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -226,9 +226,55 @@ pub struct Document { pub sections: Vec
, #[serde(default)] pub assets: BTreeMap>, + /// Hyperlinks (Window → Interactive → Hyperlinks). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hyperlinks: Vec, + /// PDF bookmarks (Window → Interactive → Bookmarks). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bookmarks: Vec, + /// Words added to the document's user dictionary (spelling). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub user_words: Vec, pub next_id: u64, } +/// What a hyperlink is attached to. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum HyperlinkSource { + Item { id: ItemId }, + Text { story: StoryId, start: usize, end: usize }, +} + +/// Where a hyperlink goes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum HyperlinkDest { + Url(String), + Email(String), + /// Absolute page index. + Page(usize), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Hyperlink { + pub id: u64, + pub name: String, + pub source: HyperlinkSource, + pub dest: HyperlinkDest, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Bookmark { + pub name: String, + /// Absolute page index. + pub page: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + impl Document { /// Allocate a fresh id. pub fn alloc(&mut self) -> u64 { @@ -267,30 +313,27 @@ impl Document { self.spreads.iter().enumerate().flat_map(|(si, s)| s.pages.iter().map(move |p| (si, p))).enumerate().map(|(i, (si, p))| (i, si, p)) } + /// The section containing absolute page `abs`. + pub fn section_of(&self, abs: usize) -> Option<&Section> { + self.sections.iter().filter(|s| s.start <= abs).max_by_key(|s| s.start) + } + + /// The page number (before formatting) of absolute page `abs`, per sections. A section without + /// a start number continues from the previous section. + pub fn page_number(&self, abs: usize) -> u32 { + let Some(sec) = self.section_of(abs) else { return abs as u32 + 1 }; + let start = match sec.start_number { + Some(n) => n, + None if sec.start == 0 => 1, + None => self.page_number(sec.start - 1) + 1, + }; + start + (abs - sec.start) as u32 + } + /// The displayed page name ("1", "iv", "A-3"…) for an absolute page index, per sections. pub fn page_name(&self, abs: usize) -> String { - let mut sec: Option<&Section> = None; - for s in &self.sections { - if s.start <= abs && sec.is_none_or(|c| s.start >= c.start) { - sec = Some(s); - } - } - let Some(sec) = sec else { return (abs + 1).to_string() }; - // Numbers continue from the previous section when start_number is None. - let start_number = match sec.start_number { - Some(n) => n, - None => { - let prev: Vec<&Section> = self.sections.iter().filter(|s| s.start < sec.start).collect(); - if prev.is_empty() { - sec.start as u32 + 1 - } else { - // Approximate continuation: absolute position + 1. - sec.start as u32 + 1 - } - } - }; - let n = start_number + (abs - sec.start) as u32; - let num = sec.style.format(n); + let Some(sec) = self.section_of(abs) else { return (abs + 1).to_string() }; + let num = sec.style.format(self.page_number(abs)); if sec.include_prefix { format!("{}{}", sec.prefix, num) } else { num } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index c4c7a4666..98dee2acd 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -78,6 +78,16 @@ fn page_names_follow_sections() { assert_eq!(d.page_name(1), "2"); assert_eq!(d.page_name(2), "A-i"); assert_eq!(d.page_name(4), "A-iii"); + // A section that continues numbering. + d.sections.push(Section { + start: 5, + start_number: None, + style: NumberStyle::Arabic, + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }); + assert_eq!(d.page_name(5), "4"); } #[test] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 94dc6c48e..ce62947f4 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -16,6 +16,7 @@ designcraft-fonts = { workspace = true } designcraft-compose = { workspace = true } designcraft-render = { workspace = true } designcraft-format = { workspace = true } +designcraft-epub = { workspace = true } designcraft-pdf = { workspace = true } designcraft-tools = { workspace = true } designcraft-idml = { workspace = true } diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs new file mode 100644 index 000000000..f0cc84ee1 --- /dev/null +++ b/crates/engine/src/cmd/datamerge.rs @@ -0,0 +1,162 @@ +//! Data Merge (Window → Utilities → Data Merge): a template spread with `<>` placeholders +//! in its stories is duplicated once per CSV record and the placeholders are replaced. One undo step. + +use designcraft_doc::{Content, ItemId, SpreadRef}; +use designcraft_geom::Vec2; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "data.merge", + "Create Merged Document…", + ["Window", "Utilities", "Data Merge"], + None, + "{csv (header row first) | rows: [{field: value}], spread?: template spread index (default 0)} → {records, pages}", + has_doc, + merge + ), + cmd!(query "data.fields", "Data Merge Fields", [], None, "{csv} → field names", has_doc, |_, p| { + let rows = parse_csv(str_param(p, "csv").unwrap_or("")); + Ok(json!(rows.first().cloned().unwrap_or_default())) + }), + ] +} + +/// RFC 4180-ish CSV: quoted fields, doubled quotes, CRLF/LF. +pub fn parse_csv(s: &str) -> Vec> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if quoted { + match c { + '"' if chars.peek() == Some(&'"') => { + field.push('"'); + chars.next(); + } + '"' => quoted = false, + c => field.push(c), + } + continue; + } + match c { + '"' if field.is_empty() => quoted = true, + ',' => row.push(std::mem::take(&mut field)), + '\r' => {} + '\n' => { + row.push(std::mem::take(&mut field)); + if !(row.len() == 1 && row[0].is_empty()) { + rows.push(std::mem::take(&mut row)); + } else { + row.clear(); + } + } + c => field.push(c), + } + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + rows.push(row); + } + rows +} + +fn merge(s: &mut Session, p: &Value) -> Result { + let records: Vec> = if let Some(rows) = p.get("rows").and_then(Value::as_array) { + rows.iter() + .map(|r| { + r.as_object() + .map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))).collect()) + .unwrap_or_default() + }) + .collect() + } else { + let rows = parse_csv(str_param(p, "csv").ok_or_else(|| bad("data.merge", "give `csv` or `rows`"))?); + let Some((head, body)) = rows.split_first() else { return Err(bad("data.merge", "empty CSV")) }; + body.iter().map(|r| head.iter().cloned().zip(r.iter().cloned().chain(std::iter::repeat(String::new()))).collect()).collect() + }; + if records.is_empty() { + return Err(bad("data.merge", "no records")); + } + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + s.edit(|d, sel| { + let template = d.spreads.get(si).cloned().ok_or_else(|| bad("data.merge", "no such spread"))?; + let tpl_ids: Vec = template.items.iter().map(|i| i.id).collect(); + let parent = template.pages.first().and_then(|p| p.parent); + let src = d.clone(); + // Spreads to fill: the template itself for record 0, then one copy per further record. + let mut targets: Vec> = vec![tpl_ids.clone()]; + for _ in 1..records.len() { + let after = d.page_count() - 1; + d.insert_pages(Some(after), template.pages.len(), parent)?; + let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("data.merge", "insert failed"))?; + let ids = super::object::duplicate_from(d, &src, &tpl_ids, SpreadRef::Doc(nsi), Vec2::ZERO)?; + targets.push(ids); + } + for (rec, ids) in records.iter().zip(&targets) { + let mut stories = Vec::new(); + for id in ids { + if let Some(it) = d.item(*id) { + it.walk(&mut |i| { + if let Content::Text(tf) = &i.content + && !stories.contains(&tf.story) + { + stories.push(tf.story); + } + }); + } + } + for sid in stories { + let Some(st) = d.story_mut(sid) else { continue }; + for (k, v) in rec { + let pat = format!("<<{k}>>"); + while let Some(pos) = st.text.find(&pat) { + st.replace(pos..pos + pat.len(), v); + } + } + } + } + *sel = Default::default(); + Ok(json!({"records": records.len(), "pages": d.page_count()})) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn csv_parsing() { + let r = parse_csv("Name,Title\n\"Ada, Countess\",\"Analyst \"\"No. 1\"\"\"\r\nGrace,Admiral\n"); + assert_eq!(r, vec![vec!["Name", "Title"], vec!["Ada, Countess", "Analyst \"No. 1\""], vec!["Grace", "Admiral"]]); + } + + #[test] + fn merge_duplicates_pages_and_fills_fields() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 400, 100], "content": "text", "text": "Dear <>, welcome aboard as <>."})) + .unwrap(); + let r = s.execute("data.merge", &json!({"csv": "Name,Title\nAda,Analyst\nGrace,Admiral\nKatherine,Mathematician\n"})).unwrap(); + assert_eq!(r["records"], 3); + assert_eq!(r["pages"], 3); + let d = &s.doc().unwrap().doc; + let mut texts: Vec<String> = d.stories.values().map(|st| st.text.clone()).collect(); + texts.sort(); + assert_eq!( + texts, + vec![ + "Dear Ada, welcome aboard as Analyst.".to_string(), + "Dear Grace, welcome aboard as Admiral.".to_string(), + "Dear Katherine, welcome aboard as Mathematician.".to_string(), + ] + ); + assert_eq!(s.doc().unwrap().history.undo.len(), 2); // create frame + merge + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b8d456d27..eeed5b888 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use designcraft_doc::{ItemId, Selection, SpreadRef}; -use serde_json::{Value, json}; +use serde_json::json; use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok}; use crate::{HistoryEntry, Result, Session}; @@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> { } /// A document holding copies of the selected items (and their stories) on spread 0. -fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> { +pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> { let st = s.doc()?; let mut d = (*st.doc).clone(); let keep: Vec<ItemId> = st.selection.items.clone(); @@ -153,11 +153,33 @@ fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> { for sp in d.spreads.iter_mut() { Arc::make_mut(sp).items.clear(); } + // Kept frames and graphics (including inside groups). + let mut frames = std::collections::HashSet::new(); + let mut assets = std::collections::HashSet::new(); + for it in &items { + it.walk(&mut |i| match &i.content { + designcraft_doc::Content::Text(_) => { + frames.insert(i.id); + } + designcraft_doc::Content::Graphic(g) => { + assets.insert(g.asset); + } + _ => {} + }); + } if let Some(sp) = d.spreads.first_mut() { Arc::make_mut(sp).items = items; } + // Parent spreads aren't part of a clipping; stories keep only the copied frames. + for p in d.parents.iter_mut() { + Arc::make_mut(p).items.clear(); + } + d.stories.retain(|_, st| st.frames.iter().any(|f| frames.contains(f))); + for st in d.stories.values_mut() { + Arc::make_mut(st).frames.retain(|f| frames.contains(f)); + } + d.assets.retain(|k, _| assets.contains(k)); + d.hyperlinks.clear(); + d.bookmarks.clear(); Ok(d) } - -#[allow(dead_code)] -fn _unused(_: Value) {} diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 6a8e118b9..bf24a52cb 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -7,9 +7,33 @@ use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{EngineError, Result, Session}; pub fn specs() -> Vec<CommandSpec> { - vec![cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None, + vec![ + cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None, "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), spreads?: bool, bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", - has_doc, export_pdf)] + has_doc, export_pdf), + cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, + "{path?, title?, author?, language?: \"en\"} → {path, bytes} (no path: {base64, bytes})", + has_doc, export_epub), + ] +} + +fn export_epub(s: &mut Session, p: &Value) -> Result<Value> { + let st = s.doc()?; + let opts = designcraft_epub::EpubOptions { + title: p.get("title").and_then(Value::as_str).map(str::to_string), + author: p.get("author").and_then(Value::as_str).map(str::to_string), + language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), + identifier: None, + }; + let bytes = designcraft_epub::export_epub(&st.doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + match p.get("path").and_then(Value::as_str) { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) + } + None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), + } } pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e15d6ac7e..883797473 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -31,6 +31,17 @@ pub fn specs() -> Vec<CommandSpec> { s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize); ok() }), + cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None, + "{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", super::has_selection, snippet_export), + cmd!( + "snippet.place", + "Place Snippet", + [], + None, + "{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)", + has_doc, + snippet_place + ), cmd!( "place.load", "Load Place Cursor", @@ -335,3 +346,49 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> { s.set_tool("selection"); Ok(r) } + +fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> { + let d = super::edit::clip_doc(s)?; + let bytes = to_bytes(&d); + 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(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) + } + None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), + } +} + +fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> { + let (bytes, _, _) = read_source(p)?; + let src = from_bytes(&bytes)?; + let ids: Vec<ItemId> = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); + if ids.is_empty() { + return Err(bad("snippet.place", "the snippet is empty")); + } + let sr = super::spread_param(p, "spread"); + let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + 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 - bounds.x0, y - bounds.y0), + _ => designcraft_geom::Vec2::ZERO, + }; + s.edit(|d, sel| { + // Bring over styles and swatches the snippet uses that this document lacks. + 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, sr, off)?; + *sel = Selection::items(new.clone()); + Ok(json!({"ids": new.iter().map(|i| i.0).collect::<Vec<_>>()})) + }) +} diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs new file mode 100644 index 000000000..21e6e6776 --- /dev/null +++ b/crates/engine/src/cmd/interactive.rs @@ -0,0 +1,90 @@ +//! Hyperlinks and bookmarks (Window → Interactive), exported to PDF as link annotations and the outline. + +use designcraft_doc::{Bookmark, Hyperlink, HyperlinkDest, HyperlinkSource}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{Result, Session}; + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + "hyperlink.create", + "New Hyperlink…", + ["Type", "Hyperlinks & Cross-References"], + None, + "{url? | email? | page? (1-based), name?, ids?} — from the selected text, or the selected frames", + has_doc, + create + ), + cmd!("hyperlink.delete", "Delete Hyperlink", [], None, "{id}", has_doc, |s, p| { + let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("hyperlink.delete", "missing id"))?; + s.edit(|d, _| { + d.hyperlinks.retain(|h| h.id != id); + Ok(Value::Null) + }) + }), + cmd!(query "hyperlink.list", "Hyperlinks", [], None, "{}", has_doc, |s, _| Ok(serde_json::to_value(&s.doc()?.doc.hyperlinks).unwrap_or_default())), + cmd!("bookmark.add", "New Bookmark", [], None, "{name?, page? (1-based; default: current selection's page or 1)}", has_doc, |s, p| { + let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1)).unwrap_or(0); + let n = s.doc()?.doc.page_count(); + if page >= n { + return Err(bad("bookmark.add", format!("no page {}", page + 1))); + } + let name = str_param(p, "name").map(str::to_string); + s.edit(|d, _| { + let name = name.clone().unwrap_or_else(|| format!("Bookmark {}", d.bookmarks.len() + 1)); + d.bookmarks.push(Bookmark { name, page, children: vec![] }); + Ok(json!({"index": d.bookmarks.len() - 1})) + }) + }), + cmd!("bookmark.delete", "Delete Bookmark", [], None, "{index}", has_doc, |s, p| { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("bookmark.delete", "missing index"))? as usize; + s.edit(|d, _| { + if i < d.bookmarks.len() { + d.bookmarks.remove(i); + } + Ok(Value::Null) + }) + }), + cmd!(query "bookmark.list", "Bookmarks", [], None, "{}", has_doc, |s, _| Ok(serde_json::to_value(&s.doc()?.doc.bookmarks).unwrap_or_default())), + ] +} + +fn create(s: &mut Session, p: &Value) -> Result<Value> { + let dest = if let Some(u) = str_param(p, "url") { + HyperlinkDest::Url(u.to_string()) + } else if let Some(e) = str_param(p, "email") { + HyperlinkDest::Email(e.to_string()) + } else if let Some(pg) = p.get("page").and_then(Value::as_u64) { + HyperlinkDest::Page((pg as usize).saturating_sub(1)) + } else { + return Err(bad("hyperlink.create", "give `url`, `email` or `page`")); + }; + let st = s.doc()?; + let sources: Vec<HyperlinkSource> = if let Some(ids) = super::ids_param(p, "ids") { + ids.into_iter().map(|id| HyperlinkSource::Item { id }).collect() + } else if let Some(t) = st.selection.text.filter(|t| !t.is_caret()) { + vec![HyperlinkSource::Text { story: t.story, start: t.range().start, end: t.range().end }] + } else { + st.selection.items.iter().map(|id| HyperlinkSource::Item { id: *id }).collect() + }; + if sources.is_empty() { + return Err(bad("hyperlink.create", "select text or frames first")); + } + let name = str_param(p, "name").map(str::to_string); + s.edit(|d, _| { + let mut ids = vec![]; + for src in sources { + let id = d.alloc(); + let name = name.clone().unwrap_or_else(|| match &dest { + HyperlinkDest::Url(u) => u.clone(), + HyperlinkDest::Email(e) => e.clone(), + HyperlinkDest::Page(p) => format!("Page {}", p + 1), + }); + d.hyperlinks.push(Hyperlink { id, name, source: src, dest: dest.clone() }); + ids.push(id); + } + Ok(json!({"ids": ids})) + }) +} diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 1c689fcad..17857303f 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -170,6 +170,77 @@ pub fn specs() -> Vec<CommandSpec> { }) } ), + cmd!( + "layout.section", + "Numbering & Section Options…", + ["Layout"], + None, + "{page (1-based; starts a section there), startNumber?: n|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters, prefix?, includePrefix?, marker?, remove?: bool}", + has_doc, + |s, p| { + let page = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad("layout.section", "missing page"))? as usize; + let n = s.doc()?.doc.page_count(); + if page == 0 || page > n { + return Err(bad("layout.section", format!("no page {page}"))); + } + let start = page - 1; + let p = p.clone(); + s.edit(|d, _| { + d.sections.retain(|x| x.start != start || start == 0); + if p.get("remove").and_then(Value::as_bool).unwrap_or(false) && start != 0 { + return ok(); + } + let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { + start, + start_number: None, + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }); + match p.get("startNumber") { + Some(Value::Null) => sec.start_number = None, + Some(v) => sec.start_number = v.as_u64().map(|v| v.max(1) as u32), + None => {} + } + if let Some(v) = p.get("style") { + sec.style = serde_json::from_value(v.clone()).map_err(|e| bad("layout.section", e.to_string()))?; + } + if let Some(v) = p.get("prefix").and_then(Value::as_str) { + sec.prefix = v.into(); + } + if let Some(v) = p.get("includePrefix").and_then(Value::as_bool) { + sec.include_prefix = v; + } + if let Some(v) = p.get("marker").and_then(Value::as_str) { + sec.marker = v.into(); + } + d.sections.retain(|x| x.start != start); + d.sections.push(sec); + d.sections.sort_by_key(|x| x.start); + Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::<Vec<_>>()})) + }) + } + ), + cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + let to = p.get("to").and_then(Value::as_u64).unwrap_or(0) as usize; + s.edit(|d, _| { + let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad("layer.move", "no such layer"))?; + let l = d.layers.remove(i); + let to = to.min(d.layers.len()); + d.layers.insert(to, l); + ok() + }) + }), + cmd!(noundo "layer.selectItems", "Select All on Layer", [], None, "{id}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + let st = s.doc_mut()?; + let ids: Vec<_> = st.doc.spreads.iter().flat_map(|sp| sp.items.iter().filter(|i| i.layer == id && !i.locked).map(|i| i.id)).collect(); + st.selection = designcraft_doc::Selection::items(ids); + st.revision += 1; + ok() + }), cmd!("layer.new", "New Layer…", [], None, "{name?}", has_doc, |s, p| { let name = str_param(p, "name").map(str::to_string); let r = s.edit(|d, _| { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bd98f417d..08d1e36b2 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,14 +1,17 @@ //! The command registry. Ids follow InDesign's menu structure. +mod datamerge; mod edit; mod export; mod file; mod find; mod inspect; +mod interactive; pub mod interchange; mod layout; mod object; pub mod preflight; +pub mod spelling; mod style; pub mod text; @@ -113,6 +116,9 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(layout::specs()); v.extend(inspect::specs()); v.extend(find::specs()); + v.extend(spelling::specs()); + v.extend(datamerge::specs()); + v.extend(interactive::specs()); v.extend(preflight::specs()); v }) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index daf1c45cb..889258256 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -245,6 +245,15 @@ pub fn specs() -> Vec<CommandSpec> { let name = str_param(p, "name").unwrap_or("").to_string(); set_flag(s, p, move |i| i.name = name.clone(), false) }), + cmd!( + "edit.stepAndRepeat", + "Step and Repeat…", + ["Edit"], + Some("Cmd+Alt+U"), + "{count?: 1, dx?, dy?, rows?, columns?} — copies of the selection offset by (dx, dy); with rows/columns, a grid", + has_selection, + step_and_repeat + ), cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create), cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append), cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close), @@ -983,3 +992,31 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> { ok() }) } + +fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> { + let ids = targets(s, p)?; + let (dx, dy) = (f64_or(p, "dx", 12.0), f64_or(p, "dy", 12.0)); + let rows = p.get("rows").and_then(Value::as_u64).map(|v| v as usize); + let cols = p.get("columns").and_then(Value::as_u64).map(|v| v as usize); + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; + let offsets: Vec<Vec2> = match (rows, cols) { + (Some(r), Some(c)) if r * c <= 1000 => (0..r) + .flat_map(|i| (0..c).map(move |j| (i, j))) + .filter(|&(i, j)| i + j > 0) + .map(|(i, j)| Vec2::new(j as f64 * dx, i as f64 * dy)) + .collect(), + _ => (1..=count).map(|k| Vec2::new(k as f64 * dx, k as f64 * dy)).collect(), + }; + s.edit(|d, sel| { + let src = d.clone(); + let mut all = ids.clone(); + for off in offsets { + for id in &ids { + let sr = src.find(*id).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0)); + all.extend(duplicate_from(d, &src, &[*id], sr, off)?); + } + } + *sel = Selection::items(all.clone()); + Ok(json!({"created": all.len() - ids.len()})) + }) +} diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs new file mode 100644 index 000000000..ab66dceb6 --- /dev/null +++ b/crates/engine/src/cmd/spelling.rs @@ -0,0 +1,190 @@ +//! Edit → Spelling: check spelling against the bundled US English word list (the public-domain +//! Moby list used for hyphenation) plus the document's user dictionary; suggestions by edit distance. + +use designcraft_compose::hyphen::Dictionary; +use designcraft_doc::StoryId; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{Result, Session}; + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!(query "spelling.check", "Check Spelling…", ["Edit", "Spelling"], Some("Cmd+I"), + "{story?, suggestions?: true} → [{story, start, end, word, suggestions}]", has_doc, check), + cmd!("spelling.addWord", "Add to Dictionary", ["Edit", "Spelling"], None, "{word}", has_doc, |s, p| { + let w = str_param(p, "word").ok_or_else(|| bad("spelling.addWord", "missing word"))?.to_lowercase(); + s.edit(|d, _| { + if !d.user_words.contains(&w) { + d.user_words.push(w.clone()); + } + Ok(Value::Null) + }) + }), + cmd!("spelling.change", "Change", [], None, "{story, start, end, to}", has_doc, |s, p| { + let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("spelling.change", "missing story"))?); + let (a, b) = (p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, p.get("end").and_then(Value::as_u64).unwrap_or(0) as usize); + let to = str_param(p, "to").unwrap_or("").to_string(); + s.edit(|d, _| { + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + st.replace(a.min(st.len())..b.min(st.len()), &to); + Ok(Value::Null) + }) + }), + ] +} + +/// Is `w` (lowercase) a known word, allowing common inflections of dictionary stems? +pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { + if w.len() <= 1 || dict.get(w).is_some() || user.iter().any(|u| u == w) { + return true; + } + let w = w.trim_end_matches("'s").trim_end_matches("’s"); + if dict.get(w).is_some() { + return true; + } + let stems: [(&str, &[&str]); 9] = [ + ("ies", &["y"]), + ("es", &["", "e"]), + ("s", &[""]), + ("ed", &["", "e"]), + ("ing", &["", "e"]), + ("ly", &[""]), + ("er", &["", "e"]), + ("est", &["", "e"]), + ("ness", &[""]), + ]; + for (suf, adds) in stems { + if let Some(base) = w.strip_suffix(suf) { + for add in adds { + let cand = format!("{base}{add}"); + if cand.len() > 1 && dict.get(&cand).is_some() { + return true; + } + // Doubled consonant: "stopped" → "stop". + if let Some(c) = base.chars().last() + && base.len() > 2 + && base.ends_with(&format!("{c}{c}")) + && dict.get(&base[..base.len() - c.len_utf8()]).is_some() + { + return true; + } + } + } + } + false +} + +/// Up to 6 suggestions at edit distance 1, ranked: transpositions, replacements, deletions, insertions. +pub fn suggest(dict: &Dictionary, w: &str) -> Vec<String> { + let chars: Vec<char> = w.chars().collect(); + let mut out: Vec<String> = Vec::new(); + let mut push = |c: Vec<char>| { + let c: String = c.into_iter().collect(); + if c != w && !out.contains(&c) && dict.get(&c).is_some() { + out.push(c); + } + }; + for i in 0..chars.len().saturating_sub(1) { + let mut c = chars.clone(); + c.swap(i, i + 1); + push(c); + } + for i in 0..chars.len() { + for l in 'a'..='z' { + let mut c = chars.clone(); + c[i] = l; + push(c); + } + } + for i in 0..chars.len() { + let mut c = chars.clone(); + c.remove(i); + push(c); + } + for i in 0..=chars.len() { + for l in 'a'..='z' { + let mut c = chars.clone(); + c.insert(i, l); + push(c); + } + } + out.truncate(6); + out +} + +fn check(s: &mut Session, p: &Value) -> Result<Value> { + let st = s.doc()?; + let d = &st.doc; + let dict = Dictionary::en_us(); + let want_sugg = p.get("suggestions").and_then(Value::as_bool).unwrap_or(true); + let stories: Vec<StoryId> = match p.get("story").and_then(Value::as_u64) { + Some(id) => vec![StoryId(id)], + None => d.stories.keys().copied().collect(), + }; + let mut out = Vec::new(); + for sid in stories { + let Some(story) = d.story(sid) else { continue }; + let text = &story.text; + let mut i = 0; + let bytes: Vec<(usize, char)> = text.char_indices().collect(); + while i < bytes.len() { + if !bytes[i].1.is_alphabetic() { + i += 1; + continue; + } + let start = bytes[i].0; + let mut j = i; + while j < bytes.len() + && (bytes[j].1.is_alphabetic() + || ((bytes[j].1 == '\'' || bytes[j].1 == '’') && j + 1 < bytes.len() && bytes[j + 1].1.is_alphabetic())) + { + j += 1; + } + let end = bytes.get(j).map(|b| b.0).unwrap_or(text.len()); + let word = &text[start..end]; + let lower = word.to_lowercase(); + // Skip words with digits around them and all-caps acronyms. + let acronym = word.chars().all(|c| c.is_uppercase()) && word.chars().count() <= 5; + if !acronym && !known(dict, &d.user_words, &lower) { + let sugg = if want_sugg { suggest(dict, &lower) } else { vec![] }; + out.push(json!({"story": sid.0, "start": start, "end": end, "word": word, "suggestions": sugg})); + } + i = j.max(i + 1); + } + } + Ok(Value::Array(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_words_and_suggestions() { + let d = Dictionary::en_us(); + if d.is_empty() { + return; // data not bundled in this build + } + for w in ["typography", "pages", "composed", "arranging", "quickly", "layouts", "stopped"] { + assert!(known(d, &[], w), "{w}"); + } + assert!(!known(d, &[], "typograpy")); + assert!(suggest(d, "typograpy").contains(&"typography".to_string())); + assert!(known(d, &["designcraft".into()], "designcraft")); + } + + #[test] + fn check_finds_misspellings() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Thw quick brown fox jumpd over the NASA dog."})) + .unwrap(); + let r = s.execute("spelling.check", &json!({})).unwrap(); + let words: Vec<&str> = r.as_array().unwrap().iter().map(|m| m["word"].as_str().unwrap()).collect(); + assert_eq!(words, vec!["Thw", "jumpd"]); + assert!(r[0]["suggestions"].as_array().unwrap().iter().any(|v| v == "the")); + s.execute("spelling.addWord", &json!({"word": "jumpd"})).unwrap(); + assert_eq!(s.execute("spelling.check", &json!({})).unwrap().as_array().unwrap().len(), 1); + } +} diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 21b12d2af..928bcbac3 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -40,6 +40,8 @@ pub fn specs() -> Vec<CommandSpec> { "object": st.doc.styles.object.iter().map(|p| &p.name).collect::<Vec<_>>(), })) }), + cmd!("style.object.apply", "Apply Object Style", [], None, "{name, ids?}", super::has_selection, apply_object), + cmd!("style.object.create", "New Object Style…", [], None, "{name, fromSelection?: true, fill?, paragraphStyle?}", has_doc, create_object), cmd!( "swatch.create", "New Color Swatch…", @@ -276,3 +278,61 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> { _ => None, } } + +fn apply_object(s: &mut Session, p: &Value) -> Result<Value> { + let name = str_param(p, "name").ok_or_else(|| bad("style.object.apply", "missing name"))?.to_string(); + let os = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad("style.object.apply", format!("no object style `{name}`")))?; + let ids = super::targets(s, p)?; + s.edit(|d, _| { + let mut stories = Vec::new(); + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + it.object_style = name.clone(); + if let Some(f) = &os.fill { + it.fill = f.clone(); + } + if let Some(st) = &os.stroke { + it.stroke = st.clone(); + } + if let Some(tf) = it.text_frame_mut() { + if let Some(o) = &os.text_frame { + tf.options = o.clone(); + } + stories.push(tf.story); + } + } + if let Some(ps) = &os.paragraph_style { + for sid in stories { + if let Some(st) = d.story_mut(sid) { + let len = st.len(); + st.format_paras(0..len, |f| f.style = ps.clone()); + } + } + } + Ok(Value::Null) + }) +} + +fn create_object(s: &mut Session, p: &Value) -> Result<Value> { + let base = str_param(p, "name").unwrap_or("Object Style 1").to_string(); + let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); + let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None }; + let mut os = designcraft_doc::ObjectStyle::default(); + if let Some(it) = &src { + os.fill = Some(it.fill.clone()); + os.stroke = Some(it.stroke.clone()); + os.text_frame = it.text_frame().map(|t| t.options.clone()); + } + if let Some(sw) = str_param(p, "fill") { + os.fill = Some(designcraft_doc::Fill::swatch(sw)); + } + if let Some(ps) = str_param(p, "paragraphStyle") { + os.paragraph_style = Some(ps.to_string()); + } + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.styles.object_style(n).is_some(), &base); + os.name = name.clone(); + d.styles_mut().object.push(os.clone()); + Ok(json!({"name": name})) + }) +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index c9637a2d3..0559e8535 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -191,7 +191,7 @@ pub fn magazine() -> Document { "Cover Title", None, ParaAttrs { align: Some(Align::Left), hyphenate: Some(false), ..Default::default() }, - CharAttrs { leading: Some(Leading::Points(88.0)), tracking: Some(-30.0), fill: Some("[Paper]".into()), ..serif("Bold", 96.0) }, + CharAttrs { leading: Some(Leading::Points(82.0)), tracking: Some(-30.0), fill: Some("[Paper]".into()), ..serif("Bold", 84.0) }, )); st.paragraph.push(para_style( "Cover Deck", @@ -232,15 +232,15 @@ pub fn magazine() -> Document { } // Page 1: cover (spread 0, single right page, no parent). let _ = d.apply_parent(&[0], None); - let cover_art = image(&mut d, "dusk-cover.png", 1224, 1584, 0.0); - graphic_frame(&mut d, SpreadRef::Doc(0), Rect::new(-9.0, -9.0, w + 9.0, h + 9.0), cover_art, (1224, 1584)); + let cover_art = image(&mut d, "dusk-cover.png", 1400, 1812, 0.0); + graphic_frame(&mut d, SpreadRef::Doc(0), Rect::new(-9.0, -9.0, w + 9.0, h + 9.0), cover_art, (1400, 1812)); let (_, _) = text(&mut d, SpreadRef::Doc(0), Rect::new(42.0, 60.0, 570.0, 120.0), "THE SPRING ISSUE · NO. 01", "Kicker"); if let Some(st) = d.stories.values().last().map(|s| s.id) && let Some(st) = d.story_mut(st) { st.format_chars(0..st.len(), |f| f.over.fill = Some("[Paper]".into())); } - let (_, cs) = text(&mut d, SpreadRef::Doc(0), Rect::new(42.0, 450.0, 570.0, 690.0), "The Quiet\nArt of Layout", "Cover Title"); + let (_, cs) = text(&mut d, SpreadRef::Doc(0), Rect::new(42.0, 420.0, 570.0, 698.0), "The Quiet\nArt of Layout", "Cover Title"); let _ = cs; text( &mut d, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 46587f546..8b658cc4c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -218,6 +218,75 @@ fn preflight_reports_overset_and_missing_fonts() { assert_eq!(r["errors"], 2); } +#[test] +fn hyperlinks_and_bookmarks_reach_the_pdf() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + s.execute("selection.set", &json!({"ids": [39]})).unwrap(); + s.execute("hyperlink.create", &json!({"url": "https://example.com/designcraft"})).unwrap(); + let d = s.execute("document.inspect", &json!({})).unwrap(); + let sid = d["stories"][0]["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + s.execute("hyperlink.create", &json!({"page": 3})).unwrap(); + s.execute("bookmark.add", &json!({"name": "Cover", "page": 1})).unwrap(); + s.execute("bookmark.add", &json!({"name": "Feature", "page": 2})).unwrap(); + assert_eq!(s.execute("hyperlink.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = cmd::base64_decode(r["base64"].as_str().unwrap()); + let pdf = hayro_syntax::Pdf::new(bytes).expect("valid pdf"); + assert_eq!(pdf.pages().len(), 4); + // Round trip through the native format keeps them. + let back = cmd::file_from(&cmd::file_bytes(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(back.hyperlinks.len(), 2); + assert_eq!(back.bookmarks.len(), 2); +} + +#[test] +fn step_and_repeat_grid() { + let mut s = session(); + s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap(); + let r = s.execute("edit.stepAndRepeat", &json!({"rows": 3, "columns": 4, "dx": 40, "dy": 40})).unwrap(); + assert_eq!(r["created"], 11); + assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), 12); + let last = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + assert_eq!(last.x0, 36.0 + 3.0 * 40.0); + assert_eq!(last.y0, 36.0 + 2.0 * 40.0); +} + +#[test] +fn snippets_roundtrip_between_documents() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + s.execute("selection.set", &json!({"ids": [39]})).unwrap(); + let snip = s.execute("snippet.export", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("snippet.place", &json!({"base64": snip["base64"], "x": 36.0, "y": 36.0})).unwrap(); + let id = designcraft_doc::ItemId(r["ids"][0].as_u64().unwrap()); + let st = s.doc().unwrap(); + let it = st.doc.item(id).unwrap(); + assert_eq!(it.bounds().x0, 36.0); + let sid = it.text_frame().unwrap().story; + assert!(st.doc.story(sid).unwrap().text.starts_with("Every page begins")); + assert!(st.doc.styles.para("Body").is_some()); + st.doc.check().unwrap(); +} + +#[test] +fn object_styles_create_and_apply() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 136, 136], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.stroke", &json!({"weight": 4.0})).unwrap(); + s.execute("style.object.create", &json!({"name": "Magenta Box"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 36, 300, 136], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("style.object.apply", &json!({"name": "Magenta Box"})).unwrap(); + let d = &s.doc().unwrap().doc; + let (ia, ib) = (d.item(designcraft_doc::ItemId(a)).unwrap(), d.item(designcraft_doc::ItemId(b)).unwrap()); + assert_eq!(ia.fill, ib.fill); + assert_eq!(ib.stroke.weight, 4.0); + assert_eq!(ib.object_style, "Magenta Box"); +} + /// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText). fn pdf_text(bytes: &[u8]) -> Vec<String> { use hayro_interpret::font::Glyph; diff --git a/crates/epub/Cargo.toml b/crates/epub/Cargo.toml new file mode 100644 index 000000000..0cbe1673c --- /dev/null +++ b/crates/epub/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "designcraft-epub" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DesignCraft EPUB 3 (reflowable) export: stories in reading order, CSS from styles, images" + +[lints] +workspace = true + +[dependencies] +designcraft-doc = { workspace = true } +designcraft-color = { workspace = true } +zip = { workspace = true } diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs new file mode 100644 index 000000000..be55f7c68 --- /dev/null +++ b/crates/epub/src/lib.rs @@ -0,0 +1,418 @@ +//! Reflowable EPUB 3 export (File → Export → EPUB (Reflowable)). +//! +//! Stories become XHTML in reading order (page, then top-to-bottom, left-to-right of their first +//! frame); paragraph styles become CSS classes (`p.<slug>`), character styles become `span` +//! classes, local overrides become inline styles. Placed graphics become `<figure>` elements at +//! their position in the reading order. Parent-page items (folios, running heads) are skipped. +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::io::Write; + +use designcraft_doc::{Align, CharAttrs, CharProps, Content, Document, Item, ItemId, ParaProps, SpreadRef, StoryId, Styles, story}; + +#[derive(Clone, Debug)] +pub struct EpubOptions { + pub title: Option<String>, + pub author: Option<String>, + pub language: String, + /// Stable identifier (urn:uuid:…); derived from the title when absent. + pub identifier: Option<String>, +} + +impl Default for EpubOptions { + fn default() -> Self { + EpubOptions { title: None, author: None, language: "en".into(), identifier: None } + } +} + +#[derive(Debug)] +pub struct EpubError(pub String); + +impl std::fmt::Display for EpubError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for EpubError {} + +fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\u{E000}'..='\u{E0FF}' => {} + '\u{2028}' => o.push_str("<br/>"), + '\u{AD}' => o.push_str("­"), + c => o.push(c), + } + } + o +} + +/// CSS class name for a style name. +pub fn slug(name: &str) -> String { + let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); + let s = s.trim_matches('-').to_string(); + if s.is_empty() { + "basic".into() + } else if s.starts_with(|c: char| c.is_ascii_digit()) { + format!("s-{s}") + } else { + s + } +} + +fn hex(doc: &Document, swatch: &str, tint: f32) -> Option<String> { + let c = doc.resolve_color(swatch, tint)?; + let [r, g, b, _] = c.to_rgba8(1.0); + Some(format!("#{r:02x}{g:02x}{b:02x}")) +} + +fn char_css(doc: &Document, c: &CharProps, base_size: f64, out: &mut String) { + let _ = write!(out, "font-family: \"{}\", serif; ", c.font_family); + let st = c.font_style.to_lowercase(); + if st.contains("bold") { + out.push_str("font-weight: bold; "); + } else if st.contains("semibold") { + out.push_str("font-weight: 600; "); + } + if st.contains("italic") { + out.push_str("font-style: italic; "); + } + let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); + if c.tracking != 0.0 { + let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); + } + match c.capitalization { + designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), + designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { + out.push_str("font-variant: small-caps; ") + } + _ => {} + } + if c.underline { + out.push_str("text-decoration: underline; "); + } + if c.strikethrough { + out.push_str("text-decoration: line-through; "); + } + if let Some(h) = hex(doc, &c.fill, c.fill_tint) { + let _ = write!(out, "color: {h}; "); + } +} + +fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) { + let align = match p.align { + Align::Left | Align::TowardsSpine => "left", + Align::Center => "center", + Align::Right | Align::AwayFromSpine => "right", + _ => "justify", + }; + let _ = write!(out, "text-align: {align}; "); + let em = |v: f64| v / base_size; + let _ = write!( + out, + "margin: {:.3}em {:.3}em {:.3}em {:.3}em; text-indent: {:.3}em; line-height: {:.3}; ", + em(p.space_before), + em(p.right_indent), + em(p.space_after), + em(p.left_indent), + em(p.first_line_indent), + line_px + ); + out.push_str(if p.hyphenate { "hyphens: auto; -webkit-hyphens: auto; " } else { "hyphens: manual; " }); +} + +/// The stylesheet for all paragraph and character styles. +pub fn stylesheet(doc: &Document) -> String { + let st: &Styles = &doc.styles; + let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH); + let base = basic.size.max(1.0); + let mut css = String::from( + "body { margin: 0 5%; font-size: 1em; }\nfigure { margin: 1em 0; text-align: center; }\nfigure img { max-width: 100%; }\nh1, h2, h3 { font-size: inherit; margin: 0; }\n", + ); + for ps in &st.paragraph { + if ps.name == designcraft_doc::NO_PARA_STYLE { + continue; + } + let (pp, cp) = st.resolve_para_style(&ps.name); + let lead = match cp.leading { + designcraft_doc::Leading::Auto => pp.auto_leading, + designcraft_doc::Leading::Points(v) => v / cp.size.max(1.0), + }; + let mut rule = String::new(); + para_css(&pp, base, lead, &mut rule); + char_css(doc, &cp, base, &mut rule); + let _ = writeln!(css, "p.{} {{ {rule}}}", slug(&ps.name)); + } + for cs in &st.character { + if cs.name == story::NO_CHAR_STYLE { + continue; + } + let mut rule = String::new(); + let c = &cs.chars; + if let Some(f) = &c.font_style { + let f = f.to_lowercase(); + if f.contains("bold") { + rule.push_str("font-weight: bold; "); + } + if f.contains("italic") { + rule.push_str("font-style: italic; "); + } + } + if let Some(fill) = &c.fill + && let Some(h) = hex(doc, fill, c.fill_tint.unwrap_or(1.0)) + { + let _ = write!(rule, "color: {h}; "); + } + if let Some(sz) = c.size { + let _ = write!(rule, "font-size: {:.3}em; ", sz / base); + } + let _ = writeln!(css, "span.{} {{ {rule}}}", slug(&cs.name)); + } + css +} + +fn override_css(doc: &Document, o: &CharAttrs) -> String { + let mut s = String::new(); + if let Some(f) = &o.font_style { + let f = f.to_lowercase(); + if f.contains("bold") { + s.push_str("font-weight: bold; "); + } + if f.contains("italic") { + s.push_str("font-style: italic; "); + } + } + if let Some(fill) = &o.fill + && let Some(h) = hex(doc, fill, o.fill_tint.unwrap_or(1.0)) + { + let _ = write!(s, "color: {h}; "); + } + if o.underline == Some(true) { + s.push_str("text-decoration: underline; "); + } + if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) { + s.push_str("text-transform: uppercase; "); + } + s.trim_end().to_string() +} + +/// One story as XHTML paragraphs. +pub fn story_html(doc: &Document, sid: StoryId) -> String { + let Some(st) = doc.story(sid) else { return String::new() }; + let mut out = String::new(); + for (pi, r) in st.para_ranges().iter().enumerate() { + let pf = &st.paras[pi]; + let class = slug(&pf.style); + let text_empty = st.text[r.clone()].trim().is_empty(); + let _ = write!(out, "<p class=\"{class}\">"); + if text_empty { + out.push_str(" "); + } + for (rr, f) in st.runs() { + let a = rr.start.max(r.start); + let b = rr.end.min(r.end); + if a >= b { + continue; + } + let t = esc(&st.text[a..b]); + let cls = if f.style != story::NO_CHAR_STYLE { format!(" class=\"{}\"", slug(&f.style)) } else { String::new() }; + let style = override_css(doc, &f.over); + let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{style}\"") }; + if cls.is_empty() && style_attr.is_empty() { + out.push_str(&t); + } else { + let _ = write!(out, "<span{cls}{style_attr}>{t}</span>"); + } + } + out.push_str("</p>\n"); + } + out +} + +/// Reading order of document content: (page, y, x) of each story's first frame and of graphics. +#[derive(Debug, Clone, PartialEq)] +pub enum Block { + Story(StoryId), + Image(ItemId), +} + +pub fn reading_order(doc: &Document) -> Vec<Block> { + let mut keyed: Vec<((usize, i64, i64), Block)> = Vec::new(); + let mut seen_stories = Vec::new(); + for (si, sp) in doc.spreads.iter().enumerate() { + let first = doc.first_page_of_spread(si); + let mut items: Vec<&Item> = Vec::new(); + for top in &sp.items { + top.walk(&mut |i| items.push(i)); + } + for it in items { + if it.hidden { + continue; + } + let b = it.bounds(); + let page = first + sp.page_at_x(b.center().x).unwrap_or(0); + let key = (page, (b.y0 * 10.0) as i64, (b.x0 * 10.0) as i64); + match &it.content { + Content::Text(tf) => { + let Some(story) = doc.story(tf.story) else { continue }; + // A story is placed where its first frame is. + if story.frames.first() == Some(&it.id) && !seen_stories.contains(&tf.story) && !story.text.trim().is_empty() { + seen_stories.push(tf.story); + keyed.push((key, Block::Story(tf.story))); + } + } + Content::Graphic(_) => keyed.push((key, Block::Image(it.id))), + _ => {} + } + } + } + let _ = SpreadRef::Doc(0); + keyed.sort_by_key(|(k, _)| *k); + keyed.into_iter().map(|(_, b)| b).collect() +} + +fn ext(mime: &str) -> &'static str { + match mime { + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + _ => "png", + } +} + +/// Export the document as a reflowable EPUB 3. +pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubError> { + let io = |e: std::io::Error| EpubError(e.to_string()); + let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); + let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); + let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); + let order = reading_order(doc); + // Body and images. + let mut body = String::new(); + let mut images: BTreeMap<String, (&str, &[u8])> = BTreeMap::new(); + let mut toc: Vec<(String, String)> = Vec::new(); + for (k, b) in order.iter().enumerate() { + match b { + Block::Story(sid) => { + let anchor = format!("s{k}"); + // First paragraph text as the TOC label. + if let Some(st) = doc.story(*sid) { + let first: String = + st.text.split('\n').next().unwrap_or("").chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(60).collect(); + if !first.trim().is_empty() { + toc.push((anchor.clone(), first)); + } + } + let _ = write!(body, "<section id=\"{anchor}\">\n{}</section>\n", story_html(doc, *sid)); + } + Block::Image(iid) => { + let Some(Content::Graphic(g)) = doc.item(*iid).map(|i| &i.content) else { continue }; + let Some(a) = doc.assets.get(&g.asset) else { continue }; + if a.data.is_empty() { + continue; + } + let name = format!("images/{}.{}", g.asset.0, ext(&a.mime)); + images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice())); + let _ = writeln!(body, "<figure><img src=\"{name}\" alt=\"{}\"/></figure>", esc(&a.name)); + } + } + } + let lang = &opts.language; + let chapter = format!( + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}\n\n{body}\n\n", + esc(&title) + ); + let mut nav_items = String::new(); + for (a, label) in &toc { + let _ = write!(nav_items, "
  • {}
  • ", esc(label)); + } + if nav_items.is_empty() { + nav_items = format!("
  • {}
  • ", esc(&title)); + } + let nav = format!( + "\n\n\n{}\n\n\n", + esc(&title) + ); + let mut manifest = String::from( + "\n\n\n", + ); + for (k, (name, (mime, _))) in images.iter().enumerate() { + let _ = writeln!(manifest, ""); + } + let author = opts.author.as_deref().map(|a| format!("{}", esc(a))).unwrap_or_default(); + let opf = format!( + "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\n\n\n\n{manifest}\n\n\n\n\n", + esc(&id), + esc(&title) + ); + let container = "\n\n"; + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("mimetype", stored).map_err(zerr)?; + z.write_all(b"application/epub+zip").map_err(io)?; + for (name, data) in [ + ("META-INF/container.xml", container.as_bytes()), + ("OEBPS/content.opf", opf.as_bytes()), + ("OEBPS/nav.xhtml", nav.as_bytes()), + ("OEBPS/style.css", stylesheet(doc).as_bytes()), + ("OEBPS/content.xhtml", chapter.as_bytes()), + ] { + z.start_file(name, deflate).map_err(zerr)?; + z.write_all(data).map_err(io)?; + } + for (name, (_, data)) in &images { + z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?; + z.write_all(data).map_err(io)?; + } + z.finish().map_err(zerr)?; + } + Ok(buf.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use designcraft_doc::build::NewDocument; + use designcraft_doc::geom::Rect; + use designcraft_doc::{ParaFormat, SpreadRef}; + use std::io::Read; + + #[test] + fn exports_valid_package_in_reading_order() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Second & co", ParaFormat::default()).unwrap(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "First\nline two", ParaFormat::default()).unwrap(); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + assert_eq!(z.by_index(0).unwrap().name(), "mimetype"); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let a = html.find("First").unwrap(); + let b = html.find("Second <b> & co").unwrap(); + assert!(a < b, "reading order"); + assert!(html.contains("

    ")); + let mut css = String::new(); + z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); + assert!(css.contains("p.basic-paragraph")); + assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); + } + + #[test] + fn slugs() { + assert_eq!(slug("[Basic Paragraph]"), "basic-paragraph"); + assert_eq!(slug("Body First"), "body-first"); + assert_eq!(slug("1 Head"), "s-1-head"); + } +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index f976f507c..3fc536751 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1351,6 +1351,9 @@ impl<'r> Importer<'r> { swatches: std::mem::take(&mut self.swatches), sections: std::mem::take(&mut self.sections), assets: std::mem::take(&mut self.assets), + hyperlinks: vec![], + bookmarks: vec![], + user_words: vec![], next_id: self.next_id, }; Ok(d) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 1ad75b508..8fd563eca 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -153,7 +153,10 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) if ex.rgb_only { ex.warn("PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)"); } - for sh in &sheets { + if let Some(o) = crate::links::outline(doc, &sheets) { + pdf.set_outline(o); + } + for (sheet_idx, sh) in sheets.iter().enumerate() { let size = Size::from_wh(sh.media.width().max(1.0) as f32, sh.media.height().max(1.0) as f32).ok_or(PdfError::NoPages)?; let local = |r: Rect| { krilla::geom::Rect::from_ltrb( @@ -176,6 +179,9 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) ex.marks(&mut s, sh, &title, created); s.pop(); s.finish(); + for a in crate::links::annotations(doc, cache, &sheets, sheet_idx) { + page.add_annotation(a); + } page.finish(); } let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 0c645c107..5b9566b92 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -26,6 +26,7 @@ #![forbid(unsafe_code)] mod export; +mod links; mod marks; mod text; diff --git a/crates/pdf/src/links.rs b/crates/pdf/src/links.rs new file mode 100644 index 000000000..d025e49f4 --- /dev/null +++ b/crates/pdf/src/links.rs @@ -0,0 +1,104 @@ +//! Interactive PDF: hyperlink annotations (frames and text ranges → URLs, e-mail, pages) and the +//! bookmark outline. + +use designcraft_doc::{Bookmark, Document, HyperlinkDest, HyperlinkSource, SpreadRef}; +use designcraft_geom::{Affine, Rect}; +use krilla::action::{Action, LinkAction}; +use krilla::annotation::{Annotation, LinkAnnotation, Target}; +use krilla::destination::XyzDestination; +use krilla::outline::{Outline, OutlineNode}; + +use crate::export::Sheet; + +/// Index of the output sheet showing absolute page `abs`. +pub(crate) fn sheet_of_page(doc: &Document, sheets: &[Sheet], abs: usize) -> Option { + let (si, pi) = doc.page_loc(abs)?; + let page = doc.spreads.get(si)?.pages.get(pi)?.bounds(); + sheets.iter().position(|s| s.spread == si && s.trim.x0 <= page.center().x && page.center().x <= s.trim.x1) +} + +/// Spread-space rectangles covered by a hyperlink source on spread `si`. +fn source_rects(doc: &Document, cache: &designcraft_compose::Cache, src: &HyperlinkSource, si: usize) -> Vec { + match src { + HyperlinkSource::Item { id } => match (doc.find(*id), doc.item(*id)) { + (Some(loc), Some(it)) if loc.spread == SpreadRef::Doc(si) => vec![doc.parent_xf(&loc).transform_rect_bbox(it.bounds())], + _ => vec![], + }, + HyperlinkSource::Text { story, start, end } => { + let cs = cache.get(doc, *story, None); + let mut out = Vec::new(); + for ft in &cs.frames { + let (Some(loc), Some(it)) = (doc.find(ft.frame), doc.item(ft.frame)) else { continue }; + if loc.spread != SpreadRef::Doc(si) { + continue; + } + let m: Affine = doc.parent_xf(&loc) * it.xf; + for l in &ft.lines { + if l.range.end <= *start || l.range.start >= *end { + continue; + } + let gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= *start && g.byte < *end).collect(); + let (Some(a), Some(b)) = (gs.first(), gs.last()) else { continue }; + let r = Rect::new(a.x, l.baseline - l.ascent, b.x + b.adv, l.baseline + l.descent); + out.push(m.transform_rect_bbox(r)); + } + } + out + } + } +} + +/// Link annotations for the sheet at `idx`. +pub(crate) fn annotations(doc: &Document, cache: &designcraft_compose::Cache, sheets: &[Sheet], idx: usize) -> Vec { + let sh = &sheets[idx]; + let mut out = Vec::new(); + for h in &doc.hyperlinks { + for r in source_rects(doc, cache, &h.source, sh.spread) { + let r = r.intersect(sh.bleed); + if r.width() <= 0.0 || r.height() <= 0.0 { + continue; + } + let rect = krilla::geom::Rect::from_ltrb( + (r.x0 - sh.media.x0) as f32, + (r.y0 - sh.media.y0) as f32, + (r.x1 - sh.media.x0) as f32, + (r.y1 - sh.media.y0) as f32, + ); + let Some(rect) = rect else { continue }; + let target = match &h.dest { + HyperlinkDest::Url(u) => Target::Action(Action::Link(LinkAction::new(u.clone()))), + HyperlinkDest::Email(e) => Target::Action(Action::Link(LinkAction::new(format!("mailto:{e}")))), + HyperlinkDest::Page(p) => match sheet_of_page(doc, sheets, *p) { + Some(i) => Target::Destination(XyzDestination::new(i, krilla::geom::Point::from_xy(0.0, 0.0)).into()), + None => continue, + }, + }; + out.push(Annotation::new_link(LinkAnnotation::new(rect, target), Some(h.name.clone()))); + } + } + out +} + +/// The document outline from the bookmarks (bookmarks to pages that weren't exported are skipped). +pub(crate) fn outline(doc: &Document, sheets: &[Sheet]) -> Option { + fn node(doc: &Document, sheets: &[Sheet], b: &Bookmark) -> Option { + let i = sheet_of_page(doc, sheets, b.page)?; + let mut n = OutlineNode::new(b.name.clone(), XyzDestination::new(i, krilla::geom::Point::from_xy(0.0, 0.0))); + for c in &b.children { + if let Some(cn) = node(doc, sheets, c) { + n.push_child(cn); + } + } + Some(n) + } + if doc.bookmarks.is_empty() { + return None; + } + let mut o = Outline::new(); + for b in &doc.bookmarks { + if let Some(n) = node(doc, sheets, b) { + o.push_child(n); + } + } + Some(o) +} diff --git a/docs/images/page-2.png b/docs/images/page-2.png index c2a35414e..fc1bbdfae 100644 Binary files a/docs/images/page-2.png and b/docs/images/page-2.png differ diff --git a/docs/images/page-3.png b/docs/images/page-3.png index 8d3fd9b32..8e47e8fd8 100644 Binary files a/docs/images/page-3.png and b/docs/images/page-3.png differ diff --git a/docs/images/page-4.png b/docs/images/page-4.png new file mode 100644 index 000000000..16bf67f3e Binary files /dev/null and b/docs/images/page-4.png differ diff --git a/docs/images/page-cover.png b/docs/images/page-cover.png index 8785dc2c7..325cf514a 100644 Binary files a/docs/images/page-cover.png and b/docs/images/page-cover.png differ diff --git a/docs/images/ui-spread.png b/docs/images/ui-spread.png index 123b54018..1da4a169c 100644 Binary files a/docs/images/ui-spread.png and b/docs/images/ui-spread.png differ diff --git a/docs/images/ui-typing.png b/docs/images/ui-typing.png deleted file mode 100644 index f0d1d28ee..000000000 Binary files a/docs/images/ui-typing.png and /dev/null differ diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 11ec4da5d..2a154dd0d 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -42,6 +42,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("render", Class::Layer(3)), ("pdf", Class::Layer(3)), ("idml", Class::Layer(3)), + ("epub", Class::Layer(3)), ("format", Class::Layer(3)), ("tools", Class::Layer(4)), ("engine", Class::Layer(5)),