From 6fafa9b0a3dba97ec5d1f83032597834e9bbc0fb Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:41:08 +0200 Subject: [PATCH] EPUB/HTML: generic font fallback from the font's class, and optional font embedding (#775) - Every font-family fell back to serif, sans-serif typefaces included. The fallback now comes from the font's classification (PANOSE, OS/2 class, name: serif, sans-serif, monospace, cursive, fantasy), passed in by the engine; without one, from the family name. - file.exportEpub takes embedFonts: the faces the stylesheet names are packaged (OEBPS/fonts, @font-face, manifest items with font/ttf, otf, woff or woff2). Faces that aren't available, whose licence (OS/2 fsType) forbids embedding, or that sit in a font collection are left out and listed in the new warnings array. Fonts are packaged whole; subsetting is a follow-up. Fixes #775 --- crates/engine/src/cmd/export.rs | 91 ++++++++++++++- crates/epub/src/lib.rs | 200 ++++++++++++++++++++++++++++++-- 2 files changed, 277 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..7975c6842 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -12,7 +12,7 @@ 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?} → {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\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", + "{path?, title?, author?, language?: \"en\", 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})", has_doc, export_epub), cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", @@ -53,8 +53,11 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result { fn export_html(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let opts = - designcraft_epub::HtmlOptions { title: str_param(p, "title").map(str::to_string), language: str_param(p, "language").map(str::to_string) }; + let opts = designcraft_epub::HtmlOptions { + title: str_param(p, "title").map(str::to_string), + language: str_param(p, "language").map(str::to_string), + generic_families: generic_families(&st.doc), + }; let text = designcraft_epub::export_html(&st.doc, &opts); match str_param(p, "path") { Some(path) => { @@ -120,6 +123,49 @@ fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, out } +/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts' +/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks. +fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap { + use designcraft_fonts::FontClass; + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = std::collections::BTreeMap::new(); + for (family, _) in designcraft_epub::stylesheet_fonts(d) { + if out.contains_key(&family) { + continue; + } + let generic = match db.family_kind(&family).map(|(k, _)| k.class) { + Some(FontClass::Serif | FontClass::SlabSerif) => "serif", + Some(FontClass::SansSerif) => "sans-serif", + Some(FontClass::Monospaced) => "monospace", + Some(FontClass::Script) => "cursive", + Some(FontClass::Decorative) => "fantasy", + Some(FontClass::Symbol | FontClass::Other) | None => continue, + }; + out.insert(family, generic.to_string()); + } + out +} + +/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out +/// (not available, a licence that forbids embedding, or part of a font collection). +fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> Vec { + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = Vec::new(); + for (family, style) in designcraft_epub::stylesheet_fonts(d) { + let face = db.face(&family, &style); + if !face.family.eq_ignore_ascii_case(&family) { + warnings.push(format!("font `{family} {style}` is not available; not embedded")); + } else if face.restricted_licence() { + warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded")); + } else if face.data().starts_with(b"ttcf") { + warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded")); + } else { + out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() }); + } + } + out +} + fn export_epub(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; // Object Export Options › Rasterize: those objects go in as images. @@ -130,6 +176,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { let mut rr = designcraft_render::Renderer::new(); rr.threads = designcraft_render::default_threads(); let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let mut warnings: Vec = Vec::new(); let cover = (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(); let opts = designcraft_epub::EpubOptions { title: p.get("title").and_then(Value::as_str).map(str::to_string), @@ -137,6 +184,12 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), identifier: None, cover, + 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) + } else { + Vec::new() + }, }; if fixed { let d = &st.doc; @@ -161,9 +214,9 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { Some(path) => { #[cfg(not(target_arch = "wasm32"))] crate::atomic::write_file(path, &bytes)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) } - None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), + None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), } } @@ -672,6 +725,34 @@ mod text_tests { assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); } + /// #775: the fallback follows the font's class, and embedFonts packages the stylesheet's fonts + /// (and reports those it can't). + #[test] + fn epub_font_fallbacks_and_embedding() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Sans", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Regular"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Gone", "chars": {"fontFamily": "No Such Font Family", "fontStyle": "Regular"}})) + .unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(css.contains("font-family: \"Source Sans 3\", sans-serif;"), "{css}"); + assert!(!css.contains("@font-face") && r["warnings"] == json!([]), "nothing embedded unless asked"); + let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(css.contains("@font-face { font-family: \"Source Sans 3\"; src: url(\"fonts/"), "{css}"); + let opf = read(&mut z, "OEBPS/content.opf"); + let font = opf.split("href=\"fonts/").nth(1).and_then(|r| r.split('"').next()).map(|n| format!("OEBPS/fonts/{n}")); + assert!(font.as_ref().is_some_and(|f| z.by_name(f).is_ok_and(|e| e.size() > 1000)), "{opf}"); + let warnings = r["warnings"].as_array().unwrap(); + assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("No Such Font Family")), "{warnings:?}"); + let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(html.contains("\"Source Sans 3\", sans-serif;")); + } + #[test] fn flattener_rasterises_spreads_with_transparency() { let mut s = Session::new(); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 77b509d0b..b2c73b532 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -22,6 +22,93 @@ pub struct EpubOptions { pub identifier: Option, /// Cover image (PNG), shown first and marked as the book's cover. pub cover: Option>, + /// Generic CSS family (`serif`, `sans-serif`, `monospace`, `cursive`, `fantasy`) per font + /// family, from the fonts' classification; families not in it are guessed from their name. + pub generic_families: BTreeMap, + /// Font files packaged with the book and declared with `@font-face`. + pub fonts: Vec, +} + +/// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). +#[derive(Clone, Debug)] +pub struct EmbeddedFont { + pub family: String, + pub style: String, + pub data: Vec, +} + +/// The `(family, style)` pairs the stylesheet names, one each, in style order: the fonts an +/// export can embed. +pub fn stylesheet_fonts(doc: &Document) -> Vec<(String, String)> { + let st: &Styles = &doc.styles; + let mut out: Vec<(String, String)> = Vec::new(); + for ps in &st.paragraph { + if ps.name == designcraft_doc::NO_PARA_STYLE { + continue; + } + let (_, cp) = st.resolve_para_style(&ps.name); + let f = (cp.font_family.clone(), cp.font_style.clone()); + if !f.0.is_empty() && !out.contains(&f) { + out.push(f); + } + } + out +} + +/// The CSS generic family for `family`: the known one, else a guess from the name +/// (`… Mono`/`Courier`/`… Code` → monospace, `… Sans`/`Gothic`/`Grotesk`/`Helvetica`/`Arial`/ +/// `Verdana` → sans-serif), else serif. +fn generic_family<'a>(family: &str, known: &'a BTreeMap) -> &'a str { + const GENERIC: [&str; 6] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"]; + if let Some(g) = known.get(family).and_then(|g| GENERIC.iter().find(|x| **x == g.as_str())) { + return g; + } + let f = family.to_lowercase(); + let has = |words: &[&str]| words.iter().any(|w| f.contains(w)); + if has(&["mono", "courier", " code", "consol"]) { + "monospace" + } else if has(&["sans", "gothic", "grotesk", "grotesque", "helvetica", "arial", "verdana"]) { + "sans-serif" + } else { + "serif" + } +} + +/// CSS weight and style of a style name (`Semibold Italic` → 600, italic). +fn css_weight_style(style: &str) -> (u16, &'static str) { + let s = style.to_lowercase().replace([' ', '-'], ""); + let weight = [ + ("thin", 100), + ("extralight", 200), + ("ultralight", 200), + ("light", 300), + ("medium", 500), + ("semibold", 600), + ("demibold", 600), + ("extrabold", 800), + ("ultrabold", 800), + ("bold", 700), + ("black", 900), + ("heavy", 900), + ] + .iter() + .find(|(n, _)| s.contains(n)) + .map_or(400, |(_, w)| *w); + (weight, if s.contains("italic") || s.contains("oblique") { "italic" } else { "normal" }) +} + +/// Package path and media type of an embedded font (`fonts/{k}-{family}-{style}.{ext}`). +fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { + let (ext, mime) = match f.data.get(..4) { + Some(b"OTTO") => ("otf", "font/otf"), + Some(b"wOFF") => ("woff", "font/woff"), + Some(b"wOF2") => ("woff2", "font/woff2"), + _ => ("ttf", "font/ttf"), + }; + let name: String = + format!("{}-{}", f.family, f.style).chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); + let name = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); + (format!("fonts/{k}-{name}.{ext}"), mime) } /// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search, @@ -100,7 +187,15 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions impl Default for EpubOptions { fn default() -> Self { - EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None } + EpubOptions { + title: None, + author: None, + language: "en".into(), + identifier: None, + cover: None, + generic_families: BTreeMap::new(), + fonts: Vec::new(), + } } } @@ -246,8 +341,8 @@ fn hex(doc: &Document, swatch: &str, tint: f32) -> Option { Some(format!("#{r:02x}{g:02x}{b:02x}")) } -fn char_css(doc: &Document, c: &CharProps, base_size: f64, out: &mut String) { - let _ = write!(out, "font-family: \"{}\", serif; ", css_string(&c.font_family)); +fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap, out: &mut String) { + let _ = write!(out, "font-family: \"{}\", {}; ", css_string(&c.font_family), generic_family(&c.font_family, generics)); let st = c.font_style.to_lowercase(); if st.contains("bold") { out.push_str("font-weight: bold; "); @@ -303,10 +398,27 @@ fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) { /// The stylesheet for all paragraph and character styles. pub fn stylesheet(doc: &Document) -> String { + stylesheet_with(doc, &BTreeMap::new(), &[]) +} + +/// [`stylesheet`] with the generic families to fall back on and `@font-face` rules for the +/// embedded fonts (packaged as [`font_file`] names them). +fn stylesheet_with(doc: &Document, generics: &BTreeMap, fonts: &[EmbeddedFont]) -> String { let st: &Styles = &doc.styles; + let mut faces = String::new(); + for (k, f) in fonts.iter().enumerate() { + let (path, _) = font_file(k, f); + let (weight, style) = css_weight_style(&f.style); + let _ = writeln!( + faces, + "@font-face {{ font-family: \"{}\"; src: url(\"{path}\"); font-weight: {weight}; font-style: {style}; }}", + css_string(&f.family) + ); + } let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH); let base = basic.size.max(1.0); - let mut css = String::from( + let mut css = faces; + css.push_str( "body { margin: 0 5%; font-size: 1em; }\nfigure { margin: 1em 0; text-align: center; }\nfigure img { max-width: 100%; }\nh1, h2, h3 { font-size: inherit; margin: 0; }\n", ); let (pc, cc) = (para_classes(st), char_classes(st)); @@ -321,7 +433,7 @@ 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); + char_css(doc, &cp, base, generics, &mut rule); let (tag, class) = export_mapping(st, &ps.name, false, &pc); if let Some(selector) = class_selector(tag, &class) { let _ = writeln!(css, "{selector} {{ {rule}}}"); @@ -652,6 +764,8 @@ fn body_html<'a>( pub struct HtmlOptions { pub title: Option, pub language: Option, + /// Generic CSS family per font family (see [`EpubOptions::generic_families`]). + pub generic_families: BTreeMap, } /// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the @@ -664,7 +778,7 @@ pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), - stylesheet(doc) + stylesheet_with(doc, &opts.generic_families, &[]) ) } @@ -709,6 +823,10 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr for (k, (name, (mime, _))) in images.iter().enumerate() { let _ = writeln!(manifest, "", xml_attr(name), xml_attr(mime)); } + for (k, f) in opts.fonts.iter().enumerate() { + let (name, mime) = font_file(k, f); + let _ = writeln!(manifest, "", xml_attr(&name)); + } // Cover: an image page first in reading order. if opts.cover.is_some() { manifest.push_str("\n\n"); @@ -732,7 +850,7 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr ("META-INF/container.xml", container.as_bytes()), ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes()), - ("OEBPS/style.css", stylesheet(doc).as_bytes()), + ("OEBPS/style.css", stylesheet_with(doc, &opts.generic_families, &opts.fonts).as_bytes()), ("OEBPS/content.xhtml", chapter.as_bytes()), ] { z.start_file(name, deflate).map_err(zerr)?; @@ -742,6 +860,10 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?; z.write_all(data).map_err(io)?; } + for (k, f) in opts.fonts.iter().enumerate() { + z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?; + z.write_all(&f.data).map_err(io)?; + } if let Some(c) = &opts.cover { z.start_file("OEBPS/images/cover.png", stored).map_err(zerr)?; z.write_all(c).map_err(io)?; @@ -837,7 +959,7 @@ mod tests { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello ", ParaFormat::default()).unwrap(); - let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None }); + let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None, ..Default::default() }); assert!(html.starts_with("") && html.contains("T") && html.contains("Hello <web>")); assert!(html.contains("p.basic-paragraph"), "styles inline"); assert_eq!(base64(b"Man"), "TWFu"); @@ -845,6 +967,65 @@ mod tests { assert_eq!(base64(b"M"), "TQ=="); } + /// #775: the generic fallback follows the font, and embedded fonts are packaged and declared. + #[test] + fn generic_fallbacks_and_embedded_fonts() { + let none = BTreeMap::new(); + for (family, want) in [ + ("Source Serif 4", "serif"), + ("Source Sans 3", "sans-serif"), + ("Microsoft Sans Serif", "sans-serif"), + ("JetBrains Mono", "monospace"), + ("Courier New", "monospace"), + ("Franklin Gothic", "sans-serif"), + ("Garamond", "serif"), + ] { + assert_eq!(generic_family(family, &none), want, "{family}"); + } + let known: BTreeMap = + [("Montserrat".to_string(), "sans-serif".to_string()), ("Odd".to_string(), "nonsense".to_string())].into(); + assert_eq!(generic_family("Montserrat", &known), "sans-serif"); + assert_eq!(generic_family("Odd", &known), "serif", "only CSS generic families are used"); + assert_eq!(css_weight_style("Semibold Italic"), (600, "italic")); + assert_eq!(css_weight_style("Bold"), (700, "normal")); + assert_eq!(css_weight_style("Extra Bold"), (800, "normal")); + assert_eq!(css_weight_style("Regular"), (400, "normal")); + + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { + basic.chars.font_family = Some("Montserrat".into()); + basic.chars.font_style = Some("Bold Italic".into()); + } + assert_eq!(stylesheet_fonts(&d), vec![("Montserrat".to_string(), "Bold Italic".to_string())]); + let opts = EpubOptions { + generic_families: known, + fonts: vec![EmbeddedFont { family: "Montserrat".into(), style: "Bold Italic".into(), data: b"OTTO font".to_vec() }], + ..Default::default() + }; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &opts).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 css = read("OEBPS/style.css"); + assert!( + css.starts_with( + "@font-face { font-family: \"Montserrat\"; src: url(\"fonts/0-montserrat-bold-italic.otf\"); font-weight: 700; font-style: italic; }" + ), + "{css}" + ); + assert!(css.contains("font-family: \"Montserrat\", sans-serif;"), "{css}"); + let opf = read("OEBPS/content.opf"); + assert!(opf.contains(""), "{opf}"); + assert_eq!(read("OEBPS/fonts/0-montserrat-bold-italic.otf"), "OTTO font"); + // HTML takes the generic families too, and embeds nothing. + let html = export_html(&d, &HtmlOptions { generic_families: opts.generic_families.clone(), ..Default::default() }); + assert!(html.contains("\"Montserrat\", sans-serif;") && !html.contains("@font-face"), "{html}"); + } + #[test] fn metadata_is_serialized_for_its_output_context() { let mut d = Document::new(&NewDocument::default()); @@ -885,7 +1066,7 @@ mod tests { let story = story_html(&d, sid); assert!(story.contains("

A&B <C> "D"

")); - let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()) }); + let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()), ..Default::default() }); assert!(html.contains("")); assert!(html.contains("Field Notes & <Edition>")); assert!(html.contains("alt=\"Cover "A" & B\"")); @@ -902,6 +1083,7 @@ mod tests { language: "English (US)".into(), identifier: Some("notes&edition".into()), cover: None, + ..Default::default() }, ) .unwrap();