diff --git a/ROADMAP.md b/ROADMAP.md index bb70baf15..4337c28d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. - **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a509fdd8b..d8d197387 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -343,6 +343,26 @@ pub enum ChapterSource { SameAsPrevious, } +/// EPUB Export Options › Split Document: where a reflowable EPUB starts a new content file. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EpubSplit { + /// One content file. + #[default] + Off, + /// Single Paragraph Style: a file starts before each paragraph of the named style. + Style(String), + /// Based on Paragraph Style Export Tags: a file starts before each paragraph of a style whose + /// Export Tagging has Split Document on. + ExportTags, +} + +impl EpubSplit { + pub fn is_off(&self) -> bool { + *self == EpubSplit::Off + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct DocSettings { @@ -417,6 +437,9 @@ pub struct DocSettings { /// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets /// with nothing selected (InDesign's document defaults). pub new_object: NewObjectColors, + /// EPUB Export Options › Split Document. + #[serde(skip_serializing_if = "EpubSplit::is_off")] + pub epub_split: EpubSplit, } fn is_true(b: &bool) -> bool { @@ -482,6 +505,7 @@ impl Default for DocSettings { zero_point_locked: false, column_guides_locked: true, new_object: NewObjectColors::default(), + epub_split: EpubSplit::Off, } } } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 5c747fdbf..7605cc447 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -113,6 +113,10 @@ pub struct ExportTag { pub tag: String, /// CSS class (empty = from the style name). pub class: String, + /// Split Document (EPUB only): an EPUB content file starts before each paragraph of the + /// style, when the document's EPUB split is based on export tags (`EpubSplit::ExportTags`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub split: bool, } impl Styles { diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 52886c33c..908814809 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -13,8 +13,30 @@ pub fn specs() -> Vec { "{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) -> 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 { + 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 { 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 { 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) @@ -848,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(); diff --git a/crates/engine/src/cmd/load_styles.rs b/crates/engine/src/cmd/load_styles.rs index b89b42fa5..acc20f719 100644 --- a/crates/engine/src/cmd/load_styles.rs +++ b/crates/engine/src/cmd/load_styles.rs @@ -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::>(), [("Steps", false)]); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index ab24a05d8..9568d842e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -249,7 +249,7 @@ pub fn specs() -> Vec { "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 { } 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 { 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}"); } diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 62de05cb7..38cfd68ad 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -15,8 +15,8 @@ use std::io::Write; use designcraft_doc::geom::{Affine, Rect}; use designcraft_doc::{ - Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK, ParaProps, - SpreadRef, Story, StoryId, Styles, Table, story, + Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, EpubSplit, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK, + ParaProps, SpreadRef, Story, StoryId, Styles, Table, story, }; #[derive(Clone, Debug)] @@ -38,6 +38,8 @@ pub struct EpubOptions { pub modified: Option, /// Title of the navigation (table of contents); from `language` when absent. pub toc_title: Option, + /// Split Document: where content files start; the document's setting when absent. + pub split: Option, } /// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). @@ -288,6 +290,7 @@ impl Default for EpubOptions { fonts: Vec::new(), modified: None, toc_title: None, + split: None, } } } @@ -669,6 +672,8 @@ struct StoryWriter<'a, 'f> { /// Prefix of the `id`s the current story's headings get (`{prefix}-p{paragraph index}`, the /// targets of the table of contents); only top-level paragraphs get one. heading_ids: Option, + /// Where EPUB content files start (see [`starts_chapter`]). + split: EpubSplit, } impl<'a, 'f> StoryWriter<'a, 'f> { @@ -687,6 +692,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> { images: BTreeMap::new(), no_alt: Vec::new(), heading_ids: None, + split: doc.settings.epub_split.clone(), } } @@ -725,6 +731,12 @@ impl<'a, 'f> StoryWriter<'a, 'f> { let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &self.pc); let props = doc.styles.resolve_para(pf).0; let rtl = props.direction == designcraft_doc::TextDirection::RightToLeft; + // Chapter files start only at top-level paragraphs, after any open list or + // preformatted block is closed, so each file stays balanced. + if depth == 0 && self.heading_ids.is_some() && starts_chapter(&self.split, &doc.styles, &pf.style) { + close_blocks(&mut list, &mut pre, out); + out.push_str(CHAPTER_MARK); + } let continues_pre = tag == "pre" && pre.as_ref() == Some(&(class.clone(), rtl)); if !continues_pre && pre.take().is_some() { out.push_str("\n"); @@ -741,11 +753,6 @@ impl<'a, 'f> StoryWriter<'a, 'f> { } list = list_tag; } - // An EPUB chapter file starts before each top-level h1, outside any open list or - // preformatted block. - if depth == 0 && self.heading_ids.is_some() && heading_level(tag) == Some(1) { - out.push_str(CHAPTER_MARK); - } if continues_pre { out.push('\n'); } else { @@ -1050,7 +1057,18 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { StoryWriter::new(doc, true, None).story(sid, None) } -/// Where the EPUB body splits into chapter files: before each `h1`. +/// Whether an EPUB chapter file starts before a paragraph of `style` (Split Document): never +/// when it's off, before each paragraph of the one style it names, or before each paragraph of +/// a style whose Export Tagging has Split Document on. +fn starts_chapter(split: &EpubSplit, st: &Styles, style: &str) -> bool { + match split { + EpubSplit::Off => false, + EpubSplit::Style(name) => name == style, + EpubSplit::ExportTags => st.export_tag(style, false).is_some_and(|e| e.split), + } +} + +/// Where the EPUB body splits into chapter files (see [`starts_chapter`]). const CHAPTER_MARK: &str = ""; /// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed @@ -1306,9 +1324,12 @@ struct Body<'a> { /// 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). -fn body_html<'a>(doc: &'a Document, epub: bool, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> { +fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSplit>, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> { let order = reading_order(doc); let mut writer = StoryWriter::new(doc, epub, Some(img_src)); + if let Some(split) = split { + writer.split = split.clone(); + } let mut body = String::new(); let mut toc = toc_entries(doc, &order); for (k, b) in order.iter().enumerate() { @@ -1355,7 +1376,7 @@ fn body_html<'a>(doc: &'a Document, epub: bool, img_src: &dyn Fn(&str, &str, &[u /// Placed and anchored images (not video or sound) without alt text, by asset name: they are exported as /// decorative (`alt=""`), which an export can report. pub fn images_without_alt(doc: &Document) -> Vec { - body_html(doc, true, &|name, _, _| name.to_string()).no_alt + body_html(doc, true, None, &|name, _, _| name.to_string()).no_alt } /// Options for [`export_html`]. @@ -1373,7 +1394,7 @@ pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let language = opts.language.as_deref().unwrap_or("en"); let lang = language_tag(language); - let body = body_html(doc, false, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; + let body = body_html(doc, false, None, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), @@ -1398,9 +1419,9 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); - let Body { html: body, images, toc, .. } = body_html(doc, true, &|name, _, _| name.to_string()); + let Body { html: body, images, toc, .. } = body_html(doc, true, opts.split.as_ref(), &|name, _, _| name.to_string()); let lang = language_tag(&opts.language); - // One file per chapter (split before each h1), so e-readers needn't load the whole book. + // One file per chapter (see `starts_chapter`); one file in all when Split Document is off. let chapters: Vec = split_chapters(&body) .into_iter() .map(|part| { @@ -1653,7 +1674,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); } let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed"; let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); @@ -1696,7 +1719,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); } let text = "One\nBody\nOne point one\nMore\nTwo\nText"; let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); @@ -1715,12 +1740,12 @@ mod tests { let nl = EpubOptions { language: "nl".into(), ..Default::default() }; let nav = read(&d, &nl, "OEBPS/nav.xhtml"); let link = |f: &str, t: &str, l: &str| format!("{l}"); - // Each h1 starts a chapter file (#768). + // Split Document is off: every heading is in the one content file. let want = format!( "

