diff --git a/ROADMAP.md b/ROADMAP.md index 28eab3f..aee6318 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -110,6 +110,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 463c911..34de5c6 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -60,6 +60,9 @@ pub struct ObjectStyle { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Styles { + /// Export Tagging per style (`p:Name` or `c:Name`): the HTML tag and class for EPUB/HTML. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub export_tags: std::collections::BTreeMap, pub paragraph: Vec, pub character: Vec, pub object: Vec, @@ -75,9 +78,28 @@ pub struct Styles { pub default_graphic_frame: String, } +/// A style's Export Tagging. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ExportTag { + /// HTML element (`p`, `h1`…`h6`, `blockquote`, `pre`, `li` for paragraphs; `span`, `em`, + /// `strong`, `code`, `sup`, `sub` for characters). Empty = automatic. + pub tag: String, + /// CSS class (empty = from the style name). + pub class: String, +} + +impl Styles { + /// Export tagging of a paragraph (`character` false) or character style. + pub fn export_tag(&self, name: &str, character: bool) -> Option<&ExportTag> { + self.export_tags.get(&format!("{}:{name}", if character { "c" } else { "p" })) + } +} + impl Default for Styles { fn default() -> Self { Styles { + export_tags: Default::default(), paragraph: vec![ ParagraphStyle { name: NO_PARA_STYLE.into(), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index a224366..8496ade 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -9,6 +9,39 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ + cmd!( + "style.exportTag", + "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", + has_doc, + |s, p| { + let name = str_param(p, "style").ok_or_else(|| bad("style.exportTag", "`style` required"))?.to_string(); + let character = p.get("character").and_then(Value::as_bool).unwrap_or(false); + let exists = if character { s.doc()?.doc.styles.char_style(&name).is_some() } else { s.doc()?.doc.styles.para(&name).is_some() }; + if !exists { + return Err(bad("style.exportTag", format!("no style `{name}`"))); + } + 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)); + s.edit(|d, _| { + let st = d.styles_mut(); + let e = st.export_tags.entry(key.clone()).or_default(); + if let Some(t) = tag.clone() { + e.tag = t; + } + if let Some(c) = class.clone() { + e.class = c; + } + let out = serde_json::to_value(&*e).unwrap_or_default(); + if e.tag.is_empty() && e.class.is_empty() { + st.export_tags.remove(&key); + } + Ok(out) + }) + } + ), cmd!("style.paragraph.apply", "Apply Paragraph Style", [], None, "{name, clearOverrides?: bool}", has_text_or_frames, apply_para), cmd!("style.character.apply", "Apply Character Style", [], None, "{name}", has_text_or_frames, apply_char), cmd!( @@ -1036,3 +1069,30 @@ mod ink_tests { assert!(s.execute("ink.options", &json!({"ink": "[Black]", "toProcess": true})).is_err()); } } + +#[cfg(test)] +mod export_tag_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn export_tagging_shapes_epub_markup() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter Title", "chars": {"size": 24}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap(); + 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()); + 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(); + s.execute("style.paragraph.apply", &json!({"name": "Chapter Title"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 10, "focus": 14})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Key Term"})).unwrap(); + let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(html.contains("

Origins

"), "{html}"); + assert!(html.contains("word"), "{html}"); + } +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index b5b7120..93fd9a3 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -203,16 +203,28 @@ fn override_css(doc: &Document, o: &CharAttrs) -> String { s.trim_end().to_string() } +/// Elements Export Tagging may choose (paragraph or character level). +fn is_tag(t: &str, character: bool) -> bool { + if character { + matches!(t, "span" | "em" | "strong" | "i" | "b" | "code" | "sup" | "sub" | "small" | "mark" | "cite" | "q") + } else { + matches!(t, "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "pre" | "li" | "figcaption" | "aside" | "div") + } +} + /// One story as XHTML paragraphs. pub fn story_html(doc: &Document, sid: StoryId) -> String { let Some(st) = doc.story(sid) else { return String::new() }; let mut out = String::new(); for (pi, r) in st.para_ranges().iter().enumerate() { let pf = &st.paras[pi]; - let class = slug(&pf.style); + // Export Tagging: the style's element and class. + let et = doc.styles.export_tag(&pf.style, false); + let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, false)).unwrap_or("p"); + let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).unwrap_or_else(|| slug(&pf.style)); let text_empty = st.text[r.clone()].trim().is_empty(); let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; - let _ = write!(out, "

