Files
zemion efd80eb18a
Verify / verify (push) Canceled after 0s
Release EPUB Tools 0.2.0
2026-09-02 08:53:49 +02:00

443 lines
14 KiB
TypeScript

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(
"<strong>local</strong>",
);
expect(markdown.issues).toContainEqual(
expect.objectContaining({ code: "adapted-publication" }),
);
const html = await openPublication(
new File(
[
"<!doctype html><title>Safe source</title><style>body{color:red}</style><h1 onclick='bad()'>Heading</h1><script>bad()</script><a href='https://example.invalid'>external</a>",
],
"source.html",
{ type: "text/html" },
),
);
opened.push(html);
const adaptedHtml = await readEntryText(html, "EPUB/content.xhtml");
expect(adaptedHtml).toContain("<h1>Heading</h1>");
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(
"<pre>first\nsecond</pre>",
);
});
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(/<script|<form|<input/iu);
expect(chapter.html).toContain('src="blob:fixture-cover"');
expect(chapter.html).toContain('data-epub-href="nav.xhtml"');
expect(chapter.html).toContain("default-src 'none'");
revokeRenderedChapter(chapter);
});
it("resolves SVG XLink images, disables XLink navigation, and caches repeats", async () => {
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 = "<!doctype html><html><head></head><body>Text</body></html>";
expect(applyReaderTheme(source, "dark")).toContain(
'<html data-epub-reader-theme="dark">',
);
expect(applyReaderTheme(source, "light")).toContain(
'<html data-epub-reader-theme="light">',
);
});
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("<script");
expect(html).not.toContain('http-equiv="refresh"');
expect(html).not.toContain("<template");
expect(html).not.toContain("example.invalid");
});
it("round-trips bounded publication-scoped bookmarks and annotations", () => {
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(/<dc:creator/gu)).toHaveLength(2);
const rebuilt = await rebuildEpub(book, {
metadata,
normalizeTimestamps: true,
});
const reopened = await openEpub(
new File([rebuilt.blob], "rebuilt.epub", {
type: "application/epub+zip",
}),
);
opened.push(reopened);
expect(reopened.package.metadata.title).toBe("Updated title");
expect(reopened.package.metadata.creators).toEqual(["One", "Two"]);
expect(reopened.summary).toMatchObject({
mimetypeFirst: true,
mimetypeStored: true,
});
});
it("re-keys standardized obfuscated fonts when the identifier changes", async () => {
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");
});
});