// THE EPUB READER, tested where it is testable: the identity of a place in a // reflowing text, the walk of a package document, the sanitizer, the two // stylesheet passes, and the in-book search. // // THE FIXTURES ARE BUILT HERE, not committed. An EPUB is a zip of XML, and a // zip of XML is a thing this repo can already write (shared/zip.ts, the // export's own writer) — so the books below are assembled in memory from a // handful of strings and written to a temp vault. Nothing binary lands in the // tree, the fixtures are readable as source, and a test that needs a // right-to-left book with a `` + `

Still prose.

` + `
` + `` + `

outward and inward

` + `
A plate
` + `
kept words
`, ), }, { name: "ch2.xhtml", body: chapter(`

Two

The second chapter says nightingale.

`) }, { name: "ch3.xhtml", body: chapter(`

Three

And a third.

`) }, ], }); const ARABIC = makeEpub({ language: "ar", title: "ديوان", author: "أدونيس", contents: "ncx", chapters: [ { name: "ch1.xhtml", body: chapter(`

مقدّمة

هذه محاولة أخرى لبناء سياق مشترك.

`, "ar") }, { name: "ch2.xhtml", body: chapter(`

الْمُقَدِّمَة تعود هنا مرة أخرى.

`, "ar") }, ], }); const RTL_BY_SPINE = makeEpub({ language: "en", progression: "rtl", title: "Bound the other way", author: "—", contents: "none", chapters: [{ name: "ch1.xhtml", body: chapter(`

one

`) }], }); before(() => { initSite({ ASTROLABE_DATA: data }); initVault(vault); writeFileSync(path.join(vault, "Books", "Short.epub"), ENGLISH); writeFileSync(path.join(vault, "Books", "Diwan.epub"), ARABIC); writeFileSync(path.join(vault, "Books", "Rtl.epub"), RTL_BY_SPINE); writeFileSync(path.join(vault, "Books", "NotABook.epub"), "this is not a zip at all"); }); after(() => { removeVault(vault); removeVault(data); }); const url = (href: string): string => `/item?href=${href}`; describe("the shelf holds both formats", () => { it("knows a book from a PDF", () => { assert.ok(isBookPath("Books/A.pdf")); assert.ok(isBookPath("Books/A.epub")); assert.ok(isBookPath("Books/A.EPUB")); assert.ok(!isBookPath("Books/A.txt")); // The narrow question is still askable, for the callers that are about // the FORMAT (a text layer is a pdf.js fact). assert.ok(isPdfPath("A.pdf")); assert.ok(!isPdfPath("A.epub")); assert.ok(isEpubPath("A.epub")); }); it("lists EPUBs beside PDFs, each with a key of its own", async () => { const { books } = await listBooks(); const names = books.map((b) => b.path).sort(); assert.deepEqual(names, ["Books/Diwan.epub", "Books/Muqaddimah.pdf", "Books/NotABook.epub", "Books/Rtl.epub", "Books/Short.epub"]); assert.equal(new Set(books.map((b) => b.key)).size, books.length); }); it("keys an EPUB in its own space, so no PDF's position can collide with one", async () => { // Same bytes, two extensions: the keys must differ, because the format is // hashed in front of the sample. const bytes = `%PDF-1.7\n${"z".repeat(400)}\n%%EOF\n`; writeFileSync(path.join(vault, "Books", "Twin.pdf"), bytes); writeFileSync(path.join(vault, "Books", "Twin.epub"), bytes); assert.notEqual(await bookKey("Books/Twin.pdf"), await bookKey("Books/Twin.epub")); }); }); describe("the package document", () => { it("reads the metadata, the spine and the nav contents", async () => { const m = await epubManifest("Books/Short.epub", url); assert.equal(m.title, "A Short Book"); assert.equal(m.author, "Someone"); assert.equal(m.language, "en"); assert.equal(m.direction, "ltr"); assert.deepEqual(m.spine.map((s) => s.href), ["OEBPS/ch1.xhtml", "OEBPS/ch2.xhtml", "OEBPS/ch3.xhtml"]); assert.equal(m.cover, "/item?href=OEBPS/cover.png"); assert.equal(m.toc.length, 3); assert.equal(m.toc[0].label, "Chapter 1"); assert.equal(m.toc[0].href, "OEBPS/ch1.xhtml"); assert.equal(m.toc[0].fragment, "top"); assert.equal(m.toc[0].children[0].fragment, "deep"); }); it("names each spine item from the contents, so the outline reads as the book does", async () => { const m = await epubManifest("Books/Short.epub", url); assert.deepEqual(m.spine.map((s) => s.title), ["Chapter 1", "Chapter 2", "Chapter 3"]); }); it("falls back to the NCX when there is no nav document", async () => { const m = await epubManifest("Books/Diwan.epub", url); assert.equal(m.toc.length, 2); assert.equal(m.toc[0].label, "Chapter 1"); assert.equal(m.toc[0].href, "OEBPS/ch1.xhtml"); }); it("takes the binding from the spine first and the language second", async () => { assert.equal((await epubManifest("Books/Diwan.epub", url)).direction, "rtl"); assert.equal((await epubManifest("Books/Rtl.epub", url)).direction, "rtl"); assert.equal((await epubManifest("Books/Short.epub", url)).direction, "ltr"); }); it("refuses a file that is not an EPUB, rather than reading garbage", async () => { await assert.rejects(() => epubManifest("Books/NotABook.epub", url), (e: unknown) => e instanceof VaultError); await assert.rejects(() => epubManifest("Books/Muqaddimah.pdf", url), (e: unknown) => e instanceof VaultError); await assert.rejects(() => epubManifest("Books/Nothing.epub", url), (e: unknown) => e instanceof VaultError); }); it("counts progress in chapters, which is the only unit it has", async () => { const { spine } = await epubManifest("Books/Short.epub", url); assert.equal(epubProgress({ href: "OEBPS/ch1.xhtml", fraction: 0 }, spine), 0); assert.equal(epubProgress({ href: "OEBPS/ch2.xhtml", fraction: 0 }, spine), 1 / 3); assert.equal(epubProgress({ href: "OEBPS/ch3.xhtml", fraction: 1 }, spine), 1); assert.equal(epubProgress({ href: "nowhere.xhtml", fraction: 0.5 }, spine), 0); }); }); describe("a chapter, rebuilt", () => { it("never writes out a script, a form or a frame", async () => { const item = await epubItem("Books/Short.epub", "OEBPS/ch1.xhtml", url); const html = item.html ?? ""; assert.equal(item.kind, "chapter"); assert.doesNotMatch(html, /