From 2f104f83a8f45a7c8314f0432207ee464e8d605c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:54:53 -0400 Subject: [PATCH] O: EPUB cover and fixed-layout EPUB export Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/export.rs | 85 ++++++++++++++++++++++++++++- crates/epub/src/lib.rs | 96 ++++++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 11 +++- docs/parity.md | 6 +-- 5 files changed, 191 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 684b6021b..f415fd01e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,6 +113,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML. - Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters. - Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text. +- EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud. - 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 87e344549..a451b5517 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -12,8 +12,15 @@ pub fn specs() -> Vec { "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\"} → {path, bytes} (no path: {base64, bytes})", + "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", has_doc, export_epub), + cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, + "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", + has_doc, |s, p| { + let mut q = p.clone(); + q["fixedLayout"] = serde_json::json!(true); + export_epub(s, &q) + }), cmd!(noundo "file.printBooklet", "Print Booklet…", ["File"], None, "{path?, type?: saddleStitch|twoUpConsecutive, spaceBetween? (pt)} — printer spreads as PDF (pages imposed in booklet order) → {path, bytes, sheets} (no path: {base64, …})", has_doc, print_booklet), @@ -93,14 +100,61 @@ fn export_text(s: &mut Session, p: &Value) -> Result { } } +/// Text of the frames on page `abs`, in line order (for fixed-layout pages). +fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, abs: usize) -> String { + let mut out = String::new(); + for st in d.stories.values() { + let cs = cache.get(d, st.id, None); + for ft in &cs.frames { + if d.page_of_item(ft.frame) != Some(abs) { + continue; + } + for l in &ft.lines { + let r = l.range.start.min(st.text.len())..l.range.end.min(st.text.len()); + let t: String = st.text[r].chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect(); + if !t.trim().is_empty() { + out.push_str(t.trim_end_matches('\n')); + out.push('\n'); + } + } + } + } + out +} + fn export_epub(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; + let fixed = p.get("fixedLayout").and_then(Value::as_bool).unwrap_or(false); + let want_cover = p.get("cover").and_then(Value::as_bool).unwrap_or(false); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let cover = (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(); 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, + cover, }; + if fixed { + let d = &st.doc; + let mut pages = Vec::new(); + for i in 0..d.page_count() { + let Some((si, pi)) = d.page_loc(i) else { continue }; + let b = d.spreads[si].pages[pi].bounds(); + let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else { continue }; + pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) }); + } + let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + return match p.get("path").and_then(Value::as_str) { + Some(path) => { + write_file(path, &bytes)?; + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "pages": pages.len()})) + } + None => Ok(serde_json::json!({"base64": super::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})), + }; + } 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) => { @@ -497,6 +551,35 @@ mod text_tests { use crate::Session; + fn unzip(b64: &str) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() + } + + fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); + s + } + + #[test] + fn epub_cover_and_fixed_layout() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Page one words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({"cover": true})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let opf = read(&mut z, "OEBPS/content.opf"); + assert!(opf.contains("cover-image") && opf.contains("")); + assert!(z.by_name("OEBPS/images/cover.png").is_ok()); + let r = s.execute("file.exportFixedEpub", &json!({})).unwrap(); + assert_eq!(r["pages"], 2); + let mut z = unzip(r["base64"].as_str().unwrap()); + let opf = read(&mut z, "OEBPS/content.opf"); + assert!(opf.contains("rendition:layout\">pre-paginated") && opf.contains("page2.xhtml")); + let p1 = read(&mut z, "OEBPS/page1.xhtml"); + assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); + } + #[test] fn flattener_rasterises_spreads_with_transparency() { let mut s = Session::new(); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 93fd9a364..11b30b1bc 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -19,11 +19,87 @@ pub struct EpubOptions { pub language: String, /// Stable identifier (urn:uuid:…); derived from the title when absent. pub identifier: Option, + /// Cover image (PNG), shown first and marked as the book's cover. + pub cover: Option>, +} + +/// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search, +/// read-aloud and accessibility). +#[derive(Clone, Debug)] +pub struct FixedPage { + pub png: Vec, + pub width: f64, + pub height: f64, + pub text: String, +} + +/// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page. +pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], 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 lang = &opts.language; + let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); + let mut manifest = String::from("\n"); + let mut spine = String::new(); + let mut files: Vec<(String, Vec)> = Vec::new(); + let mut nav_items = String::new(); + for (i, p) in pages.iter().enumerate() { + let n = i + 1; + let (w, h) = (p.width.round().max(1.0) as u32, p.height.round().max(1.0) as u32); + let xhtml = format!( + "\n\n\n{} — {n}\n\n\"\"/
{}
\n\n", + esc(&title), + esc(&p.text).replace('\n', "
") + ); + let _ = writeln!( + manifest, + "\n", + if n == 1 { " properties=\"cover-image\"" } else { "" } + ); + let _ = writeln!(spine, ""); + let _ = write!(nav_items, "
  • {n}
  • "); + files.push((format!("OEBPS/page{n}.xhtml"), xhtml.into_bytes())); + files.push((format!("OEBPS/pages/p{n}.png"), p.png.clone())); + } + let (vw, vh) = pages.first().map_or((612, 792), |p| (p.width.round() as u32, p.height.round() as u32)); + let nav = format!( + "\n\n\n{}\n\n\n", + esc(&title) + ); + 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\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\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())] + { + z.start_file(name, deflate).map_err(zerr)?; + z.write_all(data).map_err(io)?; + } + for (name, data) in &files { + let o = if name.ends_with(".png") { stored } else { deflate }; + z.start_file(name.as_str(), o).map_err(zerr)?; + z.write_all(data).map_err(io)?; + } + z.finish().map_err(zerr)?; + } + Ok(buf.into_inner()) } impl Default for EpubOptions { fn default() -> Self { - EpubOptions { title: None, author: None, language: "en".into(), identifier: None } + EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None } } } @@ -479,9 +555,14 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr for (k, (name, (mime, _))) in images.iter().enumerate() { let _ = writeln!(manifest, ""); } + // Cover: an image page first in reading order. + if opts.cover.is_some() { + manifest.push_str("\n\n"); + } + let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; 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", + "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\n\n\n\n{manifest}\n\n{cover_spine}\n\n\n", esc(&id), esc(&title) ); @@ -507,6 +588,17 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?; z.write_all(data).map_err(io)?; } + if let Some(c) = &opts.cover { + z.start_file("OEBPS/images/cover.png", stored).map_err(zerr)?; + z.write_all(c).map_err(io)?; + let xhtml = format!( + "\n\n\n{}\n\"{}\"/\n\n", + esc(&title), + esc(&title) + ); + z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?; + z.write_all(xhtml.as_bytes()).map_err(io)?; + } z.finish().map_err(zerr)?; } Ok(buf.into_inner()) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b5154ff04..85cddcab9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -17,7 +17,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("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.exportEpub", "Export EPUB…", None, "{path?} — reflowable EPUB 3 (with the first page as its cover)"), + ("app.exportFixedEpub", "Export EPUB (Fixed Layout)…", None, "{path?} — pre-paginated EPUB 3"), ("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"), ("app.exportXml", "Export XML…", None, "{path?} — the tagged content"), ("app.printBooklet", "Print Booklet…", None, "{path?} — saddle-stitched printer spreads as PDF"), @@ -149,6 +150,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.exportPng", "ui:app.exportIdml", "ui:app.exportEpub", + "ui:app.exportFixedEpub", "ui:app.exportHtml", "ui:app.exportXml", "ui:app.exportText", @@ -697,7 +699,12 @@ 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.exportEpub" => { + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q["cover"] = json!(true); + export_bytes(app, &q, "epub", "file.exportEpub") + } + "app.exportFixedEpub" => export_bytes(app, p, "epub", "file.exportFixedEpub"), "app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"), "app.exportXml" => export_bytes(app, p, "xml", "file.exportXml"), "app.printBooklet" => export_bytes(app, p, "pdf", "file.printBooklet"), diff --git a/docs/parity.md b/docs/parity.md index 1570084c0..f3c923ae9 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 | 100 | 2 | 0 | 99% | | P1 | 97 | 94 | 3 | 0 | 98% | -| P2 | 76 | 56 | 20 | 0 | 87% | +| P2 | 76 | 57 | 19 | 0 | 88% | | Area | Parity | |---|---| @@ -29,7 +29,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Tables | 98% | | Long documents | 100% | | Interactivity & digital | 90% | -| Output & production | 85% | +| Output & production | 88% | | XML & automation | 83% | | View & navigation | 96% | | Undo, history, saving | 100% | @@ -284,7 +284,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Output & production | Preflight | P1 | D | | Output & production | Export JPEG/PNG | P1 | D | | Output & production | Export IDML | P0 | D | -| Output & production | Export EPUB/HTML | P2 | P | +| Output & production | Export EPUB/HTML | P2 | D | | Output & production | Export RTF/TXT/Tagged text | P2 | D | | Output & production | Export XML | P2 | P | | Output & production | Separations / Flattener / Ink limit preview | P2 | D |