X: Export HTML — one self-contained page; alt text in EPUB/HTML figures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:38:03 -04:00
parent 98d8edf764
commit 9ab1f9f330
5 changed files with 93 additions and 11 deletions

View File

@ -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.

View File

@ -14,12 +14,30 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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</p>"));
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()));

View File

@ -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<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)));
/// 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<String, (&'a str, &'a [u8])>, Vec<(String, String)>) {
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();
@ -333,10 +334,54 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, 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, "<figure><img src=\"{name}\" alt=\"{}\"/></figure>", 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, "<figure><img src=\"{}\" alt=\"{}\"/></figure>", 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<String>,
pub language: Option<String>,
}
/// 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!(
"<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"generator\" content=\"DesignCraft\">\n<title>{}</title>\n<style>\nbody {{ max-width: 40em; margin: 2em auto; padding: 0 1em; }}\nfigure {{ margin: 1em 0; }} figure img {{ max-width: 100%; height: auto; }}\n{}</style>\n</head>\n<body>\n{body}</body>\n</html>\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<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 (body, images, toc) = body_html(doc, &|name, _, _| name.to_string());
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",
@ -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 <web>", ParaFormat::default()).unwrap();
let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None });
assert!(html.starts_with("<!DOCTYPE html>") && html.contains("<title>T</title>") && html.contains("Hello &lt;web&gt;"));
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==");
}
}

View File

@ -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<Result<Value,
"app.exportPng" => 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();

View File

@ -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 |