diff --git a/.gitignore b/.gitignore index 2f69a856..4d29ec2e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,8 @@ fastlane/report.xml # nix result* + + .playwright-mcp/ +.claude/ + diff --git a/apps/readest-app/AGENTS.md b/apps/readest-app/AGENTS.md index a9e5cd9d..7ec1ed68 100644 --- a/apps/readest-app/AGENTS.md +++ b/apps/readest-app/AGENTS.md @@ -57,6 +57,12 @@ Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Cu ## Project Rules +### Test-First Development + +- Always write a failing unit test **before** implementing a fix. +- Run the test to confirm it reproduces the bug or fails as expected, then apply the fix and verify the test passes. +- Run the full test suite (`pnpm test`) after changes to ensure no regressions. + ### TypeScript - Never use the `any` type. Use `unknown`, proper types, or generics instead. diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 9768cb3e..4f2cf5fc 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -15,10 +15,10 @@ "start-web:vinext": "dotenv -e .env.web -- vinext start", "build-tauri": "dotenv -e .env.tauri -- next build", "i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs", - "lint": "eslint .", + "lint": "tsc --noEmit && eslint .", "test": "dotenv -e .env -e .env.test.local vitest", "test:browser": "vitest --config vitest.browser.config.mts --watch=false", - "test:tauri": "vitest --config vitest.tauri.config.mts --watch=false", + "test:tauri": "bash scripts/test-tauri.sh", "test:pr:web": "pnpm test -- --watch=false && pnpm test:browser", "test:pr:tauri": "pnpm test -- --watch=false && bash scripts/test-tauri.sh", "test:all": "pnpm test -- --watch=false && pnpm test:browser && bash scripts/test-tauri.sh", @@ -213,7 +213,7 @@ "eslint-config-next": "16.1.6", "eslint-plugin-jsx-a11y": "^6.10.2", "i18next-scanner": "^4.6.0", - "jsdom": "^26.1.0", + "jsdom": "^28.1.0", "mkdirp": "^3.0.1", "node-env-run": "^4.0.2", "playwright": "^1.58.2", diff --git a/apps/readest-app/scripts/test-tauri.sh b/apps/readest-app/scripts/test-tauri.sh index d680014d..5326e694 100755 --- a/apps/readest-app/scripts/test-tauri.sh +++ b/apps/readest-app/scripts/test-tauri.sh @@ -68,4 +68,4 @@ while ! curl -sf "http://127.0.0.1:${WEBDRIVER_PORT}/status" >/dev/null 2>&1; do done echo "WebDriver is ready. Running Tauri tests..." -pnpm test:tauri +pnpm vitest --config vitest.tauri.config.mts --watch=false diff --git a/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx b/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx index 71e6dd09..5cd0414a 100644 --- a/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx @@ -41,6 +41,7 @@ describe('ProofreadPopup Component', () => { key: 'test-book', text: 'test word', cfi: 'epubcfi(/6/2[chapter1]!/4/1:0)', + index: 0, range: { deleteContents: vi.fn(), insertNode: vi.fn(), @@ -49,7 +50,7 @@ describe('ProofreadPopup Component', () => { startOffset: 5, endOffset: 9, } as unknown as Range, - index: 0, + page: 1, }, position: { point: { x: 100, y: 100 } }, trianglePosition: { point: { x: 100, y: 100 } }, diff --git a/apps/readest-app/src/__tests__/document/page-progress-epub.test.ts b/apps/readest-app/src/__tests__/document/page-progress-epub.test.ts index 02db7807..1264d1c7 100644 --- a/apps/readest-app/src/__tests__/document/page-progress-epub.test.ts +++ b/apps/readest-app/src/__tests__/document/page-progress-epub.test.ts @@ -6,18 +6,6 @@ import type { BookDoc } from '@/libs/document'; import type { FoliateView } from '@/types/view'; import { wrappedFoliateView } from '@/types/view'; -// jsdom's Blob doesn't implement arrayBuffer(), polyfill it via FileReader -if (typeof Blob.prototype.arrayBuffer !== 'function') { - Blob.prototype.arrayBuffer = function () { - return new Promise((res, reject) => { - const reader = new FileReader(); - reader.onload = () => res(reader.result as ArrayBuffer); - reader.onerror = () => reject(reader.error); - reader.readAsArrayBuffer(this); - }); - }; -} - // Register a stub paginator custom element so View.open() doesn't fail in jsdom if (!customElements.get('foliate-paginator')) { customElements.define( @@ -38,7 +26,7 @@ let view: FoliateView; let totalSections: number; const loadEPUB = async () => { - const epubPath = resolve(__dirname, '../fixtures/sample-alice.epub'); + 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); diff --git a/apps/readest-app/src/__tests__/document/pdf-cfi.test.ts b/apps/readest-app/src/__tests__/document/pdf-cfi.test.ts new file mode 100644 index 00000000..5f142187 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/pdf-cfi.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve, join } from 'path'; +import { parse, toRange, fromRange } from 'foliate-js/epubcfi.js'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; + +const vendorDir = join(process.cwd(), 'public/vendor'); + +/** + * Tests EPUB CFI resolution with a real PDF loaded via DocumentLoader. + * + * Reference CFIs captured from sample-alice.pdf in a full browser environment: + * - index 3: epubcfi(/6/8!/4/4,/94/1:0,/118/1:10) + * - index 4: epubcfi(/6/10!/4/4/42,/1:0,/1:46) + * + * In jsdom the canvas 2d context is unavailable, so createDocument() uses a + * fallback that produces one per getTextContent() item. The element + * count differs from the full TextLayer render, so these tests generate CFIs + * from the fallback DOM and verify round-trip resolution. + */ +describe('PDF CFI resolution with real document', () => { + let book: BookDoc; + let doc3: Document; + let doc4: Document; + + /** Shift the spine-level part from a parsed CFI (as view.js resolveCFI does). */ + const shiftSpine = (parts: ReturnType) => { + (parts.parent ?? parts).shift(); + return parts; + }; + + beforeAll(async () => { + await import('foliate-js/pdf.js'); + const pdfjsLib = (globalThis as Record)['pdfjsLib'] as { + GlobalWorkerOptions: { workerSrc: string }; + }; + pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + `file://${join(vendorDir, 'pdfjs/pdf.worker.min.mjs')}`, + ).href; + + const pdfPath = resolve(__dirname, '../fixtures/data/sample-alice.pdf'); + const buffer = readFileSync(pdfPath); + const file = new File([buffer], 'sample-alice.pdf', { type: 'application/pdf' }); + const loader = new DocumentLoader(file); + const result = await loader.open(); + book = result.book; + + doc3 = await book.sections[3]!.createDocument(); + doc4 = await book.sections[4]!.createDocument(); + }, 30_000); + + // ---------- DOM structure ------------------------------------------------- + + it('should have textLayer, canvas, and annotationLayer wrappers', () => { + expect(doc3.querySelector('#canvas')).toBeTruthy(); + expect(doc3.querySelector('.textLayer')).toBeTruthy(); + expect(doc3.querySelector('.annotationLayer')).toBeTruthy(); + + const textLayer = doc3.querySelector('.textLayer')!; + expect(textLayer.children.length).toBeGreaterThan(0); + }); + + // ---------- Round-trip: fromRange → parse → toRange ----------------------- + + it('should round-trip a range CFI on page 3', () => { + const textLayer = doc3.querySelector('.textLayer')!; + const spans = textLayer.querySelectorAll('span'); + // Find a span with enough text content + let targetSpan: Element | null = null; + for (const span of spans) { + if (span.firstChild && span.firstChild.textContent!.length >= 10) { + targetSpan = span; + break; + } + } + expect(targetSpan).toBeTruthy(); + + const srcRange = doc3.createRange(); + srcRange.setStart(targetSpan!.firstChild!, 0); + srcRange.setEnd(targetSpan!.firstChild!, 10); + const expectedText = srcRange.toString(); + + const cfi = fromRange(srcRange); + const parts = parse(cfi); + const resolved = toRange(doc3, parts); + + expect(resolved).toBeInstanceOf(Range); + expect(resolved!.toString()).toBe(expectedText); + }); + + it('should round-trip a range CFI on page 4', () => { + const textLayer = doc4.querySelector('.textLayer')!; + const spans = textLayer.querySelectorAll('span'); + let targetSpan: Element | null = null; + for (const span of spans) { + if (span.firstChild && span.firstChild.textContent!.length >= 10) { + targetSpan = span; + break; + } + } + expect(targetSpan).toBeTruthy(); + + const srcRange = doc4.createRange(); + srcRange.setStart(targetSpan!.firstChild!, 0); + srcRange.setEnd(targetSpan!.firstChild!, 10); + const expectedText = srcRange.toString(); + + const cfi = fromRange(srcRange); + const parts = parse(cfi); + const resolved = toRange(doc4, parts); + + expect(resolved).toBeInstanceOf(Range); + expect(resolved!.toString()).toBe(expectedText); + }); + + it('should round-trip a multi-span range CFI', () => { + const textLayer = doc3.querySelector('.textLayer')!; + const spans = textLayer.querySelectorAll('span'); + // Select a range spanning two different spans + const span1 = spans[0]!; + const span2 = spans[2]!; + expect(span1.firstChild).toBeTruthy(); + expect(span2.firstChild).toBeTruthy(); + + const srcRange = doc3.createRange(); + srcRange.setStart(span1.firstChild!, 0); + const endOffset = Math.min(5, span2.firstChild!.textContent!.length); + srcRange.setEnd(span2.firstChild!, endOffset); + const expectedText = srcRange.toString(); + + const cfi = fromRange(srcRange); + const parts = parse(cfi); + const resolved = toRange(doc3, parts); + + expect(resolved).toBeInstanceOf(Range); + expect(resolved!.toString()).toBe(expectedText); + }); + + it('should round-trip a collapsed (point) CFI', () => { + const textLayer = doc3.querySelector('.textLayer')!; + const span = textLayer.querySelector('span')!; + expect(span.firstChild).toBeTruthy(); + + const srcRange = doc3.createRange(); + srcRange.setStart(span.firstChild!, 3); + srcRange.collapse(true); + + const cfi = fromRange(srcRange); + const parts = parse(cfi); + const resolved = toRange(doc3, parts); + + expect(resolved).toBeInstanceOf(Range); + expect(resolved!.collapsed).toBe(true); + }); + + // ---------- Cross-page CFI mismatch --------------------------------------- + + it('should fail to resolve a page 3 CFI on page 4 document', () => { + const textLayer = doc3.querySelector('.textLayer')!; + const spans = textLayer.querySelectorAll('span'); + // Pick the last span so its index likely exceeds page 4's element count + const lastSpan = spans[spans.length - 1]!; + expect(lastSpan.firstChild).toBeTruthy(); + + const srcRange = doc3.createRange(); + srcRange.setStart(lastSpan.firstChild!, 0); + const endOffset = Math.min(5, lastSpan.firstChild!.textContent!.length); + srcRange.setEnd(lastSpan.firstChild!, endOffset); + + const cfi = fromRange(srcRange); + const parts = parse(cfi); + const range = toRange(doc4, parts); + // May resolve to a different node or return null depending on DOM sizes; + // either way it should NOT match the original text + if (range) { + expect(range.toString()).not.toBe(srcRange.toString()); + } + }); + + // ---------- Reference CFI format verification ----------------------------- + + it('should parse real browser-captured CFIs correctly', () => { + // These CFIs were captured from sample-alice.pdf in a full browser with + // TextLayer. They verify that the CFI format is structurally valid. + const cfi3 = 'epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)'; + const cfi4 = 'epubcfi(/6/10!/4/4/42,/1:0,/1:46)'; + + const parts3 = parse(cfi3); + expect(parts3.parent).toBeTruthy(); + expect(parts3.start).toBeTruthy(); + expect(parts3.end).toBeTruthy(); + + const parts4 = parse(cfi4); + expect(parts4.parent).toBeTruthy(); + expect(parts4.start).toBeTruthy(); + expect(parts4.end).toBeTruthy(); + }); + + it('should encode the correct section index in CFI spine step', () => { + // Section index 3 → spine step /6/8, section index 4 → spine step /6/10 + const cfi3 = 'epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)'; + const cfi4 = 'epubcfi(/6/10!/4/4/42,/1:0,/1:46)'; + + expect(cfi3).toContain('/6/8!'); + expect(cfi4).toContain('/6/10!'); + + // Spine step /6/N: N = (index + 1) * 2 + // index 3 → (3+1)*2 = 8, index 4 → (4+1)*2 = 10 + const parts3 = parse(cfi3); + expect(parts3.parent[0][0].index).toBe(6); + expect(parts3.parent[0][1].index).toBe(8); + + const parts4 = parse(cfi4); + expect(parts4.parent[0][0].index).toBe(6); + expect(parts4.parent[0][1].index).toBe(10); + }); + + // ---------- Out-of-bounds CFI indices ------------------------------------- + + it('should return null when CFI child indices exceed the DOM', () => { + const cfi = 'epubcfi(/6/8!/4/4,/9000/1:0,/9002/1:5)'; + const parts = shiftSpine(parse(cfi)); + const range = toRange(doc3, parts); + expect(range).toBeNull(); + }); + + it('should return null for a simple CFI with an unreachable node', () => { + const cfi = 'epubcfi(/6/8!/4/9000/1:0)'; + const parts = shiftSpine(parse(cfi)); + const range = toRange(doc3, parts); + expect(range).toBeNull(); + }); + + it('should return null when start resolves but end does not', () => { + const cfi = 'epubcfi(/6/8!/4/4,/2/1:0,/9999/1:5)'; + const parts = shiftSpine(parse(cfi)); + const range = toRange(doc3, parts); + expect(range).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/pdf-tts.test.ts b/apps/readest-app/src/__tests__/document/pdf-tts.test.ts new file mode 100644 index 00000000..8856f522 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/pdf-tts.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve, join } from 'path'; +import { textWalker } from 'foliate-js/text-walker.js'; +import { TTS } from 'foliate-js/tts.js'; +import { createRejectFilter } from '@/utils/node'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; + +// The @pdfjs alias in vitest.config.mts resolves to public/vendor/pdfjs, +// mirroring how foliate-js/pdf.js does `import '@pdfjs/pdf.min.mjs'`. +const vendorDir = join(process.cwd(), 'public/vendor'); + +/** Strip all XML/SSML tags to get plain text content */ +const stripTags = (ssml: string): string => ssml.replace(/<[^>]+\/?>/g, '').trim(); + +const highlight = vi.fn(); + +/** + * Build a document that mimics a rendered PDF page with text layer, + * matching the structure that pdf.js produces in the iframe. + */ +const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Document => { + const parser = new DOMParser(); + const spans = textSpans.map((t) => `${t}`).join(''); + const annotation = annotationText + ? `` + : '
'; + const html = + `` + + `` + + `
` + + `
${spans}
` + + `${annotation}` + + ``; + return parser.parseFromString(html, 'text/html'); +}; + +/** Node filter matching what TTSController uses for PDFs */ +const pdfNodeFilter = createRejectFilter({ + tags: ['rt', 'canvas'], + classes: ['annotationLayer'], + contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }], +}); + +describe('PDF TTS', () => { + describe('TTS with PDF text layer document', () => { + it('should generate SSML from text layer spans', () => { + const doc = createPDFTextLayerDoc(['Alice was beginning to get very ', 'tired of sitting']); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + const text = stripTags(ssml!); + expect(text).toContain('Alice'); + expect(text).toContain('tired'); + }); + + it('should filter out canvas content', () => { + const doc = createPDFTextLayerDoc(['Hello world']); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Hello world'); + }); + + it('should filter out annotation layer text', () => { + const doc = createPDFTextLayerDoc(['Main text content'], 'Link annotation text'); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const allText: string[] = []; + let ssml = tts.start(); + while (ssml) { + allText.push(stripTags(ssml)); + ssml = tts.next(); + } + const combined = allText.join(' '); + + expect(combined).toContain('Main text content'); + expect(combined).not.toContain('Link annotation text'); + }); + + it('should produce valid SSML with speak root element', () => { + const doc = createPDFTextLayerDoc(['Test content']); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(ssml).toContain(''); + }); + + it('should include mark elements in SSML output', () => { + const doc = createPDFTextLayerDoc(['Some text with multiple words']); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(ssml).toContain(' { + it('should reject canvas elements', () => { + const canvas = document.createElement('canvas'); + expect(pdfNodeFilter(canvas)).toBe(NodeFilter.FILTER_REJECT); + }); + + it('should reject annotationLayer elements', () => { + const div = document.createElement('div'); + div.className = 'annotationLayer'; + expect(pdfNodeFilter(div)).toBe(NodeFilter.FILTER_REJECT); + }); + + it('should reject rt elements', () => { + const rt = document.createElement('rt'); + expect(pdfNodeFilter(rt)).toBe(NodeFilter.FILTER_REJECT); + }); + + it('should skip regular div elements', () => { + const div = document.createElement('div'); + div.className = 'textLayer'; + expect(pdfNodeFilter(div)).toBe(NodeFilter.FILTER_SKIP); + }); + + it('should accept text nodes', () => { + const text = document.createTextNode('hello'); + expect(pdfNodeFilter(text)).toBe(NodeFilter.FILTER_ACCEPT); + }); + + it('should reject footnote-like anchor content', () => { + const a = document.createElement('a'); + a.textContent = '[1]'; + expect(pdfNodeFilter(a)).toBe(NodeFilter.FILTER_REJECT); + }); + + it('should not reject normal anchor content', () => { + const a = document.createElement('a'); + a.textContent = 'Click here for more'; + expect(pdfNodeFilter(a)).toBe(NodeFilter.FILTER_SKIP); + }); + }); + + describe('DocumentLoader with sample-alice.pdf', () => { + let book: BookDoc; + + beforeAll(async () => { + // Override workerSrc to an absolute file path so the pdfjs fake-worker + // can import it inside jsdom (the module-level code in pdf.js sets it + // to a URL path that only works in a real browser). + // Import pdf.js first to trigger the @pdfjs side-effect that sets globalThis.pdfjsLib. + await import('foliate-js/pdf.js'); + const pdfjsLib = (globalThis as Record)['pdfjsLib'] as { + GlobalWorkerOptions: { workerSrc: string }; + }; + pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + `file://${join(vendorDir, 'pdfjs/pdf.worker.min.mjs')}`, + ).href; + + const pdfPath = resolve(__dirname, '../fixtures/data/sample-alice.pdf'); + const buffer = readFileSync(pdfPath); + const file = new File([buffer], 'sample-alice.pdf', { type: 'application/pdf' }); + const loader = new DocumentLoader(file); + const result = await loader.open(); + book = result.book; + expect(result.format).toBe('PDF'); + }, 30_000); + + it('should load the sample PDF and return a book object', () => { + expect(book).toBeTruthy(); + expect(book.rendition.layout).toBe('pre-paginated'); + }); + + it('should have sections matching the number of pages', () => { + expect(book.sections).toBeTruthy(); + expect(book.sections.length).toBeGreaterThan(0); + }); + + it('should extract metadata', () => { + expect(book.metadata).toBeTruthy(); + // sample-alice.pdf should have a title + expect(book.metadata.title).toBeTruthy(); + }); + + it('should provide createDocument on every section', () => { + for (const section of book.sections) { + expect(typeof section.createDocument).toBe('function'); + } + }); + + it('should generate TTS SSML from createDocument output', async () => { + const doc = await book.sections[0]!.createDocument(); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(ssml).toContain(' { + const doc = await book.sections[0]!.createDocument(); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + blocks.push(stripTags(ssml)); + ssml = tts.next(); + } + + expect(blocks.length).toBeGreaterThan(0); + for (const block of blocks) { + expect(block.length).toBeGreaterThan(0); + } + }); + + it('should produce createDocument output for multiple pages', async () => { + const pagesToTest = Math.min(book.sections.length, 3); + + for (let i = 0; i < pagesToTest; i++) { + const doc = await book.sections[i]!.createDocument(); + expect(doc).toBeTruthy(); + + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!).length).toBeGreaterThan(0); + } + }); + + it('should return consistent text across repeated createDocument calls', async () => { + const doc1 = await book.sections[0]!.createDocument(); + const doc2 = await book.sections[0]!.createDocument(); + + const tts1 = new TTS(doc1, textWalker, undefined, highlight, 'word'); + const tts2 = new TTS(doc2, textWalker, undefined, highlight, 'word'); + + expect(stripTags(tts1.start()!)).toBe(stripTags(tts2.start()!)); + }); + + it('should work with sentence granularity on real PDF content', async () => { + const doc = await book.sections[0]!.createDocument(); + const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!).length).toBeGreaterThan(0); + }); + + it('should call highlight callback when marking words from PDF', async () => { + highlight.mockClear(); + const doc = await book.sections[0]!.createDocument(); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + tts.start(); + + const range = tts.setMark('0'); + expect(range).toBeTruthy(); + expect(highlight).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/tts.test.ts b/apps/readest-app/src/__tests__/document/tts.test.ts index a92283ce..448e7be1 100644 --- a/apps/readest-app/src/__tests__/document/tts.test.ts +++ b/apps/readest-app/src/__tests__/document/tts.test.ts @@ -1,18 +1,7 @@ -import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { textWalker } from 'foliate-js/text-walker.js'; import { TTS } from 'foliate-js/tts.js'; -beforeAll(() => { - if (typeof CSS === 'undefined' || !CSS.escape) { - Object.defineProperty(globalThis, 'CSS', { - value: { - escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'), - }, - writable: true, - }); - } -}); - const createHTMLDoc = (bodyHTML: string, attrs: Record = {}): Document => { const parser = new DOMParser(); const attrStr = Object.entries(attrs) diff --git a/apps/readest-app/src/__tests__/fixtures/sample-alice.epub b/apps/readest-app/src/__tests__/fixtures/data/sample-alice.epub similarity index 100% rename from apps/readest-app/src/__tests__/fixtures/sample-alice.epub rename to apps/readest-app/src/__tests__/fixtures/data/sample-alice.epub diff --git a/apps/readest-app/src/__tests__/fixtures/data/sample-alice.pdf b/apps/readest-app/src/__tests__/fixtures/data/sample-alice.pdf new file mode 100644 index 00000000..bed52a7a Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/sample-alice.pdf differ diff --git a/apps/readest-app/src/__tests__/fixtures/data/sample-paper.pdf b/apps/readest-app/src/__tests__/fixtures/data/sample-paper.pdf new file mode 100644 index 00000000..367bf88a Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/sample-paper.pdf differ diff --git a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts index 4dcfb172..7f6f1d24 100644 --- a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts +++ b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts @@ -171,9 +171,11 @@ describe('TxtToEpubConverter', () => { const reader = new ZipReader(new BlobReader(blob)); try { const entries = await reader.getEntries(); - const chapterEntry = entries.find((entry) => entry.filename === 'OEBPS/chapter1.xhtml'); + const chapterEntry = entries.find((entry) => entry.filename === 'OEBPS/chapter1.xhtml') as { + getData?: (writer: unknown) => Promise; + }; expect(chapterEntry).toBeDefined(); - const chapterContent = await chapterEntry!.getData(new TextWriter()); + const chapterContent = await chapterEntry?.getData?.(new TextWriter()); expect(chapterContent).toContain('lang="zh"'); expect(chapterContent).toContain('xml:lang="zh"'); } finally { diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 6c80b15b..3915812e 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -657,7 +657,7 @@ const FoliateViewer: React.FC<{ role='document' aria-label={_('Book Content')} className={clsx( - 'foliate-viewer h-[100%] w-[100%] focus:outline-none', + 'foliate-viewer absolute h-[100%] w-[100%] focus:outline-none', viewState?.loading && 'bg-base-100', )} style={{ diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx index e5319706..6c85111d 100644 --- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx +++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx @@ -193,7 +193,7 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { )} onClick={resetZoom} > - {zoomLevel}% + {Math.round(zoomLevel)}%