egui037: Integrate DesignCraft main EPUB and PDF updates

This commit is contained in:
skylartaylor
2026-10-25 01:34:16 +02:00
27 changed files with 2284 additions and 87 deletions
+73 -3
View File
@@ -13,8 +13,30 @@ 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, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), 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), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {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\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), split?: \"off\"|\"exportTags\"|{style: name} (Split Document for this export, not stored; the document's `document.epubSplit` by default), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
has_doc, export_epub),
cmd!(
"document.epubSplit",
"EPUB Split Document",
[],
None,
"{split?: \"off\"|\"exportTags\"|{style: name}} — EPUB Export Options › Split Document, stored with the document: one content file (off, the default), a file before each paragraph whose style has Export Tagging › Split Document on (exportTags), or before each paragraph of one style → {split}",
has_doc,
|s, p| {
const ID: &str = "document.epubSplit";
let cur = s.doc()?.doc.settings.epub_split.clone();
let Some(v) = p.get("split") else { return Ok(json!({"split": cur})) };
let split = epub_split_param(ID, &s.doc()?.doc, v)?;
if split != cur {
let stored = split.clone();
s.edit(move |d, _| {
d.settings.epub_split = stored;
Ok(Value::Null)
})?;
}
Ok(json!({"split": split}))
}
),
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| {
@@ -242,8 +264,22 @@ fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec<String>) ->
out
}
/// A Split Document setting (`"off"`, `"exportTags"` or `{"style": name}`), naming a paragraph
/// style the document has.
pub(crate) fn epub_split_param(id: &str, doc: &designcraft_doc::Document, v: &Value) -> Result<designcraft_doc::EpubSplit> {
let split: designcraft_doc::EpubSplit =
serde_json::from_value(v.clone()).map_err(|_| bad(id, "`split`: \"off\", \"exportTags\" or {\"style\": name}"))?;
if let designcraft_doc::EpubSplit::Style(name) = &split
&& doc.styles.para(name).is_none()
{
return Err(bad(id, format!("no paragraph style `{name}`")));
}
Ok(split)
}
fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let split = p.get("split").map(|v| epub_split_param("file.exportEpub", &st.doc, v)).transpose()?;
// Object Export Options › Rasterize: those objects go in as images.
let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it));
let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc);
@@ -270,6 +306,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string),
cover,
toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string),
split,
generic_families: generic_families(&st.doc),
fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed {
embedded_fonts(&st.doc, &mut warnings)
@@ -733,9 +770,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
),
};
let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi));
// Placed PDFs with hidden layers go out as images showing just their visible layers.
let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0);
let (layered, layer_count) = rasterize_layered(base)?;
let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) };
let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base);
let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache);
let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2);
@@ -849,6 +885,40 @@ mod text_tests {
s
}
/// Split Document is stored with the document as one undo step, follows a renamed style, and
/// an export can override it without storing its own.
#[test]
fn epub_split_document_setting() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nText\nTwo\nMore"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap();
for at in [0, 9] {
s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap();
}
let files = |s: &mut Session, p: serde_json::Value| {
let mut z = unzip(s.execute("file.exportEpub", &p).unwrap()["base64"].as_str().unwrap());
(1..).take_while(|n| z.by_name(&if *n == 1 { "OEBPS/content.xhtml".into() } else { format!("OEBPS/content{n}.xhtml") }).is_ok()).count()
};
assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off");
assert_eq!(files(&mut s, json!({})), 1, "one content file by default");
assert!(s.execute("document.epubSplit", &json!({"split": {"style": "Nope"}})).is_err());
assert!(s.execute("document.epubSplit", &json!({"split": "h1"})).is_err());
assert!(s.execute("file.exportEpub", &json!({"split": {"style": "Nope"}})).is_err());
assert_eq!(s.execute("document.epubSplit", &json!({"split": {"style": "Chapter"}})).unwrap()["split"], json!({"style": "Chapter"}));
assert_eq!(files(&mut s, json!({})), 2);
assert_eq!(files(&mut s, json!({"split": "off"})), 1);
assert_eq!(s.doc().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Style("Chapter".into()), "the override isn't stored");
s.execute("style.paragraph.edit", &json!({"name": "Chapter", "rename": "Part"})).unwrap();
assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], json!({"style": "Part"}));
assert_eq!(files(&mut s, json!({})), 2);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off");
}
#[test]
fn object_export_options() {
let mut s = Session::new();
+3 -3
View File
@@ -583,8 +583,8 @@ mod tests {
d.settings.lists.push(NumberedList { name: "Steps".into(), continue_across_stories: false });
d.settings.lists.push(NumberedList { name: "Unused".into(), ..Default::default() });
let st = d.styles_mut();
st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into() });
st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new() });
st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into(), split: true });
st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new(), ..Default::default() });
st.para_mut("Quote").unwrap().para.list_name = Some("Steps".into());
Ok(())
})
@@ -592,7 +592,7 @@ mod tests {
let mut s = doc_with_body_and_lead();
s.execute("style.load", &json!({"base64": file_of(&src), "conflicts": "autoRename"})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.styles.export_tag("Head", false).map(|t| t.tag.as_str()), Some("h1"));
assert_eq!(d.styles.export_tag("Head", false).map(|t| (t.tag.as_str(), t.split)), Some(("h1", true)), "Split Document comes along");
assert_eq!(d.styles.export_tag("Lead 2", true).map(|t| t.tag.as_str()), Some("em"), "the tag follows the new name");
assert_eq!(d.styles.export_tag("Lead", true), None);
assert_eq!(d.settings.lists.iter().map(|l| (l.name.as_str(), l.continue_across_stories)).collect::<Vec<_>>(), [("Steps", false)]);
+6 -2
View File
@@ -374,8 +374,12 @@ pub(crate) mod tests {
assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}");
assert!(px(&s, 125)[2] > 200, "blue still shows");
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false);
// PDF export carries the choice (the graphic goes out as an image).
let r = s.execute("file.exportPdf", &json!({})).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}");
assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}");
let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap());
let image = designcraft_render::render_pdf_page(&bytes, 0, 792).unwrap();
let width = usize::from(image.width());
assert!(image.data()[150 * width + 125].a > 200);
assert_eq!(image.data()[150 * width + 175].a, 0);
}
}
+21 -2
View File
@@ -249,7 +249,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Tagging",
[],
None,
"{style, character?: bool, tag?: p|h1…h6|blockquote|pre|li|… (span|em|strong|code|sup|sub… for characters; \"\" = automatic), class?} → the tagging",
"{style, character?: bool, tag?: p|h1…h6|blockquote|pre|li|… (span|em|strong|code|sup|sub… for characters; \"\" = automatic), class?, split?: bool (paragraphs: Split Document, EPUB only)} → the tagging",
has_doc,
|s, p| {
let name = str_param(p, "style").ok_or_else(|| bad("style.exportTag", "`style` required"))?.to_string();
@@ -260,6 +260,10 @@ pub fn specs() -> Vec<CommandSpec> {
}
let key = format!("{}:{name}", if character { "c" } else { "p" });
let (tag, class) = (str_param(p, "tag").map(str::to_string), str_param(p, "class").map(str::to_string));
let split = p.get("split").and_then(Value::as_bool);
if character && split == Some(true) {
return Err(bad("style.exportTag", "`split` is for paragraph styles"));
}
s.edit(|d, _| {
let st = d.styles_mut();
let e = st.export_tags.entry(key.clone()).or_default();
@@ -269,8 +273,11 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(c) = class.clone() {
e.class = c;
}
if let Some(v) = split {
e.split = v;
}
let out = serde_json::to_value(&*e).unwrap_or_default();
if e.tag.is_empty() && e.class.is_empty() {
if *e == designcraft_doc::ExportTag::default() {
st.export_tags.remove(&key);
}
Ok(out)
@@ -1020,6 +1027,9 @@ fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str
*r = Some(to.to_string());
}
}
if let designcraft_doc::EpubSplit::Style(n) = &mut d.settings.epub_split {
rename(n);
}
} else {
let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref()));
for s in &mut st.character {
@@ -2810,6 +2820,13 @@ mod export_tag_tests {
s.execute("style.exportTag", &json!({"style": "Chapter Title", "tag": "h1", "class": "chapter"})).unwrap();
s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "tag": "strong"})).unwrap();
assert!(s.execute("style.exportTag", &json!({"style": "Nope"})).is_err());
// Split Document is for paragraph styles; it alone keeps the tagging.
assert!(s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "split": true})).is_err());
s.execute("style.paragraph.create", &json!({"name": "Part"})).unwrap();
assert_eq!(s.execute("style.exportTag", &json!({"style": "Part", "split": true})).unwrap()["split"], true);
assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_some_and(|t| t.split));
s.execute("style.exportTag", &json!({"style": "Part", "split": false})).unwrap();
assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_none(), "an untouched tagging goes");
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Origins\nA word"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
@@ -2981,6 +2998,7 @@ mod delete_tests {
d.footnote_options.para_style = "Table Body".into();
d.endnote_options.para_style = "Table Body".into();
d.endnote_options.heading_style = "Table Body".into();
d.settings.epub_split = designcraft_doc::EpubSplit::Style("Table Body".into());
d.toc = Some(Toc {
story,
title: "Contents".into(),
@@ -2999,6 +3017,7 @@ mod delete_tests {
assert_eq!((sub.based_on.as_deref(), sub.next_style.as_deref()), (Some("Body"), Some("Body")));
assert_eq!(d.styles.default_paragraph, "Body");
assert_eq!(d.endnote_options.heading_style, "Body");
assert_eq!(d.settings.epub_split, designcraft_doc::EpubSplit::Style("Body".into()), "EPUB Split Document follows the replacement");
let after = s.execute("preflight.run", &json!({})).unwrap();
assert!(!after.to_string().contains("Table Body"), "{after}");
}