diff --git a/apps/readest-app/src/__tests__/fixtures/data/repro-3688.epub b/apps/readest-app/src/__tests__/fixtures/data/repro-3688.epub new file mode 100644 index 00000000..bc44d41b Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/repro-3688.epub differ diff --git a/apps/readest-app/src/__tests__/utils/toc-cfi-mapping.test.ts b/apps/readest-app/src/__tests__/utils/toc-cfi-mapping.test.ts new file mode 100644 index 00000000..e8901c81 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/toc-cfi-mapping.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc, TOCItem } from '@/libs/document'; +import { updateToc, findTocItemBS } from '@/utils/toc'; + +// Polyfill CSS.escape for jsdom +if (typeof globalThis['CSS'] === 'undefined') { + (globalThis as Record)['CSS'] = { + escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'), + }; +} + +// Register a stub paginator custom element so View.open() doesn't fail in jsdom +if (!customElements.get('foliate-paginator')) { + customElements.define( + 'foliate-paginator', + class extends HTMLElement { + override setAttribute() {} + override addEventListener() {} + open() {} + }, + ); +} + +// Mock the paginator module import so View doesn't try to load the real one +vi.mock('foliate-js/paginator.js', () => ({})); + +/** + * Regression test for https://github.com/readest/readest/issues/3688 + * + * When TOC entries use fragment-suffixed hrefs (e.g. ch01.xhtml#ch01), + * all annotations were incorrectly grouped under the last chapter. + * This happened because section subitems created during EPUB init had + * cfi: undefined, and the sectionsMap lookup found these subitems + * instead of the parent sections with valid CFIs. + */ +describe('TOC-to-CFI mapping with fragment hrefs (#3688)', () => { + let book: BookDoc; + + beforeAll(async () => { + const epubPath = resolve(__dirname, '../fixtures/data/repro-3688.epub'); + const buffer = readFileSync(epubPath); + const file = new File([buffer], 'repro-3688.epub', { + type: 'application/epub+zip', + }); + const loader = new DocumentLoader(file); + const result = await loader.open(); + book = result.book; + await updateToc(book, false, 'none'); + }, 30000); + + it('should assign valid CFIs to all TOC items', () => { + const toc = book.toc ?? []; + expect(toc.length).toBeGreaterThan(0); + + const collectItems = (items: TOCItem[]): TOCItem[] => + items.flatMap((item) => [item, ...(item.subitems ? collectItems(item.subitems) : [])]); + + const allItems = collectItems(toc); + const itemsWithHref = allItems.filter((item) => item.href); + expect(itemsWithHref.length).toBeGreaterThan(1); + + // Every TOC item with an href should have a valid (non-empty) CFI + for (const item of itemsWithHref) { + expect( + item.cfi, + `TOC item "${item.label}" (href: ${item.href}) should have a valid CFI`, + ).toBeTruthy(); + } + }); + + it('should map CFIs from different sections to different TOC items', () => { + const toc = book.toc ?? []; + const sections = book.sections ?? []; + expect(sections.length).toBeGreaterThan(1); + + const linearSections = sections.filter((s) => s.linear !== 'no' && s.cfi); + expect(linearSections.length).toBeGreaterThan(2); + + const firstSection = linearSections[0]!; + const lastSection = linearSections[linearSections.length - 1]!; + + const firstTocItem = findTocItemBS(toc, firstSection.cfi); + const lastTocItem = findTocItemBS(toc, lastSection.cfi); + + expect(firstTocItem).not.toBeNull(); + expect(lastTocItem).not.toBeNull(); + + // Annotations from different sections must NOT all map to the same chapter + expect(firstTocItem!.label).toBe('Chapter One'); + expect(lastTocItem!.label).toBe('Chapter Three'); + }); + + it('should map a mid-book CFI to a mid-book TOC item, not the last one', () => { + const toc = book.toc ?? []; + const sections = book.sections ?? []; + const linearSections = sections.filter((s) => s.linear !== 'no' && s.cfi); + + const midSection = linearSections[1]!; + const midTocItem = findTocItemBS(toc, midSection.cfi); + + expect(midTocItem).not.toBeNull(); + expect(midTocItem!.label).toBe('Chapter Two'); + }); +}); diff --git a/apps/readest-app/src/utils/toc.ts b/apps/readest-app/src/utils/toc.ts index 00886be5..84108f88 100644 --- a/apps/readest-app/src/utils/toc.ts +++ b/apps/readest-app/src/utils/toc.ts @@ -193,7 +193,9 @@ const updateTocLocation = ( item.id ??= index++; if (item.href) { const id = bookDoc.splitTOCHref(item.href)[0]!; - const section = sectionsMap[item.href] || sectionsMap[id]; + const exactMatch = sectionsMap[item.href]; + const baseMatch = sectionsMap[id]; + const section = (exactMatch?.cfi ? exactMatch : null) || baseMatch || exactMatch; if (section) { item.cfi = section.cfi; if (