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:
Gert Pellin
2026-10-13 16:41:08 +02:00
parent e862e0a613
commit 6fafa9b0a3
2 changed files with 277 additions and 14 deletions
+86 -5
View File
@@ -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
View File
@@ -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 &lt;web&gt;"));
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 &quot;Copy&quot;\">A&amp;B &lt;C&gt; &quot;D&quot;</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 &amp; &lt;Edition&gt;</title>"));
assert!(html.contains("alt=\"Cover &quot;A&quot; &amp; B\""));
@@ -902,6 +1083,7 @@ mod tests {
language: "English (US)".into(),
identifier: Some("notes&edition".into()),
cover: None,
..Default::default()
},
)
.unwrap();