forked from akai/readest
8850e6c00f
* chore: bump jsdom to the latest version * feat(pdf): support TTS and annotation on PDFs, closes #2149 & closes #3462
304 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
});
|
|
});
|