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:
parent
98d8edf764
commit
9ab1f9f330
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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 <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==");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue