Files
readest/apps/readest-app/src/__tests__/document/page-progress-epub.test.ts
T
Huang Xin 8850e6c00f feat(pdf): support TTS and annotation on PDFs, closes #2149 & #3462 (#3493)
* chore: bump jsdom to the latest version

* feat(pdf): support TTS and annotation on PDFs, closes #2149 & closes #3462
2026-03-09 10:28:19 +01:00

304 lines
12 KiB
TypeScript

import { describe, it, expect, beforeAll, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { FoliateView } from '@/types/view';
import { wrappedFoliateView } from '@/types/view';
// 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', () => ({}));
let book: BookDoc;
let view: FoliateView;
let totalSections: number;
const loadEPUB = async () => {
const epubPath = resolve(__dirname, '../fixtures/data/sample-alice.epub');
const buffer = readFileSync(epubPath);
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const { book } = await loader.open();
return book;
};
describe('getCFIProgress with real EPUB', () => {
beforeAll(async () => {
book = await loadEPUB();
totalSections = book.sections!.length;
// Import View and create the element, wrapping it like the app does
await import('foliate-js/view.js');
const rawView = document.createElement('foliate-view') as FoliateView;
view = wrappedFoliateView(rawView);
await view.open(book);
}, 30000);
it('should load the EPUB with sections', () => {
expect(book.sections!.length).toBeGreaterThan(0);
expect(view.book).toBe(book);
});
it('should return a valid progress object with all fields', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const firstTextNode = walker.nextNode();
expect(firstTextNode).not.toBeNull();
const range = doc.createRange();
range.setStart(firstTextNode!, 0);
range.setEnd(firstTextNode!, 0);
const cfi = view.getCFI(sectionIndex, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
// fraction is the overall book progress
expect(result!.fraction).toBeGreaterThanOrEqual(0);
expect(result!.fraction).toBeLessThanOrEqual(1);
// section info
expect(result!.section.current).toBe(sectionIndex);
expect(result!.section.total).toBe(totalSections);
// location info
expect(result!.location.current).toBeGreaterThanOrEqual(0);
expect(result!.location.next).toBeGreaterThanOrEqual(result!.location.current);
expect(result!.location.total).toBeGreaterThan(0);
// time info
expect(result!.time.section).toBeGreaterThanOrEqual(0);
expect(result!.time.total).toBeGreaterThanOrEqual(0);
});
it('should return the smallest fraction for a CFI at the start of a section', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
expect(sectionIndex).toBeGreaterThanOrEqual(0);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const firstTextNode = walker.nextNode();
expect(firstTextNode).not.toBeNull();
const range = doc.createRange();
range.setStart(firstTextNode!, 0);
range.setEnd(firstTextNode!, 0);
const cfi = view.getCFI(sectionIndex, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
expect(result!.section.current).toBe(sectionIndex);
// At the start of a section, fraction should equal the section's start fraction
// (i.e. only prior sections' sizes contribute)
expect(result!.fraction).toBeGreaterThanOrEqual(0);
});
it('should return a larger fraction for a CFI at the end of a section', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
// Get start CFI
const walkerStart = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const firstTextNode = walkerStart.nextNode()!;
const startRange = doc.createRange();
startRange.setStart(firstTextNode, 0);
startRange.setEnd(firstTextNode, 0);
const startCfi = view.getCFI(sectionIndex, startRange);
const startResult = await view.getCFIProgress(startCfi);
// Get end CFI
const walkerEnd = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
let lastTextNode: Node | null = null;
for (let node = walkerEnd.nextNode(); node; node = walkerEnd.nextNode()) {
lastTextNode = node;
}
expect(lastTextNode).not.toBeNull();
const endRange = doc.createRange();
endRange.setStart(lastTextNode!, lastTextNode!.textContent!.length);
endRange.setEnd(lastTextNode!, lastTextNode!.textContent!.length);
const endCfi = view.getCFI(sectionIndex, endRange);
const endResult = await view.getCFIProgress(endCfi);
expect(startResult).not.toBeNull();
expect(endResult).not.toBeNull();
expect(endResult!.section.current).toBe(sectionIndex);
expect(endResult!.fraction).toBeGreaterThan(startResult!.fraction);
});
it('should return a fraction between start and end for a midpoint CFI', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
expect(sectionIndex).toBeGreaterThanOrEqual(0);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const textNodes: Node[] = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
textNodes.push(node);
}
expect(textNodes.length).toBeGreaterThan(0);
const midNodeIndex = Math.floor(textNodes.length / 2);
const midNode = textNodes[midNodeIndex]!;
const midOffset = Math.floor((midNode.textContent?.length ?? 0) / 2);
const range = doc.createRange();
range.setStart(midNode, midOffset);
range.setEnd(midNode, midOffset);
const cfi = view.getCFI(sectionIndex, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
expect(result!.section.current).toBe(sectionIndex);
expect(result!.fraction).toBeGreaterThan(0);
expect(result!.fraction).toBeLessThan(1);
});
it('should produce consistent results for the same CFI', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 500);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const node = walker.nextNode();
expect(node).not.toBeNull();
const range = doc.createRange();
const offset = Math.min(3, node!.textContent!.length);
range.setStart(node!, offset);
range.setEnd(node!, offset);
const cfi = view.getCFI(sectionIndex, range);
const result1 = await view.getCFIProgress(cfi);
const result2 = await view.getCFIProgress(cfi);
expect(result1).not.toBeNull();
expect(result2).not.toBeNull();
expect(result1!.fraction).toBe(result2!.fraction);
expect(result1!.section.current).toBe(result2!.section.current);
expect(result1!.location.current).toBe(result2!.location.current);
});
it('should return increasing fractions for successive CFIs in the same section', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
expect(sectionIndex).toBeGreaterThanOrEqual(0);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const textNodes: Node[] = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
textNodes.push(node);
}
const positions = [0, Math.floor(textNodes.length / 4), Math.floor((textNodes.length * 3) / 4)];
const fractions: number[] = [];
for (const pos of positions) {
const node = textNodes[pos]!;
const range = doc.createRange();
range.setStart(node, 0);
range.setEnd(node, 0);
const cfi = view.getCFI(sectionIndex, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
expect(result!.section.current).toBe(sectionIndex);
fractions.push(result!.fraction);
}
for (let i = 1; i < fractions.length; i++) {
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
}
});
it('should return increasing fractions across different sections', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no' && s.size > 0);
expect(linearSections.length).toBeGreaterThan(1);
const fractions: number[] = [];
for (const { s, i } of linearSections.slice(0, 3)) {
const doc = await s.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const firstTextNode = walker.nextNode();
if (!firstTextNode) continue;
const range = doc.createRange();
range.setStart(firstTextNode, 0);
range.setEnd(firstTextNode, 0);
const cfi = view.getCFI(i, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
expect(result!.section.current).toBe(i);
expect(result!.fraction).toBeGreaterThanOrEqual(0);
expect(result!.fraction).toBeLessThanOrEqual(1);
fractions.push(result!.fraction);
}
// Fractions across sections should be increasing
for (let i = 1; i < fractions.length; i++) {
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
}
});
it('should return null for an invalid CFI', async () => {
const result = await view.getCFIProgress('epubcfi(/99/999!/0)');
expect(result).toBeNull();
});
it('should return progress for real CFIs', async () => {
const expectedData = [
{ cfi: 'epubcfi(/99/999!/0)', fraction: null },
{ cfi: 'epubcfi(/6/4!/4/2/6,/1:0,/1:13)', fraction: 0.009, page: 1 },
{ cfi: 'epubcfi(/6/8!/4/2/2[chapter_458]/4/18,/1:0,/1:681)', fraction: 0.045, page: 5 },
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/2,/3:1,/3:18)', fraction: 0.098, page: 11 },
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/4/70,/1:0,/1:261)', fraction: 0.161, page: 19 },
{ cfi: 'epubcfi(/6/12!/4/2/2[chapter_462]/4/22,/1:0,/1:156)', fraction: 0.179, page: 21 },
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/6,/1:0,/1:65)', fraction: 0.411, page: 46 },
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/94,/1:1,/1:30)', fraction: 0.446, page: 51 },
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/134,/1:0,/1:58)', fraction: 0.473, page: 54 },
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/170,/1:0,/1:124)', fraction: 0.491, page: 55 },
{ cfi: 'epubcfi(/6/26!/4/2/2[chapter_476]/4/120,/1:0,/1:117)', fraction: 0.804, page: 91 },
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4,/82/1:0,/86/1:26)', fraction: 0.955, page: 107 },
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:1,/1:118)', fraction: 0.955, page: 108 },
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:119,/1:358)', fraction: 0.955, page: 108 },
];
for (const { cfi, fraction, page } of expectedData) {
const result = await view.getCFIProgress(cfi);
if (fraction === null) {
expect(result).toBeNull();
} else {
expect(result).not.toBeNull();
expect(result!.fraction).toBeCloseTo(fraction, 1);
expect(result!.location.current).toBe(page! - 1);
expect(Math.abs(result!.fraction - fraction)).toBeLessThan(0.006);
}
}
});
});