Merge main

# Conflicts:
#	ASSETS.md
This commit is contained in:
Brandon Thomas
2026-10-01 12:32:10 -04:00
35 changed files with 1453 additions and 45 deletions
+1 -1
View File
@@ -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 |
Generated
+10
View File
@@ -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"
+1
View File
@@ -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" }
+1 -1
View File
@@ -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.
+9 -7
View File
@@ -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, `<<Field>>` 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 | |
+5 -2
View File
@@ -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())?;
+3
View File
@@ -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());
+65 -22
View File
@@ -226,9 +226,55 @@ pub struct Document {
pub sections: Vec<Section>,
#[serde(default)]
pub assets: BTreeMap<AssetId, Arc<Asset>>,
/// Hyperlinks (Window → Interactive → Hyperlinks).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hyperlinks: Vec<Hyperlink>,
/// PDF bookmarks (Window → Interactive → Bookmarks).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bookmarks: Vec<Bookmark>,
/// Words added to the document's user dictionary (spelling).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub user_words: Vec<String>,
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<Bookmark>,
}
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 }
}
+10
View File
@@ -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]
+1
View File
@@ -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 }
+162
View File
@@ -0,0 +1,162 @@
//! Data Merge (Window → Utilities → Data Merge): a template spread with `<<Field>>` 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<CommandSpec> {
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<Vec<String>> {
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<Value> {
let records: Vec<Vec<(String, String)>> = 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<ItemId> = 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<ItemId>> = 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 <<Name>>, welcome aboard as <<Title>>."}))
.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
}
}
+27 -5
View File
@@ -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) {}
+26 -2
View File
@@ -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> {
+57
View File
@@ -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<_>>()}))
})
}
+90
View File
@@ -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}))
})
}
+71
View File
@@ -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, _| {
+6
View File
@@ -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
})
+37
View File
@@ -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()}))
})
}
+190
View File
@@ -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);
}
}
+60
View File
@@ -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}))
})
}
+4 -4
View File
@@ -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,
+69
View File
@@ -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;
+14
View File
@@ -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 }
+418
View File
@@ -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("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\u{E000}'..='\u{E0FF}' => {}
'\u{2028}' => o.push_str("<br/>"),
'\u{AD}' => o.push_str("&#173;"),
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("&#160;");
}
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>{}</title><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/></head>\n<body>\n{body}</body>\n</html>\n",
esc(&title)
);
let mut nav_items = String::new();
for (a, label) in &toc {
let _ = write!(nav_items, "<li><a href=\"content.xhtml#{a}\">{}</a></li>", esc(label));
}
if nav_items.is_empty() {
nav_items = format!("<li><a href=\"content.xhtml\">{}</a></li>", esc(&title));
}
let nav = 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}\">\n<head><meta charset=\"utf-8\"/><title>{}</title></head>\n<body><nav epub:type=\"toc\" id=\"toc\"><h1>Contents</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
esc(&title)
);
let mut manifest = String::from(
"<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n<item id=\"css\" href=\"style.css\" media-type=\"text/css\"/>\n<item id=\"content\" href=\"content.xhtml\" media-type=\"application/xhtml+xml\"/>\n",
);
for (k, (name, (mime, _))) in images.iter().enumerate() {
let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{name}\" media-type=\"{mime}\"/>");
}
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", esc(a))).unwrap_or_default();
let opf = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">2026-01-01T00:00:00Z</meta>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n<itemref idref=\"content\"/>\n</spine>\n</package>\n",
esc(&id),
esc(&title)
);
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\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 <b> & 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 &lt;b&gt; &amp; co").unwrap();
assert!(a < b, "reading order");
assert!(html.contains("<p class=\"basic-paragraph\">"));
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");
}
}
+3
View File
@@ -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)
+7 -1
View File
@@ -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:?}")))?;
+1
View File
@@ -26,6 +26,7 @@
#![forbid(unsafe_code)]
mod export;
mod links;
mod marks;
mod text;
+104
View File
@@ -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<usize> {
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<Rect> {
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<Annotation> {
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<Outline> {
fn node(doc: &Document, sheets: &[Sheet], b: &Bookmark) -> Option<OutlineNode> {
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)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

+1
View File
@@ -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)),