diff --git a/ROADMAP.md b/ROADMAP.md index 543d0ba..b5759c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,6 +42,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`. - Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign. - Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts. +- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB). - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 84dee62..f1735a4 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -14,12 +14,30 @@ pub fn specs() -> Vec { cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, "{path?, title?, author?, language?: \"en\"} → {path, bytes} (no path: {base64, bytes})", has_doc, export_epub), + cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None, + "{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes} (no path: {text, bytes})", + has_doc, export_html), cmd!(noundo "file.exportText", "Export Text…", ["File"], None, "{path?, format?: \"txt\"|\"rtf\" (default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", has_story_target, export_text), ] } +fn export_html(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let opts = + designcraft_epub::HtmlOptions { title: str_param(p, "title").map(str::to_string), language: str_param(p, "language").map(str::to_string) }; + let text = designcraft_epub::export_html(&st.doc, &opts); + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "bytes": text.len()})) + } + None => Ok(json!({"bytes": text.len(), "text": text})), + } +} + fn has_story_target(s: &Session) -> std::result::Result<(), String> { super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) } @@ -154,6 +172,8 @@ mod text_tests { s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nTwo"})).unwrap(); assert!(s.execute("file.exportText", &json!({"frame": r["id"]})).unwrap()["text"] == "One\r\nTwo"); + let html = s.execute("file.exportHtml", &json!({})).unwrap(); + assert!(html["text"].as_str().unwrap().contains("One

")); let rtf = s.execute("file.exportText", &json!({"frame": r["id"], "format": "rtf"})).unwrap(); assert!(rtf["text"].as_str().unwrap().starts_with("{\\rtf1")); let dir = std::env::temp_dir().join(format!("dc-export-text-{}", std::process::id())); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 3b3aadc..6662ec5 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -300,14 +300,15 @@ fn ext(mime: &str) -> &'static str { } } -/// Export the document as a reflowable EPUB 3. -pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, 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))); +/// The content in reading order as HTML sections and figures; `img_src` names each image +/// (a package path, or a data URI for single-file HTML). Returns (body, images by name, table of +/// contents). +#[allow(clippy::type_complexity)] +fn body_html<'a>( + doc: &'a Document, + img_src: &dyn Fn(&str, &str, &[u8]) -> String, +) -> (String, BTreeMap, Vec<(String, String)>) { let order = reading_order(doc); - // Body and images. let mut body = String::new(); let mut images: BTreeMap = BTreeMap::new(); let mut toc: Vec<(String, String)> = Vec::new(); @@ -333,10 +334,54 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr } 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, "
\"{}\"/
", esc(&a.name)); + // Alt text from Object Export Options, else the file name. + let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name); + let _ = writeln!(body, "
\"{}\"/
", img_src(&name, &a.mime, &a.data), esc(alt)); } } } + (body, images, toc) +} + +/// Options for [`export_html`]. +#[derive(Clone, Debug, Default)] +pub struct HtmlOptions { + pub title: Option, + pub language: Option, +} + +/// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the +/// document's stories and graphics in reading order. +pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { + let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); + let lang = opts.language.clone().unwrap_or_else(|| "en".into()); + let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))); + format!( + "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", + esc(&title), + stylesheet(doc) + ) +} + +fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() { + out.push(if i <= c.len() { T[(n >> shift & 63) as usize] as char } else { '=' }); + } + } + out +} + +/// Export the document as a reflowable EPUB 3. +pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, 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 (body, images, toc) = body_html(doc, &|name, _, _| name.to_string()); let lang = &opts.language; let chapter = format!( "\n\n\n{}\n\n{body}\n\n", @@ -427,4 +472,17 @@ mod tests { assert_eq!(slug("Body First"), "body-first"); assert_eq!(slug("1 Head"), "s-1-head"); } + + #[test] + fn html_is_one_self_contained_page() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello ", ParaFormat::default()).unwrap(); + let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None }); + assert!(html.starts_with("") && html.contains("T") && html.contains("Hello <web>")); + assert!(html.contains("p.basic-paragraph"), "styles inline"); + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Ma"), "TWE="); + assert_eq!(base64(b"M"), "TQ=="); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 015c401..2f77ddd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -18,6 +18,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), ("app.exportEpub", "Export EPUB…", None, "{path?} — reflowable EPUB 3"), + ("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"), ("app.exportText", "Export Text…", None, "{path?} — the story being edited, as Text Only (.txt) or Rich Text Format (.rtf)"), ("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"), ("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"), @@ -105,6 +106,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.exportPng", "ui:app.exportIdml", "ui:app.exportEpub", + "ui:app.exportHtml", "ui:app.exportText", ">Export", "cmd:snippet.export", @@ -528,6 +530,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option export_png(app, p), "app.exportIdml" => export_idml(app, p), "app.exportEpub" => export_bytes(app, p, "epub", "file.exportEpub"), + "app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"), "app.exportText" => export_bytes(app, p, "rtf", "file.exportText"), "app.packageDialog" => { let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); diff --git a/docs/parity.md b/docs/parity.md index 15408e7..8901879 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 74 | 21 | 2 | 87% | -| P2 | 76 | 12 | 13 | 51 | 24% | +| P2 | 76 | 13 | 13 | 50 | 26% | | Area | Parity | |---|---| @@ -28,7 +28,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Styles | 92% | | Tables | 88% | | Long documents | 79% | -| Interactivity & digital | 35% | +| Interactivity & digital | 45% | | Output & production | 70% | | XML & automation | 50% | | View & navigation | 82% | @@ -275,7 +275,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Interactivity & digital | Articles panel | P2 | M | | Interactivity & digital | Object Export Options | P2 | P | | Interactivity & digital | EPUB export | P2 | D | -| Interactivity & digital | HTML export | P2 | M | +| Interactivity & digital | HTML export | P2 | D | | Output & production | Export to PDF (print) | P0 | P | | Output & production | Interactive PDF | P2 | P | | Output & production | Print dialog | P1 | D |