diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts index d487ce2..8c5a78a 100644 --- a/src/app/(app)/epub/[id]/useEpubDocument.ts +++ b/src/app/(app)/epub/[id]/useEpubDocument.ts @@ -101,7 +101,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { baseUrl, providerRef, ttsSegmentMaxBlockLength, - smartSentenceSplitting, epubTheme, epubHighlightEnabled, } = useConfig(); @@ -239,22 +238,13 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const leadingPreview = collectLeadingContextFromRange(range); const continuationPreview = collectContinuationFromRange(range); - if (smartSentenceSplitting) { - setTTSText(textContent, { - shouldPause, - location: start.cfi, - previousText: leadingPreview, - nextLocation: end.cfi, - nextText: continuationPreview - }); - } else { - // When smart splitting is disabled, behave like the original implementation: - // send only the current page/location text without any continuation preview. - setTTSText(textContent, { - shouldPause, - location: start.cfi, - }); - } + setTTSText(textContent, { + shouldPause, + location: start.cfi, + previousText: leadingPreview, + nextLocation: end.cfi, + nextText: continuationPreview + }); setCurrDocText(textContent); return textContent; @@ -262,7 +252,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { console.error('Error extracting EPUB text:', error); return ''; } - }, [setRenderedTextMaps, setTTSText, smartSentenceSplitting]); + }, [setRenderedTextMaps, setTTSText]); /** * Resolves a draft EPUB locator (typically `{ readerType: 'epub', location: diff --git a/src/app/(app)/html/[id]/useHtmlDocument.ts b/src/app/(app)/html/[id]/useHtmlDocument.ts index e8e854d..fe23c0f 100644 --- a/src/app/(app)/html/[id]/useHtmlDocument.ts +++ b/src/app/(app)/html/[id]/useHtmlDocument.ts @@ -68,7 +68,6 @@ export function useHtmlDocument(): HtmlDocumentState { apiKey, baseUrl, providerRef, - smartSentenceSplitting, ttsSegmentMaxBlockLength, } = useConfig(); @@ -133,10 +132,9 @@ export function useHtmlDocument(): HtmlDocumentState { createHtmlAudiobookSourceAdapter({ blocks, isTxt, - smartSentenceSplitting, maxBlockLength: ttsSegmentMaxBlockLength, }), - [blocks, isTxt, smartSentenceSplitting, ttsSegmentMaxBlockLength], + [blocks, isTxt, ttsSegmentMaxBlockLength], ); const createFullAudioBook = useCallback( diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 5aafa0a..3476931 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -37,6 +37,7 @@ import { highlightWordIndex, } from '@/lib/client/pdf'; import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; +import { buildPdfPageSourceUnits, buildPdfPrefetchPayload } from '@/lib/client/pdf-tts-planning'; import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; import { DEFAULT_DOCUMENT_SETTINGS, @@ -130,7 +131,6 @@ export function usePdfDocument(): PdfDocumentState { apiKey, baseUrl, providerRef, - smartSentenceSplitting, segmentPreloadDepthPages, ttsSegmentMaxBlockLength, } = useConfig(); @@ -149,9 +149,8 @@ export function usePdfDocument(): PdfDocumentState { const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({ parsed: parsedDocument ?? undefined, settings: documentSettings, - smartSentenceSplitting, maxBlockLength: ttsSegmentMaxBlockLength, - }), [parsedDocument, documentSettings, smartSentenceSplitting, ttsSegmentMaxBlockLength]); + }), [parsedDocument, documentSettings, ttsSegmentMaxBlockLength]); const pageTextCacheRef = useRef>(new Map()); const [currDocPage, setCurrDocPage] = useState(currDocPageNumber); @@ -266,20 +265,7 @@ export function usePdfDocument(): PdfDocumentState { 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: pageNum, - blockId: block.id, - } as TTSSegmentLocator, - })) - .filter((unit) => unit.text.trim().length > 0); + return buildPdfPageSourceUnits(page, pageNum, documentSettings.pdf?.skipBlockKinds ?? []); }; const getPageText = async (pageNumber: number, shouldCache = false): Promise => { @@ -327,16 +313,15 @@ export function usePdfDocument(): PdfDocumentState { prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve(undefined), ...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)), ]); - const nextText = upcomingTexts[0]; - const nextSourceUnits = nextPageNumber ? sourceUnitsFromParsedPage(nextPageNumber) : []; - const additionalUpcoming = upcomingPageNumbers - .slice(1) - .map((pageNum, idx) => ({ - location: pageNum, - text: upcomingTexts[idx + 1] || '', - sourceUnits: sourceUnitsFromParsedPage(pageNum), - })) - .filter((item) => item.text.trim().length > 0); + const { + nextText, + nextSourceUnits, + additionalUpcoming, + } = buildPdfPrefetchPayload( + upcomingPageNumbers, + upcomingTexts, + sourceUnitsFromParsedPage, + ); if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { return; diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 96191fe..34c5e54 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -137,7 +137,6 @@ function sanitizePreferencesPatch( break; case 'skipBlank': case 'epubTheme': - case 'smartSentenceSplitting': case 'pdfHighlightEnabled': case 'pdfWordHighlightEnabled': case 'epubHighlightEnabled': diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index 27d1576..0516b02 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -113,7 +113,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { viewType, skipBlank, epubTheme, - smartSentenceSplitting, segmentPreloadDepthPages, segmentPreloadSentenceLookahead, ttsSegmentMaxBlockLength, @@ -272,13 +271,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { /> )} - updateConfigKey('smartSentenceSplitting', checked)} - variant="flat" - />
unit.sourceKey)); const contextSourceUnits: CanonicalTtsSourceUnit[] = []; - if (smartSentenceSplitting && normalizedOptions.previousText?.trim()) { + if (normalizedOptions.previousText?.trim()) { const previousLocation = normalizedOptions.previousLocation; contextSourceUnits.push({ sourceKey: previousLocation !== undefined @@ -1221,9 +1220,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } } for (const item of pendingPrefetches) { - if (smartSentenceSplitting) { - sourceUnits.push(...item.sourceUnits); - } + sourceUnits.push(...item.sourceUnits); } const plan = planCanonicalTtsSegments(sourceUnits, { @@ -1232,27 +1229,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, }); - const currentSegments = smartSentenceSplitting - ? plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)) - : effectiveCurrentUnits.flatMap((unit) => - planCanonicalTtsSegments([unit], { - readerType: activeReaderType, - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, - }).segments); + const currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)); const newSentences = currentSegments.map((segment) => segment.text); for (const item of pendingPrefetches) { const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey)); - const planned = smartSentenceSplitting - ? plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)) - : planCanonicalTtsSegments(item.sourceUnits, { - readerType: activeReaderType, - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, - }).segments; + const planned = plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)); if (planned.length > 0) { plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); } @@ -1333,8 +1315,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentWordIndex(null); if ( - smartSentenceSplitting - && !isEPUB + !isEPUB && normalizedOptions.nextLocation !== undefined && effectiveCurrentUnits.length === 1 ) { @@ -1384,7 +1365,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement abortAudio, isEPUB, activeReaderType, - smartSentenceSplitting, invalidatePlaybackRun, currDocPage, documentId, @@ -2483,7 +2463,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? currentSourceContextUnitsRef.current : (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []); const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits( - smartSentenceSplitting, liveContextUnits, upcomingUnits, ); @@ -2835,7 +2814,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement openApiBaseUrl, providerModelPolicy.supportsInstructions, ttsInstructions, - smartSentenceSplitting, onTTSStart, onTTSComplete, processSentence, diff --git a/src/lib/client/audiobooks/adapters/html.ts b/src/lib/client/audiobooks/adapters/html.ts index 9880c5a..f44e8ca 100644 --- a/src/lib/client/audiobooks/adapters/html.ts +++ b/src/lib/client/audiobooks/adapters/html.ts @@ -5,7 +5,6 @@ import type { HtmlBlock } from '@/lib/client/html/blocks'; interface HtmlAudiobookAdapterOptions { blocks: HtmlBlock[]; isTxt: boolean; - smartSentenceSplitting: boolean; maxBlockLength?: number; /** * For markdown: any heading with `headingLevel <= chapterHeadingLevel` @@ -83,7 +82,6 @@ function buildChapterDrafts({ function chapterText( draft: ChapterDraft, - smartSentenceSplitting: boolean, maxBlockLength?: number, ): string { const joined = draft.blocks @@ -91,14 +89,14 @@ function chapterText( .filter((t) => t && t.trim()) .join('\n\n'); if (!joined) return ''; - return smartSentenceSplitting ? normalizeTextForTts(joined, { maxBlockLength }) : joined; + return normalizeTextForTts(joined, { maxBlockLength }); } function preparedChapters(options: HtmlAudiobookAdapterOptions): PreparedAudiobookChapter[] { const drafts = buildChapterDrafts(options); const out: PreparedAudiobookChapter[] = []; for (const draft of drafts) { - const text = chapterText(draft, options.smartSentenceSplitting, options.maxBlockLength); + const text = chapterText(draft, options.maxBlockLength); if (!text.trim()) continue; out.push({ index: out.length, diff --git a/src/lib/client/audiobooks/adapters/pdf.ts b/src/lib/client/audiobooks/adapters/pdf.ts index a462c32..19c86e8 100644 --- a/src/lib/client/audiobooks/adapters/pdf.ts +++ b/src/lib/client/audiobooks/adapters/pdf.ts @@ -7,13 +7,11 @@ import { DEFAULT_DOCUMENT_SETTINGS } from '@/types/document-settings'; interface PdfAudiobookAdapterOptions { parsed?: ParsedPdfDocument; settings?: DocumentSettings; - smartSentenceSplitting: boolean; maxBlockLength?: number; } function chapterTextFromBlocks( blocks: ParsedPdfBlock[], - smartSentenceSplitting: boolean, maxBlockLength?: number, ): string { const text = blocks @@ -21,18 +19,16 @@ function chapterTextFromBlocks( .filter(Boolean) .join('\n\n'); if (!text) return ''; - return smartSentenceSplitting ? normalizeTextForTts(text, { maxBlockLength }) : text; + return normalizeTextForTts(text, { maxBlockLength }); } function prepareParsedChapters({ parsed, settings, - smartSentenceSplitting, maxBlockLength, }: { parsed: ParsedPdfDocument; settings: DocumentSettings; - smartSentenceSplitting: boolean; maxBlockLength?: number; }): PreparedAudiobookChapter[] { const skip = new Set(settings.pdf?.skipBlockKinds ?? DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? []); @@ -49,7 +45,7 @@ function prepareParsedChapters({ const flush = () => { if (!currentBlocks.length) return; - const text = chapterTextFromBlocks(currentBlocks, smartSentenceSplitting, maxBlockLength); + const text = chapterTextFromBlocks(currentBlocks, maxBlockLength); if (text) { chapters.push({ index: chapters.length, @@ -77,7 +73,6 @@ function prepareParsedChapters({ async function extractPreparedPdfChapters({ parsed, settings = DEFAULT_DOCUMENT_SETTINGS, - smartSentenceSplitting, maxBlockLength, }: PdfAudiobookAdapterOptions): Promise { if (!parsed) { @@ -87,7 +82,6 @@ async function extractPreparedPdfChapters({ return prepareParsedChapters({ parsed, settings, - smartSentenceSplitting, maxBlockLength, }); } diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts index cf8e3ce..3bb4d0a 100644 --- a/src/lib/client/dexie.ts +++ b/src/lib/client/dexie.ts @@ -194,8 +194,6 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow { voice: '', skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank, epubTheme: raw.epubTheme === 'true', - smartSentenceSplitting: - raw.smartSentenceSplitting === 'false' ? false : APP_CONFIG_DEFAULTS.smartSentenceSplitting, headerMargin: raw.headerMargin ? parseFloat(raw.headerMargin) : APP_CONFIG_DEFAULTS.headerMargin, footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin, leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin, diff --git a/src/lib/client/epub/tts-epub-preload.ts b/src/lib/client/epub/tts-epub-preload.ts index 36858ea..5f23e14 100644 --- a/src/lib/client/epub/tts-epub-preload.ts +++ b/src/lib/client/epub/tts-epub-preload.ts @@ -29,10 +29,8 @@ export function selectUpcomingWalkerItems( * (previous/current) so walker boundary behavior aligns with setText. */ export function buildWalkerPlanningSourceUnits( - smartSentenceSplitting: boolean, contextUnits: readonly CanonicalTtsSourceUnit[], upcomingUnits: readonly CanonicalTtsSourceUnit[], ): CanonicalTtsSourceUnit[] { - if (!smartSentenceSplitting) return [...upcomingUnits]; return [...contextUnits, ...upcomingUnits]; } diff --git a/src/lib/client/pdf-tts-planning.ts b/src/lib/client/pdf-tts-planning.ts new file mode 100644 index 0000000..23e2074 --- /dev/null +++ b/src/lib/client/pdf-tts-planning.ts @@ -0,0 +1,58 @@ +import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; +import type { TTSSegmentLocator } from '@/types/client'; +import type { ParsedPdfBlockKind, ParsedPdfPage } from '@/types/parsed-pdf'; + +type PdfUpcomingLocation = { + location: number; + text: string; + sourceUnits: CanonicalTtsSourceUnit[]; +}; + +export function buildPdfPageSourceUnits( + page: ParsedPdfPage | undefined, + pageNum: number, + skipKinds: ParsedPdfBlockKind[] = [], +): CanonicalTtsSourceUnit[] { + if (!page) return []; + const skip = new Set(skipKinds); + return page.blocks + .filter((block) => !skip.has(block.kind)) + .map((block) => ({ + sourceKey: `pdf:${pageNum}:${block.id}`, + text: block.text, + locator: { + readerType: 'pdf', + page: pageNum, + blockId: block.id, + } as TTSSegmentLocator, + })) + .filter((unit) => unit.text.trim().length > 0); +} + +export function buildPdfPrefetchPayload( + upcomingPageNumbers: number[], + upcomingTexts: string[], + sourceUnitsForPage: (pageNum: number) => CanonicalTtsSourceUnit[], +): { + nextText: string | undefined; + nextSourceUnits: CanonicalTtsSourceUnit[]; + additionalUpcoming: PdfUpcomingLocation[]; +} { + const nextPageNumber = upcomingPageNumbers[0]; + const nextText = upcomingTexts[0]; + const nextSourceUnits = nextPageNumber ? sourceUnitsForPage(nextPageNumber) : []; + const additionalUpcoming = upcomingPageNumbers + .slice(1) + .map((pageNum, idx) => ({ + location: pageNum, + text: upcomingTexts[idx + 1] || '', + sourceUnits: sourceUnitsForPage(pageNum), + })) + .filter((item) => item.text.trim().length > 0); + + return { + nextText, + nextSourceUnits, + additionalUpcoming, + }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 58bb388..53db98c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -62,7 +62,6 @@ export interface AppConfigValues { ttsModel: string; ttsInstructions: string; savedVoices: SavedVoices; - smartSentenceSplitting: boolean; segmentPreloadDepthPages: number; segmentPreloadSentenceLookahead: number; ttsSegmentMaxBlockLength: number; @@ -111,7 +110,6 @@ export function getAppConfigDefaults(): AppConfigValues { ttsModel: defaultModel, ttsInstructions: '', savedVoices: {}, - smartSentenceSplitting: true, segmentPreloadDepthPages: 1, segmentPreloadSentenceLookahead: 3, ttsSegmentMaxBlockLength: 450, diff --git a/src/types/user-state.ts b/src/types/user-state.ts index 7a0e275..3fc796e 100644 --- a/src/types/user-state.ts +++ b/src/types/user-state.ts @@ -7,7 +7,6 @@ export const SYNCED_PREFERENCE_KEYS = [ 'voice', 'skipBlank', 'epubTheme', - 'smartSentenceSplitting', 'segmentPreloadDepthPages', 'segmentPreloadSentenceLookahead', 'ttsSegmentMaxBlockLength', diff --git a/tests/unit/html-audiobook-adapter.spec.ts b/tests/unit/html-audiobook-adapter.spec.ts index 15c1a31..9395533 100644 --- a/tests/unit/html-audiobook-adapter.spec.ts +++ b/tests/unit/html-audiobook-adapter.spec.ts @@ -30,7 +30,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.map((c) => c.title)).toEqual(['Alpha', 'Beta', 'Delta']); @@ -46,7 +45,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.map((c) => c.title)).toEqual(['Introduction', 'First Heading']); @@ -64,7 +62,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, fallbackBlocksPerChapter: 3, }); const chapters = await adapter.prepareChapters(); @@ -88,7 +85,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, chapterHeadingLevel: 1, }); const chapters = await adapter.prepareChapters(); @@ -109,7 +105,6 @@ test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: true, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.map((c) => c.title)).toEqual(['Part 1', 'Part 2', 'Part 3']); @@ -128,7 +123,6 @@ test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: true, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.length).toBe(1); @@ -142,7 +136,6 @@ test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => { const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); const list = await adapter.prepareChapters(); const second = await adapter.prepareChapter(1); @@ -155,7 +148,6 @@ test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => { const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); await expect(adapter.prepareChapter(42)).rejects.toThrow(/invalid chapter index/i); }); diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts index 4acdeaa..dd110b5 100644 --- a/tests/unit/pdf-audiobook-adapter.spec.ts +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -61,7 +61,6 @@ test.describe('pdf audiobook adapter', () => { const adapter = createPdfAudiobookSourceAdapter({ parsed, settings, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); @@ -125,7 +124,6 @@ test.describe('pdf audiobook adapter', () => { const adapter = createPdfAudiobookSourceAdapter({ parsed, settings, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); diff --git a/tests/unit/pdf-tts-planning.spec.ts b/tests/unit/pdf-tts-planning.spec.ts new file mode 100644 index 0000000..1066d7b --- /dev/null +++ b/tests/unit/pdf-tts-planning.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; + +import { buildPdfPageSourceUnits, buildPdfPrefetchPayload } from '../../src/lib/client/pdf-tts-planning'; +import type { ParsedPdfPage } from '../../src/types/parsed-pdf'; + +function buildPage(pageNumber: number): ParsedPdfPage { + return { + pageNumber, + width: 800, + height: 1200, + blocks: [ + { + id: `h-${pageNumber}`, + kind: 'header', + text: 'Header text', + fragments: [{ page: pageNumber, bbox: [0, 0, 1, 1], text: 'Header text', readingOrder: 0 }], + }, + { + id: `p-${pageNumber}-a`, + kind: 'text', + text: `Paragraph A on page ${pageNumber}.`, + // Regression guard: fragment page can drift; locator must stay pinned + // to the requested page number for stable planning/grouping. + fragments: [{ page: pageNumber + 100, bbox: [0, 0, 1, 1], text: 'x', readingOrder: 1 }], + }, + { + id: `p-${pageNumber}-b`, + kind: 'paragraph_title', + text: `Section title ${pageNumber}`, + fragments: [{ page: pageNumber, bbox: [0, 0, 1, 1], text: 'y', readingOrder: 2 }], + }, + { + id: `empty-${pageNumber}`, + kind: 'text', + text: ' ', + fragments: [{ page: pageNumber, bbox: [0, 0, 1, 1], text: 'z', readingOrder: 3 }], + }, + ], + }; +} + +test.describe('pdf tts planning helpers', () => { + test('buildPdfPageSourceUnits uses parsed blocks, honors skip kinds, and pins locator page', () => { + const page = buildPage(2); + const units = buildPdfPageSourceUnits(page, 2, ['header']); + + expect(units.map((u) => u.sourceKey)).toEqual([ + 'pdf:2:p-2-a', + 'pdf:2:p-2-b', + ]); + expect(units.map((u) => u.text)).toEqual([ + 'Paragraph A on page 2.', + 'Section title 2', + ]); + expect(units.map((u) => u.locator)).toEqual([ + { readerType: 'pdf', page: 2, blockId: 'p-2-a' }, + { readerType: 'pdf', page: 2, blockId: 'p-2-b' }, + ]); + }); + + test('buildPdfPrefetchPayload includes parsed sourceUnits for next and upcoming pages', () => { + const pages = new Map([ + [2, buildPage(2)], + [3, buildPage(3)], + [4, buildPage(4)], + ]); + const payload = buildPdfPrefetchPayload( + [2, 3, 4], + ['Page 2 text', 'Page 3 text', 'Page 4 text'], + (pageNum) => buildPdfPageSourceUnits(pages.get(pageNum), pageNum, ['header']), + ); + + expect(payload.nextText).toBe('Page 2 text'); + expect(payload.nextSourceUnits.map((u) => u.sourceKey)).toEqual([ + 'pdf:2:p-2-a', + 'pdf:2:p-2-b', + ]); + expect(payload.additionalUpcoming).toHaveLength(2); + expect(payload.additionalUpcoming[0]).toMatchObject({ + location: 3, + text: 'Page 3 text', + }); + expect(payload.additionalUpcoming[0].sourceUnits.map((u) => u.sourceKey)).toEqual([ + 'pdf:3:p-3-a', + 'pdf:3:p-3-b', + ]); + expect(payload.additionalUpcoming[1].sourceUnits.map((u) => u.sourceKey)).toEqual([ + 'pdf:4:p-4-a', + 'pdf:4:p-4-b', + ]); + }); + + test('buildPdfPrefetchPayload drops blank upcoming text entries', () => { + const pages = new Map([ + [2, buildPage(2)], + [3, buildPage(3)], + ]); + const payload = buildPdfPrefetchPayload( + [2, 3], + ['Page 2 text', ' '], + (pageNum) => buildPdfPageSourceUnits(pages.get(pageNum), pageNum, ['header']), + ); + + expect(payload.nextText).toBe('Page 2 text'); + expect(payload.additionalUpcoming).toHaveLength(0); + }); +}); diff --git a/tests/unit/tts-epub-preload.spec.ts b/tests/unit/tts-epub-preload.spec.ts index ea24f88..d780e51 100644 --- a/tests/unit/tts-epub-preload.spec.ts +++ b/tests/unit/tts-epub-preload.spec.ts @@ -30,7 +30,7 @@ test.describe('EPUB walker preload helpers', () => { expect(selectUpcomingWalkerItems(items, 'epubcfi(/6/2!/4/2)', 0)).toEqual([]); }); - test('buildWalkerPlanningSourceUnits includes live context when smart splitting is enabled', () => { + test('buildWalkerPlanningSourceUnits includes live context', () => { const contextUnits: CanonicalTtsSourceUnit[] = [ { sourceKey: 'previous:page-a', text: 'prev sentence', locator: null }, { sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } }, @@ -40,7 +40,7 @@ test.describe('EPUB walker preload helpers', () => { { sourceKey: 'page-c', text: 'upcoming two', locator: { readerType: 'epub', location: 'page-c' } }, ]; - const planned = buildWalkerPlanningSourceUnits(true, contextUnits, upcomingUnits); + const planned = buildWalkerPlanningSourceUnits(contextUnits, upcomingUnits); expect(planned.map((item) => item.sourceKey)).toEqual([ 'previous:page-a', 'page-a', @@ -48,17 +48,4 @@ test.describe('EPUB walker preload helpers', () => { 'page-c', ]); }); - - test('buildWalkerPlanningSourceUnits uses only upcoming units when smart splitting is disabled', () => { - const contextUnits: CanonicalTtsSourceUnit[] = [ - { sourceKey: 'previous:page-a', text: 'prev sentence', locator: null }, - { sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } }, - ]; - const upcomingUnits: CanonicalTtsSourceUnit[] = [ - { sourceKey: 'page-b', text: 'upcoming one', locator: { readerType: 'epub', location: 'page-b' } }, - ]; - - const planned = buildWalkerPlanningSourceUnits(false, contextUnits, upcomingUnits); - expect(planned.map((item) => item.sourceKey)).toEqual(['page-b']); - }); });