refactor(epub): move the tests module of lib.rs to tests.rs

This commit is contained in:
aiongg
2026-10-26 18:18:13 +00:00
parent 56905e5076
commit 39da80d792
2 changed files with 925 additions and 931 deletions
+1 -931
View File
@@ -1504,934 +1504,4 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_doc::build::NewDocument;
use designcraft_doc::geom::{Affine, Rect, shapes};
use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table};
use std::io::Read;
use std::sync::Arc;
#[test]
fn exports_valid_package_in_reading_order() {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Second <b> & co", ParaFormat::default()).unwrap();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "First\nline two", ParaFormat::default()).unwrap();
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
assert_eq!(z.by_index(0).unwrap().name(), "mimetype");
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let a = html.find("First").unwrap();
let b = html.find("Second &lt;b&gt; &amp; co").unwrap();
assert!(a < b, "reading order");
assert!(html.contains("<p class=\"basic-paragraph\">"));
let mut css = String::new();
z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap();
assert!(css.contains("p.basic-paragraph"));
assert!(z.by_name("OEBPS/nav.xhtml").is_ok());
}
/// #766: anchored graphics are exported where they are anchored (inline in the text, an
/// above-line object as a block of its own), and their images go into the package.
#[test]
fn anchored_objects_are_exported_in_place() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap();
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: "dot.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(vec![7]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let graphic = |d: &mut Document, alt: &str| {
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
clip: None,
wrap: Default::default(),
fill: Default::default(),
});
item
};
let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 });
let above = designcraft_doc::AnchoredObject::new(
graphic(&mut d, "above"),
designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 },
);
let st = d.story_mut(sid).unwrap();
st.insert_object(1, inline);
// Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D.
st.insert_object(7, above);
// An anchored image without alt text is decorative and reported, like a placed one.
let bare = designcraft_doc::AnchoredObject::new(graphic(&mut d, ""), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 });
let end = d.story(sid).unwrap().text.len();
d.story_mut(sid).unwrap().insert_object(end, bare);
assert_eq!(images_without_alt(&d), vec!["dot.png".to_string()]);
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let img = |alt: &str| format!("<img src=\"images/dot-{}.png\" alt=\"{alt}\"/>", aid.0);
assert!(html.contains(&format!("A{}B</p>", img("inline"))), "{html}");
assert!(html.contains(&format!("C<span class=\"anchored\" style=\"display:block;text-align:center\">{}</span>D", img("above"))), "{html}");
assert!(z.by_name(&format!("OEBPS/images/dot-{}.png", aid.0)).is_ok(), "the image is in the package");
let page = export_html(&d, &HtmlOptions::default());
assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}");
// story_html on its own still leaves objects out.
assert!(!story_html(&d, sid).contains("<img"));
}
#[test]
fn iso8601_timestamps() {
for (t, want) in [
(0, "1970-01-01T00:00:00Z"),
(951_782_400, "2000-02-29T00:00:00Z"),
(1_791_891_000, "2026-10-13T11:30:00Z"),
(-86_400, "1969-12-31T00:00:00Z"),
// Years past 9999 keep the plain zero-padded year (no `+`).
(253_402_300_800, "10000-01-01T00:00:00Z"),
// Beyond chrono's range the old `civil_from_days` still converted; pin that output
// (never the epoch) in both directions and at the `i64` extremes.
(10_000_000_000_000, "318857-05-20T17:46:40Z"),
(-10_000_000_000_000, "-314918-08-13T06:13:20Z"),
(i64::MAX, "292277026596-12-04T15:30:07Z"),
(i64::MIN, "-292277022657-01-27T08:29:52Z"),
] {
assert_eq!(iso8601(t), want);
}
}
/// #771, #773: dcterms:modified is a real time, the default identifier is a stable urn:uuid
/// that differs between documents with the same title, and a JPEG cover keeps its type.
#[test]
fn package_metadata_and_cover_image() {
let opf = |d: &Document, opts: &EpubOptions| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut s = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut s).unwrap();
let names: Vec<String> = z.file_names().map(str::to_string).collect();
(s, names)
};
let id_of = |opf: &str| opf.split("<dc:identifier id=\"bookid\">").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string();
let mut a = Document::new(&NewDocument::default());
a.title = "Same".into();
a.created = 1_700_000_000;
let mut b = a.clone();
b.created = 1_700_000_001;
let (oa, _) = opf(&a, &EpubOptions::default());
let (oa2, _) = opf(&a, &EpubOptions::default());
let (ob, _) = opf(&b, &EpubOptions::default());
assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa));
assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export");
assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title");
// Modified: the given time, else the document's save (or creation) time.
assert!(oa.contains("<meta property=\"dcterms:modified\">2023-11-14T22:13:20Z</meta>"), "{oa}");
let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() };
assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z"));
// A JPEG cover is packaged as such.
let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() };
let (o, names) = opf(&a, &jpeg);
assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}");
assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}");
}
fn st_typed(d: &mut Document, sid: StoryId) {
if let Some(st) = d.story_mut(sid) {
st.paras[8].style = "Typed".into();
}
}
/// #767: consecutive `li` paragraphs share one list (`ol` for a numbered style), and
/// consecutive `pre` paragraphs of one style one block with their lines on separate lines.
#[test]
fn lists_and_preformatted_blocks_are_grouped() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
for (name, tag, list) in [
("Bullet", "li", Some(designcraft_doc::ListType::Bullets)),
("Number", "li", Some(designcraft_doc::ListType::Numbers)),
("Typed", "li", None),
("Code", "pre", None),
] {
let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() };
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para,
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() });
}
let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed";
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in ["Bullet", "Bullet", "Number", "Number", "Code", "Code"].into_iter().enumerate() {
st.paras[i + 1].style = style.into();
}
let html = story_html(&d, sid);
let li = |c: &str, t: &str| format!("<li class=\"{c}\">{t}</li>");
assert!(
html.contains(&format!(
"<ul>\n{}\n{}\n</ul>\n<ol>\n{}\n{}\n</ol>\n",
li("bullet", "first"),
li("bullet", "second"),
li("number", "one"),
li("number", "two")
)),
"{html}"
);
assert!(html.contains("<pre class=\"code\">x = 1\ny = 2</pre>\n<p class=\"basic-paragraph\">End</p>"), "{html}");
assert_eq!(html.matches("<pre").count(), 1, "{html}");
// A style without a list type of its own (typed bullets) gets no second marker.
st_typed(&mut d, sid);
let html = story_html(&d, sid);
assert!(html.contains("<ul style=\"list-style:none\">\n<li class=\"typed\">• typed</li>\n</ul>\n"), "{html}");
}
/// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them,
/// with its title in the book's language; without headings, each story's first paragraph.
#[test]
fn navigation_follows_the_headings() {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
let lid = d.default_layer();
for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() });
}
let text = "One\nBody\nOne point one\nMore\nTwo\nText";
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] {
st.paras[i].style = style.into();
}
// A story that starts with body text adds nothing to the table of contents.
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap();
let read = |d: &Document, opts: &EpubOptions, path: &str| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut s = String::new();
z.by_name(path).unwrap().read_to_string(&mut s).unwrap();
s
};
let nl = EpubOptions { language: "nl".into(), ..Default::default() };
let nav = read(&d, &nl, "OEBPS/nav.xhtml");
let link = |f: &str, t: &str, l: &str| format!("<a href=\"{f}#{t}\">{l}</a>");
// Split Document is off: every heading is in the one content file.
let want = format!(
"<h1>Inhoud</h1><ol><li>{}<ol><li>{}</li></ol></li><li>{}</li></ol>",
link("content.xhtml", "s0-p0", "One"),
link("content.xhtml", "s0-p2", "One point one"),
link("content.xhtml", "s0-p4", "Two")
);
assert!(nav.contains(&want), "{nav}");
assert!(!nav.contains("Text after the figure"), "{nav}");
let content = read(&d, &nl, "OEBPS/content.xhtml");
assert!(content.contains("<h1 id=\"s0-p0\" class=\"chapter\">One</h1>") && content.contains("<h2 id=\"s0-p2\""), "{content}");
let custom = EpubOptions { toc_title: Some("In dit boek".into()), ..Default::default() };
assert!(read(&d, &custom, "OEBPS/nav.xhtml").contains("<h1>In dit boek</h1>"));
// Without headings: one entry per story, its first paragraph.
let mut plain = Document::new(&NewDocument::default());
let lid = plain.default_layer();
plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap();
let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml");
assert!(nav.contains(&format!("<h1>Contents</h1><ol><li>{}</li></ol>", link("content.xhtml", "s0", "Opening"))), "{nav}");
// A heading shallower than the one before it but deeper than its parent stays under the
// parent: h1 A, h3 B, h2 C nest B and C under A.
let files: BTreeMap<String, String> = "ABCD".chars().map(|c| (c.to_string(), c.to_string())).collect();
let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l);
assert_eq!(
nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &files),
format!(
"<li>{}<ol><li>{}</li><li>{}</li></ol></li><li>{}</li>",
link("A", "A", "A"),
link("B", "B", "B"),
link("C", "C", "C"),
link("D", "D", "D")
)
);
assert_eq!(
nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &files),
format!(
"<li>{}<ol><li>{}<ol><li>{}</li></ol></li></ol></li><li>{}</li>",
link("A", "A", "A"),
link("B", "B", "B"),
link("C", "C", "C"),
link("D", "D", "D")
)
);
}
/// Split Document off (the default) gives one content file, h1s and all. Single Paragraph
/// Style starts a file before each paragraph of that style, with well-formed sections; the
/// spine and manifest list the files in order.
#[test]
fn chapters_split_before_a_single_paragraph_style() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: "Chapter".into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() });
// A title page without a heading, then a story that opens with a chapter and has a
// second one in the middle.
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap();
let (_, sid) = d
.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default())
.unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].style = "Chapter".into();
st.paras[2].style = "Chapter".into();
let one_file = |d: &Document, opts: &EpubOptions| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut c = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut c).unwrap();
z.by_name("OEBPS/content2.xhtml").is_err() && c.contains("Title page") && c.contains("Second text") && !c.contains(CHAPTER_MARK)
};
assert!(one_file(&d, &EpubOptions::default()), "off by default");
d.settings.epub_split = EpubSplit::Style("Chapter".into());
assert!(one_file(&d, &EpubOptions { split: Some(EpubSplit::Off), ..Default::default() }), "the export's own setting wins");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).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 (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml"));
assert!(c1.contains("Title page") && !c1.contains("One"), "{c1}");
assert!(c2.contains("<section id=\"s1\">\n<h1 id=\"s1-p0\"") && c2.contains("First text") && !c2.contains("Two"), "{c2}");
assert!(c3.contains("<section>\n<h1 id=\"s1-p2\""), "{c3}");
for c in [&c1, &c2, &c3] {
assert_eq!(c.matches("<section").count(), c.matches("</section>").count(), "{c}");
assert!(!c.contains(CHAPTER_MARK));
}
let opf = read("OEBPS/content.opf");
assert!(opf.contains("<itemref idref=\"content\"/>\n<itemref idref=\"content2\"/>\n<itemref idref=\"content3\"/>\n</spine>"), "{opf}");
assert!(opf.contains("<item id=\"content3\" href=\"content3.xhtml\""), "{opf}");
let nav = read("OEBPS/nav.xhtml");
assert!(nav.contains("href=\"content2.xhtml#s1-p0\"") && nav.contains("href=\"content3.xhtml#s1-p2\""), "{nav}");
assert!(z.by_name("OEBPS/content4.xhtml").is_err());
// HTML stays one page, without the marks.
let html = export_html(&d, &HtmlOptions::default());
assert!(!html.contains(CHAPTER_MARK) && html.contains("Second text"));
}
/// Based on export tags, a style with Split Document on starts each chapter file and an h1
/// without it doesn't; navigation and endnote links point at the files their targets ended
/// up in.
#[test]
fn chapters_split_before_split_document_styles() {
let mut d = Document::new(&NewDocument::default());
d.settings.epub_split = EpubSplit::ExportTags;
let lid = d.default_layer();
for (name, tag, split) in [("Part", "p", true), ("Chapter", "h1", false)] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split, ..Default::default() });
}
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap();
let (_, sid) = d
.add_text_frame(
SpreadRef::Doc(0),
Rect::new(36.0, 200.0, 300.0, 600.0),
lid,
"Part One\nChapter A\ntext a\nPart Two\nChapter B\ntext b",
ParaFormat::default(),
)
.unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in [(0, "Part"), (1, "Chapter"), (3, "Part"), (4, "Chapter")] {
st.paras[i].style = style.into();
}
// After "text a".
st.insert_endnote(25, "End note", ParaFormat::default());
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).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 (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml"));
assert!(c1.contains("Title page") && !c1.contains("Part One"), "{c1}");
assert!(c2.contains("Part One") && c2.contains("<h1 id=\"s1-p1\"") && !c2.contains("Part Two"), "the h1 stays with its part: {c2}");
assert!(c3.contains("Part Two") && c3.contains("<h1 id=\"s1-p4\"") && c3.contains("End note"), "{c3}");
for c in [&c1, &c2, &c3] {
assert_eq!(c.matches("<section").count(), c.matches("</section>").count(), "{c}");
}
let nav = read("OEBPS/nav.xhtml");
assert!(nav.contains("href=\"content2.xhtml#s1-p1\"") && nav.contains("href=\"content3.xhtml#s1-p4\""), "{nav}");
assert!(c2.contains("href=\"content3.xhtml#en-1\""), "{c2}");
assert!(c3.contains("href=\"content2.xhtml#enref-1\""), "the endnote links back across files: {c3}");
assert!(z.by_name("OEBPS/content4.xhtml").is_err());
}
/// Note links stay working across chapter files: endnotes end the book, in the last file.
#[test]
fn note_links_point_at_the_chapter_file_of_their_target() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: "Chapter".into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() });
let (_, sid) = d
.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default())
.unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].style = "Chapter".into();
st.paras[2].style = "Chapter".into();
// After "First": an endnote and a footnote.
st.insert_endnote(9, "End note", ParaFormat::default());
st.insert_note(9, "Foot note", ParaFormat::default());
d.settings.epub_split = EpubSplit::Style("Chapter".into());
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).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 (first, last) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"));
assert!(first.contains("id=\"enref-1\" href=\"content2.xhtml#en-1\""), "{first}");
assert!(first.contains("href=\"#fn-1\"") && first.contains("href=\"#fnref-1\""), "a footnote stays in its chapter: {first}");
assert!(last.contains("Second text") && last.contains("<a href=\"content.xhtml#enref-1\">"), "{last}");
}
/// A split before a list item or a preformatted line closes the open list or block first and
/// reopens it in the new file, so every file is balanced.
#[test]
fn chapter_files_split_inside_lists_and_pre_stay_balanced() {
let mut d = Document::new(&NewDocument::default());
d.settings.epub_split = EpubSplit::ExportTags;
let lid = d.default_layer();
for (name, tag) in [("Item", "li"), ("Code", "pre")] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split: true, ..Default::default() });
}
let (_, sid) = d
.add_text_frame(
SpreadRef::Doc(0),
Rect::new(36.0, 36.0, 300.0, 300.0),
lid,
"Intro
one
two
x = 1
y = 2
End",
ParaFormat::default(),
)
.unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in [(1, "Item"), (2, "Item"), (3, "Code"), (4, "Code")] {
st.paras[i].style = style.into();
}
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).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 files: Vec<String> =
["content", "content2", "content3", "content4", "content5"].iter().map(|f| read(&format!("OEBPS/{f}.xhtml"))).collect();
for (c, text) in files.iter().zip(["Intro", "one", "two", "x = 1", "y = 2"]) {
assert!(c.contains(text), "{text}: {c}");
for (open, close) in [("<ul", "</ul>"), ("<pre", "</pre>"), ("<section", "</section>")] {
assert_eq!(c.matches(open).count(), c.matches(close).count(), "{open}: {c}");
}
}
assert!(files[1].contains("<ul") && files[2].contains("<ul") && files[3].contains("<pre") && files[4].contains("<pre"));
assert!(files[4].contains("End"));
assert!(z.by_name("OEBPS/content6.xhtml").is_err());
}
#[test]
fn split_chapters_keeps_sections_well_formed() {
let m = CHAPTER_MARK;
assert_eq!(split_chapters("<section id=\"s0\">\n<p>a</p>\n</section>\n"), vec!["<section id=\"s0\">\n<p>a</p>\n</section>\n"]);
assert_eq!(
split_chapters(&format!("<section id=\"s0\">\n{m}<h1>A</h1>\n</section>\n")),
vec!["<section id=\"s0\">\n<h1>A</h1>\n</section>\n"]
);
assert_eq!(
split_chapters(&format!("<section id=\"s0\">\n<p>a</p>\n{m}<h1>B</h1>\n</section>\n")),
vec!["<section id=\"s0\">\n<p>a</p>\n</section>\n", "<section>\n<h1>B</h1>\n</section>\n"]
);
assert_eq!(split_chapters(""), vec![String::new()]);
}
#[test]
fn styles_whose_names_slug_alike_keep_their_own_classes() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
for (name, size) in [("見出し", 30.0), ("本文", 10.0), ("Body Text", 12.0), ("Body-Text", 20.0)] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs { size: Some(size), ..Default::default() },
shortcut: String::new(),
});
}
for (k, name) in ["見出し", "本文", "Body Text", "Body-Text"].into_iter().enumerate() {
let y = 36.0 + 100.0 * k as f64;
let pf = ParaFormat { style: name.into(), ..Default::default() };
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, y, 300.0, y + 50.0), lid, &format!("P{k}"), pf).unwrap();
}
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let mut css = String::new();
z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap();
let class_of = |text: &str| {
let end = html.find(&format!("\">{text}</p>")).unwrap();
let start = html[..end].rfind("class=\"").unwrap() + 7;
html[start..end].to_string()
};
let classes: Vec<String> = (0..4).map(|k| class_of(&format!("P{k}"))).collect();
for (k, c) in classes.iter().enumerate() {
assert!(!classes[..k].contains(c), "class `{c}` shared: {classes:?}");
assert_eq!(css.matches(&format!("p.{c} {{")).count(), 1, "one rule for `{c}`:\n{css}");
}
}
#[test]
fn slugs() {
assert_eq!(slug("[Basic Paragraph]"), "basic-paragraph");
assert_eq!(slug("Body First"), "body-first");
assert_eq!(slug("1 Head"), "s-1-head");
}
#[test]
fn html_is_one_self_contained_page() {
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, ..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");
assert_eq!(base64(b"Ma"), "TWE=");
assert_eq!(base64(b"M"), "TQ==");
}
/// #774: an image without alt text is decorative (`alt=""`) and reported, and package names
/// come from the original file names.
#[test]
fn images_get_readable_names_and_no_file_name_alt() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| {
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: name.into(),
mime: mime.into(),
link: None,
data: Arc::new(vec![1]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
clip: None,
wrap: Default::default(),
fill: Default::default(),
});
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
aid.0
};
let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0);
let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0);
let c = place(&mut d, "???.png", "image/png", "Mark", 200.0);
assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap();
let mut x = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap();
assert!(x.contains(&format!("<img src=\"images/anim-frame-1-{a}.gif\" alt=\"\"/>")), "{x}");
assert!(x.contains(&format!("<img src=\"images/photo-{b}.jpg\" alt=\"A heron\"/>")), "{x}");
assert!(x.contains(&format!("<img src=\"images/{c}.png\" alt=\"Mark\"/>")), "{x}");
assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok());
}
/// #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"));
assert_eq!(css_weight_style("Light"), (300, "normal"));
assert_eq!(css_weight_style("Thin Oblique"), (100, "italic"));
assert_eq!(css_weight_style("Black"), (900, "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());
d.title = "Field Notes & <Edition>".into();
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B <C> \"D\"", ParaFormat::default()).unwrap();
d.styles_mut().export_tags.insert(
format!("p:{}", story::BASIC_PARAGRAPH),
designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into(), ..Default::default() },
);
if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) {
basic.chars.font_family = Some("Quoted \"Serif\" <Alt>".into());
}
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
id: aid,
name: "Cover & Notes".into(),
mime: "image/png; profile=screen".into(),
data: Arc::new(vec![1]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0)));
item.alt_text = "Cover \"A\" & B".into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
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()), ..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\""));
assert!(html.contains("src=\"data:image/png;base64,AQ==\""));
assert!(!html.contains("image/png; profile=screen"));
assert!(!stylesheet(&d).contains("<Alt>"));
assert!(stylesheet(&d).contains("\\3c Alt\\3e "));
let bytes = export_epub(
&d,
&EpubOptions {
title: None,
author: Some("A & B".into()),
language: "English (US)".into(),
identifier: Some("notes&edition".into()),
cover: None,
..Default::default()
},
)
.unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
assert!(opf.contains("xml:lang=\"und\""));
assert!(opf.contains("<dc:identifier id=\"bookid\">notes&amp;edition</dc:identifier>"));
assert!(opf.contains("<dc:creator>A &amp; B</dc:creator>"));
assert!(opf.contains("media-type=\"image/png\""));
}
/// File › File Info's Document Title and Author name the EPUB and the HTML page; the export
/// options' title and author replace them.
#[test]
fn file_info_titles_the_epub_and_the_html_page() {
let mut d = Document::new(&NewDocument { title: "flyer-v3".into(), ..Default::default() });
d.file_info.document_title = "Spring & Summer".into();
d.file_info.author = "Ana Ruiz".into();
let opf = |d: &Document, opts: &EpubOptions| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
opf
};
let o = opf(&d, &EpubOptions::default());
assert!(o.contains("<dc:title>Spring &amp; Summer</dc:title>") && o.contains("<dc:creator>Ana Ruiz</dc:creator>"), "{o}");
let o = opf(&d, &EpubOptions { title: Some("Proof".into()), author: Some("Studio".into()), ..Default::default() });
assert!(o.contains("<dc:title>Proof</dc:title>") && o.contains("<dc:creator>Studio</dc:creator>"), "{o}");
let html = export_html(&d, &HtmlOptions::default());
assert!(html.contains("<title>Spring &amp; Summer</title>"));
d.file_info = Default::default();
let o = opf(&d, &EpubOptions::default());
assert!(o.contains("<dc:title>flyer-v3</dc:title>") && !o.contains("<dc:creator>"), "{o}");
}
/// "Body¹ text", a 2×2 table, then "Closing" with an endnote.
fn tables_and_notes() -> Document {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "Body text", ParaFormat::default()).unwrap();
let st = d.story_mut(sid).unwrap();
st.insert_note(4, "Foot note text", ParaFormat::default());
let mut t = Table::new(1, 2, 2, 0, 0, 200.0);
for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] {
t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default());
}
let end = st.len();
st.insert_table(end, t);
let end = st.len();
st.insert(end, "Closing");
let end = st.len();
st.insert_endnote(end, "End note text", ParaFormat::default());
d
}
#[test]
fn tables_footnotes_and_endnotes_are_exported() {
let d = tables_and_notes();
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let (t0, t1) = (html.find("<table>").unwrap(), html.find("</table>").unwrap());
for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] {
let at = html.find(&format!("<td><p class=\"basic-paragraph\">{cell}</p>\n</td>")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}"));
assert!(t0 < at && at < t1);
}
assert_eq!(html.matches("<tr>").count(), 2);
let noteref = "Body<sup><a epub:type=\"noteref\" role=\"doc-noteref\" id=\"fnref-1\" href=\"#fn-1\">1</a></sup> text";
assert!(html.contains(noteref), "footnote reference:\n{html}");
let aside = html.find("<aside epub:type=\"footnote\" role=\"doc-footnote\" id=\"fn-1\">").unwrap();
let aside_end = aside + html[aside..].find("</aside>").unwrap();
assert!(html[aside..aside_end].contains("<a href=\"#fnref-1\">1</a> Foot note text"));
assert!(aside < t0, "the footnote follows its paragraph");
assert!(html.contains("Closing<sup><a epub:type=\"noteref\" role=\"doc-noteref\" id=\"enref-1\" href=\"#en-1\">1</a></sup>"));
let section = html.find("<section epub:type=\"endnotes\" role=\"doc-endnotes\" id=\"endnotes\">").unwrap();
assert!(section > html.find("Closing").unwrap());
assert!(html[section..].contains("<li epub:type=\"endnote\" id=\"en-1\">"));
assert!(html[section..].contains("<a href=\"#enref-1\">1</a> End note text"));
let mut nav = String::new();
z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap();
assert!(nav.contains("href=\"content.xhtml#endnotes\""));
let page = export_html(&d, &HtmlOptions::default());
assert!(page.contains("<aside role=\"doc-footnote\" id=\"fn-1\">") && page.contains("Cell B2") && page.contains("End note text"));
assert!(!page.contains("epub:type"), "plain HTML has no EPUB namespace");
}
/// A footnote or a table after a list paragraph or a preformatted line closes the list or
/// block first: `aside` and `table` can't sit inside `ul` or `pre`.
#[test]
fn notes_and_tables_close_an_open_list_or_pre_block() {
for (tag, list, open, close) in [("li", Some(designcraft_doc::ListType::Bullets), "<ul>", "</ul>"), ("pre", None, "<pre", "</pre>")] {
let mut d = tables_and_notes();
let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() };
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: "Grouped".into(),
based_on: None,
next_style: None,
para,
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert("p:Grouped".into(), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() });
let sid = d.stories.keys().copied().find(|sid| d.story(*sid).is_some_and(|st| !st.notes.is_empty())).unwrap();
for p in &mut d.story_mut(sid).unwrap().paras {
p.style = "Grouped".into();
}
let html = story_html(&d, sid);
let mut rest = html.as_str();
while let Some(at) = rest.find(open) {
let block = &rest[at..];
let end = block.find(close).unwrap_or_else(|| panic!("{open} is closed:\n{html}"));
assert!(!block[..end].contains("<aside") && !block[..end].contains("<table"), "{html}");
rest = &block[end..];
}
assert!(html.contains("<aside") && html.contains("<table>"), "{html}");
}
}
#[test]
fn table_rows_groups_and_merged_cells() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "", ParaFormat::default()).unwrap();
// One header row, two body rows, one footer row; the header spans both columns and the
// first body cell spans both body rows and runs into the footer.
let mut t = Table::new(1, 2, 2, 1, 1, 200.0);
for (r, c, s) in [(0, 0, "Head"), (1, 0, "Tall"), (1, 1, "B1"), (2, 1, "B2"), (3, 0, "F1"), (3, 1, "F2")] {
t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default());
}
t.cell_mut(0, 0).unwrap().col_span = 2;
t.cell_mut(1, 0).unwrap().row_span = 3;
d.story_mut(sid).unwrap().insert_table(0, t);
let html = story_html(&d, sid);
let p = |s: &str| format!("<p class=\"basic-paragraph\">{s}</p>\n");
let expected = format!(
"<table>\n<thead>\n<tr><th colspan=\"2\">{}</th>\n</tr>\n</thead>\n<tbody>\n<tr><td rowspan=\"2\">{}</td>\n<td>{}</td>\n</tr>\n<tr><td>{}</td>\n</tr>\n</tbody>\n<tfoot>\n<tr><td>{}</td>\n<td>{}</td>\n</tr>\n</tfoot>\n</table>\n",
p("Head"),
p("Tall"),
p("B1"),
p("B2"),
p("F1"),
p("F2")
);
assert!(html.contains(&expected), "{html}");
}
/// Note links stay working across chapter files: a link whose target ended up in another
/// file (endnotes end the book) names that file; one in its own file stays as it is.
#[test]
fn note_links_name_the_chapter_file_of_their_target() {
let files: BTreeMap<String, String> =
[("enref-1", "content.xhtml"), ("fn-1", "content.xhtml"), ("fnref-1", "content.xhtml"), ("en-1", "content2.xhtml")]
.into_iter()
.map(|(id, f)| (id.to_string(), f.to_string()))
.collect();
let first = "<p>First<a id=\"enref-1\" href=\"#en-1\">1</a><a id=\"fnref-1\" href=\"#fn-1\">*</a></p><aside id=\"fn-1\"><a href=\"#fnref-1\">*</a></aside><a href=\"#gone\">x</a>";
assert_eq!(
link_across_chapters(first, "content.xhtml", &files),
"<p>First<a id=\"enref-1\" href=\"content2.xhtml#en-1\">1</a><a id=\"fnref-1\" href=\"#fn-1\">*</a></p><aside id=\"fn-1\"><a href=\"#fnref-1\">*</a></aside><a href=\"#gone\">x</a>"
);
let last = "<li id=\"en-1\"><a href=\"#enref-1\">1</a> End note</li>";
assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "<li id=\"en-1\"><a href=\"content.xhtml#enref-1\">1</a> End note</li>");
}
#[test]
fn char_css_joins_underline_and_strikethrough_in_one_declaration() {
let d = Document::new(&NewDocument::default());
let css = |u: bool, s: bool| {
let c = CharProps { underline: u, strikethrough: s, ..CharProps::default() };
let mut out = String::new();
char_css(&d, &c, 12.0, &BTreeMap::new(), &mut out);
out
};
let both = css(true, true);
assert_eq!(both.matches("text-decoration:").count(), 1, "{both}");
assert!(both.contains("text-decoration: underline line-through;"), "{both}");
assert!(css(true, false).contains("text-decoration: underline;"));
assert!(css(false, true).contains("text-decoration: line-through;"));
assert!(!css(false, false).contains("text-decoration"));
}
}
mod tests;
+924
View File
@@ -0,0 +1,924 @@
use super::*;
use designcraft_doc::build::NewDocument;
use designcraft_doc::geom::{Affine, Rect, shapes};
use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table};
use std::io::Read;
use std::sync::Arc;
#[test]
fn exports_valid_package_in_reading_order() {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Second <b> & co", ParaFormat::default()).unwrap();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "First\nline two", ParaFormat::default()).unwrap();
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
assert_eq!(z.by_index(0).unwrap().name(), "mimetype");
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let a = html.find("First").unwrap();
let b = html.find("Second &lt;b&gt; &amp; co").unwrap();
assert!(a < b, "reading order");
assert!(html.contains("<p class=\"basic-paragraph\">"));
let mut css = String::new();
z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap();
assert!(css.contains("p.basic-paragraph"));
assert!(z.by_name("OEBPS/nav.xhtml").is_ok());
}
/// #766: anchored graphics are exported where they are anchored (inline in the text, an
/// above-line object as a block of its own), and their images go into the package.
#[test]
fn anchored_objects_are_exported_in_place() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap();
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: "dot.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(vec![7]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let graphic = |d: &mut Document, alt: &str| {
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
clip: None,
wrap: Default::default(),
fill: Default::default(),
});
item
};
let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 });
let above = designcraft_doc::AnchoredObject::new(
graphic(&mut d, "above"),
designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 },
);
let st = d.story_mut(sid).unwrap();
st.insert_object(1, inline);
// Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D.
st.insert_object(7, above);
// An anchored image without alt text is decorative and reported, like a placed one.
let bare = designcraft_doc::AnchoredObject::new(graphic(&mut d, ""), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 });
let end = d.story(sid).unwrap().text.len();
d.story_mut(sid).unwrap().insert_object(end, bare);
assert_eq!(images_without_alt(&d), vec!["dot.png".to_string()]);
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let img = |alt: &str| format!("<img src=\"images/dot-{}.png\" alt=\"{alt}\"/>", aid.0);
assert!(html.contains(&format!("A{}B</p>", img("inline"))), "{html}");
assert!(html.contains(&format!("C<span class=\"anchored\" style=\"display:block;text-align:center\">{}</span>D", img("above"))), "{html}");
assert!(z.by_name(&format!("OEBPS/images/dot-{}.png", aid.0)).is_ok(), "the image is in the package");
let page = export_html(&d, &HtmlOptions::default());
assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}");
// story_html on its own still leaves objects out.
assert!(!story_html(&d, sid).contains("<img"));
}
#[test]
fn iso8601_timestamps() {
for (t, want) in [
(0, "1970-01-01T00:00:00Z"),
(951_782_400, "2000-02-29T00:00:00Z"),
(1_791_891_000, "2026-10-13T11:30:00Z"),
(-86_400, "1969-12-31T00:00:00Z"),
// Years past 9999 keep the plain zero-padded year (no `+`).
(253_402_300_800, "10000-01-01T00:00:00Z"),
// Beyond chrono's range the old `civil_from_days` still converted; pin that output
// (never the epoch) in both directions and at the `i64` extremes.
(10_000_000_000_000, "318857-05-20T17:46:40Z"),
(-10_000_000_000_000, "-314918-08-13T06:13:20Z"),
(i64::MAX, "292277026596-12-04T15:30:07Z"),
(i64::MIN, "-292277022657-01-27T08:29:52Z"),
] {
assert_eq!(iso8601(t), want);
}
}
/// #771, #773: dcterms:modified is a real time, the default identifier is a stable urn:uuid
/// that differs between documents with the same title, and a JPEG cover keeps its type.
#[test]
fn package_metadata_and_cover_image() {
let opf = |d: &Document, opts: &EpubOptions| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut s = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut s).unwrap();
let names: Vec<String> = z.file_names().map(str::to_string).collect();
(s, names)
};
let id_of = |opf: &str| opf.split("<dc:identifier id=\"bookid\">").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string();
let mut a = Document::new(&NewDocument::default());
a.title = "Same".into();
a.created = 1_700_000_000;
let mut b = a.clone();
b.created = 1_700_000_001;
let (oa, _) = opf(&a, &EpubOptions::default());
let (oa2, _) = opf(&a, &EpubOptions::default());
let (ob, _) = opf(&b, &EpubOptions::default());
assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa));
assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export");
assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title");
// Modified: the given time, else the document's save (or creation) time.
assert!(oa.contains("<meta property=\"dcterms:modified\">2023-11-14T22:13:20Z</meta>"), "{oa}");
let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() };
assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z"));
// A JPEG cover is packaged as such.
let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() };
let (o, names) = opf(&a, &jpeg);
assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}");
assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}");
}
fn st_typed(d: &mut Document, sid: StoryId) {
if let Some(st) = d.story_mut(sid) {
st.paras[8].style = "Typed".into();
}
}
/// #767: consecutive `li` paragraphs share one list (`ol` for a numbered style), and
/// consecutive `pre` paragraphs of one style one block with their lines on separate lines.
#[test]
fn lists_and_preformatted_blocks_are_grouped() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
for (name, tag, list) in [
("Bullet", "li", Some(designcraft_doc::ListType::Bullets)),
("Number", "li", Some(designcraft_doc::ListType::Numbers)),
("Typed", "li", None),
("Code", "pre", None),
] {
let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() };
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para,
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() });
}
let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed";
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in ["Bullet", "Bullet", "Number", "Number", "Code", "Code"].into_iter().enumerate() {
st.paras[i + 1].style = style.into();
}
let html = story_html(&d, sid);
let li = |c: &str, t: &str| format!("<li class=\"{c}\">{t}</li>");
assert!(
html.contains(&format!(
"<ul>\n{}\n{}\n</ul>\n<ol>\n{}\n{}\n</ol>\n",
li("bullet", "first"),
li("bullet", "second"),
li("number", "one"),
li("number", "two")
)),
"{html}"
);
assert!(html.contains("<pre class=\"code\">x = 1\ny = 2</pre>\n<p class=\"basic-paragraph\">End</p>"), "{html}");
assert_eq!(html.matches("<pre").count(), 1, "{html}");
// A style without a list type of its own (typed bullets) gets no second marker.
st_typed(&mut d, sid);
let html = story_html(&d, sid);
assert!(html.contains("<ul style=\"list-style:none\">\n<li class=\"typed\">• typed</li>\n</ul>\n"), "{html}");
}
/// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them,
/// with its title in the book's language; without headings, each story's first paragraph.
#[test]
fn navigation_follows_the_headings() {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
let lid = d.default_layer();
for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() });
}
let text = "One\nBody\nOne point one\nMore\nTwo\nText";
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] {
st.paras[i].style = style.into();
}
// A story that starts with body text adds nothing to the table of contents.
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap();
let read = |d: &Document, opts: &EpubOptions, path: &str| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut s = String::new();
z.by_name(path).unwrap().read_to_string(&mut s).unwrap();
s
};
let nl = EpubOptions { language: "nl".into(), ..Default::default() };
let nav = read(&d, &nl, "OEBPS/nav.xhtml");
let link = |f: &str, t: &str, l: &str| format!("<a href=\"{f}#{t}\">{l}</a>");
// Split Document is off: every heading is in the one content file.
let want = format!(
"<h1>Inhoud</h1><ol><li>{}<ol><li>{}</li></ol></li><li>{}</li></ol>",
link("content.xhtml", "s0-p0", "One"),
link("content.xhtml", "s0-p2", "One point one"),
link("content.xhtml", "s0-p4", "Two")
);
assert!(nav.contains(&want), "{nav}");
assert!(!nav.contains("Text after the figure"), "{nav}");
let content = read(&d, &nl, "OEBPS/content.xhtml");
assert!(content.contains("<h1 id=\"s0-p0\" class=\"chapter\">One</h1>") && content.contains("<h2 id=\"s0-p2\""), "{content}");
let custom = EpubOptions { toc_title: Some("In dit boek".into()), ..Default::default() };
assert!(read(&d, &custom, "OEBPS/nav.xhtml").contains("<h1>In dit boek</h1>"));
// Without headings: one entry per story, its first paragraph.
let mut plain = Document::new(&NewDocument::default());
let lid = plain.default_layer();
plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap();
let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml");
assert!(nav.contains(&format!("<h1>Contents</h1><ol><li>{}</li></ol>", link("content.xhtml", "s0", "Opening"))), "{nav}");
// A heading shallower than the one before it but deeper than its parent stays under the
// parent: h1 A, h3 B, h2 C nest B and C under A.
let files: BTreeMap<String, String> = "ABCD".chars().map(|c| (c.to_string(), c.to_string())).collect();
let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l);
assert_eq!(
nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &files),
format!(
"<li>{}<ol><li>{}</li><li>{}</li></ol></li><li>{}</li>",
link("A", "A", "A"),
link("B", "B", "B"),
link("C", "C", "C"),
link("D", "D", "D")
)
);
assert_eq!(
nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &files),
format!(
"<li>{}<ol><li>{}<ol><li>{}</li></ol></li></ol></li><li>{}</li>",
link("A", "A", "A"),
link("B", "B", "B"),
link("C", "C", "C"),
link("D", "D", "D")
)
);
}
/// Split Document off (the default) gives one content file, h1s and all. Single Paragraph
/// Style starts a file before each paragraph of that style, with well-formed sections; the
/// spine and manifest list the files in order.
#[test]
fn chapters_split_before_a_single_paragraph_style() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: "Chapter".into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() });
// A title page without a heading, then a story that opens with a chapter and has a
// second one in the middle.
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap();
let (_, sid) = d
.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default())
.unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].style = "Chapter".into();
st.paras[2].style = "Chapter".into();
let one_file = |d: &Document, opts: &EpubOptions| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut c = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut c).unwrap();
z.by_name("OEBPS/content2.xhtml").is_err() && c.contains("Title page") && c.contains("Second text") && !c.contains(CHAPTER_MARK)
};
assert!(one_file(&d, &EpubOptions::default()), "off by default");
d.settings.epub_split = EpubSplit::Style("Chapter".into());
assert!(one_file(&d, &EpubOptions { split: Some(EpubSplit::Off), ..Default::default() }), "the export's own setting wins");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).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 (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml"));
assert!(c1.contains("Title page") && !c1.contains("One"), "{c1}");
assert!(c2.contains("<section id=\"s1\">\n<h1 id=\"s1-p0\"") && c2.contains("First text") && !c2.contains("Two"), "{c2}");
assert!(c3.contains("<section>\n<h1 id=\"s1-p2\""), "{c3}");
for c in [&c1, &c2, &c3] {
assert_eq!(c.matches("<section").count(), c.matches("</section>").count(), "{c}");
assert!(!c.contains(CHAPTER_MARK));
}
let opf = read("OEBPS/content.opf");
assert!(opf.contains("<itemref idref=\"content\"/>\n<itemref idref=\"content2\"/>\n<itemref idref=\"content3\"/>\n</spine>"), "{opf}");
assert!(opf.contains("<item id=\"content3\" href=\"content3.xhtml\""), "{opf}");
let nav = read("OEBPS/nav.xhtml");
assert!(nav.contains("href=\"content2.xhtml#s1-p0\"") && nav.contains("href=\"content3.xhtml#s1-p2\""), "{nav}");
assert!(z.by_name("OEBPS/content4.xhtml").is_err());
// HTML stays one page, without the marks.
let html = export_html(&d, &HtmlOptions::default());
assert!(!html.contains(CHAPTER_MARK) && html.contains("Second text"));
}
/// Based on export tags, a style with Split Document on starts each chapter file and an h1
/// without it doesn't; navigation and endnote links point at the files their targets ended
/// up in.
#[test]
fn chapters_split_before_split_document_styles() {
let mut d = Document::new(&NewDocument::default());
d.settings.epub_split = EpubSplit::ExportTags;
let lid = d.default_layer();
for (name, tag, split) in [("Part", "p", true), ("Chapter", "h1", false)] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split, ..Default::default() });
}
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap();
let (_, sid) = d
.add_text_frame(
SpreadRef::Doc(0),
Rect::new(36.0, 200.0, 300.0, 600.0),
lid,
"Part One\nChapter A\ntext a\nPart Two\nChapter B\ntext b",
ParaFormat::default(),
)
.unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in [(0, "Part"), (1, "Chapter"), (3, "Part"), (4, "Chapter")] {
st.paras[i].style = style.into();
}
// After "text a".
st.insert_endnote(25, "End note", ParaFormat::default());
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).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 (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml"));
assert!(c1.contains("Title page") && !c1.contains("Part One"), "{c1}");
assert!(c2.contains("Part One") && c2.contains("<h1 id=\"s1-p1\"") && !c2.contains("Part Two"), "the h1 stays with its part: {c2}");
assert!(c3.contains("Part Two") && c3.contains("<h1 id=\"s1-p4\"") && c3.contains("End note"), "{c3}");
for c in [&c1, &c2, &c3] {
assert_eq!(c.matches("<section").count(), c.matches("</section>").count(), "{c}");
}
let nav = read("OEBPS/nav.xhtml");
assert!(nav.contains("href=\"content2.xhtml#s1-p1\"") && nav.contains("href=\"content3.xhtml#s1-p4\""), "{nav}");
assert!(c2.contains("href=\"content3.xhtml#en-1\""), "{c2}");
assert!(c3.contains("href=\"content2.xhtml#enref-1\""), "the endnote links back across files: {c3}");
assert!(z.by_name("OEBPS/content4.xhtml").is_err());
}
/// Note links stay working across chapter files: endnotes end the book, in the last file.
#[test]
fn note_links_point_at_the_chapter_file_of_their_target() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: "Chapter".into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() });
let (_, sid) = d
.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default())
.unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].style = "Chapter".into();
st.paras[2].style = "Chapter".into();
// After "First": an endnote and a footnote.
st.insert_endnote(9, "End note", ParaFormat::default());
st.insert_note(9, "Foot note", ParaFormat::default());
d.settings.epub_split = EpubSplit::Style("Chapter".into());
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).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 (first, last) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"));
assert!(first.contains("id=\"enref-1\" href=\"content2.xhtml#en-1\""), "{first}");
assert!(first.contains("href=\"#fn-1\"") && first.contains("href=\"#fnref-1\""), "a footnote stays in its chapter: {first}");
assert!(last.contains("Second text") && last.contains("<a href=\"content.xhtml#enref-1\">"), "{last}");
}
/// A split before a list item or a preformatted line closes the open list or block first and
/// reopens it in the new file, so every file is balanced.
#[test]
fn chapter_files_split_inside_lists_and_pre_stay_balanced() {
let mut d = Document::new(&NewDocument::default());
d.settings.epub_split = EpubSplit::ExportTags;
let lid = d.default_layer();
for (name, tag) in [("Item", "li"), ("Code", "pre")] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split: true, ..Default::default() });
}
let (_, sid) = d
.add_text_frame(
SpreadRef::Doc(0),
Rect::new(36.0, 36.0, 300.0, 300.0),
lid,
"Intro
one
two
x = 1
y = 2
End",
ParaFormat::default(),
)
.unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in [(1, "Item"), (2, "Item"), (3, "Code"), (4, "Code")] {
st.paras[i].style = style.into();
}
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).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 files: Vec<String> = ["content", "content2", "content3", "content4", "content5"].iter().map(|f| read(&format!("OEBPS/{f}.xhtml"))).collect();
for (c, text) in files.iter().zip(["Intro", "one", "two", "x = 1", "y = 2"]) {
assert!(c.contains(text), "{text}: {c}");
for (open, close) in [("<ul", "</ul>"), ("<pre", "</pre>"), ("<section", "</section>")] {
assert_eq!(c.matches(open).count(), c.matches(close).count(), "{open}: {c}");
}
}
assert!(files[1].contains("<ul") && files[2].contains("<ul") && files[3].contains("<pre") && files[4].contains("<pre"));
assert!(files[4].contains("End"));
assert!(z.by_name("OEBPS/content6.xhtml").is_err());
}
#[test]
fn split_chapters_keeps_sections_well_formed() {
let m = CHAPTER_MARK;
assert_eq!(split_chapters("<section id=\"s0\">\n<p>a</p>\n</section>\n"), vec!["<section id=\"s0\">\n<p>a</p>\n</section>\n"]);
assert_eq!(split_chapters(&format!("<section id=\"s0\">\n{m}<h1>A</h1>\n</section>\n")), vec!["<section id=\"s0\">\n<h1>A</h1>\n</section>\n"]);
assert_eq!(
split_chapters(&format!("<section id=\"s0\">\n<p>a</p>\n{m}<h1>B</h1>\n</section>\n")),
vec!["<section id=\"s0\">\n<p>a</p>\n</section>\n", "<section>\n<h1>B</h1>\n</section>\n"]
);
assert_eq!(split_chapters(""), vec![String::new()]);
}
#[test]
fn styles_whose_names_slug_alike_keep_their_own_classes() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
for (name, size) in [("見出し", 30.0), ("本文", 10.0), ("Body Text", 12.0), ("Body-Text", 20.0)] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs { size: Some(size), ..Default::default() },
shortcut: String::new(),
});
}
for (k, name) in ["見出し", "本文", "Body Text", "Body-Text"].into_iter().enumerate() {
let y = 36.0 + 100.0 * k as f64;
let pf = ParaFormat { style: name.into(), ..Default::default() };
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, y, 300.0, y + 50.0), lid, &format!("P{k}"), pf).unwrap();
}
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let mut css = String::new();
z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap();
let class_of = |text: &str| {
let end = html.find(&format!("\">{text}</p>")).unwrap();
let start = html[..end].rfind("class=\"").unwrap() + 7;
html[start..end].to_string()
};
let classes: Vec<String> = (0..4).map(|k| class_of(&format!("P{k}"))).collect();
for (k, c) in classes.iter().enumerate() {
assert!(!classes[..k].contains(c), "class `{c}` shared: {classes:?}");
assert_eq!(css.matches(&format!("p.{c} {{")).count(), 1, "one rule for `{c}`:\n{css}");
}
}
#[test]
fn slugs() {
assert_eq!(slug("[Basic Paragraph]"), "basic-paragraph");
assert_eq!(slug("Body First"), "body-first");
assert_eq!(slug("1 Head"), "s-1-head");
}
#[test]
fn html_is_one_self_contained_page() {
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, ..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");
assert_eq!(base64(b"Ma"), "TWE=");
assert_eq!(base64(b"M"), "TQ==");
}
/// #774: an image without alt text is decorative (`alt=""`) and reported, and package names
/// come from the original file names.
#[test]
fn images_get_readable_names_and_no_file_name_alt() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| {
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: name.into(),
mime: mime.into(),
link: None,
data: Arc::new(vec![1]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
clip: None,
wrap: Default::default(),
fill: Default::default(),
});
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
aid.0
};
let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0);
let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0);
let c = place(&mut d, "???.png", "image/png", "Mark", 200.0);
assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap();
let mut x = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap();
assert!(x.contains(&format!("<img src=\"images/anim-frame-1-{a}.gif\" alt=\"\"/>")), "{x}");
assert!(x.contains(&format!("<img src=\"images/photo-{b}.jpg\" alt=\"A heron\"/>")), "{x}");
assert!(x.contains(&format!("<img src=\"images/{c}.png\" alt=\"Mark\"/>")), "{x}");
assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok());
}
/// #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"));
assert_eq!(css_weight_style("Light"), (300, "normal"));
assert_eq!(css_weight_style("Thin Oblique"), (100, "italic"));
assert_eq!(css_weight_style("Black"), (900, "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());
d.title = "Field Notes & <Edition>".into();
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B <C> \"D\"", ParaFormat::default()).unwrap();
d.styles_mut().export_tags.insert(
format!("p:{}", story::BASIC_PARAGRAPH),
designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into(), ..Default::default() },
);
if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) {
basic.chars.font_family = Some("Quoted \"Serif\" <Alt>".into());
}
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
id: aid,
name: "Cover & Notes".into(),
mime: "image/png; profile=screen".into(),
data: Arc::new(vec![1]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0)));
item.alt_text = "Cover \"A\" & B".into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
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()), ..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\""));
assert!(html.contains("src=\"data:image/png;base64,AQ==\""));
assert!(!html.contains("image/png; profile=screen"));
assert!(!stylesheet(&d).contains("<Alt>"));
assert!(stylesheet(&d).contains("\\3c Alt\\3e "));
let bytes = export_epub(
&d,
&EpubOptions {
title: None,
author: Some("A & B".into()),
language: "English (US)".into(),
identifier: Some("notes&edition".into()),
cover: None,
..Default::default()
},
)
.unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
assert!(opf.contains("xml:lang=\"und\""));
assert!(opf.contains("<dc:identifier id=\"bookid\">notes&amp;edition</dc:identifier>"));
assert!(opf.contains("<dc:creator>A &amp; B</dc:creator>"));
assert!(opf.contains("media-type=\"image/png\""));
}
/// File › File Info's Document Title and Author name the EPUB and the HTML page; the export
/// options' title and author replace them.
#[test]
fn file_info_titles_the_epub_and_the_html_page() {
let mut d = Document::new(&NewDocument { title: "flyer-v3".into(), ..Default::default() });
d.file_info.document_title = "Spring & Summer".into();
d.file_info.author = "Ana Ruiz".into();
let opf = |d: &Document, opts: &EpubOptions| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
opf
};
let o = opf(&d, &EpubOptions::default());
assert!(o.contains("<dc:title>Spring &amp; Summer</dc:title>") && o.contains("<dc:creator>Ana Ruiz</dc:creator>"), "{o}");
let o = opf(&d, &EpubOptions { title: Some("Proof".into()), author: Some("Studio".into()), ..Default::default() });
assert!(o.contains("<dc:title>Proof</dc:title>") && o.contains("<dc:creator>Studio</dc:creator>"), "{o}");
let html = export_html(&d, &HtmlOptions::default());
assert!(html.contains("<title>Spring &amp; Summer</title>"));
d.file_info = Default::default();
let o = opf(&d, &EpubOptions::default());
assert!(o.contains("<dc:title>flyer-v3</dc:title>") && !o.contains("<dc:creator>"), "{o}");
}
/// "Body¹ text", a 2×2 table, then "Closing" with an endnote.
fn tables_and_notes() -> Document {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "Body text", ParaFormat::default()).unwrap();
let st = d.story_mut(sid).unwrap();
st.insert_note(4, "Foot note text", ParaFormat::default());
let mut t = Table::new(1, 2, 2, 0, 0, 200.0);
for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] {
t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default());
}
let end = st.len();
st.insert_table(end, t);
let end = st.len();
st.insert(end, "Closing");
let end = st.len();
st.insert_endnote(end, "End note text", ParaFormat::default());
d
}
#[test]
fn tables_footnotes_and_endnotes_are_exported() {
let d = tables_and_notes();
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let (t0, t1) = (html.find("<table>").unwrap(), html.find("</table>").unwrap());
for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] {
let at = html.find(&format!("<td><p class=\"basic-paragraph\">{cell}</p>\n</td>")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}"));
assert!(t0 < at && at < t1);
}
assert_eq!(html.matches("<tr>").count(), 2);
let noteref = "Body<sup><a epub:type=\"noteref\" role=\"doc-noteref\" id=\"fnref-1\" href=\"#fn-1\">1</a></sup> text";
assert!(html.contains(noteref), "footnote reference:\n{html}");
let aside = html.find("<aside epub:type=\"footnote\" role=\"doc-footnote\" id=\"fn-1\">").unwrap();
let aside_end = aside + html[aside..].find("</aside>").unwrap();
assert!(html[aside..aside_end].contains("<a href=\"#fnref-1\">1</a> Foot note text"));
assert!(aside < t0, "the footnote follows its paragraph");
assert!(html.contains("Closing<sup><a epub:type=\"noteref\" role=\"doc-noteref\" id=\"enref-1\" href=\"#en-1\">1</a></sup>"));
let section = html.find("<section epub:type=\"endnotes\" role=\"doc-endnotes\" id=\"endnotes\">").unwrap();
assert!(section > html.find("Closing").unwrap());
assert!(html[section..].contains("<li epub:type=\"endnote\" id=\"en-1\">"));
assert!(html[section..].contains("<a href=\"#enref-1\">1</a> End note text"));
let mut nav = String::new();
z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap();
assert!(nav.contains("href=\"content.xhtml#endnotes\""));
let page = export_html(&d, &HtmlOptions::default());
assert!(page.contains("<aside role=\"doc-footnote\" id=\"fn-1\">") && page.contains("Cell B2") && page.contains("End note text"));
assert!(!page.contains("epub:type"), "plain HTML has no EPUB namespace");
}
/// A footnote or a table after a list paragraph or a preformatted line closes the list or
/// block first: `aside` and `table` can't sit inside `ul` or `pre`.
#[test]
fn notes_and_tables_close_an_open_list_or_pre_block() {
for (tag, list, open, close) in [("li", Some(designcraft_doc::ListType::Bullets), "<ul>", "</ul>"), ("pre", None, "<pre", "</pre>")] {
let mut d = tables_and_notes();
let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() };
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: "Grouped".into(),
based_on: None,
next_style: None,
para,
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut()
.export_tags
.insert("p:Grouped".into(), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() });
let sid = d.stories.keys().copied().find(|sid| d.story(*sid).is_some_and(|st| !st.notes.is_empty())).unwrap();
for p in &mut d.story_mut(sid).unwrap().paras {
p.style = "Grouped".into();
}
let html = story_html(&d, sid);
let mut rest = html.as_str();
while let Some(at) = rest.find(open) {
let block = &rest[at..];
let end = block.find(close).unwrap_or_else(|| panic!("{open} is closed:\n{html}"));
assert!(!block[..end].contains("<aside") && !block[..end].contains("<table"), "{html}");
rest = &block[end..];
}
assert!(html.contains("<aside") && html.contains("<table>"), "{html}");
}
}
#[test]
fn table_rows_groups_and_merged_cells() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "", ParaFormat::default()).unwrap();
// One header row, two body rows, one footer row; the header spans both columns and the
// first body cell spans both body rows and runs into the footer.
let mut t = Table::new(1, 2, 2, 1, 1, 200.0);
for (r, c, s) in [(0, 0, "Head"), (1, 0, "Tall"), (1, 1, "B1"), (2, 1, "B2"), (3, 0, "F1"), (3, 1, "F2")] {
t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default());
}
t.cell_mut(0, 0).unwrap().col_span = 2;
t.cell_mut(1, 0).unwrap().row_span = 3;
d.story_mut(sid).unwrap().insert_table(0, t);
let html = story_html(&d, sid);
let p = |s: &str| format!("<p class=\"basic-paragraph\">{s}</p>\n");
let expected = format!(
"<table>\n<thead>\n<tr><th colspan=\"2\">{}</th>\n</tr>\n</thead>\n<tbody>\n<tr><td rowspan=\"2\">{}</td>\n<td>{}</td>\n</tr>\n<tr><td>{}</td>\n</tr>\n</tbody>\n<tfoot>\n<tr><td>{}</td>\n<td>{}</td>\n</tr>\n</tfoot>\n</table>\n",
p("Head"),
p("Tall"),
p("B1"),
p("B2"),
p("F1"),
p("F2")
);
assert!(html.contains(&expected), "{html}");
}
/// Note links stay working across chapter files: a link whose target ended up in another
/// file (endnotes end the book) names that file; one in its own file stays as it is.
#[test]
fn note_links_name_the_chapter_file_of_their_target() {
let files: BTreeMap<String, String> =
[("enref-1", "content.xhtml"), ("fn-1", "content.xhtml"), ("fnref-1", "content.xhtml"), ("en-1", "content2.xhtml")]
.into_iter()
.map(|(id, f)| (id.to_string(), f.to_string()))
.collect();
let first = "<p>First<a id=\"enref-1\" href=\"#en-1\">1</a><a id=\"fnref-1\" href=\"#fn-1\">*</a></p><aside id=\"fn-1\"><a href=\"#fnref-1\">*</a></aside><a href=\"#gone\">x</a>";
assert_eq!(
link_across_chapters(first, "content.xhtml", &files),
"<p>First<a id=\"enref-1\" href=\"content2.xhtml#en-1\">1</a><a id=\"fnref-1\" href=\"#fn-1\">*</a></p><aside id=\"fn-1\"><a href=\"#fnref-1\">*</a></aside><a href=\"#gone\">x</a>"
);
let last = "<li id=\"en-1\"><a href=\"#enref-1\">1</a> End note</li>";
assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "<li id=\"en-1\"><a href=\"content.xhtml#enref-1\">1</a> End note</li>");
}
#[test]
fn char_css_joins_underline_and_strikethrough_in_one_declaration() {
let d = Document::new(&NewDocument::default());
let css = |u: bool, s: bool| {
let c = CharProps { underline: u, strikethrough: s, ..CharProps::default() };
let mut out = String::new();
char_css(&d, &c, 12.0, &BTreeMap::new(), &mut out);
out
};
let both = css(true, true);
assert_eq!(both.matches("text-decoration:").count(), 1, "{both}");
assert!(both.contains("text-decoration: underline line-through;"), "{both}");
assert!(css(true, false).contains("text-decoration: underline;"));
assert!(css(false, true).contains("text-decoration: line-through;"));
assert!(!css(false, false).contains("text-decoration"));
}