Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/books/src/logic/epub-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,38 @@ describe("htmlToBlocks", () => {
expect(blocks.map((b) => b.text)).toEqual(["Title", "Body"]);
});

// FB2\u2192EPUB converters (most Russian-language EPUBs, and plenty of
// others) mark chapter titles as a classed div rather than an <h*>. The
// walker used to descend past a div holding only text and emit nothing,
// so every chapter title in such a book was silently deleted.
it("reads a title-classed container as a heading", () => {
const blocks = htmlToBlocks('<div class="title2"> Chapter One </div><p>Body</p>');
expect(blocks).toEqual([
{ kind: BlockKind.Heading, text: "Chapter One" },
{ kind: BlockKind.Paragraph, text: "Body" },
]);
});

it("reads a title-classed paragraph as a heading", () => {
const blocks = htmlToBlocks('<p class="chapter-title">Annotation</p>');
expect(blocks).toEqual([{ kind: BlockKind.Heading, text: "Annotation" }]);
});

it("keeps a container's own loose text instead of dropping it", () => {
const blocks = htmlToBlocks("<div>Loose text<p>Nested</p>More loose</div>");
expect(blocks.map((b) => b.text)).toEqual(["Loose text", "Nested", "More loose"]);
});

it("treats inline markup as part of the surrounding text run", () => {
const blocks = htmlToBlocks("<div>Hello <em>brave</em> <a href='#x'>world</a></div>");
expect(blocks).toEqual([{ kind: BlockKind.Paragraph, text: "Hello brave world" }]);
});

it("does not double-count text claimed by a nested block", () => {
const blocks = htmlToBlocks("<div><p>Only once</p></div>");
expect(blocks.map((b) => b.text)).toEqual(["Only once"]);
});

it("skips script/style and empty blocks, treats li as paragraphs", () => {
const blocks = htmlToBlocks(
"<style>.x{}</style><p></p><ul><li>One</li><li>Two</li></ul><script>x()</script>",
Expand Down Expand Up @@ -52,6 +84,31 @@ describe("bookContentFrom", () => {
expect(content.spine[0]?.title).toBe("Chapter 1");
});

// The positional fallback used to count SOURCE sections, so a book whose
// cover/nav pages dropped out started at "Chapter 2" and skipped numbers.
it("numbers the fallback by kept position, not source index", () => {
const content = bookContentFrom({ title: "T", author: "" }, [
{ title: "", html: "<img src='cover.png'/>" },
{ title: "", html: "<p>One</p>" },
{ title: "", html: "<p>Two</p>" },
]);
expect(content.spine.map((item) => item.title)).toEqual(["Chapter 1", "Chapter 2"]);
});

it("falls back to the section's own first heading before a positional name", () => {
const content = bookContentFrom({ title: "T", author: "" }, [
{ title: "", html: '<div class="title2">\u0413\u041b\u0410\u0412\u0410</div><p>Body</p>' },
]);
expect(content.spine[0]?.title).toBe("\u0413\u041b\u0410\u0412\u0410");
});

it("prefers the navigation label over the section's heading", () => {
const content = bookContentFrom({ title: "T", author: "" }, [
{ title: "From the NCX", html: "<h1>In the body</h1><p>Body</p>" },
]);
expect(content.spine[0]?.title).toBe("From the NCX");
});

it("keeps one spine item even when every section is empty", () => {
const content = bookContentFrom({ title: "Empty", author: "" }, [{ title: "", html: "<img/>" }]);
expect(content.spine).toHaveLength(1);
Expand Down
122 changes: 105 additions & 17 deletions apps/books/src/logic/epub-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,78 @@ const HEADING_TAGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
const BLOCK_TAGS = new Set(["P", "LI", "BLOCKQUOTE", "DD", "DT", "FIGCAPTION"]);
const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "HEAD"]);

/** Tags whose text belongs to the surrounding block, never to a block of their
* own. Without this list the walker treated `<span>`/`<a>` as generic
* containers and descended past their text, dropping it on the floor. */
const INLINE_TAGS = new Set([
"A",
"ABBR",
"B",
"BDI",
"BDO",
"BIG",
"BR",
"CITE",
"CODE",
"DEL",
"EM",
"FONT",
"I",
"IMG",
"INS",
"KBD",
"MARK",
"NOBR",
"Q",
"RUBY",
"S",
"SAMP",
"SMALL",
"SPAN",
"STRIKE",
"STRONG",
"SUB",
"SUP",
"TIME",
"TT",
"U",
"VAR",
"WBR",
]);

/**
* Chapter titles are not always `<h1>`–`<h6>`. FB2→EPUB converters — the
* dominant source of Russian-language EPUBs, and common well beyond them —
* emit `<div class="title2">ГЛАВА ПЕРВАЯ</div>`, and Calibre-style pipelines
* emit `<p class="chapter-title">`. Matching the class as well as the tag is
* what keeps those books' headings instead of silently flattening every
* chapter into unbroken prose.
*/
const TITLE_CLASS_RE = /(?:^|[\s_-])(?:title|subtitle|head(?:er|ing)?|chapter)\d*(?:[\s_-]|$)/i;

/** Collapse runs of whitespace (incl. newlines from the source markup) to single
* spaces and trim — the reflow renderer owns visual wrapping. */
function normalizeText(raw: string): string {
return raw.replace(/\s+/g, " ").trim();
}

/** Heading or paragraph, decided by tag first and then by the title-ish class
* the converter left behind. */
function blockKindFor(node: Element): BlockKind {
if (HEADING_TAGS.has(node.tagName.toUpperCase())) return BlockKind.Heading;
return TITLE_CLASS_RE.test(node.getAttribute("class") ?? "")
? BlockKind.Heading
: BlockKind.Paragraph;
}

/**
* Extract reflowable blocks from a section's XHTML. Headings (`h1`–`h6`) become
* {@link BlockKind.Heading}; paragraph-like blocks (`p`, `li`, `blockquote`, …)
* become {@link BlockKind.Paragraph}. The walk descends through generic
* containers (`div`/`section`/`article`) but never re-emits text already claimed
* by a nested block, so content isn't duplicated. Empty blocks drop.
* Extract reflowable blocks from a section's XHTML. Headings (`h1`–`h6`, or a
* title-classed element) become {@link BlockKind.Heading}; paragraph-like
* blocks (`p`, `li`, `blockquote`, …) become {@link BlockKind.Paragraph}. The
* walk descends through generic containers (`div`/`section`/`article`) but
* never re-emits text already claimed by a nested block, so content isn't
* duplicated — and a container's own loose text is emitted in document order
* rather than dropped. Empty blocks drop.
*/
export function htmlToBlocks(html: string): ContentBlock[] {
const doc = new DOMParser().parseFromString(html, "text/html");
Expand All @@ -43,18 +103,35 @@ export function htmlToBlocks(html: string): ContentBlock[] {
const visit = (node: Element): void => {
const tag = node.tagName.toUpperCase();
if (SKIP_TAGS.has(tag)) return;
if (HEADING_TAGS.has(tag)) {
if (HEADING_TAGS.has(tag) || BLOCK_TAGS.has(tag)) {
const text = normalizeText(node.textContent ?? "");
if (text) blocks.push({ kind: BlockKind.Heading, text });
if (text) blocks.push({ kind: blockKindFor(node), text });
return;
}
if (BLOCK_TAGS.has(tag)) {
const text = normalizeText(node.textContent ?? "");
if (text) blocks.push({ kind: BlockKind.Paragraph, text });
return;
// Generic container. Its own text runs (bare text nodes + inline
// elements) are real content and become blocks in document order;
// nested block-level children are descended into.
let pending = "";
const flush = (): void => {
const text = normalizeText(pending);
pending = "";
if (text) blocks.push({ kind: blockKindFor(node), text });
};
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
pending += child.nodeValue ?? "";
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const el = child as Element;
if (INLINE_TAGS.has(el.tagName.toUpperCase())) {
pending += el.textContent ?? "";
continue;
}
flush();
visit(el);
}
// Generic container — descend so nested headings/paragraphs are found.
for (const child of Array.from(node.children)) visit(child);
flush();
};

const root = doc.body ?? doc.documentElement;
Expand All @@ -69,6 +146,17 @@ export function htmlToBlocks(html: string): ContentBlock[] {
return blocks;
}

/** The chapter title for a section: the EPUB's own navigation label when the
* parser resolved one, else the section's first heading block, else a
* positional fallback numbered by the section's place in the *kept* spine
* (cover/nav pages drop out before this, so the count has no holes). */
function titleFor(section: RawSection, blocks: readonly ContentBlock[], position: number): string {
if (section.title) return section.title;
const heading = blocks.find((b) => b.kind === BlockKind.Heading);
if (heading) return heading.text;
return `Chapter ${position}`;
}

/** Build `BookContent` from EPUB metadata + the parsed spine sections. Sections
* with no extractable text are dropped (cover/nav pages), but at least one
* empty spine item is kept so the reader never mounts an empty book. */
Expand All @@ -77,11 +165,11 @@ export function bookContentFrom(
sections: readonly RawSection[],
): BookContent {
const spine: SpineItem[] = [];
sections.forEach((section, index) => {
for (const section of sections) {
const blocks = htmlToBlocks(section.html);
if (blocks.length === 0) return;
spine.push({ title: section.title || `Chapter ${index + 1}`, blocks });
});
if (blocks.length === 0) continue;
spine.push({ title: titleFor(section, blocks, spine.length + 1), blocks });
}
if (spine.length === 0) spine.push({ title: meta.title || "Untitled", blocks: [] });
return {
title: meta.title || "Untitled",
Expand Down
61 changes: 61 additions & 0 deletions apps/books/src/logic/epub-nav.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** EPUB navigation matching — nav labels are the book's own chapter titles,
* and producers disagree about how they spell an href. */
import { describe, expect, it } from "vitest";
import { navigationTitles, normalizeHref, titleForHref } from "./epub-nav";

describe("normalizeHref", () => {
it("strips fragments, leading ./ or /, and percent-encoding", () => {
expect(normalizeHref("./content_1.html#ch1")).toBe("content_1.html");
expect(normalizeHref("/OEBPS/content_1.html")).toBe("OEBPS/content_1.html");
expect(normalizeHref("text/%D0%B3%D0%BB%D0%B0%D0%B2%D0%B0.html")).toBe("text/глава.html");
});

it("survives a malformed percent-escape rather than throwing", () => {
expect(normalizeHref("a%zz.html")).toBe("a%zz.html");
});
});

describe("navigationTitles", () => {
it("indexes an entry under both its path and its bare filename", () => {
const titles = navigationTitles([{ href: "text/ch1.html", label: "One" }]);
expect(titleForHref(titles, "text/ch1.html")).toBe("One");
expect(titleForHref(titles, "ch1.html")).toBe("One");
});

it("collapses whitespace in the label", () => {
const titles = navigationTitles([{ href: "a.html", label: " ГЛАВА ПЕРВАЯ " }]);
expect(titleForHref(titles, "a.html")).toBe("ГЛАВА ПЕРВАЯ");
});

it("matches a spine href against a nav entry that carries a fragment", () => {
const titles = navigationTitles([{ href: "ch1.html#start", label: "One" }]);
expect(titleForHref(titles, "ch1.html")).toBe("One");
});

it("walks nested subitems", () => {
const titles = navigationTitles([
{ href: "part1.html", label: "Part One", subitems: [{ href: "ch2.html", label: "Two" }] },
]);
expect(titleForHref(titles, "ch2.html")).toBe("Two");
});

// Several nav entries commonly point into one file with different
// fragments; the chapter's own top-level label must win over a sub-heading.
it("lets the first entry for a file win", () => {
const titles = navigationTitles([
{ href: "ch1.html", label: "Chapter" },
{ href: "ch1.html#s2", label: "A subsection" },
]);
expect(titleForHref(titles, "ch1.html")).toBe("Chapter");
});

it("ignores entries with no label or no href", () => {
const titles = navigationTitles([{ href: "a.html" }, { label: "orphan" }]);
expect(titleForHref(titles, "a.html")).toBe("");
});

it("returns empty for an uncovered section (a cover page, or no nav doc)", () => {
expect(titleForHref(navigationTitles([]), "cover.html")).toBe("");
expect(titleForHref(navigationTitles([]), undefined)).toBe("");
});
});
74 changes: 74 additions & 0 deletions apps/books/src/logic/epub-nav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* EPUB navigation (NCX / nav-doc) → per-section chapter titles. Split out of
* the epub.js glue so the href matching — the part that actually varies between
* producers — is pure and unit-testable without an archive.
*
* The matching is deliberately forgiving: a nav entry's `href` and a spine
* item's `href` are both relative to the OPF, but producers disagree about
* `./` prefixes, percent-encoding, and fragments (`content_1.html#ch1`), so
* entries are indexed under both their normalized path and their bare
* filename.
*/

/** One entry of an epub.js `navigation.toc` (recursive). */
export type NavEntry = {
href?: string;
label?: string;
subitems?: readonly NavEntry[];
};

function decode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}

/** Strip the fragment and any leading `./` or `/`, then percent-decode. */
export function normalizeHref(href: string): string {
const path = href.split("#")[0] ?? "";
return decode(path.replace(/^\.?\//, ""));
}

/** The bare filename of an href — the last-resort key when two producers
* disagree about how deep the path is. */
function basename(path: string): string {
return path.split("/").pop() ?? path;
}

/**
* Flatten a nav tree into a lookup of href → label. Each entry is registered
* under its normalized path AND its bare filename; the first entry to claim a
* key wins, so a chapter's own top-level label beats a deep sub-heading that
* points into the same file.
*/
export function navigationTitles(toc: readonly NavEntry[]): ReadonlyMap<string, string> {
const titles = new Map<string, string>();
const walk = (entries: readonly NavEntry[]): void => {
for (const entry of entries) {
const label = (entry.label ?? "").replace(/\s+/g, " ").trim();
const href = entry.href ?? "";
if (label && href) {
const path = normalizeHref(href);
if (path && !titles.has(path)) titles.set(path, label);
const file = basename(path);
if (file && !titles.has(file)) titles.set(file, label);
}
if (entry.subitems?.length) walk(entry.subitems);
}
};
walk(toc);
return titles;
}

/** The navigation label for a spine item's href, or `""` when the book's
* navigation doesn't cover it (a cover page, or no nav document at all). */
export function titleForHref(
titles: ReadonlyMap<string, string>,
href: string | undefined,
): string {
if (!href) return "";
const path = normalizeHref(href);
return titles.get(path) ?? titles.get(basename(path)) ?? "";
}
Loading
Loading