mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
refactor(epub): move the tests module of lib.rs to tests.rs
This commit is contained in:
+1
-931
@@ -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 <b> & 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 <web>"));
|
||||
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 "Copy"\">A&B <C> "D"</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 & <Edition></title>"));
|
||||
assert!(html.contains("alt=\"Cover "A" & 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&edition</dc:identifier>"));
|
||||
assert!(opf.contains("<dc:creator>A & 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 & 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 & 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;
|
||||
|
||||
@@ -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 <b> & 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 <web>"));
|
||||
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 "Copy"\">A&B <C> "D"</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 & <Edition></title>"));
|
||||
assert!(html.contains("alt=\"Cover "A" & 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&edition</dc:identifier>"));
|
||||
assert!(opf.contains("<dc:creator>A & 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 & 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 & 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"));
|
||||
}
|
||||
Reference in New Issue
Block a user