mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-03 07:01:09 -06:00
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
This commit is contained in:
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{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<Value> {
|
||||
|
||||
fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String, String> {
|
||||
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<String>) -> Vec<designcraft_epub::EmbeddedFont> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<String> = 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<Value> {
|
||||
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<Value> {
|
||||
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();
|
||||
|
||||
+191
-9
@@ -22,6 +22,93 @@ pub struct EpubOptions {
|
||||
pub identifier: Option<String>,
|
||||
/// Cover image (PNG), shown first and marked as the book's cover.
|
||||
pub cover: Option<Vec<u8>>,
|
||||
/// 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<String, String>,
|
||||
/// Font files packaged with the book and declared with `@font-face`.
|
||||
pub fonts: Vec<EmbeddedFont>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
}
|
||||
|
||||
/// 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<String, String>) -> &'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::<Vec<_>>().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<String> {
|
||||
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<String, String>, 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<String, String>, 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<String>,
|
||||
pub language: Option<String>,
|
||||
/// Generic CSS family per font family (see [`EpubOptions::generic_families`]).
|
||||
pub generic_families: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// 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!(
|
||||
"<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"generator\" content=\"DesignCraft\">\n<title>{}</title>\n<style>\nbody {{ max-width: 40em; margin: 2em auto; padding: 0 1em; }}\nfigure {{ margin: 1em 0; }} figure img {{ max-width: 100%; height: auto; }}\n{}</style>\n</head>\n<body>\n{body}</body>\n</html>\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<Vec<u8>, EpubEr
|
||||
for (k, (name, (mime, _))) in images.iter().enumerate() {
|
||||
let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{}\" media-type=\"{}\"/>", xml_attr(name), xml_attr(mime));
|
||||
}
|
||||
for (k, f) in opts.fonts.iter().enumerate() {
|
||||
let (name, mime) = font_file(k, f);
|
||||
let _ = writeln!(manifest, "<item id=\"font{k}\" href=\"{}\" media-type=\"{mime}\"/>", xml_attr(&name));
|
||||
}
|
||||
// Cover: an image page first in reading order.
|
||||
if opts.cover.is_some() {
|
||||
manifest.push_str("<item id=\"cover-img\" href=\"images/cover.png\" media-type=\"image/png\" properties=\"cover-image\"/>\n<item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>\n");
|
||||
@@ -732,7 +850,7 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, 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<Vec<u8>, 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 <web>", 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("<!DOCTYPE html>") && html.contains("<title>T</title>") && 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<String, String> =
|
||||
[("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("<item id=\"font0\" href=\"fonts/0-montserrat-bold-italic.otf\" media-type=\"font/otf\"/>"), "{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("<h2 class=\"Primary "Copy"\">A&B <C> "D"</h2>"));
|
||||
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("<html lang=\"und\">"));
|
||||
assert!(html.contains("<title>Field Notes & <Edition></title>"));
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user