From b3ec8dc908bebba44812cce36ccca80a8983bc42 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:53:54 +1000 Subject: [PATCH] PARITY-EPUB-STYLE: match CSS to exported style mappings Co-Authored-By: Codex --- crates/epub/src/lib.rs | 62 ++++++++--- crates/epub/tests/export_tag_styles.rs | 138 +++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 crates/epub/tests/export_tag_styles.rs diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 643f593..77b509d 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -322,7 +322,10 @@ pub fn stylesheet(doc: &Document) -> String { let mut rule = String::new(); para_css(&pp, base, lead, &mut rule); char_css(doc, &cp, base, &mut rule); - let _ = writeln!(css, "p.{} {{ {rule}}}", pc[ps.name.as_str()]); + let (tag, class) = export_mapping(st, &ps.name, false, &pc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } for cs in &st.character { if cs.name == story::NO_CHAR_STYLE { @@ -347,7 +350,10 @@ pub fn stylesheet(doc: &Document) -> String { if let Some(sz) = c.size { let _ = write!(rule, "font-size: {:.3}em; ", sz / base); } - let _ = writeln!(css, "span.{} {{ {rule}}}", cc[cs.name.as_str()]); + let (tag, class) = export_mapping(st, &cs.name, true, &cc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } css } @@ -386,6 +392,39 @@ fn is_tag(t: &str, character: bool) -> bool { } } +/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, +/// collision-free class when no custom class was supplied, and the existing tag fallback. +fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { + let et = st.export_tag(name, character); + let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); + let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); + // Match the characters xml_attr actually emits, before escaping either output context. + let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + (tag, class) +} + +/// Each class token must match on the same element. Escape CSS identifier punctuation and +/// leading digits without changing the class written into XHTML. +fn class_selector(tag: &str, class: &str) -> Option { + let mut tokens = class.split_ascii_whitespace().peekable(); + // A nonempty but whitespace-only custom class has no token to select. Never emit a bare + // element selector, which would apply this style to unrelated paragraphs or character runs. + tokens.peek()?; + let mut selector = tag.to_string(); + for token in tokens { + selector.push('.'); + for (i, c) in token.chars().enumerate() { + let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); + if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { + selector.push(c); + } else { + let _ = write!(selector, "\\{:x} ", c as u32); + } + } + } + Some(selector) +} + /// One story as XHTML paragraphs. pub fn story_html(doc: &Document, sid: StoryId) -> String { let Some(st) = doc.story(sid) else { return String::new() }; @@ -394,10 +433,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { for (pi, r) in st.para_ranges().iter().enumerate() { let pf = &st.paras[pi]; // 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()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style)); + let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc); 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, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); @@ -427,17 +463,11 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { } else { story_markup(raw) }; - 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 { - let class = et - .map(|e| e.class.clone()) - .filter(|c| !c.is_empty()) - .or_else(|| cc.get(f.style.as_str()).cloned()) - .unwrap_or_else(|| slug(&f.style)); - format!(" class=\"{}\"", xml_attr(&class)) + let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { + let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc); + (tag, format!(" class=\"{}\"", xml_attr(&class))) } else { - String::new() + ("span", String::new()) }; let style = override_css(doc, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/export_tag_styles.rs new file mode 100644 index 0000000..47485b5 --- /dev/null +++ b/crates/epub/tests/export_tag_styles.rs @@ -0,0 +1,138 @@ +use std::io::{Cursor, Read}; + +use designcraft_doc::build::NewDocument; +use designcraft_doc::geom::Rect; +use designcraft_doc::{Align, CharAttrs, CharacterStyle, Document, ExportTag, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, story}; +use designcraft_epub::{EpubOptions, HtmlOptions, export_epub, export_html}; + +fn fixture() -> Document { + let mut doc = Document::new(&NewDocument::default()); + let styles = doc.styles_mut(); + styles.paragraph.iter_mut().find(|p| p.name == story::BASIC_PARAGRAPH).unwrap().chars.size = Some(12.0); + styles.paragraph.push(ParagraphStyle { + name: "Chapter Title".into(), + based_on: None, + next_style: None, + para: ParaAttrs { align: Some(Align::Center), ..Default::default() }, + chars: CharAttrs { size: Some(24.0), ..Default::default() }, + shortcut: String::new(), + }); + styles.character.push(CharacterStyle { + name: "Key Term".into(), + based_on: None, + chars: CharAttrs { size: Some(18.0), ..Default::default() }, + shortcut: String::new(), + }); + let layer = doc.default_layer(); + doc.add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 100.0), + layer, + "Heading", + ParaFormat { style: "Chapter Title".into(), ..Default::default() }, + ) + .unwrap(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 120.0, 300.0, 200.0), layer, "Accent plain", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().format_chars(0..6, |f| f.style = "Key Term".into()); + doc +} + +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() }); +} + +fn package(doc: &Document) -> (String, String) { + let mut zip = zip::ZipArchive::new(Cursor::new(export_epub(doc, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut text = String::new(); + zip.by_name(path).unwrap().read_to_string(&mut text).unwrap(); + text + }; + let xhtml = read("OEBPS/content.xhtml"); + let css = read("OEBPS/style.css"); + let opf = read("OEBPS/content.opf"); + assert!(xhtml.contains("")); + assert!(opf.contains("href=\"style.css\" media-type=\"text/css\"")); + (xhtml, css) +} + +fn rule<'a>(css: &'a str, selector: &str) -> &'a str { + css.lines().find_map(|line| line.strip_prefix(&format!("{selector} {{ "))).unwrap_or_else(|| panic!("missing {selector} in {css}")) +} + +#[test] +fn mapped_tags_and_classes_keep_their_packaged_style_declarations() { + let mut doc = fixture(); + map(&mut doc, "h1", "chapter", "em", "term"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("

Heading

"), "{xhtml}"); + assert!(xhtml.contains("Accent plain"), "{xhtml}"); + assert!(rule(&css, "h1.chapter").contains("font-size: 2.000em;")); + assert!(rule(&css, "h1.chapter").contains("text-align: center;")); + assert!(rule(&css, "em.term").contains("font-size: 1.500em;")); + assert!(!css.contains("p.chapter-title {") && !css.contains("span.key-term {")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); + // HTML uses the same mapped stylesheet inline, while EPUB links its packaged CSS. + let html = export_html(&doc, &HtmlOptions::default()); + assert!(html.contains(&css)); + assert!(html.contains("

Heading

")); +} + +#[test] +fn tag_only_class_only_and_unsupported_tags_keep_existing_fallbacks() { + for (pt, pc, ct, cc, paragraph, character) in [ + ("h2", "", "em", "", "h2.chapter-title", "em.key-term"), + ("", "chapter", "", "term", "p.chapter", "span.term"), + ("section", "", "h2", "", "p.chapter-title", "span.key-term"), + ("", "", "", "", "p.chapter-title", "span.key-term"), + ] { + let mut doc = fixture(); + map(&mut doc, pt, pc, ct, cc); + let (xhtml, css) = package(&doc); + for (selector, text, size) in [(paragraph, "Heading", "2.000em"), (character, "Accent", "1.500em")] { + let (tag, class) = selector.split_once('.').unwrap(); + assert!(xhtml.contains(&format!("<{tag} class=\"{class}\">{text}")), "{xhtml}"); + assert!(rule(&css, selector).contains(&format!("font-size: {size};"))); + } + } + let (xhtml, css) = package(&fixture()); + assert!(xhtml.contains("

Heading

")); + assert!(rule(&css, "p.chapter-title").contains("font-size: 2.000em;")); + assert!(rule(&css, "span.key-term").contains("font-size: 1.500em;")); +} + +#[test] +fn custom_class_tokens_match_without_renaming_them() { + for (class, attribute, selector) in [ + ("lead wide", "lead wide", ".lead.wide"), + ("lead\twide\n", "lead\twide\n", ".lead.wide"), + ("chapter:lead", "chapter:lead", r".chapter\3a lead"), + ("2nd-edition", "2nd-edition", r".\32 nd-edition"), + ("-2nd", "-2nd", r".-\32 nd"), + ("-", "-", r".\2d "), + ("見出し.café", "見出し.café", r".見出し\2e café"), + ("edition \"wide\"", "edition "wide"", r".edition.\22 wide\22 "), + ] { + let mut doc = fixture(); + map(&mut doc, "h2", class, "em", class); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains(&format!("

Heading

")), "{xhtml}"); + assert!(xhtml.contains(&format!("Accent")), "{xhtml}"); + assert!(rule(&css, &format!("h2{selector}")).contains("font-size: 2.000em;")); + assert!(rule(&css, &format!("em{selector}")).contains("font-size: 1.500em;")); + } +} + +#[test] +fn whitespace_only_custom_classes_do_not_apply_rules_to_every_element() { + let mut doc = fixture(); + map(&mut doc, "p", " \t", "span", " \n"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("

Heading

")); + assert!(xhtml.contains("Accent")); + assert!(!css.lines().any(|line| line.starts_with("p {") || line.starts_with("span {"))); + assert!(!css.contains("font-size: 2.000em;") && !css.contains("font-size: 1.500em;")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); +}