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 |