O: EPUB cover and fixed-layout EPUB export

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 10:48:58 -04:00
1 parent 3d4bb45f9e
commit 2f104f83a8
5 files changed
+191 -8

No files matched your search

+1
View File
@@ -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.
+84 -1
View File
@@ -12,8 +12,15 @@ pub fn specs() -> Vec<CommandSpec> {
"{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<Value> {
}
}
/// 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<Value> {
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<std::io::Cursor<Vec<u8>>> {
zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap()
}
fn read(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>, 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("<itemref idref=\"cover\"/>"));
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();
+94 -2
View File
@@ -19,11 +19,87 @@ pub struct EpubOptions {
pub language: String,
/// Stable identifier (urn:uuid:…); derived from the title when absent.
pub identifier: Option<String>,
/// Cover image (PNG), shown first and marked as the book's cover.
pub cover: Option<Vec<u8>>,
}
/// 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<u8>,
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<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 lang = &opts.language;
let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title)));
let mut manifest = String::from("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n");
let mut spine = String::new();
let mut files: Vec<(String, Vec<u8>)> = 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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width={w}, height={h}\"/><title>{} — {n}</title>\n<style>body{{margin:0;width:{w}px;height:{h}px;position:relative}} img{{position:absolute;left:0;top:0;width:{w}px;height:{h}px}} .t{{position:absolute;left:0;top:0;width:{w}px;color:transparent;font-size:1px;overflow:hidden}}</style></head>\n<body><img src=\"pages/p{n}.png\" alt=\"\"/><div class=\"t\">{}</div></body>\n</html>\n",
esc(&title),
esc(&p.text).replace('\n', "<br/>")
);
let _ = writeln!(
manifest,
"<item id=\"page{n}\" href=\"page{n}.xhtml\" media-type=\"application/xhtml+xml\"/>\n<item id=\"pimg{n}\" href=\"pages/p{n}.png\" media-type=\"image/png\"{}/>",
if n == 1 { " properties=\"cover-image\"" } else { "" }
);
let _ = writeln!(spine, "<itemref idref=\"page{n}\"/>");
let _ = write!(nav_items, "<li><a href=\"page{n}.xhtml\">{n}</a></li>");
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!(
"<?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=\"page-list\" id=\"toc\"><h1>Pages</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
esc(&title)
);
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}\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">\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 property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</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())]
{
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<Vec<u8>, EpubEr
for (k, (name, (mime, _))) in images.iter().enumerate() {
let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{name}\" media-type=\"{mime}\"/>");
}
// Cover: an image page first in reading order.
if opts.cover.is_some() {
manifest.push_str("<item id=\"cover-img\" href=\"images/cover.png\" media-type=\"image/png\" properties=\"cover-image\"/>\n<item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>\n");
}
let cover_spine = if opts.cover.is_some() { "<itemref idref=\"cover\"/>\n" } else { "" };
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",
"<?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{cover_spine}<itemref idref=\"content\"/>\n</spine>\n</package>\n",
esc(&id),
esc(&title)
);
@@ -507,6 +588,17 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, 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!(
"<?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><style>body{{margin:0;text-align:center}} img{{max-width:100%;max-height:100vh}}</style></head>\n<body epub:type=\"cover\"><img src=\"images/cover.png\" alt=\"{}\"/></body>\n</html>\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())
+9 -2
View File
@@ -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<Result<Value,
}
"app.exportPng" => 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"),
+3 -3
View File
@@ -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 |