Inhoud

  1. {}
    1. {}
  2. {}
", link("content.xhtml", "s0-p0", "One"), link("content.xhtml", "s0-p2", "One point one"), - link("content2.xhtml", "s0-p4", "Two") + link("content.xhtml", "s0-p4", "Two") ); assert!(nav.contains(&want), "{nav}"); assert!(!nav.contains("Text after the figure"), "{nav}"); @@ -1760,10 +1785,11 @@ mod tests { ); } - /// #768: the book is split into one file per chapter, before each h1, with well-formed - /// sections; the spine and manifest list the files in order. + /// Split Document off (the default) gives one content file, h1s and all. Single Paragraph + /// Style starts a file before each paragraph of that style, with well-formed sections; the + /// spine and manifest list the files in order. #[test] - fn chapters_are_split_at_h1() { + fn chapters_split_before_a_single_paragraph_style() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); let lid = d.default_layer(); d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { @@ -1774,7 +1800,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); // A title page without a heading, then a story that opens with a chapter and has a // second one in the middle. d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); @@ -1784,6 +1812,15 @@ mod tests { let st = d.story_mut(sid).unwrap(); st.paras[0].style = "Chapter".into(); st.paras[2].style = "Chapter".into(); + let one_file = |d: &Document, opts: &EpubOptions| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut c = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut c).unwrap(); + z.by_name("OEBPS/content2.xhtml").is_err() && c.contains("Title page") && c.contains("Second text") && !c.contains(CHAPTER_MARK) + }; + assert!(one_file(&d, &EpubOptions::default()), "off by default"); + d.settings.epub_split = EpubSplit::Style("Chapter".into()); + assert!(one_file(&d, &EpubOptions { split: Some(EpubSplit::Off), ..Default::default() }), "the export's own setting wins"); let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); let mut read = |path: &str| { let mut s = String::new(); @@ -1809,6 +1846,61 @@ mod tests { assert!(!html.contains(CHAPTER_MARK) && html.contains("Second text")); } + /// Based on export tags, a style with Split Document on starts each chapter file and an h1 + /// without it doesn't; navigation and endnote links point at the files their targets ended + /// up in. + #[test] + fn chapters_split_before_split_document_styles() { + let mut d = Document::new(&NewDocument::default()); + d.settings.epub_split = EpubSplit::ExportTags; + let lid = d.default_layer(); + for (name, tag, split) in [("Part", "p", true), ("Chapter", "h1", false)] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split, ..Default::default() }); + } + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 200.0, 300.0, 600.0), + lid, + "Part One\nChapter A\ntext a\nPart Two\nChapter B\ntext b", + ParaFormat::default(), + ) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(0, "Part"), (1, "Chapter"), (3, "Part"), (4, "Chapter")] { + st.paras[i].style = style.into(); + } + // After "text a". + st.insert_endnote(25, "End note", ParaFormat::default()); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml")); + assert!(c1.contains("Title page") && !c1.contains("Part One"), "{c1}"); + assert!(c2.contains("Part One") && c2.contains("

