S: style Export Tagging for EPUB/HTML
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
84476ffef4
commit
55e0efbc62
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<String, ExportTag>,
|
||||
pub paragraph: Vec<ParagraphStyle>,
|
||||
pub character: Vec<CharacterStyle>,
|
||||
pub object: Vec<ObjectStyle>,
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,39 @@ use crate::{Result, Session};
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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("<h1 class=\"chapter\">Origins</h1>"), "{html}");
|
||||
assert!(html.contains("<strong class=\"key-term\">word</strong>"), "{html}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "<p class=\"{class}\"{}>", 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, "<span{cls}{style_attr}>{t}</span>");
|
||||
let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}</{ctag}>");
|
||||
}
|
||||
}
|
||||
out.push_str("</p>\n");
|
||||
let _ = writeln!(out, "</{tag}>");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1332,7 +1332,14 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
|||
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<String> = 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<Value> = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue