refactor(nav): refactor book nav service with TOC enrichment (#3874)

This commit is contained in:
Huang Xin 2026-04-15 23:08:17 +08:00 committed by GitHub
parent b0cc5461af
commit 3e292af990
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 905 additions and 547 deletions

View file

@ -19,6 +19,7 @@ crate-type = ["staticlib", "cdylib", "lib"]
cargo-clippy = []
# Enable WebDriver plugin for E2E testing (use with `tauri build --debug --features webdriver`)
webdriver = ["tauri-plugin-webdriver"]
devtools = ["tauri/devtools"]
[build-dependencies]
tauri-build = { version = "2", features = [] }

View file

@ -77,7 +77,7 @@ vi.mock('@/utils/misc', () => ({
}));
// Transitive imports needed by readerStore's module
vi.mock('@/utils/toc', () => ({ updateToc: vi.fn() }));
vi.mock('@/services/nav', () => ({ updateToc: vi.fn() }));
vi.mock('@/utils/book', () => ({
formatTitle: vi.fn((t: string) => t),
getMetadataHash: vi.fn(() => 'hash'),

View file

@ -40,7 +40,7 @@ vi.mock('@/utils/misc', () => ({
}));
// These are transitive imports needed by readerStore
vi.mock('@/utils/toc', () => ({ updateToc: vi.fn() }));
vi.mock('@/services/nav', () => ({ updateToc: vi.fn() }));
vi.mock('@/utils/book', () => ({
formatTitle: vi.fn((t: string) => t),
getMetadataHash: vi.fn(() => 'hash'),

View file

@ -11,7 +11,7 @@ import {
BOOK_NAV_VERSION,
type BookNav,
type SectionFragment,
} from '@/utils/toc';
} from '@/services/nav';
// Polyfill CSS.escape for jsdom
if (typeof globalThis['CSS'] === 'undefined') {

View file

@ -3,7 +3,7 @@ import { readFileSync } from 'fs';
import { resolve } from 'path';
import { DocumentLoader, CFI } from '@/libs/document';
import type { BookDoc, TOCItem } from '@/libs/document';
import { updateToc, findTocItemBS } from '@/utils/toc';
import { computeBookNav, hydrateBookNav, updateToc, findTocItemBS } from '@/services/nav';
// Simulates an annotation deep inside a section, past the chapter heading.
// Section CFIs look like `epubcfi(/6/2)` and TOC item CFIs point at the heading
@ -54,6 +54,11 @@ describe('TOC-to-CFI mapping with fragment hrefs (#3688)', () => {
const loader = new DocumentLoader(file);
const result = await loader.open();
book = result.book;
// Mirror the production open flow in readerStore: build (or hydrate) the
// BookNav — which bakes section/fragment locations and TOC cfi+location —
// and then apply per-open settings via updateToc.
const nav = await computeBookNav(book);
hydrateBookNav(book, nav);
await updateToc(book, false, 'none');
}, 30000);

View file

@ -0,0 +1,167 @@
import { describe, it, expect, vi } from 'vitest';
import type { BookDoc, SectionItem, TOCItem } from '@/libs/document';
import { computeBookNav } from '@/services/nav';
// Polyfill CSS.escape for jsdom — matches book-nav-cache.test.ts.
if (typeof globalThis['CSS'] === 'undefined') {
(globalThis as Record<string, unknown>)['CSS'] = {
escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'),
};
}
// Mirrors foliate-js: section ids are path-resolved manifest item hrefs like
// "OEBPS/text00001.html"; the TOC href uses the same form, optionally suffixed
// with a fragment.
const splitTOCHref = (href: string): Array<string | number> => {
if (!href) return [''];
const hashIdx = href.indexOf('#');
if (hashIdx < 0) return [href];
return [href.slice(0, hashIdx), href.slice(hashIdx + 1)];
};
interface MakeBookOpts {
sectionCount?: number;
tocHrefs?: string[];
navSectionId?: string; // section whose loadText returns <nav>...</nav> HTML
navHtml?: string;
}
const makeBook = (opts: MakeBookOpts): BookDoc => {
const sectionCount = opts.sectionCount ?? 40;
const sections: SectionItem[] = [];
for (let i = 1; i <= sectionCount; i++) {
const id = `OEBPS/text${String(i).padStart(5, '0')}.html`;
const isNavSection = id === opts.navSectionId;
sections.push({
id,
cfi: `epubcfi(/6/${i * 2}[s${i}]!)`,
size: 1000,
linear: 'yes',
href: id,
loadText: isNavSection && opts.navHtml ? async () => opts.navHtml! : async () => '',
createDocument: async () => {
const html = isNavSection && opts.navHtml ? opts.navHtml : '<html><body/></html>';
return new DOMParser().parseFromString(html, 'application/xhtml+xml');
},
});
}
const tocHrefs = opts.tocHrefs ?? [];
const toc: TOCItem[] = tocHrefs.map((href, i) => ({
id: i,
label: `Volume ${i + 1}`,
href,
index: 0,
}));
return {
metadata: { title: 'test', author: '', language: 'en' },
rendition: { layout: 'reflowable' },
dir: 'ltr',
toc,
sections,
splitTOCHref,
getCover: async () => null,
};
};
const NAV_WITH_SIX_CHAPTERS = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<body>
<nav id="toc">
<h1></h1>
<ol>
<li><a href="text00003.html">1</a></li>
<li><a href="text00004.html">2</a></li>
<li><a href="text00005.html">3</a></li>
<li><a href="text00006.html">4</a></li>
<li><a href="text00007.html">5</a></li>
<li><a href="text00008.html">6</a></li>
</ol>
</nav>
</body>
</html>`;
// Thresholds: section count > 64 AND section count > 8 × flatTocCount.
const LARGE_SECTION_COUNT = 100;
const SMALL_SECTION_COUNT = 40;
describe('computeBookNav nav-enrichment fallback', () => {
it('adds embedded <nav> items when sections >> flat TOC', async () => {
const book = makeBook({
sectionCount: LARGE_SECTION_COUNT,
tocHrefs: ['OEBPS/text00001.html'], // sparse: 1 TOC entry, 100 sections
navSectionId: 'OEBPS/text00002.html',
navHtml: NAV_WITH_SIX_CHAPTERS,
});
const originalTocLen = (book.toc ?? []).length;
const nav = await computeBookNav(book);
// 6 new items merged in; each label and href from the embedded <nav>
expect(nav.toc.length).toBe(originalTocLen + 6);
const newItems = nav.toc.slice(originalTocLen);
expect(newItems.map((i) => i.label)).toEqual([
'第1回',
'第2回',
'第3回',
'第4回',
'第5回',
'第6回',
]);
expect(newItems[0]!.href).toBe('OEBPS/text00003.html');
expect(newItems[5]!.href).toBe('OEBPS/text00008.html');
});
it('skips enrichment when sections are below the min-sections threshold', async () => {
const book = makeBook({
sectionCount: SMALL_SECTION_COUNT, // below min-sections threshold
tocHrefs: ['OEBPS/text00001.html'],
navSectionId: 'OEBPS/text00002.html',
navHtml: NAV_WITH_SIX_CHAPTERS,
});
const loadTextSpies = (book.sections ?? []).map((s) =>
typeof s.loadText === 'function' ? vi.spyOn(s, 'loadText') : null,
);
const nav = await computeBookNav(book);
expect(nav.toc.length).toBe(1); // no enrichment
for (const spy of loadTextSpies) {
if (spy) expect(spy).not.toHaveBeenCalled();
}
});
it('skips enrichment when the TOC is not sparse enough vs sections', async () => {
// 100 sections with 30 existing TOC items — ratio too low to trigger fallback.
const tocHrefs: string[] = [];
for (let i = 1; i <= 30; i++) tocHrefs.push(`OEBPS/text${String(i).padStart(5, '0')}.html`);
const book = makeBook({
sectionCount: LARGE_SECTION_COUNT,
tocHrefs,
navSectionId: 'OEBPS/text00002.html',
navHtml: NAV_WITH_SIX_CHAPTERS,
});
const nav = await computeBookNav(book);
expect(nav.toc.length).toBe(30); // unchanged
});
it('does not duplicate items already in the existing TOC', async () => {
// TOC already has text00003.html — the nav's first item should be dropped.
const book = makeBook({
sectionCount: LARGE_SECTION_COUNT,
tocHrefs: ['OEBPS/text00001.html', 'OEBPS/text00003.html'],
navSectionId: 'OEBPS/text00002.html',
navHtml: NAV_WITH_SIX_CHAPTERS,
});
const originalTocLen = (book.toc ?? []).length;
const nav = await computeBookNav(book);
expect(nav.toc.length).toBe(originalTocLen + 5); // 6 nav items minus the 1 dup
const hrefs = nav.toc.map((i) => i.href);
expect(hrefs.filter((h) => h === 'OEBPS/text00003.html').length).toBe(1);
});
});

View file

@ -24,7 +24,7 @@ import { useTextSelector } from '../../hooks/useTextSelector';
import { Point, Position, TextSelection } from '@/utils/sel';
import { getPopupPosition, getPosition, getTextFromRange } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/utils/toc';
import { findTocItemBS } from '@/services/nav';
import { throttle } from '@/utils/throttle';
import { runSimpleCC } from '@/utils/simplecc';
import { getWordCount } from '@/utils/word';

View file

@ -195,7 +195,10 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
}
if (pageStr || timestampStr) {
lines.push('');
const infoStr = pageStr ? `${pageStr} · ${timestampStr}`.trim() : timestampStr;
const infoStr =
pageStr && timestampStr
? `${pageStr} · ${timestampStr}`.trim()
: pageStr || timestampStr;
lines.push(`*${infoStr}*`);
}

View file

@ -17,7 +17,7 @@ import { eventDispatcher } from '@/utils/event';
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { navigateToLogin } from '@/utils/nav';
import { saveSysSettings } from '@/helpers/settings';
import { saveSysSettings, saveViewSettings } from '@/helpers/settings';
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
import { setReadwiseSettingsWindowVisible } from '@/app/reader/components/ReadwiseSettings';
import { setHardcoverSettingsWindowVisible } from '@/app/reader/components/HardcoverSettings';
@ -39,7 +39,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
const { user } = useAuth();
const { settings } = useSettingsStore();
const { getConfig, setConfig, saveConfig } = useBookDataStore();
const { bookKeys, recreateViewer, getViewSettings, setViewSettings } = useReaderStore();
const { bookKeys, recreateViewer, getViewSettings } = useReaderStore();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
const { sideBarBookKey } = useSidebarStore();
@ -75,10 +75,11 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
setIsSortedTOC((prev) => !prev);
setIsDropdownOpen?.(false);
if (sideBarBookKey) {
const viewSettings = getViewSettings(sideBarBookKey)!;
viewSettings.sortedTOC = !isSortedTOC;
setViewSettings(sideBarBookKey, viewSettings);
recreateViewer(envConfig, sideBarBookKey);
saveViewSettings(envConfig, sideBarBookKey, 'sortedTOC', !isSortedTOC, true, false).then(
() => {
recreateViewer(envConfig, sideBarBookKey);
},
);
}
};
const handleSetParallel = () => {

View file

@ -3,7 +3,7 @@ import * as CFI from 'foliate-js/epubcfi.js';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { findTocItemBS } from '@/utils/toc';
import { findTocItemBS } from '@/services/nav';
import { findNearestCfi } from '@/utils/cfi';
import { TOCItem } from '@/libs/document';
import { BooknoteGroup, BookNoteType } from '@/types/book';

View file

@ -6,7 +6,7 @@ import 'overlayscrollbars/overlayscrollbars.css';
import { TOCItem } from '@/libs/document';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { findParentPath } from '@/utils/toc';
import { findParentPath } from '@/services/nav';
import { eventDispatcher } from '@/utils/event';
import { useTextTranslation } from '../../hooks/useTextTranslation';
import { FlatTOCItem, StaticListRow } from './TOCItem';

View file

@ -4,7 +4,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { isCfiInLocation } from '@/utils/cfi';
import { findTocItemBS } from '@/utils/toc';
import { findTocItemBS } from '@/services/nav';
import { BookNoteType } from '@/types/book';
import { TOCItem } from '@/libs/document';

View file

@ -14,7 +14,7 @@ import {
import { DatabaseOpts, DatabaseService } from '@/types/database';
import { SchemaType } from '@/services/database/migrate';
import { Book, BookConfig, BookContent, ImportBookOptions, ViewSettings } from '@/types/book';
import type { BookNav } from '@/utils/toc';
import type { BookNav } from '@/services/nav';
import { getLibraryFilename, getLibraryBackupFilename } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';

View file

@ -22,7 +22,7 @@ import {
getPrimaryLanguage,
getMetadataHash,
} from '@/utils/book';
import type { BookNav } from '@/utils/toc';
import type { BookNav } from '@/services/nav';
import { partialMD5, md5 } from '@/utils/md5';
import { getBaseFilename, getFilename } from '@/utils/path';
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';

View file

@ -0,0 +1,147 @@
// Embedded-nav fallback
//
// Some EPUBs ship a sparse toc.ncx (top-level volume/part entries only) while
// the real chapter-level TOC lives inside a content HTML as a <nav> block.
// foliate-js only parses <nav> from files flagged with properties="nav" in the
// OPF, so those embedded navs are invisible to bookDoc.toc. When the spine has
// far more sections than the TOC references, scan section HTMLs for <nav>
// elements and merge their links as ordinary top-level TOC items.
import { BookDoc, TOCItem } from '@/libs/document';
import { collectAllTocItems } from './grouping';
const NAV_ENRICH_MIN_SECTIONS = 64;
const NAV_ENRICH_TOC_RATIO = 8;
const EPUB_NS = 'http://www.idpf.org/2007/ops';
interface ExtractedNavItem {
label: string;
href: string;
subitems?: ExtractedNavItem[];
}
const resolveNavHref = (href: string, sectionId: string): string => {
if (!href) return '';
try {
const base = `epub:///${encodeURI(sectionId)}`;
const url = new URL(href, base);
if (url.protocol !== 'epub:') return '';
const path = decodeURIComponent(url.pathname.replace(/^\//, ''));
return path + url.hash;
} catch {
return '';
}
};
const firstChildByLocalName = (parent: Element, names: readonly string[]): Element | null => {
for (const child of Array.from(parent.children)) {
if (names.includes(child.localName)) return child;
}
return null;
};
const childrenByLocalName = (parent: Element, name: string): Element[] =>
Array.from(parent.children).filter((c) => c.localName === name);
const normalizeWhitespace = (s: string): string => s.replace(/\s+/g, ' ').trim();
const parseNavList = (list: Element, sectionId: string): ExtractedNavItem[] => {
const items: ExtractedNavItem[] = [];
for (const li of childrenByLocalName(list, 'li')) {
const anchor = firstChildByLocalName(li, ['a', 'span']);
const sublist = firstChildByLocalName(li, ['ol', 'ul']);
const rawHref = anchor?.getAttribute('href') ?? '';
const href = resolveNavHref(rawHref, sectionId);
const label =
normalizeWhitespace(anchor?.textContent ?? '') || (anchor?.getAttribute('title') ?? '');
const subitems = sublist ? parseNavList(sublist, sectionId) : undefined;
if (!href && !subitems?.length) continue;
items.push({
label,
href,
subitems: subitems?.length ? subitems : undefined,
});
}
return items;
};
const extractTocFromNav = (nav: Element, sectionId: string): ExtractedNavItem[] => {
const types = (nav.getAttributeNS(EPUB_NS, 'type') ?? '').split(/\s+/).filter(Boolean);
if (types.includes('page-list') || types.includes('landmarks')) return [];
const list = firstChildByLocalName(nav, ['ol', 'ul']);
return list ? parseNavList(list, sectionId) : [];
};
const toTocItems = (items: ExtractedNavItem[]): TOCItem[] =>
items.map((item) => ({
id: 0,
label: item.label,
href: item.href,
index: 0,
subitems: item.subitems?.length ? toTocItems(item.subitems) : undefined,
}));
const hrefSection = (href: string): string => {
const idx = href.indexOf('#');
return idx < 0 ? href : href.slice(0, idx);
};
export const enrichTocFromNavElements = async (
bookDoc: BookDoc,
tocClone: TOCItem[],
): Promise<boolean> => {
const sections = bookDoc.sections ?? [];
if (sections.length <= NAV_ENRICH_MIN_SECTIONS) return false;
const flatCount = collectAllTocItems(tocClone).length;
if (flatCount > 0 && sections.length <= NAV_ENRICH_TOC_RATIO * flatCount) return false;
const existingHrefs = new Set<string>();
for (const item of collectAllTocItems(tocClone)) {
if (!item.href) continue;
const [rawSection] = bookDoc.splitTOCHref(item.href) as [string | undefined];
existingHrefs.add(rawSection ?? hrefSection(item.href));
}
const sectionIdSet = new Set(sections.map((s) => s.id));
const collected: ExtractedNavItem[] = [];
const seenHref = new Set<string>();
for (const section of sections) {
if (!section.loadText) continue;
let content: string | null = null;
try {
content = await section.loadText();
} catch {
content = null;
}
if (!content || !content.includes('<nav')) continue;
let doc: Document;
try {
doc = await section.createDocument();
} catch {
continue;
}
const navs = Array.from(doc.getElementsByTagName('nav'));
if (navs.length === 0) continue;
for (const navEl of navs) {
const extracted = extractTocFromNav(navEl, section.id);
for (const item of extracted) {
const sectionPart = hrefSection(item.href);
if (!sectionPart || !sectionIdSet.has(sectionPart)) continue;
if (existingHrefs.has(sectionPart)) continue;
if (seenHref.has(item.href)) continue;
seenHref.add(item.href);
collected.push(item);
}
}
}
if (!collected.length) return false;
tocClone.push(...toTocItems(collected));
return true;
};

View file

@ -0,0 +1,78 @@
import { CFI, SectionFragment, SectionItem, TOCItem } from '@/libs/document';
const findFragmentPosition = (html: string, fragmentId: string | undefined): number => {
if (!fragmentId) return html.length;
const patterns = [
new RegExp(`\\sid=["']${CSS.escape(fragmentId)}["']`, 'i'),
new RegExp(`\\sname=["']${CSS.escape(fragmentId)}["']`, 'i'),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match && typeof match.index === 'number') return match.index;
}
return -1;
};
const calculateFragmentSize = (
content: string,
fragmentId: string | undefined,
prevFragmentId: string | undefined,
): number => {
const endPos = findFragmentPosition(content, fragmentId);
if (endPos < 0) return 0;
const startPos = prevFragmentId ? findFragmentPosition(content, prevFragmentId) : 0;
const validStartPos = Math.max(0, startPos);
if (endPos < validStartPos) return 0;
return new Blob([content.substring(validStartPos, endPos)]).size;
};
const getHTMLFragmentElement = (doc: Document, id: string | undefined): Element | null => {
if (!id) return null;
return doc.getElementById(id) ?? doc.querySelector(`[name="${CSS.escape(id)}"]`);
};
type CFIModule = {
joinIndir: (...xs: string[]) => string;
fromElements: (elements: Element[]) => string[];
};
const buildFragmentCfi = (section: SectionItem, element: Element | null): string => {
const cfiLib = CFI as unknown as CFIModule;
const rel = element ? (cfiLib.fromElements([element])[0] ?? '') : '';
return cfiLib.joinIndir(section.cfi, rel);
};
export const buildSectionFragments = (
section: SectionItem,
fragments: TOCItem[],
base: TOCItem | null,
content: string,
doc: Document,
splitHref: (href: string) => Array<string | number>,
): SectionFragment[] => {
const out: SectionFragment[] = [];
for (let i = 0; i < fragments.length; i++) {
const fragment = fragments[i]!;
const [, rawFragmentId] = splitHref(fragment.href) as [string | undefined, string | undefined];
const fragmentId = rawFragmentId;
const prev = i > 0 ? fragments[i - 1] : base;
const [, rawPrevFragmentId] = prev
? (splitHref(prev.href) as [string | undefined, string | undefined])
: [undefined, undefined];
const prevFragmentId = rawPrevFragmentId;
const element = getHTMLFragmentElement(doc, fragmentId);
const cfi = buildFragmentCfi(section, element);
const size = calculateFragmentSize(content, fragmentId, prevFragmentId);
out.push({
id: fragment.href,
href: fragment.href,
cfi,
size,
linear: section.linear,
});
}
return out;
};

View file

@ -0,0 +1,117 @@
import { BookDoc, SectionFragment, TOCItem } from '@/libs/document';
export const cloneTocItems = (items: TOCItem[]): TOCItem[] =>
items.map((item) => ({
...item,
subitems: item.subitems ? cloneTocItems(item.subitems) : undefined,
}));
export const cloneSectionFragments = (fragments: SectionFragment[]): SectionFragment[] =>
fragments.map((f) => ({
id: f.id,
href: f.href,
cfi: f.cfi,
size: f.size,
linear: f.linear,
fragments: f.fragments ? cloneSectionFragments(f.fragments) : undefined,
}));
export const collectAllTocItems = (items: TOCItem[]): TOCItem[] => {
const out: TOCItem[] = [];
const walk = (xs: TOCItem[]) => {
for (const x of xs) {
out.push(x);
if (x.subitems?.length) walk(x.subitems);
}
};
walk(items);
return out;
};
export interface SectionGroup {
base: TOCItem | null;
fragments: TOCItem[];
}
export const groupItemsBySection = (
bookDoc: BookDoc,
items: TOCItem[],
): Map<string, SectionGroup> => {
const groups = new Map<string, SectionGroup>();
for (const item of items) {
if (!item.href) continue;
const [sectionId, fragmentId] = bookDoc.splitTOCHref(item.href) as [
string | undefined,
string | undefined,
];
if (!sectionId) continue;
let group = groups.get(sectionId);
if (!group) {
group = { base: null, fragments: [] };
groups.set(sectionId, group);
}
const isBase = !fragmentId || item.href === sectionId;
if (isBase) group.base = item;
else group.fragments.push(item);
}
return groups;
};
// Ported from foliate-js: restructure TOC so that fragment-linked subitems under
// the same section are regrouped under a natural parent when one exists.
export const groupTocSubitems = (bookDoc: BookDoc, items: TOCItem[]): void => {
const splitHref = (href: string) => bookDoc.splitTOCHref(href);
const groupBySection = (subitems: TOCItem[]) => {
const grouped = new Map<string, TOCItem[]>();
for (const subitem of subitems) {
const [sectionId] = splitHref(subitem.href) as [string | undefined];
const key = sectionId ?? '';
const bucket = grouped.get(key) ?? [];
bucket.push(subitem);
grouped.set(key, bucket);
}
return grouped;
};
const separateParentAndFragments = (sectionId: string, subitems: TOCItem[]) => {
let parent: TOCItem | null = null;
const fragments: TOCItem[] = [];
for (const subitem of subitems) {
const [, fragmentId] = splitHref(subitem.href) as [string | undefined, string | undefined];
if (!fragmentId || subitem.href === sectionId) {
parent = subitem;
} else {
fragments.push(subitem);
}
}
return { parent, fragments };
};
for (const item of items) {
if (!item.subitems?.length) continue;
const groupedBySection = groupBySection(item.subitems);
if (groupedBySection.size <= 3) continue;
const newSubitems: TOCItem[] = [];
for (const [sectionId, subitems] of groupedBySection.entries()) {
if (item.href === sectionId) {
newSubitems.push(...subitems);
continue;
}
if (subitems.length === 1) {
newSubitems.push(subitems[0]!);
} else {
const { parent, fragments } = separateParentAndFragments(sectionId, subitems);
if (parent) {
parent.subitems = fragments.length > 0 ? fragments : parent.subitems;
newSubitems.push(parent);
} else {
newSubitems.push(...subitems);
}
}
}
item.subitems = newSubitems;
}
};

View file

@ -0,0 +1,163 @@
import { ConvertChineseVariant } from '@/types/book';
import { BookDoc, SectionFragment, TOCItem } from '@/libs/document';
import { initSimpleCC, runSimpleCC } from '@/utils/simplecc';
import {
cloneSectionFragments,
cloneTocItems,
collectAllTocItems,
groupItemsBySection,
groupTocSubitems,
} from './grouping';
import { bakeLocationsAndCfis, sortTocItems } from './locations';
import { buildSectionFragments } from './fragments';
import { enrichTocFromNavElements } from './enrichment';
export { findParentPath, findTocItemBS } from './lookup';
export type { SectionFragment };
// -----------------------------------------------------------------------------
// Book navigation artifact (persisted to Books/{hash}/nav.json).
// Bump BOOK_NAV_VERSION whenever computeBookNav output semantics change
// (TOC grouping heuristic, fragment CFI/size math, hierarchy rules).
// v2: fragment CFIs are derived from the section DOM via CFI.joinIndir instead
// of inherited from the TOC item (ported from foliate-js 317051e).
// v3: nav-enrichment fallback — when toc.ncx is sparse, scan section HTMLs for
// embedded <nav> elements and merge their links as top-level TOC items.
// -----------------------------------------------------------------------------
export const BOOK_NAV_VERSION = 3;
export interface BookNavSection {
id: string;
fragments: SectionFragment[];
}
export interface BookNav {
version: number;
toc: TOCItem[];
sections: Record<string, BookNavSection>;
}
const convertTocLabels = (items: TOCItem[], convertChineseVariant: ConvertChineseVariant) => {
items.forEach((item) => {
if (item.label) {
item.label = runSimpleCC(item.label, convertChineseVariant);
}
if (item.subitems) {
convertTocLabels(item.subitems, convertChineseVariant);
}
});
};
// Per-open transformations: label conversion (user setting) and optional sort.
// Structural location/cfi baking lives in computeBookNav/hydrateBookNav so
// the cached BookNav carries the expensive-to-derive fields across opens.
export const updateToc = async (
bookDoc: BookDoc,
sortedTOC: boolean,
convertChineseVariant: ConvertChineseVariant,
) => {
if (bookDoc.rendition?.layout === 'pre-paginated') return;
const items = bookDoc?.toc || [];
if (!items.length) return;
if (convertChineseVariant && convertChineseVariant !== 'none') {
await initSimpleCC();
convertTocLabels(items, convertChineseVariant);
}
if (sortedTOC) sortTocItems(items);
};
/**
* Compute a per-book navigation artifact from a freshly opened BookDoc.
* Expensive: for each referenced section, loads the HTML text and parses the
* XHTML DOM to compute per-fragment CFIs (via CFI.joinIndir) and byte-size
* offsets between TOC-fragment anchors. Intended to run on cache miss; the
* result is persisted to Books/{hash}/nav.json and replayed via
* hydrateBookNav on subsequent opens.
*/
export const computeBookNav = async (bookDoc: BookDoc): Promise<BookNav> => {
const tocClone = cloneTocItems(bookDoc.toc ?? []);
const sections: Record<string, BookNavSection> = {};
const enrichedNav = await enrichTocFromNavElements(bookDoc, tocClone);
if (tocClone.length) {
groupTocSubitems(bookDoc, tocClone);
}
const bookSections = bookDoc.sections ?? [];
if (!tocClone.length || !bookSections.length) {
return { version: BOOK_NAV_VERSION, toc: tocClone, sections };
}
const sectionMap = new Map(bookSections.map((s) => [s.id, s]));
const allItems = collectAllTocItems(tocClone);
const groups = groupItemsBySection(bookDoc, allItems);
const splitHref = (href: string) => bookDoc.splitTOCHref(href);
for (const [sectionId, { base, fragments }] of groups.entries()) {
const section = sectionMap.get(sectionId);
if (!section || fragments.length === 0) continue;
if (!section.loadText) continue;
const content = await section.loadText();
if (!content) continue;
let doc: Document | null = null;
try {
doc = await section.createDocument();
} catch (e) {
console.warn(`Failed to parse section ${sectionId} for fragment CFIs:`, e);
}
if (!doc) continue;
const sectionFragments = buildSectionFragments(
section,
fragments,
base,
content,
doc,
splitHref,
);
if (sectionFragments.length > 0) {
sections[sectionId] = { id: sectionId, fragments: sectionFragments };
}
}
// Attach the freshly computed fragments to live sections so the bake can see
// them via createSectionsMap. hydrateBookNav on the same bookDoc (called
// right after by readerStore) will re-clone these, so the shared-ref write
// here is a temporary staging step.
for (const section of bookSections) {
section.fragments = sections[section.id]?.fragments;
}
bakeLocationsAndCfis(tocClone, bookSections, splitHref);
if (enrichedNav) {
sortTocItems(tocClone);
}
return { version: BOOK_NAV_VERSION, toc: tocClone, sections };
};
/**
* Apply a cached BookNav onto a freshly opened BookDoc, replacing its TOC
* with the cached (post-grouping) version and attaching per-section
* fragments to the corresponding Section objects, then (re)derive
* section/fragment locations and TOC item cfi+location. Pure; no I/O.
*/
export const hydrateBookNav = (bookDoc: BookDoc, bookNav: BookNav): void => {
bookDoc.toc = cloneTocItems(bookNav.toc);
const bookSections = bookDoc.sections ?? [];
for (const section of bookSections) {
const cached = bookNav.sections[section.id];
if (cached?.fragments?.length) {
section.fragments = cloneSectionFragments(cached.fragments);
} else {
section.fragments = undefined;
}
}
bakeLocationsAndCfis(bookDoc.toc ?? [], bookSections, (h) => bookDoc.splitTOCHref(h));
};

View file

@ -0,0 +1,158 @@
import { SectionFragment, SectionItem, TOCItem } from '@/libs/document';
import { SIZE_PER_LOC } from '@/services/constants';
export type SplitTOCHref = (href: string) => Array<string | number>;
const calculateCumulativeSizes = (sections: SectionItem[]): number[] => {
const sizes = sections.map((s) => (s.linear !== 'no' && s.size > 0 ? s.size : 0));
let cumulative = 0;
return sizes.reduce((acc: number[], size) => {
acc.push(cumulative);
cumulative += size;
return acc;
}, []);
};
const processFragmentLocations = (
fragments: SectionFragment[],
parentByteOffset: number,
parentLocation: { current: number; next: number; total: number },
totalLocations: number,
) => {
let currentByteOffset = parentByteOffset;
fragments.forEach((fragment, index) => {
const nextFragment = index < fragments.length - 1 ? fragments[index + 1] : null;
currentByteOffset += fragment.size || 0;
const nextByteOffset = nextFragment
? currentByteOffset + (nextFragment.size || 0)
: parentLocation.next * SIZE_PER_LOC;
fragment.location = {
current: Math.floor(currentByteOffset / SIZE_PER_LOC),
next: Math.floor(nextByteOffset / SIZE_PER_LOC),
total: totalLocations,
};
if (fragment.fragments?.length) {
processFragmentLocations(
fragment.fragments,
currentByteOffset,
fragment.location,
totalLocations,
);
}
});
};
const updateSectionLocations = (
sections: SectionItem[],
cumulativeSizes: number[],
sizes: number[],
totalLocations: number,
) => {
sections.forEach((section, index) => {
const baseOffset = cumulativeSizes[index]!;
const sectionSize = sizes[index]!;
section.location = {
current: Math.floor(baseOffset / SIZE_PER_LOC),
next: Math.floor((baseOffset + sectionSize) / SIZE_PER_LOC),
total: totalLocations,
};
if (section.fragments?.length) {
processFragmentLocations(section.fragments, baseOffset, section.location, totalLocations);
}
});
};
// Narrow type that both SectionItem and SectionFragment satisfy — the fields
// read by updateTocLocation when mapping TOC items to their section/fragment.
interface LocatedEntry {
id: string;
href?: string;
cfi: string;
location?: SectionFragment['location'];
}
type SectionsMap = Record<string, LocatedEntry>;
const addFragmentsToMap = (fragments: SectionFragment[], map: SectionsMap) => {
for (const fragment of fragments) {
if (fragment.href) map[fragment.href] = fragment;
if (fragment.fragments?.length) addFragmentsToMap(fragment.fragments, map);
}
};
const createSectionsMap = (sections: SectionItem[]): SectionsMap => {
const map: SectionsMap = {};
for (const section of sections) {
map[section.id] = section;
if (section.fragments?.length) addFragmentsToMap(section.fragments, map);
}
return map;
};
const updateTocLocation = (
splitTOCHref: SplitTOCHref,
items: TOCItem[],
sections: SectionItem[],
sectionsMap: SectionsMap,
index = 0,
): number => {
items.forEach((item) => {
item.id ??= index++;
if (item.href) {
const id = splitTOCHref(item.href)[0]!;
const exactMatch = sectionsMap[item.href];
const baseMatch = sectionsMap[id];
const section = (exactMatch?.cfi ? exactMatch : null) || baseMatch || exactMatch;
if (section) {
item.cfi = section.cfi;
if (
id === item.href ||
items.length <= sections.length ||
item.href === section.href ||
item.href === section.id
) {
item.location = section.location;
}
}
}
if (item.subitems) {
index = updateTocLocation(splitTOCHref, item.subitems, sections, sectionsMap, index);
}
});
return index;
};
// Pure: compute + write section/fragment locations and TOC item cfi+location.
// Callers are responsible for passing sections that already have any applicable
// fragments attached (hydrateBookNav does this before calling). No I/O.
export const bakeLocationsAndCfis = (
items: TOCItem[],
sections: SectionItem[],
splitTOCHref: SplitTOCHref,
) => {
if (!items.length || !sections.length) return;
const sizes = sections.map((s) => (s.linear !== 'no' && s.size > 0 ? s.size : 0));
const cumulativeSizes = calculateCumulativeSizes(sections);
const totalSize = cumulativeSizes[cumulativeSizes.length - 1]! + sizes[sizes.length - 1]!;
const totalLocations = Math.floor(totalSize / SIZE_PER_LOC);
updateSectionLocations(sections, cumulativeSizes, sizes, totalLocations);
const sectionsMap = createSectionsMap(sections);
updateTocLocation(splitTOCHref, items, sections, sectionsMap);
};
export const sortTocItems = (items: TOCItem[]): void => {
items.sort((a, b) => {
if (a.location && b.location) {
return a.location.current - b.location.current;
}
return 0;
});
};

View file

@ -0,0 +1,45 @@
import { CFI, TOCItem } from '@/libs/document';
export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
for (const item of toc) {
if (item.href === href) {
return [item];
}
if (item.subitems) {
const path = findParentPath(item.subitems, href);
if (path.length) {
return [item, ...path];
}
}
}
return [];
};
const findInSubitems = (item: TOCItem, cfi: string): TOCItem | null => {
if (!item.subitems?.length) return null;
return findTocItemBS(item.subitems, cfi);
};
export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
if (!cfi) return null;
let left = 0;
let right = toc.length - 1;
let result: TOCItem | null = null;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const item = toc[mid]!;
const currentCfi = toc[mid]!.cfi || '';
const comparison = CFI.compare(currentCfi, cfi);
if (comparison === 0) {
return findInSubitems(item, cfi) ?? item;
} else if (comparison < 0) {
result = findInSubitems(item, cfi) ?? item;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
};

View file

@ -13,7 +13,7 @@ import { Insets } from '@/types/misc';
import { EnvConfigType } from '@/services/environment';
import { FoliateView } from '@/types/view';
import { DocumentLoader, TOCItem } from '@/libs/document';
import { BOOK_NAV_VERSION, computeBookNav, hydrateBookNav, updateToc } from '@/utils/toc';
import { BOOK_NAV_VERSION, computeBookNav, hydrateBookNav, updateToc } from '@/services/nav';
import { formatTitle, getMetadataHash, getPrimaryLanguage } from '@/utils/book';
import { getBaseFilename } from '@/utils/path';
import { SUPPORTED_LANGNAMES } from '@/services/constants';
@ -184,7 +184,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
// Load cached book navigation (TOC + section fragments) or compute and persist.
if (book.format === 'EPUB' && bookDoc.rendition?.layout !== 'pre-paginated') {
const cachedNav = await appService.loadBookNav(book);
if (cachedNav?.version === BOOK_NAV_VERSION) {
if (cachedNav?.version === BOOK_NAV_VERSION && process.env.NODE_ENV === 'production') {
hydrateBookNav(bookDoc, cachedNav);
} else {
const freshNav = await computeBookNav(bookDoc);

View file

@ -1,7 +1,7 @@
import { SystemSettings } from './settings';
import { Book, BookConfig, BookContent, ImportBookOptions, ViewSettings } from './book';
import { BookMetadata } from '@/libs/document';
import type { BookNav } from '@/utils/toc';
import type { BookNav } from '@/services/nav';
import { ProgressHandler } from '@/utils/transfer';
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
import { CustomTextureInfo } from '@/styles/textures';

View file

@ -1,527 +0,0 @@
import { ConvertChineseVariant } from '@/types/book';
import { SectionFragment, SectionItem, TOCItem, CFI, BookDoc } from '@/libs/document';
import { initSimpleCC, runSimpleCC } from '@/utils/simplecc';
import { SIZE_PER_LOC } from '@/services/constants';
// -----------------------------------------------------------------------------
// Book navigation artifact (persisted to Books/{hash}/nav.json).
// Bump BOOK_NAV_VERSION whenever computeBookNav output semantics change
// (TOC grouping heuristic, fragment CFI/size math, hierarchy rules).
// v2: fragment CFIs are derived from the section DOM via CFI.joinIndir instead
// of inherited from the TOC item (ported from foliate-js 317051e).
// -----------------------------------------------------------------------------
export const BOOK_NAV_VERSION = 2;
export type { SectionFragment };
export interface BookNavSection {
id: string;
fragments: SectionFragment[];
}
export interface BookNav {
version: number;
toc: TOCItem[];
sections: Record<string, BookNavSection>;
}
export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
for (const item of toc) {
if (item.href === href) {
return [item];
}
if (item.subitems) {
const path = findParentPath(item.subitems, href);
if (path.length) {
return [item, ...path];
}
}
}
return [];
};
export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
if (!cfi) return null;
let left = 0;
let right = toc.length - 1;
let result: TOCItem | null = null;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const item = toc[mid]!;
const currentCfi = toc[mid]!.cfi || '';
const comparison = CFI.compare(currentCfi, cfi);
if (comparison === 0) {
return findInSubitems(item, cfi) ?? item;
} else if (comparison < 0) {
result = findInSubitems(item, cfi) ?? item;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
};
const findInSubitems = (item: TOCItem, cfi: string): TOCItem | null => {
if (!item.subitems?.length) return null;
return findTocItemBS(item.subitems, cfi);
};
// Helper: Calculate cumulative sizes for sections
const calculateCumulativeSizes = (sections: SectionItem[]): number[] => {
const sizes = sections.map((s) => (s.linear !== 'no' && s.size > 0 ? s.size : 0));
let cumulative = 0;
return sizes.reduce((acc: number[], size) => {
acc.push(cumulative);
cumulative += size;
return acc;
}, []);
};
// Helper: Process fragments recursively to assign locations
const processFragmentLocations = (
fragments: SectionFragment[],
parentByteOffset: number,
parentLocation: { current: number; next: number; total: number },
totalLocations: number,
) => {
let currentByteOffset = parentByteOffset;
fragments.forEach((fragment, index) => {
const nextFragment = index < fragments.length - 1 ? fragments[index + 1] : null;
currentByteOffset += fragment.size || 0;
const nextByteOffset = nextFragment
? currentByteOffset + (nextFragment.size || 0)
: parentLocation.next * SIZE_PER_LOC;
fragment.location = {
current: Math.floor(currentByteOffset / SIZE_PER_LOC),
next: Math.floor(nextByteOffset / SIZE_PER_LOC),
total: totalLocations,
};
if (fragment.fragments?.length) {
processFragmentLocations(
fragment.fragments,
currentByteOffset,
fragment.location,
totalLocations,
);
}
});
};
const updateSectionLocations = (
sections: SectionItem[],
cumulativeSizes: number[],
sizes: number[],
totalLocations: number,
) => {
sections.forEach((section, index) => {
const baseOffset = cumulativeSizes[index]!;
const sectionSize = sizes[index]!;
section.location = {
current: Math.floor(baseOffset / SIZE_PER_LOC),
next: Math.floor((baseOffset + sectionSize) / SIZE_PER_LOC),
total: totalLocations,
};
if (section.fragments?.length) {
processFragmentLocations(section.fragments, baseOffset, section.location, totalLocations);
}
});
};
// Narrow type that both SectionItem and SectionFragment satisfy — the fields
// read by updateTocLocation when mapping TOC items to their section/fragment.
interface LocatedEntry {
id: string;
href?: string;
cfi: string;
location?: SectionFragment['location'];
}
// Helper: Recursively add fragments to sections map
const addFragmentsToMap = (fragments: SectionFragment[], map: Record<string, LocatedEntry>) => {
for (const fragment of fragments) {
if (fragment.href) map[fragment.href] = fragment;
if (fragment.fragments?.length) addFragmentsToMap(fragment.fragments, map);
}
};
// Helper: Create sections lookup map including all fragments
type Href = string;
type SectionsMap = Record<Href, LocatedEntry>;
const createSectionsMap = (sections: SectionItem[]) => {
const map: SectionsMap = {};
for (const section of sections) {
map[section.id] = section;
if (section.fragments?.length) addFragmentsToMap(section.fragments, map);
}
return map;
};
// Main: Update TOC with section locations and metadata
export const updateToc = async (
bookDoc: BookDoc,
sortedTOC: boolean,
convertChineseVariant: ConvertChineseVariant,
) => {
if (bookDoc.rendition?.layout === 'pre-paginated') return;
const items = bookDoc?.toc || [];
const sections = bookDoc?.sections || [];
if (!items.length || !sections.length) return;
// Step 1: Apply Chinese variant conversion if needed
if (convertChineseVariant && convertChineseVariant !== 'none') {
await initSimpleCC();
convertTocLabels(items, convertChineseVariant);
}
// Step 2: Calculate section sizes and locations
const sizes = sections.map((s) => (s.linear !== 'no' && s.size > 0 ? s.size : 0));
const cumulativeSizes = calculateCumulativeSizes(sections);
const totalSize = cumulativeSizes[cumulativeSizes.length - 1]! + sizes[sizes.length - 1]!;
const totalLocations = Math.floor(totalSize / SIZE_PER_LOC);
// Step 3: Update locations to sections and fragments
updateSectionLocations(sections, cumulativeSizes, sizes, totalLocations);
// Step 4: Create sections map and update TOC locations
const sectionsMap = createSectionsMap(sections);
updateTocLocation(bookDoc, items, sections, sectionsMap);
// Step 5: Sort TOC if requested
if (sortedTOC) sortTocItems(items);
};
const convertTocLabels = (items: TOCItem[], convertChineseVariant: ConvertChineseVariant) => {
items.forEach((item) => {
if (item.label) {
item.label = runSimpleCC(item.label, convertChineseVariant);
}
if (item.subitems) {
convertTocLabels(item.subitems, convertChineseVariant);
}
});
};
const updateTocLocation = (
bookDoc: BookDoc,
items: TOCItem[],
sections: SectionItem[],
sectionsMap: SectionsMap,
index = 0,
): number => {
items.forEach((item) => {
item.id ??= index++;
if (item.href) {
const id = bookDoc.splitTOCHref(item.href)[0]!;
const exactMatch = sectionsMap[item.href];
const baseMatch = sectionsMap[id];
const section = (exactMatch?.cfi ? exactMatch : null) || baseMatch || exactMatch;
if (section) {
item.cfi = section.cfi;
if (
id === item.href ||
items.length <= sections.length ||
item.href === section.href ||
item.href === section.id
) {
item.location = section.location;
}
}
}
if (item.subitems) {
index = updateTocLocation(bookDoc, item.subitems, sections, sectionsMap, index);
}
});
return index;
};
const sortTocItems = (items: TOCItem[]): void => {
items.sort((a, b) => {
if (a.location && b.location) {
return a.location.current - b.location.current;
}
return 0;
});
};
// -----------------------------------------------------------------------------
// computeBookNav / hydrateBookNav
// -----------------------------------------------------------------------------
const cloneTocItems = (items: TOCItem[]): TOCItem[] =>
items.map((item) => ({
...item,
subitems: item.subitems ? cloneTocItems(item.subitems) : undefined,
}));
// Ported from foliate-js: restructure TOC so that fragment-linked subitems under
// the same section are regrouped under a natural parent when one exists.
const groupTocSubitems = (bookDoc: BookDoc, items: TOCItem[]): void => {
const splitHref = (href: string) => bookDoc.splitTOCHref(href);
const groupBySection = (subitems: TOCItem[]) => {
const grouped = new Map<string, TOCItem[]>();
for (const subitem of subitems) {
const [sectionId] = splitHref(subitem.href) as [string | undefined];
const key = sectionId ?? '';
const bucket = grouped.get(key) ?? [];
bucket.push(subitem);
grouped.set(key, bucket);
}
return grouped;
};
const separateParentAndFragments = (sectionId: string, subitems: TOCItem[]) => {
let parent: TOCItem | null = null;
const fragments: TOCItem[] = [];
for (const subitem of subitems) {
const [, fragmentId] = splitHref(subitem.href) as [string | undefined, string | undefined];
if (!fragmentId || subitem.href === sectionId) {
parent = subitem;
} else {
fragments.push(subitem);
}
}
return { parent, fragments };
};
for (const item of items) {
if (!item.subitems?.length) continue;
const groupedBySection = groupBySection(item.subitems);
if (groupedBySection.size <= 3) continue;
const newSubitems: TOCItem[] = [];
for (const [sectionId, subitems] of groupedBySection.entries()) {
if (item.href === sectionId) {
newSubitems.push(...subitems);
continue;
}
if (subitems.length === 1) {
newSubitems.push(subitems[0]!);
} else {
const { parent, fragments } = separateParentAndFragments(sectionId, subitems);
if (parent) {
parent.subitems = fragments.length > 0 ? fragments : parent.subitems;
newSubitems.push(parent);
} else {
newSubitems.push(...subitems);
}
}
}
item.subitems = newSubitems;
}
};
const findFragmentPosition = (html: string, fragmentId: string | undefined): number => {
if (!fragmentId) return html.length;
const patterns = [
new RegExp(`\\sid=["']${CSS.escape(fragmentId)}["']`, 'i'),
new RegExp(`\\sname=["']${CSS.escape(fragmentId)}["']`, 'i'),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match && typeof match.index === 'number') return match.index;
}
return -1;
};
const calculateFragmentSize = (
content: string,
fragmentId: string | undefined,
prevFragmentId: string | undefined,
): number => {
const endPos = findFragmentPosition(content, fragmentId);
if (endPos < 0) return 0;
const startPos = prevFragmentId ? findFragmentPosition(content, prevFragmentId) : 0;
const validStartPos = Math.max(0, startPos);
if (endPos < validStartPos) return 0;
return new Blob([content.substring(validStartPos, endPos)]).size;
};
const collectAllTocItems = (items: TOCItem[]): TOCItem[] => {
const out: TOCItem[] = [];
const walk = (xs: TOCItem[]) => {
for (const x of xs) {
out.push(x);
if (x.subitems?.length) walk(x.subitems);
}
};
walk(items);
return out;
};
interface SectionGroup {
base: TOCItem | null;
fragments: TOCItem[];
}
const groupItemsBySection = (bookDoc: BookDoc, items: TOCItem[]): Map<string, SectionGroup> => {
const groups = new Map<string, SectionGroup>();
for (const item of items) {
if (!item.href) continue;
const [sectionId, fragmentId] = bookDoc.splitTOCHref(item.href) as [
string | undefined,
string | undefined,
];
if (!sectionId) continue;
let group = groups.get(sectionId);
if (!group) {
group = { base: null, fragments: [] };
groups.set(sectionId, group);
}
const isBase = !fragmentId || item.href === sectionId;
if (isBase) group.base = item;
else group.fragments.push(item);
}
return groups;
};
const getHTMLFragmentElement = (doc: Document, id: string | undefined): Element | null => {
if (!id) return null;
return doc.getElementById(id) ?? doc.querySelector(`[name="${CSS.escape(id)}"]`);
};
type CFIModule = {
joinIndir: (...xs: string[]) => string;
fromElements: (elements: Element[]) => string[];
};
const buildFragmentCfi = (section: SectionItem, element: Element | null): string => {
const cfiLib = CFI as unknown as CFIModule;
const rel = element ? (cfiLib.fromElements([element])[0] ?? '') : '';
return cfiLib.joinIndir(section.cfi, rel);
};
const buildSectionFragments = (
section: SectionItem,
fragments: TOCItem[],
base: TOCItem | null,
content: string,
doc: Document,
splitHref: (href: string) => Array<string | number>,
): SectionFragment[] => {
const out: SectionFragment[] = [];
for (let i = 0; i < fragments.length; i++) {
const fragment = fragments[i]!;
const [, rawFragmentId] = splitHref(fragment.href) as [string | undefined, string | undefined];
const fragmentId = rawFragmentId;
const prev = i > 0 ? fragments[i - 1] : base;
const [, rawPrevFragmentId] = prev
? (splitHref(prev.href) as [string | undefined, string | undefined])
: [undefined, undefined];
const prevFragmentId = rawPrevFragmentId;
const element = getHTMLFragmentElement(doc, fragmentId);
const cfi = buildFragmentCfi(section, element);
const size = calculateFragmentSize(content, fragmentId, prevFragmentId);
out.push({
id: fragment.href,
href: fragment.href,
cfi,
size,
linear: section.linear,
});
}
return out;
};
/**
* Compute a per-book navigation artifact from a freshly opened BookDoc.
* Expensive: for each referenced section, loads the HTML text and parses the
* XHTML DOM to compute per-fragment CFIs (via CFI.joinIndir) and byte-size
* offsets between TOC-fragment anchors. Intended to run on cache miss; the
* result is persisted to Books/{hash}/nav.json and replayed via
* hydrateBookNav on subsequent opens.
*/
export const computeBookNav = async (bookDoc: BookDoc): Promise<BookNav> => {
const tocClone = cloneTocItems(bookDoc.toc ?? []);
const sections: Record<string, BookNavSection> = {};
if (tocClone.length) {
groupTocSubitems(bookDoc, tocClone);
}
const bookSections = bookDoc.sections ?? [];
if (!tocClone.length || !bookSections.length) {
return { version: BOOK_NAV_VERSION, toc: tocClone, sections };
}
const sectionMap = new Map(bookSections.map((s) => [s.id, s]));
const allItems = collectAllTocItems(tocClone);
const groups = groupItemsBySection(bookDoc, allItems);
const splitHref = (href: string) => bookDoc.splitTOCHref(href);
for (const [sectionId, { base, fragments }] of groups.entries()) {
const section = sectionMap.get(sectionId);
if (!section || fragments.length === 0) continue;
if (!section.loadText) continue;
const content = await section.loadText();
if (!content) continue;
let doc: Document | null = null;
try {
doc = await section.createDocument();
} catch (e) {
console.warn(`Failed to parse section ${sectionId} for fragment CFIs:`, e);
}
if (!doc) continue;
const sectionFragments = buildSectionFragments(
section,
fragments,
base,
content,
doc,
splitHref,
);
if (sectionFragments.length > 0) {
sections[sectionId] = { id: sectionId, fragments: sectionFragments };
}
}
return { version: BOOK_NAV_VERSION, toc: tocClone, sections };
};
const cloneSectionFragments = (fragments: SectionFragment[]): SectionFragment[] =>
fragments.map((f) => ({
id: f.id,
href: f.href,
cfi: f.cfi,
size: f.size,
linear: f.linear,
fragments: f.fragments ? cloneSectionFragments(f.fragments) : undefined,
}));
/**
* Apply a cached BookNav onto a freshly opened BookDoc, replacing its TOC
* with the cached (post-grouping) version and attaching per-section
* fragments to the corresponding Section objects. No I/O.
*/
export const hydrateBookNav = (bookDoc: BookDoc, bookNav: BookNav): void => {
bookDoc.toc = cloneTocItems(bookNav.toc);
const bookSections = bookDoc.sections ?? [];
for (const section of bookSections) {
const cached = bookNav.sections[section.id];
if (cached?.fragments?.length) {
section.fragments = cloneSectionFragments(cached.fragments);
} else {
section.fragments = undefined;
}
}
};