").count(), "{c}"); + } + let nav = read("OEBPS/nav.xhtml"); + assert!(nav.contains("href=\"content2.xhtml#s1-p1\"") && nav.contains("href=\"content3.xhtml#s1-p4\""), "{nav}"); + assert!(c2.contains("href=\"content3.xhtml#en-1\""), "{c2}"); + assert!(c3.contains("href=\"content2.xhtml#enref-1\""), "the endnote links back across files: {c3}"); + assert!(z.by_name("OEBPS/content4.xhtml").is_err()); + } + /// Note links stay working across chapter files: endnotes end the book, in the last file. #[test] fn note_links_point_at_the_chapter_file_of_their_target() { @@ -1822,7 +1914,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); let (_, sid) = d .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) .unwrap(); @@ -1832,6 +1926,7 @@ mod tests { // After "First": an endnote and a footnote. st.insert_endnote(9, "End note", ParaFormat::default()); st.insert_note(9, "Foot note", ParaFormat::default()); + d.settings.epub_split = EpubSplit::Style("Chapter".into()); let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); let mut read = |path: &str| { let mut s = String::new(); @@ -1844,6 +1939,61 @@ mod tests { assert!(last.contains("Second text") && last.contains(""), "{last}"); } + /// A split before a list item or a preformatted line closes the open list or block first and + /// reopens it in the new file, so every file is balanced. + #[test] + fn chapter_files_split_inside_lists_and_pre_stay_balanced() { + let mut d = Document::new(&NewDocument::default()); + d.settings.epub_split = EpubSplit::ExportTags; + let lid = d.default_layer(); + for (name, tag) in [("Item", "li"), ("Code", "pre")] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split: true, ..Default::default() }); + } + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 300.0), + lid, + "Intro +one +two +x = 1 +y = 2 +End", + ParaFormat::default(), + ) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(1, "Item"), (2, "Item"), (3, "Code"), (4, "Code")] { + st.paras[i].style = style.into(); + } + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let files: Vec = + ["content", "content2", "content3", "content4", "content5"].iter().map(|f| read(&format!("OEBPS/{f}.xhtml"))).collect(); + for (c, text) in files.iter().zip(["Intro", "one", "two", "x = 1", "y = 2"]) { + assert!(c.contains(text), "{text}: {c}"); + for (open, close) in [(""), (""), ("")] { + assert_eq!(c.matches(open).count(), c.matches(close).count(), "{open}: {c}"); + } + } + assert!(files[1].contains(" \"D\"", ParaFormat::default()).unwrap(); - d.styles_mut() - .export_tags - .insert(format!("p:{}", story::BASIC_PARAGRAPH), designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into() }); + d.styles_mut().export_tags.insert( + format!("p:{}", story::BASIC_PARAGRAPH), + designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into(), ..Default::default() }, + ); if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { basic.chars.font_family = Some("Quoted \"Serif\" ".into()); } @@ -2170,7 +2321,9 @@ mod tests { chars: CharAttrs::default(), shortcut: String::new(), }); - d.styles_mut().export_tags.insert("p:Grouped".into(), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); + d.styles_mut() + .export_tags + .insert("p:Grouped".into(), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); let sid = d.stories.keys().copied().find(|sid| d.story(*sid).is_some_and(|st| !st.notes.is_empty())).unwrap(); for p in &mut d.story_mut(sid).unwrap().paras { p.style = "Grouped".into(); diff --git a/crates/epub/tests/it/export_tag_styles.rs b/crates/epub/tests/it/export_tag_styles.rs index b25d31c36..21a280242 100644 --- a/crates/epub/tests/it/export_tag_styles.rs +++ b/crates/epub/tests/it/export_tag_styles.rs @@ -39,8 +39,8 @@ fn fixture() -> Document { fn map(doc: &mut Document, paragraph_tag: &str, paragraph_class: &str, character_tag: &str, character_class: &str) { let tags = &mut doc.styles_mut().export_tags; - tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into() }); - tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into() }); + tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into(), ..Default::default() }); + tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into(), ..Default::default() }); } fn package(doc: &Document) -> (String, String) { diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 3a9dc778d..2ec0d7105 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -778,6 +778,15 @@ impl<'a> Ex<'a> { designcraft_doc::BlendSpace::Rgb => "RGB", }, )); + // Split Document: the style is a reference, a built-in one by its `$ID/` name; an empty + // one splits on the styles' export tags. + let (break_doc, split_style) = match &s.epub_split { + designcraft_doc::EpubSplit::Off => (false, names::style_name_out(PARA_BUILTINS, st::BASIC_PARAGRAPH)), + designcraft_doc::EpubSplit::ExportTags => (true, String::new()), + designcraft_doc::EpubSplit::Style(n) if PARA_BUILTINS.iter().any(|(o, _)| o == n) => (true, names::style_name_out(PARA_BUILTINS, n)), + designcraft_doc::EpubSplit::Style(n) => (true, names::style_self("ParagraphStyle", PARA_BUILTINS, n)), + }; + root.push(El::new("EPubExportPreference").attr("BreakDocument", bool_s(break_doc)).attr("ParagraphStyleName", split_style)); let a = &s.advanced_type; root.push( El::new("TextPreference") @@ -848,6 +857,9 @@ impl<'a> Ex<'a> { let pa = with_list_tab(pa, || (styles.resolve_para_style(&ps.name).0, parent_props)); self.para_attrs(&mut el, &mut props, &pa, parent); self.char_attrs(&mut el, &mut props, &ca); + if styles.export_tag(&ps.name, false).is_some_and(|t| t.split) { + el.set("SplitDocument", "true"); + } pg.insert(&ps.name, with_props(el, props)); } let mut rp = El::new("RootParagraphStyleGroup").attr("Self", self.fresh()); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 1000638d3..548834182 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -682,6 +682,9 @@ impl<'r> Importer<'r> { if let Some(e) = top.iter().find(|e| e.local() == "FootnoteOption") { self.footnote_option(e); } + if let Some(e) = top.iter().find(|e| e.local() == "EPubExportPreference") { + self.epub_split(e); + } for e in top.iter().filter(|e| e.local() == "Layer") { let id = LayerId(self.alloc()); let color = match e.prop_el("LayerColor") { @@ -1187,6 +1190,14 @@ impl<'r> Importer<'r> { Some(slot) => *slot = s, None => self.styles.paragraph.push(s), } + // Split Document (EPUB only): on the style, and on its EPUB export tag map. Files write + // `false` on every style, so either place saying `true` turns it on. + let split = e.get("SplitDocument") == Some("true") + || e.elements() + .any(|m| m.local() == "StyleExportTagMap" && m.get("ExportType") == Some("EPUB") && m.get("SplitDocument") == Some("true")); + if split { + self.styles.export_tags.entry(format!("p:{name}")).or_default().split = true; + } } for (_, e) in &chars { let name = names::style_name_in(CHAR_BUILTINS, e.get("Name").unwrap_or("")); @@ -1461,6 +1472,21 @@ impl<'r> Importer<'r> { } } + /// Preferences › `EPubExportPreference`: Split Document (after styles: it can name one). An + /// empty style name splits on the styles' export tags; a style the file lacks splits nothing. + fn epub_split(&mut self, e: &El) { + if e.boolean("BreakDocument") != Some(true) { + return; + } + let name = e.get("ParagraphStyleName").unwrap_or("$ID/NormalParagraphStyle"); + self.settings.epub_split = if name.trim().is_empty() { + designcraft_doc::EpubSplit::ExportTags + } else { + let style = self.para_style_ref(name); + if self.styles.para(&style).is_some() { designcraft_doc::EpubSplit::Style(style) } else { designcraft_doc::EpubSplit::Off } + }; + } + /// Preferences › `FootnoteOption` (after styles: it names the footnote text and marker styles). fn footnote_option(&mut self, e: &El) { let mut o = designcraft_doc::FootnoteOptions::default(); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index fcee91706..5eedad4a0 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4631,3 +4631,57 @@ fn style_names_with_a_literal_colon_survive_a_round_trip() { assert_eq!(names::style_name_in(names::PARA_BUILTINS, &names::style_name_out(names::PARA_BUILTINS, n)), n); } } + +/// Split Document (EPUB only) comes from the style, or from its EPUB export tag map, and is +/// written back on the style. +#[test] +fn split_document_round_trips() { + let xml = r#" + + + + + + + + + +"#; + let d = import_idml_with(&fixture_with_styles(xml), &|_| None).unwrap(); + let split = |d: &designcraft_doc::Document, name: &str| d.styles.export_tag(name, false).is_some_and(|t| t.split); + assert!(split(&d, "Part") && split(&d, "Chapter") && !split(&d, "Body")); + let back = import_idml(&export_idml(&d)).unwrap(); + assert!(split(&back, "Part") && split(&back, "Chapter") && !split(&back, "Body")); +} + +/// EPUB Export Options › Split Document: off, one style (built in or not) or the styles' export +/// tags, read from the file's `EPubExportPreference` and written back. +#[test] +fn epub_split_document_round_trips() { + use designcraft_doc::EpubSplit; + let styles = + STYLES.replace("", r#""#); + assert!(styles.contains("ParagraphStyle/Chapter"), "the fixture has a paragraph style group"); + for (attrs, want) in [ + (r#"BreakDocument="false" ParagraphStyleName="ParagraphStyle/Chapter""#, EpubSplit::Off), + (r#"BreakDocument="true" ParagraphStyleName="ParagraphStyle/Chapter""#, EpubSplit::Style("Chapter".into())), + (r#"BreakDocument="true" ParagraphStyleName="ParagraphStyle/Text%3aBody""#, EpubSplit::Style("Text/Body".into())), + (r#"BreakDocument="true" ParagraphStyleName="$ID/NormalParagraphStyle""#, EpubSplit::Style(designcraft_doc::BASIC_PARAGRAPH.into())), + (r#"BreakDocument="true" ParagraphStyleName="""#, EpubSplit::ExportTags), + (r#"BreakDocument="true" ParagraphStyleName="ParagraphStyle/Missing""#, EpubSplit::Off), + ] { + let prefs = PREFS.replace("", &format!("")); + let file = zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &styles), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]); + let d = import_idml_with(&file, &|_| None).unwrap(); + assert_eq!(d.settings.epub_split, want, "{attrs}"); + assert_eq!(import_idml(&export_idml(&d)).unwrap().settings.epub_split, want, "{attrs}"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 04d2391f3..9e44501f0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -32,6 +32,7 @@ impl Dialog { let defaults = match id { "newDocument" => crate::new_document::defaults(), "goToPage" => json!({"page": "1"}), + "epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH}), "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}), "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), @@ -353,6 +354,63 @@ pub fn open_pdf_export(app: &mut DesignApp) { app.ui.dialog = Some(Dialog::new("pdfExport", fields)); } +/// File › Export EPUB…: the EPUB Export Options dialog, seeded from the document's Split +/// Document setting. +pub fn open_epub_export(app: &mut DesignApp) { + use designcraft_doc::EpubSplit; + let split = app.session.active().map(|st| st.doc.settings.epub_split.clone()).unwrap_or_default(); + let fields = match split { + EpubSplit::Off => json!({}), + EpubSplit::Style(name) => json!({"split": true, "mode": "style", "style": name}), + EpubSplit::ExportTags => json!({"split": true, "mode": "exportTags"}), + }; + app.ui.dialog = Some(Dialog::new("epubExport", fields)); +} + +/// The Split Document setting the EPUB Export Options dialog's fields describe. +fn epub_export_split(d: &Dialog) -> designcraft_doc::EpubSplit { + use designcraft_doc::EpubSplit; + if !d.b("split") { + EpubSplit::Off + } else if d.s("mode") == "exportTags" { + EpubSplit::ExportTags + } else { + EpubSplit::Style(d.s("style")) + } +} + +fn epub_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.as_str(); + let styles: Vec = app + .session + .active() + .map(|st| st.doc.styles.paragraph.iter().map(|s| s.name.clone()).filter(|n| n != designcraft_doc::NO_PARA_STYLE).collect()) + .unwrap_or_default(); + check(ui, d, "split", crate::i18n::tr(lang, "Split Document")); + ui.add_enabled_ui(d.b("split"), |ui| { + ui.indent("epub_split", |ui| { + let mode = d.s("mode"); + ui.horizontal(|ui| { + if ui.radio(mode != "exportTags", crate::rtl::widget(ui, crate::i18n::tr(lang, "Single Paragraph Style"))).clicked() { + d.fields.insert("mode".into(), json!("style")); + } + let cur = d.s("style"); + egui::ComboBox::from_id_salt("epub_split_style").selected_text(crate::rtl::widget(ui, &cur)).width(180.0).show_ui(ui, |ui| { + for name in &styles { + if ui.selectable_label(cur == *name, crate::rtl::widget(ui, name)).clicked() { + d.fields.insert("style".into(), json!(name)); + d.fields.insert("mode".into(), json!("style")); + } + } + }); + }); + if ui.radio(mode == "exportTags", crate::rtl::widget(ui, crate::i18n::tr(lang, "Based on Paragraph Style Export Tags"))).clicked() { + d.fields.insert("mode".into(), json!("exportTags")); + } + }); + }); +} + fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let printers: Vec = d .fields @@ -2147,6 +2205,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "print" => crate::i18n::tr(&app.ui.language, "Print"), "pdfImport" => crate::i18n::tr(&app.ui.language, "Place PDF"), "pdfExport" => crate::i18n::tr(&app.ui.language, "Export PDF"), + "epubExport" => crate::i18n::tr(&app.ui.language, "EPUB Export Options"), "colorSettings" => crate::i18n::tr(&app.ui.language, "Color Settings"), "layerOptions" => crate::i18n::tr(&app.ui.language, "Object Layer Options"), "keyboardShortcuts" => crate::i18n::tr(&app.ui.language, "Keyboard Shortcuts"), @@ -2885,6 +2944,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "numberingSection" => crate::section_options::body(app, ui, &mut d), "pdfExport" => pdf_export(app, ui, &mut d), + "epubExport" => epub_export(app, ui, &mut d), "alert" => { let file = d.s("file"); if !file.is_empty() { @@ -3410,6 +3470,15 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.status(crate::i18n::tr(&app.ui.language, "Sent {count} page(s) to {printer}").replace("{count}", &r["pages"].to_string()).replace("{printer}", printer)); Ok(r) } + "epubExport" => { + let split = epub_export_split(&d); + let stored = app.session.active().map(|st| st.doc.settings.epub_split.clone()); + if stored.as_ref() != Some(&split) { + let v = serde_json::to_value(&split).map_err(|e| e.to_string())?; + app.run("document.epubSplit", json!({"split": v}))?; + } + crate::menus::export_epub(app, &json!({})) + } "pdfExport" => { // Remember the last-used options; `pages` and `title` are per-job and not persisted. app.ui.pdf_export.preset = d.s("preset"); @@ -3835,7 +3904,7 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul if d.fields.contains_key("x.tag") { let tag = d.s("x.tag"); let tag = if tag == "[Automatic]" { String::new() } else { tag }; - app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class")}))?; + app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class"), "split": d.b("x.split")}))?; } Ok(r) } @@ -3986,6 +4055,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo if !d.fields.contains_key("x.tag") { d.fields.insert("x.tag".into(), json!(if et.tag.is_empty() { "[Automatic]" } else { et.tag.as_str() })); d.fields.insert("x.class".into(), json!(et.class)); + d.fields.insert("x.split".into(), json!(et.split)); } ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Tag:")); @@ -4005,6 +4075,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Class:")); text_field(ui, d, "x.class", 160.0); }); + check(ui, d, "x.split", crate::i18n::tr(&app.ui.language, "Split Document (EPUB only)")); } "nested" => drop_caps_and_nested_styles(&app.ui.language, ui, d, &pv, &cnames), "grep" => grep_styles(&app.ui.language, ui, d, &pv, &cnames), @@ -9887,6 +9958,70 @@ mod tests { assert!(st.doc.styles.char_style(&name).is_none()); } + /// File › Export EPUB… opens EPUB Export Options from the document's setting; OK stores a + /// changed Split Document as one undo step, and an unchanged one adds none. + #[test] + fn epub_export_passes_its_options_to_the_engine() { + use std::io::Read; + use std::sync::{Arc, Mutex}; + let mut app = test_app(); + app.session.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap(); + let r = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\nTwo"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + for anchor in [0, 4] { + app.session.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": anchor})).unwrap(); + app.session.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap(); + } + let written: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = written.clone(); + app.services.write = Some(Box::new(move |_: &str, bytes: &[u8]| { + *sink.lock().unwrap() = bytes.to_vec(); + Ok(()) + })); + crate::menus::export_epub(&mut app, &json!({"path": "book.epub", "split": {"style": "Chapter"}})).unwrap(); + let bytes = written.lock().unwrap().clone(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("properties=\"cover-image\""), "the first page is the cover: {opf}"); + assert!(z.by_name("OEBPS/content2.xhtml").is_ok(), "the split override reaches the export"); + assert_eq!(app.session.active().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Off, "an override is not stored"); + crate::menus::export_epub(&mut app, &json!({"path": "book.epub", "cover": false})).unwrap(); + let bytes = written.lock().unwrap().clone(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(!opf.contains("cover-image"), "a caller's cover: false stays: {opf}"); + } + + #[test] + fn epub_export_options_store_split_document() { + use designcraft_doc::EpubSplit; + let mut app = test_app(); + app.session.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap(); + let undo_steps = |app: &crate::DesignApp| app.session.active().map(|st| st.history.undo.len()); + app.run("app.exportEpub", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.id.as_str(), d.b("split"), d.s("style").as_str()), ("epubExport", false, designcraft_doc::BASIC_PARAGRAPH)); + d.fields.insert("split".into(), json!(true)); + d.fields.insert("style".into(), json!("Chapter")); + let before = undo_steps(&app); + confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::Style("Chapter".into())); + assert_eq!(undo_steps(&app), before.map(|n| n + 1)); + open_epub_export(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.b("split"), d.s("mode"), d.s("style"))), Some((true, "style".into(), "Chapter".into()))); + confirm(&mut app).unwrap(); + assert_eq!(undo_steps(&app), before.map(|n| n + 1), "unchanged: no undo step"); + open_epub_export(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("exportTags")); + confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::ExportTags); + open_epub_export(&mut app); + cancel(&mut app); + assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::ExportTags, "Cancel stores nothing"); + } + #[test] fn pdf_export_defaults() { let d = Dialog::new("pdfExport", json!({})); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5e751fbe9..347e046de 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -43,7 +43,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("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 (with the first page as its cover)"), + ( + "app.exportEpub", + "Export EPUB…", + None, + "{path?} — reflowable EPUB 3 (with the first page as its cover); without a path, EPUB Export Options (Split Document) and then the save dialog", + ), ( "app.exportInteractivePdf", "Export PDF (Interactive)…", @@ -1117,10 +1122,11 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option export_png(app, p), "app.exportIdml" => export_idml(app, p), + // From the menu, EPUB Export Options first; with a path, straight to the file. + "app.exportEpub" if p.get("path").is_some() => export_epub(app, p), "app.exportEpub" => { - let mut q = if p.is_object() { p.clone() } else { json!({}) }; - q["cover"] = json!(true); - export_bytes(app, &q, "epub", "file.exportEpub") + crate::dialogs::open_epub_export(app); + Ok(Value::Null) } "app.exportInteractivePdf" => { let mut q = if p.is_object() { p.clone() } else { json!({}) }; @@ -2110,6 +2116,16 @@ fn export_idml(app: &mut DesignApp, p: &Value) -> Result { /// Export EPUB / Text: ask for a path (default extension `ext`), run `cmd` without one and write /// what it returns (`base64` or `text`) with the platform writer. +/// File › Export EPUB…: export through `file.exportEpub` (the first page as its cover) to `path`, +/// else to a file the user picks. +pub(crate) fn export_epub(app: &mut DesignApp, p: &Value) -> Result { + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(o) = q.as_object_mut() { + o.entry("cover").or_insert(json!(true)); + } + export_bytes(app, &q, "epub", "file.exportEpub") +} + fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result { let st = app.session.active().ok_or("no document")?; let path = match p.get("path").and_then(Value::as_str) { @@ -2120,7 +2136,11 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result< } }; let Some(path) = path else { return Ok(Value::Null) }; - let mut params = json!({}); + // The caller's options go to the engine command; the path is ours. + let mut params = match p { + Value::Object(o) => Value::Object(o.iter().filter(|(k, _)| k.as_str() != "path").map(|(k, v)| (k.clone(), v.clone())).collect()), + _ => json!({}), + }; if cmd == "file.exportText" { let txt = path.to_ascii_lowercase().ends_with(".txt"); params["format"] = json!(if txt { "txt" } else { "rtf" }); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 197057ad2..28551d1a8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -400,7 +400,8 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. - View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups). - Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step). -- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML. +- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML. Its Split Document (EPUB only) flag marks the styles that start a new EPUB content file when the export splits on export tags. +- EPUB Export Options › Split Document (`document.epubSplit`, File › Export EPUB… dialog; `file.exportEpub {split}` for one export): off (one content file, the default), before each paragraph of a single paragraph style, or before each paragraph whose style has Split Document on in its Export Tagging. Navigation and note links point across the files. The setting is stored with the document, follows a renamed style, and round-trips through IDML (`EPubExportPreference`); Load Styles carries the per-style flag. - Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters. - Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text. - EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud.