import { Blob as NodeBlob, File as NodeFile } from "node:buffer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { closeBook, openEpub } from "../../src/epub/archive"; import { openPublication, publicationAdapterForFile, } from "../../src/epub/adapters"; import { readEntryText, readPublicationResourceBytes, } from "../../src/epub/entries"; import { exportMarkdown, exportReadingOrderHtml, patchPackageMetadata, rebuildEpub, } from "../../src/epub/export"; import { exportReadingNotes, importReadingNotes, type EpubReadingNotes, } from "../../src/epub/annotations"; import { resolvePackageHref, validateEntryPath } from "../../src/epub/paths"; import { applyReaderTheme, renderChapter, revokeRenderedChapter, } from "../../src/epub/reader"; import type { EpubBook } from "../../src/epub/types"; import { searchPublication } from "../../src/epub/search"; import { createEpubFixture } from "./fixture"; let opened: EpubBook[] = []; beforeEach(() => { vi.stubGlobal("Blob", NodeBlob); vi.stubGlobal("File", NodeFile); }); afterEach(async () => { for (const book of opened) await closeBook(book); opened = []; vi.unstubAllGlobals(); }); describe("EPUB path policy", () => { it("resolves package-relative references and rejects traversal", () => { expect( resolvePackageHref("EPUB/text/chapter.xhtml", "../images/cover.png#art"), ).toBe("EPUB/images/cover.png"); expect(() => resolvePackageHref("chapter.xhtml", "../outside")).toThrow( /escapes/u, ); expect(() => validateEntryPath("../escape")).toThrow(/traversal/u); }); }); describe("EPUB package inspection", () => { it("adapts bounded Markdown, HTML and plain text into explicit EPUB workspaces", async () => { expect(publicationAdapterForFile({ name: "notes.md", type: "" })).toBe( "markdown", ); const markdown = await openPublication( new File(["# Adapter title\n\nA **local** paragraph."], "notes.md", { type: "text/markdown", }), ); opened.push(markdown); expect(markdown.adapter).toMatchObject({ kind: "markdown", sourceFilename: "notes.md", }); expect(markdown.package.metadata.title).toBe("Adapter title"); expect(await readEntryText(markdown, "EPUB/content.xhtml")).toContain( "local", ); expect(markdown.issues).toContainEqual( expect.objectContaining({ code: "adapted-publication" }), ); const html = await openPublication( new File( [ "Safe source

Heading

external", ], "source.html", { type: "text/html" }, ), ); opened.push(html); const adaptedHtml = await readEntryText(html, "EPUB/content.xhtml"); expect(adaptedHtml).toContain("

Heading

"); expect(adaptedHtml).not.toMatch(/script|onclick|example\.invalid/iu); const text = await openPublication( new File(["first\nsecond"], "plain.txt", { type: "text/plain" }), ); opened.push(text); expect(await readEntryText(text, "EPUB/content.xhtml")).toContain( "
first\nsecond
", ); }); it("opens metadata, navigation, spine, and bounded inventory", async () => { const book = await openEpub(await createEpubFixture()); opened.push(book); expect(book.package.metadata).toMatchObject({ title: "Fixture Book", language: "en", identifier: "urn:test:book", }); expect(book.package.navigation[0]).toMatchObject({ label: "Opening chapter", path: "EPUB/chapter.xhtml", }); expect(book.package.spine[0]?.item?.path).toBe("EPUB/chapter.xhtml"); expect(book.summary).toMatchObject({ mimetypeFirst: true, mimetypeStored: true, encryptedResources: false, }); expect(book.issues.filter((item) => item.severity === "error")).toEqual([]); }); it("reports monotonic, named opening phases", async () => { const updates: Array<{ phase: string; progress: number }> = []; const book = await openEpub(await createEpubFixture(), (progress) => updates.push(progress), ); opened.push(book); expect(updates.map((update) => update.phase)).toEqual( expect.arrayContaining([ "archive", "entries", "container", "package", "navigation", "validation", "ready", ]), ); expect(updates.at(-1)).toMatchObject({ phase: "ready", progress: 0.96 }); for (const [index, update] of updates.entries()) { expect(update.progress).toBeGreaterThanOrEqual(0); expect(update.progress).toBeLessThanOrEqual(1); if (index > 0) expect(update.progress).toBeGreaterThanOrEqual( updates[index - 1]?.progress ?? 0, ); } }); it("reports active content and broken resources", async () => { const book = await openEpub( await createEpubFixture({ activeContent: true, brokenLink: true }), ); opened.push(book); expect(book.issues.map((item) => item.code)).toEqual( expect.arrayContaining(["active-content", "broken-link"]), ); }); it("parses direction, page progression, and fixed-layout metadata", async () => { const book = await openEpub( await createEpubFixture({ direction: "rtl", fixedLayout: true }), ); opened.push(book); expect(book.package).toMatchObject({ direction: "rtl", pageProgressionDirection: "rtl", rendition: { layout: "pre-paginated", orientation: "portrait", spread: "none", }, }); }); }); describe("safe rendering and editing", () => { it("removes active content, disables links, and resolves local images", async () => { vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn(() => "blob:fixture-cover"), revokeObjectURL: vi.fn(), }); const book = await openEpub( await createEpubFixture({ activeContent: true }), ); opened.push(book); const chapter = await renderChapter(book, "EPUB/chapter.xhtml"); expect(chapter.html).not.toMatch(/ { const createObjectURL = vi.fn(() => "blob:fixture-cover"); vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL: vi.fn(), }); const book = await openEpub( await createEpubFixture({ svgTitlePage: true, repeatedImages: 100 }), ); opened.push(book); const chapter = await renderChapter(book, "EPUB/title.svg"); const document = new DOMParser().parseFromString(chapter.html, "text/html"); const image = document.querySelector("svg image"); const anchor = document.querySelector("svg a"); expect(image?.getAttributeNS("http://www.w3.org/1999/xlink", "href")).toBe( "blob:fixture-cover", ); expect( anchor?.getAttributeNS("http://www.w3.org/1999/xlink", "href"), ).toBeNull(); expect(anchor?.getAttribute("href")).toBe("#"); expect(anchor?.getAttribute("data-epub-href")).toBe( "https://example.invalid/tracker", ); revokeRenderedChapter(chapter); const repeated = await renderChapter(book, "EPUB/chapter.xhtml"); expect(repeated.objectUrls).toHaveLength(1); expect(createObjectURL).toHaveBeenCalledTimes(2); revokeRenderedChapter(repeated); }); it("reports missing SVG XLink resources and removes them from rendering", async () => { vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn(), revokeObjectURL: vi.fn(), }); const book = await openEpub( await createEpubFixture({ svgTitlePage: true, svgImageHref: "missing-cover.jpeg", }), ); opened.push(book); expect(book.issues).toContainEqual( expect.objectContaining({ code: "broken-link", path: "EPUB/title.svg", }), ); const chapter = await renderChapter(book, "EPUB/title.svg"); const document = new DOMParser().parseFromString(chapter.html, "text/html"); const image = document.querySelector("svg image"); expect(image?.getAttribute("href")).toBeNull(); expect( image?.getAttributeNS("http://www.w3.org/1999/xlink", "href"), ).toBeNull(); }); it("applies an explicit reader theme independently of the operating system", () => { const source = "Text"; expect(applyReaderTheme(source, "dark")).toContain( '', ); expect(applyReaderTheme(source, "light")).toContain( '', ); }); it("loads bounded packaged CSS/fonts, blocks network URLs, and records internal links", async () => { let url = 0; vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn(() => `blob:fixture-${++url}`), revokeObjectURL: vi.fn(), }); const book = await openEpub( await createEpubFixture({ packagedStyles: true, secondChapter: true, direction: "rtl", fixedLayout: true, }), ); opened.push(book); const chapter = await renderChapter(book, "EPUB/chapter.xhtml"); expect(chapter).toMatchObject({ direction: "rtl", layout: "pre-paginated", }); expect(chapter.html).toContain("data-epub-publisher-stylesheet"); expect(chapter.html).toMatch(/font-family:\s*Fixture/iu); expect(chapter.html).toContain("blob:fixture-"); expect(chapter.html).not.toContain("example.invalid/tracker.png"); expect(chapter.links).toContainEqual( expect.objectContaining({ path: "EPUB/chapter-two.xhtml", fragment: "answer", external: false, }), ); }); it("decodes standardized IDPF-obfuscated fonts without treating them as DRM", async () => { const book = await openEpub( await createEpubFixture({ obfuscatedFont: true }), ); opened.push(book); expect(book.summary).toMatchObject({ encryptedResources: false, obfuscatedFontCount: 1, }); expect(book.issues).toContainEqual( expect.objectContaining({ code: "font-obfuscation-supported" }), ); const font = await readPublicationResourceBytes( book, "EPUB/fonts/book.woff2", 1024, ); expect(new TextDecoder().decode(font.slice(0, 4))).toBe("wOF2"); }); it("searches bounded reading-order text and exports safe derived formats", async () => { const book = await openEpub( await createEpubFixture({ secondChapter: true, activeContent: true }), ); opened.push(book); const progress: number[] = []; const results = await searchPublication( book, "searchable phrase", (value) => progress.push(value.current), ); expect(results).toHaveLength(2); expect(results[0]).toMatchObject({ path: "EPUB/chapter-two.xhtml", occurrences: 2, }); expect(progress.at(-1)).toBe(2); const markdown = await (await exportMarkdown(book)).text(); const html = await (await exportReadingOrderHtml(book)).text(); expect(markdown).toContain("# Second chapter"); expect(html).toContain("Content-Security-Policy"); expect(html).not.toContain(" { const notes: EpubReadingNotes = { schemaVersion: 1, publication: { identifier: "urn:test:book", title: "Fixture Book" }, bookmarks: [ { id: "bookmark-1", path: "EPUB/chapter.xhtml", label: "Opening" }, ], annotations: [ { id: "note-1", path: "EPUB/chapter-two.xhtml", fragment: "answer", label: "Answer", note: "Review", quote: "searchable phrase", }, ], }; const output = exportReadingNotes(notes); expect(importReadingNotes(output, notes.publication)).toEqual(notes); expect(() => importReadingNotes(output, { identifier: "urn:other", title: "Other", }), ).toThrow(/different publication/u); }); it("patches metadata and rebuilds a readable normalized EPUB", async () => { const book = await openEpub(await createEpubFixture()); opened.push(book); const metadata = { ...book.package.metadata, title: "Updated title", creators: ["One", "Two"], subjects: ["Testing"], }; const xml = patchPackageMetadata(book.packageXml, metadata); expect(xml).toContain("Updated title"); expect(xml.match(/ { const book = await openEpub( await createEpubFixture({ obfuscatedFont: true }), ); opened.push(book); const metadata = { ...book.package.metadata, identifier: "urn:test:updated-book", creators: [...book.package.metadata.creators], subjects: [...book.package.metadata.subjects], }; const rebuilt = await rebuildEpub(book, { metadata }); expect(rebuilt.changes).toContain( "Re-keyed 1 standardized obfuscated font(s)", ); const reopened = await openEpub( new File([rebuilt.blob], "re-keyed.epub", { type: "application/epub+zip", }), ); opened.push(reopened); expect(reopened.package.uniqueIdentifierValue).toBe( "urn:test:updated-book", ); expect(reopened.summary).toMatchObject({ encryptedResources: false, obfuscatedFontCount: 1, }); const font = await readPublicationResourceBytes( reopened, "EPUB/fonts/book.woff2", 1024, ); expect(new TextDecoder().decode(font.slice(0, 4))).toBe("wOF2"); }); });