", if rtl { " dir=\"rtl\"" } else { "" }); + let _ = write!(out, "<{tag} class=\"{class}\"{}>", if rtl { " dir=\"rtl\"" } else { "" }); if text_empty { out.push_str(" "); } @@ -239,16 +251,22 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { } else { esc(raw) }; - let cls = if f.style != story::NO_CHAR_STYLE { format!(" class=\"{}\"", slug(&f.style)) } else { String::new() }; + let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten(); + let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span"); + let cls = if f.style != story::NO_CHAR_STYLE { + format!(" class=\"{}\"", et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).unwrap_or_else(|| slug(&f.style))) + } else { + String::new() + }; let style = override_css(doc, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{style}\"") }; if cls.is_empty() && style_attr.is_empty() { out.push_str(&t); } else { - let _ = write!(out, "{t}"); + let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}"); } } - out.push_str("

\n"); + let _ = writeln!(out, ""); } out } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6ed80d8..2c9ddff 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1332,7 +1332,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { if !rename.is_empty() && rename != name { params["rename"] = json!(rename); } - app.run("style.paragraph.edit", params) + let r = app.run("style.paragraph.edit", params)?; + if d.fields.contains_key("x.tag") { + let tag = d.s("x.tag"); + let tag = if tag == "[Automatic]" { String::new() } else { tag }; + let style = if !rename.is_empty() && rename != name { rename } else { name }; + app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class")}))?; + } + Ok(r) } "colorPicker" => { let hex = d.s("hex"); @@ -1581,6 +1588,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ("nested", "Drop Caps and Nested Styles"), ("grep", "GREP Style"), ("color", "Character Color"), + ("export", "Export Tagging"), ] { if ui.selectable_label(d.s("section") == id, label).clicked() { d.fields.insert("section".into(), json!(id)); @@ -1590,6 +1598,29 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ui.separator(); let cnames: Vec = app.session.active().map(|s| s.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); ui.vertical(|ui| match d.s("section").as_str() { + "export" => { + ui.label(egui::RichText::new("EPUB and HTML").font(semibold(12.0))); + let et = app.session.active().and_then(|s| s.doc.styles.export_tag(&name, false).cloned()).unwrap_or_default(); + 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)); + } + ui.horizontal(|ui| { + ui.label("Tag:"); + let cur = d.s("x.tag"); + egui::ComboBox::from_id_salt("export_tag").selected_text(&cur).show_ui(ui, |ui| { + for t in ["[Automatic]", "p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "li", "figcaption", "aside", "div"] { + if ui.selectable_label(cur == t, t).clicked() { + d.fields.insert("x.tag".into(), json!(t)); + } + } + }); + }); + ui.horizontal(|ui| { + ui.label("Class:"); + text_field(ui, d, "x.class", 160.0); + }); + } "nested" => { ui.label(egui::RichText::new("Nested Styles").font(semibold(12.0))); let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); diff --git a/docs/parity.md b/docs/parity.md index 8ec4c26..7c49cfd 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | | P1 | 97 | 94 | 3 | 0 | 98% | -| P2 | 76 | 53 | 23 | 0 | 85% | +| P2 | 76 | 54 | 22 | 0 | 86% | | Area | Parity | |---|---| @@ -25,7 +25,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Placing & links | 93% | | Type & text frames | 99% | | Typography | 98% | -| Styles | 97% | +| Styles | 100% | | Tables | 98% | | Long documents | 100% | | Interactivity & digital | 90% | @@ -244,7 +244,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Styles | Quick Apply | P1 | D | | Styles | Break link to style | P1 | D | | Styles | Style mapping on import | P1 | D | -| Styles | Style export tagging | P2 | P | +| Styles | Style export tagging | P2 | D | | Tables | Create / Insert Table | P1 | D | | Tables | Convert Text to Table / Table to Text | P1 | D | | Tables | Rows/columns insert/delete/resize/distribute | P1 | D |