mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 14:47:05 -06:00
egui037: Integrate DesignCraft main EPUB and PDF updates
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user