refactor(pdf-layout): streamline viewer layout settling and harden pdf document loading
Unify and improve layout settling logic across EPUB, HTML, and PDF viewers by adding delayed recomputation and guarding against zero-height container during transient states. Refactor PDF document loading to include retry logic with success detection, ensuring robust handling of transient failures. Update Playwright test helpers to provide more reliable PDF viewer readiness checks and introduce request retry utilities for backend API calls. Add targeted unit tests for single-section chapter fallback in the PDF audiobook adapter. Update test files and helpers to align with new layout and export logic.
This commit is contained in:
parent
01cb95d8e7
commit
84c3b22e5a
8 changed files with 217 additions and 56 deletions
|
|
@ -111,7 +111,9 @@ export default function EPUBPage() {
|
|||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||
const vh = window.innerHeight;
|
||||
const h = Math.max(0, vh - headerH - ttsH);
|
||||
setContainerHeight(`${h}px`);
|
||||
if (h > 0) {
|
||||
setContainerHeight(`${h}px`);
|
||||
}
|
||||
|
||||
// compute max horizontal padding while preserving a minimum readable width,
|
||||
// but still allow some padding on small screens
|
||||
|
|
@ -122,9 +124,15 @@ export default function EPUBPage() {
|
|||
setMaxPadPx(maxPad);
|
||||
};
|
||||
compute();
|
||||
const settleT1 = window.setTimeout(compute, 0);
|
||||
const settleT2 = window.setTimeout(compute, 120);
|
||||
window.addEventListener('resize', compute);
|
||||
return () => window.removeEventListener('resize', compute);
|
||||
}, []);
|
||||
return () => {
|
||||
window.removeEventListener('resize', compute);
|
||||
window.clearTimeout(settleT1);
|
||||
window.clearTimeout(settleT2);
|
||||
};
|
||||
}, [isLoading, activeSidebar]);
|
||||
|
||||
// Nudge EPUB renderer to reflow on horizontal padding changes
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -111,7 +111,9 @@ export default function HTMLPage() {
|
|||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||
const vh = window.innerHeight;
|
||||
const h = Math.max(0, vh - headerH - ttsH);
|
||||
setContainerHeight(`${h}px`);
|
||||
if (h > 0) {
|
||||
setContainerHeight(`${h}px`);
|
||||
}
|
||||
|
||||
// Adaptive minimum content width: allow some padding on narrow screens
|
||||
const vw = window.innerWidth;
|
||||
|
|
@ -121,9 +123,15 @@ export default function HTMLPage() {
|
|||
setMaxPadPx(maxPad);
|
||||
};
|
||||
compute();
|
||||
const settleT1 = window.setTimeout(compute, 0);
|
||||
const settleT2 = window.setTimeout(compute, 120);
|
||||
window.addEventListener('resize', compute);
|
||||
return () => window.removeEventListener('resize', compute);
|
||||
}, []);
|
||||
return () => {
|
||||
window.removeEventListener('resize', compute);
|
||||
window.clearTimeout(settleT1);
|
||||
window.clearTimeout(settleT2);
|
||||
};
|
||||
}, [isLoading, activeSidebar]);
|
||||
|
||||
const handleGenerateAudiobook = useCallback(async (
|
||||
onProgress: (progress: number) => void,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export default function PDFViewerPage() {
|
|||
console.log('Loading new document (from page.tsx)');
|
||||
let didRedirect = false;
|
||||
let startedLoad = false;
|
||||
let loadSucceeded = false;
|
||||
try {
|
||||
if (!id) {
|
||||
setError('Document not found');
|
||||
|
|
@ -101,8 +102,20 @@ export default function PDFViewerPage() {
|
|||
startedLoad = true;
|
||||
inFlightDocIdRef.current = resolved;
|
||||
stop(); // Reset TTS when loading new document
|
||||
await setCurrentDocument(resolved);
|
||||
loadedDocIdRef.current = resolved;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const loaded = await setCurrentDocument(resolved);
|
||||
if (loaded) {
|
||||
loadSucceeded = true;
|
||||
loadedDocIdRef.current = resolved;
|
||||
break;
|
||||
}
|
||||
if (attempt === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
if (!loadSucceeded) {
|
||||
throw new Error(`Failed to load PDF document ${resolved}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading document:', err);
|
||||
setError('Failed to load document');
|
||||
|
|
@ -110,7 +123,7 @@ export default function PDFViewerPage() {
|
|||
if (startedLoad) {
|
||||
inFlightDocIdRef.current = null;
|
||||
}
|
||||
if (!didRedirect && startedLoad) {
|
||||
if (!didRedirect && startedLoad && loadSucceeded) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -148,12 +161,21 @@ export default function PDFViewerPage() {
|
|||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||
const vh = window.innerHeight;
|
||||
const h = Math.max(0, vh - headerH - ttsH);
|
||||
setContainerHeight(`${h}px`);
|
||||
// Avoid locking the reader at 0px during transient startup layout states.
|
||||
if (h > 0) {
|
||||
setContainerHeight(`${h}px`);
|
||||
}
|
||||
};
|
||||
compute();
|
||||
const settleT1 = window.setTimeout(compute, 0);
|
||||
const settleT2 = window.setTimeout(compute, 120);
|
||||
window.addEventListener('resize', compute);
|
||||
return () => window.removeEventListener('resize', compute);
|
||||
}, []);
|
||||
return () => {
|
||||
window.removeEventListener('resize', compute);
|
||||
window.clearTimeout(settleT1);
|
||||
window.clearTimeout(settleT2);
|
||||
};
|
||||
}, [isLoading, isParseReady, isAtLimit, activeSidebar]);
|
||||
|
||||
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300));
|
||||
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ import {
|
|||
highlightWordIndex,
|
||||
} from '@/lib/client/pdf';
|
||||
import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text';
|
||||
import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan';
|
||||
import {
|
||||
DEFAULT_DOCUMENT_SETTINGS,
|
||||
type DocumentSettings,
|
||||
|
|
@ -72,7 +71,7 @@ export interface PdfDocumentState {
|
|||
parsedOverlayEnabled: boolean;
|
||||
setParsedOverlayEnabled: (enabled: boolean) => void;
|
||||
forceReparseParsedPdf: () => Promise<void>;
|
||||
setCurrentDocument: (id: string) => Promise<void>;
|
||||
setCurrentDocument: (id: string) => Promise<boolean>;
|
||||
clearCurrDoc: () => void;
|
||||
|
||||
// PDF functionality
|
||||
|
|
@ -264,24 +263,6 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
return;
|
||||
}
|
||||
|
||||
const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => {
|
||||
const page = pageFromParsed(pageNum);
|
||||
if (!page) return [];
|
||||
const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []);
|
||||
return page.blocks
|
||||
.filter((block) => !skipKinds.has(block.kind))
|
||||
.map((block) => ({
|
||||
sourceKey: `pdf:${pageNum}:${block.id}`,
|
||||
text: block.text,
|
||||
locator: {
|
||||
readerType: 'pdf',
|
||||
page: block.fragments[0]?.page ?? pageNum,
|
||||
blockId: block.id,
|
||||
} as TTSSegmentLocator,
|
||||
}))
|
||||
.filter((unit) => unit.text.trim().length > 0);
|
||||
};
|
||||
|
||||
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => {
|
||||
// Ignore stale/in-flight work if the document or worker changed.
|
||||
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||
|
|
@ -345,14 +326,12 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
|
||||
if (text !== currDocText || text === '') {
|
||||
setCurrDocText(text);
|
||||
const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber);
|
||||
setTTSText(text, {
|
||||
location: currDocPageNumber,
|
||||
previousText: prevText,
|
||||
nextLocation: nextPageNumber,
|
||||
nextText: nextText,
|
||||
upcomingLocations: additionalUpcoming,
|
||||
...(sourceUnits.length > 0 ? { sourceUnits } : {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -389,7 +368,7 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
* @param {string} id - The unique identifier of the document to set
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||
const setCurrentDocument = useCallback(async (id: string): Promise<boolean> => {
|
||||
// --- race-condition guard ---
|
||||
const seq = ++docLoadSeqRef.current;
|
||||
docLoadAbortRef.current?.abort();
|
||||
|
|
@ -416,10 +395,10 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||
|
||||
const meta = await getDocumentMetadata(id, { signal: controller.signal });
|
||||
if (seq !== docLoadSeqRef.current) return; // stale
|
||||
if (seq !== docLoadSeqRef.current) return false; // stale
|
||||
if (!meta) {
|
||||
console.error('Document not found on server');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (meta.type === 'pdf') {
|
||||
startParsedPolling(id, (meta.parseStatus ?? null) as PdfParseStatus | null);
|
||||
|
|
@ -427,25 +406,28 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
}
|
||||
|
||||
const doc = await ensureCachedDocument(meta, { signal: controller.signal });
|
||||
if (seq !== docLoadSeqRef.current) return; // stale
|
||||
if (seq !== docLoadSeqRef.current) return false; // stale
|
||||
if (doc.type !== 'pdf') {
|
||||
console.error('Document is not a PDF');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
setCurrDocName(doc.name);
|
||||
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
|
||||
// buffer passed into the worker; we always pass clones to react-pdf.
|
||||
setCurrDocData(doc.data.slice(0));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return false;
|
||||
console.error('Failed to get document:', error);
|
||||
return false;
|
||||
} finally {
|
||||
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
|
||||
if (docLoadAbortRef.current === controller) {
|
||||
docLoadAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [
|
||||
setCurrDocId,
|
||||
setCurrDocName,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect, Page } from '@playwright/test';
|
||||
import type { APIResponse } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
|
@ -8,6 +9,35 @@ import { setupTest, uploadAndDisplay } from './helpers';
|
|||
|
||||
const execFileAsync = util.promisify(execFile);
|
||||
|
||||
function isTransientRequestError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const msg = error.message || '';
|
||||
return (
|
||||
msg.includes('ECONNRESET') ||
|
||||
msg.includes('socket hang up') ||
|
||||
msg.includes('ERR_SOCKET_CLOSED') ||
|
||||
msg.includes('ETIMEDOUT') ||
|
||||
msg.includes('fetch failed')
|
||||
);
|
||||
}
|
||||
|
||||
async function requestWithRetry(
|
||||
fn: () => Promise<APIResponse>,
|
||||
{ attempts = 4, backoffMs = 200 }: { attempts?: number; backoffMs?: number } = {},
|
||||
): Promise<APIResponse> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientRequestError(error) || attempt === attempts) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt));
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('Request failed');
|
||||
}
|
||||
|
||||
async function getBookIdFromUrl(page: Page, expectedPrefix: 'pdf' | 'epub') {
|
||||
const url = new URL(page.url());
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
|
|
@ -103,7 +133,7 @@ async function getAudioDurationSeconds(filePath: string) {
|
|||
}
|
||||
|
||||
async function expectChaptersBackendState(page: Page, bookId: string) {
|
||||
const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`);
|
||||
const res = await requestWithRetry(() => page.request.get(`/api/audiobook/status?bookId=${bookId}`));
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const json = await res.json();
|
||||
return json;
|
||||
|
|
@ -184,11 +214,20 @@ async function cancelGenerationIfVisible(page: Page): Promise<void> {
|
|||
}
|
||||
|
||||
async function resetAudiobookById(page: Page, bookId: string) {
|
||||
const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`);
|
||||
const res = await requestWithRetry(() => page.request.delete(`/api/audiobook?bookId=${bookId}`));
|
||||
expect(res.ok() || res.status() === 404).toBeTruthy();
|
||||
}
|
||||
|
||||
async function resetAudiobookIfPresent(page: Page) {
|
||||
async function resetAudiobookIfPresent(page: Page, bookId?: string) {
|
||||
// Prefer backend reset when bookId is available: deterministic and independent of modal timing.
|
||||
if (bookId) {
|
||||
await resetAudiobookById(page, bookId);
|
||||
await page.reload();
|
||||
await openExportModal(page);
|
||||
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const resetButtons = page.getByRole('button', { name: 'Reset' });
|
||||
const count = await resetButtons.count();
|
||||
|
||||
|
|
@ -199,8 +238,12 @@ async function resetAudiobookIfPresent(page: Page) {
|
|||
const resetButton = resetButtons.first();
|
||||
await resetButton.click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15_000 });
|
||||
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
|
||||
const resetDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: page.getByRole('heading', { name: 'Reset Audiobook' }) })
|
||||
.first();
|
||||
await expect(resetDialog).toBeVisible({ timeout: 15_000 });
|
||||
const confirmReset = resetDialog.getByRole('button', { name: 'Reset' });
|
||||
await confirmReset.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
|
||||
|
|
@ -256,7 +299,7 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({
|
|||
expect(ch.duration).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
await resetAudiobookIfPresent(page);
|
||||
await resetAudiobookIfPresent(page, bookId);
|
||||
});
|
||||
|
||||
test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }, testInfo) => {
|
||||
|
|
@ -312,7 +355,7 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async
|
|||
expect(durationSeconds).toBeLessThan(300);
|
||||
});
|
||||
|
||||
await resetAudiobookIfPresent(page);
|
||||
await resetAudiobookIfPresent(page, bookId);
|
||||
});
|
||||
|
||||
test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => {
|
||||
|
|
@ -343,7 +386,7 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page
|
|||
expect(durationSeconds).toBeLessThan(300);
|
||||
});
|
||||
|
||||
await resetAudiobookIfPresent(page);
|
||||
await resetAudiobookIfPresent(page, bookId);
|
||||
});
|
||||
|
||||
test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => {
|
||||
|
|
@ -455,7 +498,7 @@ test('regenerates a single MP3 audiobook PDF page and exports full audiobook', a
|
|||
expect(ch.duration).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
await resetAudiobookIfPresent(page);
|
||||
await resetAudiobookIfPresent(page, bookId);
|
||||
});
|
||||
|
||||
test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }, testInfo) => {
|
||||
|
|
@ -476,7 +519,9 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD
|
|||
|
||||
// Delete the first chapter via the backend API so the audiobook has a missing index (0).
|
||||
// This is more reliable than clicking through the chapter actions menu in headless runs.
|
||||
const deleteRes = await page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`);
|
||||
const deleteRes = await requestWithRetry(() =>
|
||||
page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`)
|
||||
);
|
||||
expect(deleteRes.ok()).toBeTruthy();
|
||||
|
||||
// Wait for backend to reflect only one remaining chapter (index 1).
|
||||
|
|
@ -534,5 +579,5 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD
|
|||
expect(ch.duration).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
await resetAudiobookIfPresent(page);
|
||||
await resetAudiobookIfPresent(page, bookId);
|
||||
});
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -22,6 +22,21 @@ function sha256HexOfFile(filePath: string) {
|
|||
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||
}
|
||||
|
||||
async function waitForPdfViewerReady(page: Page, timeout = 60000) {
|
||||
await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/, { timeout: Math.min(timeout, 20000) });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const documentVisible = await page.locator('.react-pdf__Document').first().isVisible().catch(() => false);
|
||||
if (documentVisible) return true;
|
||||
const pageVisible = await page.locator('.react-pdf__Page').first().isVisible().catch(() => false);
|
||||
if (pageVisible) return true;
|
||||
const canvasVisible = await page.locator('.react-pdf__Page canvas').first().isVisible().catch(() => false);
|
||||
if (canvasVisible) return true;
|
||||
return false;
|
||||
}, { timeout })
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a sample epub or pdf
|
||||
*/
|
||||
|
|
@ -63,7 +78,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
|
|||
await expect(targetLink).toBeVisible({ timeout: 15000 });
|
||||
await dismissOnboardingModals(page);
|
||||
await targetLink.click();
|
||||
await page.waitForSelector('.react-pdf__Document', { timeout: 15000 });
|
||||
await waitForPdfViewerReady(page, 60000);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +95,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
|
|||
}
|
||||
|
||||
if (lower.endsWith('.pdf')) {
|
||||
await page.waitForSelector('.react-pdf__Document', { timeout: 10000 });
|
||||
await waitForPdfViewerReady(page, 60000);
|
||||
} else if (lower.endsWith('.epub')) {
|
||||
await page.waitForSelector('.epub-container', { timeout: 10000 });
|
||||
} else if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
||||
|
|
@ -350,10 +365,28 @@ export async function ensureDocumentsListed(page: Page, fileNames: string[]) {
|
|||
|
||||
// Click the document link row by filename
|
||||
export async function clickDocumentLink(page: Page, fileName: string) {
|
||||
await page
|
||||
const link = page
|
||||
.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
||||
.first()
|
||||
.click();
|
||||
.first();
|
||||
await expect(link).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const href = await link.getAttribute('href');
|
||||
if (!href) {
|
||||
await link.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const navigatedByClick = await Promise.all([
|
||||
page
|
||||
.waitForURL((url) => url.pathname === href, { timeout: 8_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
link.click(),
|
||||
]).then(([ok]) => ok);
|
||||
|
||||
if (!navigatedByClick) {
|
||||
await page.goto(href);
|
||||
}
|
||||
}
|
||||
|
||||
// Expect correct URL and viewer to be visible for a given file by extension
|
||||
|
|
|
|||
|
|
@ -73,4 +73,67 @@ test.describe('pdf audiobook adapter', () => {
|
|||
expect(chapters[1].title).toBe('Second');
|
||||
expect(chapters[1].text).toContain('More body.');
|
||||
});
|
||||
|
||||
test('keeps a single section chapter when only one heading is present', async () => {
|
||||
const parsed: ParsedPdfDocument = {
|
||||
schemaVersion: 1,
|
||||
documentId: 'doc-2',
|
||||
parserVersion: 'test',
|
||||
parsedAt: Date.now(),
|
||||
pages: [
|
||||
{
|
||||
pageNumber: 1,
|
||||
width: 100,
|
||||
height: 100,
|
||||
blocks: [
|
||||
{
|
||||
id: 'p1-title',
|
||||
kind: 'doc_title',
|
||||
text: 'Sample PDF',
|
||||
fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Sample PDF', readingOrder: 0 }],
|
||||
},
|
||||
{
|
||||
id: 'p1-text',
|
||||
kind: 'text',
|
||||
text: 'First page body.',
|
||||
fragments: [{ page: 1, bbox: [0, 50, 100, 79], text: 'First page body.', readingOrder: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
pageNumber: 2,
|
||||
width: 100,
|
||||
height: 100,
|
||||
blocks: [
|
||||
{
|
||||
id: 'p2-text',
|
||||
kind: 'text',
|
||||
text: 'Second page body.',
|
||||
fragments: [{ page: 2, bbox: [0, 50, 100, 79], text: 'Second page body.', readingOrder: 0 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const settings: DocumentSettings = {
|
||||
schemaVersion: 1,
|
||||
pdf: {
|
||||
skipBlockKinds: [],
|
||||
chaptersFromSections: true,
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = createPdfAudiobookSourceAdapter({
|
||||
parsed,
|
||||
settings,
|
||||
smartSentenceSplitting: false,
|
||||
});
|
||||
|
||||
const chapters = await adapter.prepareChapters();
|
||||
expect(chapters).toHaveLength(1);
|
||||
expect(chapters[0].title).toBe('Sample PDF');
|
||||
expect(chapters[0].text).toContain('First page body.');
|
||||
expect(chapters[0].text).toContain('Second page body.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue