refactor(epub): unify TTS segment planning with canonical windowing
Consolidate EPUB TTS segment planning around a single canonical windowing model. Introduce `epub-canonical-window.ts` to provide stable, viewport-independent segment windows for both playback and sidebar. Refactor segment planning, prefetching, and playback handoff to use canonical ordinals and keys, ensuring that segments straddling page breaks are uniquely identified and never replayed across pages. Update TTSContext, SegmentsSidebar, and related hooks to support windowed canonical segments and precise ordinal continuity. Extend types and interfaces to carry canonical segment metadata and windowing context. Add comprehensive unit tests for canonical window selection, segment materialization, and cache behavior. BREAKING CHANGE: EPUB TTS segment planning, playback, and sidebar now rely on canonical windowing, affecting segment key generation and playback logic.
This commit is contained in:
parent
293be5bd28
commit
cb9ca37500
8 changed files with 1057 additions and 138 deletions
|
|
@ -16,6 +16,11 @@ import { getDocumentMetadata } from '@/lib/client/api/documents';
|
|||
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
||||
import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker';
|
||||
import { canonicalizeEpubSegmentAgainstSpineText } from '@/lib/client/epub/canonicalize-epub-segment';
|
||||
import {
|
||||
buildEpubCanonicalWindow,
|
||||
buildEpubCanonicalWindowFromChunk,
|
||||
materializeWindowSegments,
|
||||
} from '@/lib/client/epub/epub-canonical-window';
|
||||
import { buildEpubLocator, getSpineItemPlainText } from '@/lib/client/epub/spine-coordinates';
|
||||
import { useTTS, type EpubLocatorResolver } from '@/contexts/TTSContext';
|
||||
import { createRangeCfi } from '@/lib/client/epub';
|
||||
|
|
@ -43,9 +48,13 @@ import type {
|
|||
} from '@/types/tts';
|
||||
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
||||
import { isStableEpubLocator } from '@/types/client';
|
||||
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
||||
import { buildSegmentKeyPrefix, type CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
||||
import { normalizeOptionalLanguageTag } from '@/lib/shared/language';
|
||||
|
||||
// How many canonical segments to pre-stage for the next page so a
|
||||
// background-tab page turn can keep speaking without waiting on the rendition.
|
||||
const EPUB_PREFETCH_SEGMENT_COUNT = 24;
|
||||
|
||||
export interface EpubDocumentState {
|
||||
currDocData: ArrayBuffer | undefined;
|
||||
currDocName: string | undefined;
|
||||
|
|
@ -255,16 +264,66 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
rangeCfi,
|
||||
normalizeTtsLocationKey(start.cfi),
|
||||
));
|
||||
const leadingPreview = collectLeadingContextFromRange(range);
|
||||
const continuationPreview = collectContinuationFromRange(range);
|
||||
|
||||
setTTSText(textContent, {
|
||||
shouldPause,
|
||||
location: start.cfi,
|
||||
previousText: leadingPreview,
|
||||
nextLocation: end.cfi,
|
||||
nextText: continuationPreview
|
||||
// Canonical path: derive this page's TTS segments as a window into the
|
||||
// chapter's single, viewport-independent canonical plan. This is what
|
||||
// keeps a block that straddles a page break identical (same key/ordinal)
|
||||
// on both pages, so playback never repeats or wrongly pauses at the seam.
|
||||
const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub');
|
||||
const canonicalWindow = await buildEpubCanonicalWindow(book, {
|
||||
startCfi: start.cfi,
|
||||
viewportText: textContent,
|
||||
keyPrefix,
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
language: resolvedLanguage,
|
||||
// Match the rendered text map's sourceKey so the page's canonical
|
||||
// segments resolve to highlight ranges.
|
||||
viewportAnchorSourceKey: normalizeTtsLocationKey(start.cfi),
|
||||
});
|
||||
|
||||
if (canonicalWindow) {
|
||||
// Stage the next page's canonical segments (the slice immediately after
|
||||
// this window) so a hidden-tab page turn keeps speaking canonical
|
||||
// segments; ordinal continuity in TTSContext de-dupes the shared seam.
|
||||
const nextStart = canonicalWindow.windowEndOrdinal + 1;
|
||||
const nextSegments = nextStart < canonicalWindow.plan.length
|
||||
? materializeWindowSegments(
|
||||
canonicalWindow.plan,
|
||||
nextStart,
|
||||
Math.min(canonicalWindow.plan.length - 1, nextStart + EPUB_PREFETCH_SEGMENT_COUNT - 1),
|
||||
{
|
||||
spineHref: canonicalWindow.spineHref,
|
||||
spineIndex: canonicalWindow.spineIndex,
|
||||
cfi: end.cfi,
|
||||
},
|
||||
)
|
||||
: [];
|
||||
|
||||
setTTSText(textContent, {
|
||||
shouldPause,
|
||||
location: start.cfi,
|
||||
nextLocation: end.cfi,
|
||||
canonicalSegments: canonicalWindow.segments,
|
||||
canonicalSpine: {
|
||||
spineHref: canonicalWindow.spineHref,
|
||||
spineIndex: canonicalWindow.spineIndex,
|
||||
},
|
||||
canonicalNextSegments: nextSegments.length > 0 ? nextSegments : undefined,
|
||||
});
|
||||
} else {
|
||||
// Fallback (spine→spine boundary, footnote/nav/image pages, or text not
|
||||
// indexable in the spine): legacy preview-based plan + fuzzy handoff.
|
||||
const leadingPreview = collectLeadingContextFromRange(range);
|
||||
const continuationPreview = collectContinuationFromRange(range);
|
||||
setTTSText(textContent, {
|
||||
shouldPause,
|
||||
location: start.cfi,
|
||||
previousText: leadingPreview,
|
||||
nextLocation: end.cfi,
|
||||
nextText: continuationPreview,
|
||||
});
|
||||
}
|
||||
|
||||
setCurrDocText(textContent);
|
||||
setIsPlaybackReady(true);
|
||||
|
||||
|
|
@ -273,7 +332,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
console.error('Error extracting EPUB text:', error);
|
||||
return '';
|
||||
}
|
||||
}, [setRenderedTextMaps, setTTSText]);
|
||||
}, [setRenderedTextMaps, setTTSText, documentId, ttsSegmentMaxBlockLength, resolvedLanguage]);
|
||||
|
||||
/**
|
||||
* Resolves a draft EPUB locator (typically `{ readerType: 'epub', location:
|
||||
|
|
@ -315,6 +374,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
cfi: stable.cfi,
|
||||
keyPrefix: options?.keyPrefix,
|
||||
maxBlockLength: options?.maxBlockLength ?? ttsSegmentMaxBlockLength,
|
||||
language: resolvedLanguage,
|
||||
});
|
||||
if (!canonical) return null;
|
||||
|
||||
|
|
@ -324,7 +384,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
segmentIndex: canonical.segmentIndex,
|
||||
text: canonical.text,
|
||||
};
|
||||
}, [ttsSegmentMaxBlockLength]);
|
||||
}, [ttsSegmentMaxBlockLength, resolvedLanguage]);
|
||||
|
||||
const walkUpcomingRenderedLocations = useCallback<EpubRenderedLocationWalker>(async (startCfi, depth, signal) => {
|
||||
if (!startCfi || depth <= 0 || signal.aborted) return [];
|
||||
|
|
@ -366,7 +426,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
}
|
||||
: null;
|
||||
|
||||
return renderedLocationCloneManagerRef.current.walk({
|
||||
const items = await renderedLocationCloneManagerRef.current.walk({
|
||||
data: currDocData,
|
||||
startCfi,
|
||||
depth,
|
||||
|
|
@ -376,7 +436,32 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
spread: visibleSettings?.spread,
|
||||
theme,
|
||||
});
|
||||
}, [currDocData, epubTheme]);
|
||||
|
||||
// Enrich each walked chunk with canonical segments from the live book's
|
||||
// cached chapter plan, so preload warms audio under the same keys/locators
|
||||
// playback will request. Best-effort: a chunk that can't be canonicalized
|
||||
// is left bare and falls back to preview-based planning downstream.
|
||||
const liveBook = bookRef.current;
|
||||
if (signal.aborted || !liveBook?.isOpen || items.length === 0) return items;
|
||||
const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub');
|
||||
return Promise.all(items.map(async (item) => {
|
||||
try {
|
||||
const chunkWindow = await buildEpubCanonicalWindowFromChunk(liveBook, {
|
||||
spineHref: item.spineHref,
|
||||
spineIndex: item.spineIndex,
|
||||
chunkOffset: item.chunkOffset,
|
||||
text: item.text,
|
||||
cfi: item.cfi,
|
||||
keyPrefix,
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
language: resolvedLanguage,
|
||||
});
|
||||
return chunkWindow ? { ...item, segments: chunkWindow.segments } : item;
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
}));
|
||||
}, [currDocData, epubTheme, documentId, ttsSegmentMaxBlockLength, resolvedLanguage]);
|
||||
|
||||
const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({
|
||||
bookRef,
|
||||
|
|
|
|||
|
|
@ -237,11 +237,12 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
cfi: currDocPage,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
language: resolvedLanguage,
|
||||
});
|
||||
if (!cancelled) setSynthRowCanonical(next);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]);
|
||||
}, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength, resolvedLanguage]);
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const didAutoScrollOnOpenRef = useRef(false);
|
||||
|
|
|
|||
|
|
@ -171,6 +171,16 @@ interface SetTextOptions {
|
|||
nextSourceUnits?: CanonicalTtsSourceUnit[];
|
||||
previousText?: string;
|
||||
upcomingLocations?: Array<{ location: TTSLocation; text: string; sourceUnits?: CanonicalTtsSourceUnit[] }>;
|
||||
/**
|
||||
* EPUB canonical path: pre-windowed segments for the current page, sliced
|
||||
* from the chapter's single canonical plan (stable key + global `ordinal`).
|
||||
* When present, setText uses them verbatim and skips preview-based planning.
|
||||
*/
|
||||
canonicalSegments?: CanonicalTtsSegment[];
|
||||
/** Spine identity of `canonicalSegments`, for the ordinal-continuity gate. */
|
||||
canonicalSpine?: { spineHref: string; spineIndex: number };
|
||||
/** Canonical segments for the next page, staged for the hidden-tab fast path. */
|
||||
canonicalNextSegments?: CanonicalTtsSegment[];
|
||||
}
|
||||
|
||||
type TTSSegmentPlaybackSource = {
|
||||
|
|
@ -353,6 +363,33 @@ const resolveJumpIndex = (input: JumpResolutionInput): JumpResolution => {
|
|||
return { kind: 'fresh' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the local start index for an EPUB canonical-window page using ordinal
|
||||
* continuity. The window's segments carry chapter-global `ordinal`s; we begin at
|
||||
* the first one past the highest ordinal already spoken in this same spine item.
|
||||
*
|
||||
* Only trims on a *forward, contiguous* turn — when the window overlaps or sits
|
||||
* just after what we've played. A backward turn or a non-contiguous jump returns
|
||||
* 0 (play the whole window from the top); a different spine item returns 0.
|
||||
*/
|
||||
const resolveCanonicalStartIndex = (
|
||||
segments: CanonicalTtsSegment[],
|
||||
spine: { spineHref: string; spineIndex: number } | null,
|
||||
lastPlayed: { spineHref: string; spineIndex: number; ordinal: number } | null,
|
||||
): number => {
|
||||
if (segments.length === 0) return 0;
|
||||
if (!spine || !lastPlayed) return 0;
|
||||
if (lastPlayed.spineHref !== spine.spineHref || lastPlayed.spineIndex !== spine.spineIndex) return 0;
|
||||
|
||||
const windowStart = segments[0].ordinal;
|
||||
const windowEnd = segments[segments.length - 1].ordinal;
|
||||
if (windowStart > lastPlayed.ordinal + 1) return 0; // jumped ahead → play whole window
|
||||
if (windowEnd < lastPlayed.ordinal) return 0; // moved backward → play whole window
|
||||
|
||||
const idx = segments.findIndex((segment) => segment.ordinal > lastPlayed.ordinal);
|
||||
return idx < 0 ? segments.length : idx;
|
||||
};
|
||||
|
||||
const sourceKeyForLocation = (location: TTSLocation | undefined, fallback: TTSLocation): string =>
|
||||
normalizeLocationKey(location ?? fallback);
|
||||
|
||||
|
|
@ -618,7 +655,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
// values, so the strict locationKey match in pendingJumpTargetRef misses on
|
||||
// cross-spine jumps. We instead bump an epoch on each playFromSegment call
|
||||
// and let the next setText with a matching epoch consume the jump.
|
||||
const pendingEpubJumpRef = useRef<{ index: number; epoch: number } | null>(null);
|
||||
const pendingEpubJumpRef = useRef<{ index: number; epoch: number; charOffset?: number } | null>(null);
|
||||
const epubJumpEpochRef = useRef<number>(0);
|
||||
const epubPreloadGenerationRef = useRef<number>(0);
|
||||
const epubWalkInFlightRef = useRef<Set<string>>(new Set());
|
||||
|
|
@ -644,6 +681,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const currentSourceUnitRef = useRef<CanonicalTtsSourceUnit | null>(null);
|
||||
const currentSourceContextUnitsRef = useRef<CanonicalTtsSourceUnit[]>([]);
|
||||
const completedEpubBoundarySegmentRef = useRef<CompletedEpubBoundarySegment | null>(null);
|
||||
// Highest canonical segment ordinal already spoken in the current spine item.
|
||||
// Drives exact, viewport-independent page-turn handoff: the next page begins
|
||||
// at the first window segment whose ordinal exceeds this. Replaces fuzzy
|
||||
// fingerprint matching for within-chapter turns (the spine→spine handoff in
|
||||
// tts-epub-handoff.ts remains the fallback path's safety net).
|
||||
const lastPlayedCanonicalRef = useRef<{ spineHref: string; spineIndex: number; ordinal: number } | null>(null);
|
||||
const pendingNextLocationRef = useRef<TTSLocation | undefined>(undefined);
|
||||
|
||||
const audioUnlockAttemptRef = useRef(0);
|
||||
|
|
@ -1060,7 +1103,28 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
}
|
||||
}
|
||||
}
|
||||
locationChangeHandlerRef.current(nextIndex >= sentences.length ? 'next' : 'prev');
|
||||
const direction = nextIndex >= sentences.length ? 'next' : 'prev';
|
||||
// EPUB navigation is asynchronous (rendition.next/prev → relocated →
|
||||
// skipToLocation → setText), unlike PDF which resets synchronously via
|
||||
// skipToLocation right here. Without clearing the just-finished page now,
|
||||
// an unrelated re-render during the async gap — e.g. setActiveHowl(null)
|
||||
// in onend changing playAudio's identity — re-fires the playback effect
|
||||
// against the *stale* last index and replays the final segment before the
|
||||
// next page loads. Reset synchronously to get PDF's deterministic handoff.
|
||||
if (isPlayingRef.current) {
|
||||
resumeAfterLocationChangeRef.current = true;
|
||||
}
|
||||
if (backwards) {
|
||||
// Backward navigation breaks forward ordinal continuity.
|
||||
lastPlayedCanonicalRef.current = null;
|
||||
}
|
||||
invalidatePlaybackRun();
|
||||
setCurrentIndex(0);
|
||||
setSentences([]);
|
||||
setPlaybackSegments([]);
|
||||
setCurrentSentenceAlignment(undefined);
|
||||
setCurrentWordIndex(null);
|
||||
locationChangeHandlerRef.current(direction);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1111,7 +1175,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
}, [currentIndex, sentences, currDocPageNumber, currDocPages, isEPUB, skipToLocation]);
|
||||
}, [currentIndex, sentences, currDocPageNumber, currDocPages, isEPUB, skipToLocation, invalidatePlaybackRun]);
|
||||
|
||||
/**
|
||||
* Handles blank text sections based on document type
|
||||
|
|
@ -1178,87 +1242,121 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const effectiveCurrentUnits = currentUnits && currentUnits.length > 0 ? currentUnits : [currentSource];
|
||||
const currentSourceKeySet = new Set(effectiveCurrentUnits.map((unit) => unit.sourceKey));
|
||||
|
||||
const contextSourceUnits: CanonicalTtsSourceUnit[] = [];
|
||||
if (normalizedOptions.previousText?.trim()) {
|
||||
const previousLocation = normalizedOptions.previousLocation;
|
||||
contextSourceUnits.push({
|
||||
sourceKey: previousLocation !== undefined
|
||||
? sourceKeyForLocation(previousLocation, currDocPage)
|
||||
: `previous:${currentSourceKey}`,
|
||||
text: normalizedOptions.previousText,
|
||||
locator: previousLocation !== undefined
|
||||
? locatorForLocation(previousLocation, activeReaderType)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
contextSourceUnits.push(...effectiveCurrentUnits);
|
||||
const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits];
|
||||
// EPUB canonical path: the caller already windowed this page out of the
|
||||
// chapter's single canonical plan, so we use those segments verbatim and
|
||||
// skip preview-based planning entirely. This is what makes a page-straddling
|
||||
// block identical (same key + global ordinal) on both pages.
|
||||
const useCanonicalEpub = isEPUB
|
||||
&& Array.isArray(normalizedOptions.canonicalSegments)
|
||||
&& normalizedOptions.canonicalSegments.length > 0;
|
||||
|
||||
plannedSegmentsByLocationRef.current.clear();
|
||||
pendingNextLocationRef.current = normalizedOptions.nextLocation;
|
||||
const pendingPrefetches: Array<{
|
||||
location: TTSLocation;
|
||||
sourceUnits: CanonicalTtsSourceUnit[];
|
||||
}> = [];
|
||||
if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) {
|
||||
const provided = normalizedOptions.nextSourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? [];
|
||||
pendingPrefetches.push(provided.length > 0
|
||||
? {
|
||||
location: normalizedOptions.nextLocation,
|
||||
sourceUnits: provided,
|
||||
}
|
||||
: {
|
||||
location: normalizedOptions.nextLocation,
|
||||
sourceUnits: [{
|
||||
sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage),
|
||||
text: normalizedOptions.nextText,
|
||||
locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType),
|
||||
}],
|
||||
});
|
||||
}
|
||||
if (Array.isArray(normalizedOptions.upcomingLocations)) {
|
||||
for (const item of normalizedOptions.upcomingLocations) {
|
||||
if (item.location === undefined || !item.text?.trim()) continue;
|
||||
const provided = item.sourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? [];
|
||||
let currentSegments: CanonicalTtsSegment[];
|
||||
let newSentences: string[];
|
||||
|
||||
if (useCanonicalEpub) {
|
||||
currentSegments = normalizedOptions.canonicalSegments!;
|
||||
newSentences = currentSegments.map((segment) => segment.text);
|
||||
|
||||
plannedSegmentsByLocationRef.current.clear();
|
||||
pendingNextLocationRef.current = normalizedOptions.nextLocation;
|
||||
// Stage the next page's canonical segments for the hidden-tab fast path.
|
||||
if (
|
||||
normalizedOptions.nextLocation !== undefined
|
||||
&& normalizedOptions.canonicalNextSegments
|
||||
&& normalizedOptions.canonicalNextSegments.length > 0
|
||||
) {
|
||||
plannedSegmentsByLocationRef.current.set(
|
||||
normalizeLocationKey(normalizedOptions.nextLocation),
|
||||
normalizedOptions.canonicalNextSegments,
|
||||
);
|
||||
}
|
||||
// The walker reads these for preview fallback only; the canonical path
|
||||
// attaches its own per-chunk segments, so clear them.
|
||||
currentSourceUnitRef.current = null;
|
||||
currentSourceContextUnitsRef.current = [];
|
||||
} else {
|
||||
const contextSourceUnits: CanonicalTtsSourceUnit[] = [];
|
||||
if (normalizedOptions.previousText?.trim()) {
|
||||
const previousLocation = normalizedOptions.previousLocation;
|
||||
contextSourceUnits.push({
|
||||
sourceKey: previousLocation !== undefined
|
||||
? sourceKeyForLocation(previousLocation, currDocPage)
|
||||
: `previous:${currentSourceKey}`,
|
||||
text: normalizedOptions.previousText,
|
||||
locator: previousLocation !== undefined
|
||||
? locatorForLocation(previousLocation, activeReaderType)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
contextSourceUnits.push(...effectiveCurrentUnits);
|
||||
const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits];
|
||||
|
||||
plannedSegmentsByLocationRef.current.clear();
|
||||
pendingNextLocationRef.current = normalizedOptions.nextLocation;
|
||||
const pendingPrefetches: Array<{
|
||||
location: TTSLocation;
|
||||
sourceUnits: CanonicalTtsSourceUnit[];
|
||||
}> = [];
|
||||
if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) {
|
||||
const provided = normalizedOptions.nextSourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? [];
|
||||
pendingPrefetches.push(provided.length > 0
|
||||
? {
|
||||
location: item.location,
|
||||
location: normalizedOptions.nextLocation,
|
||||
sourceUnits: provided,
|
||||
}
|
||||
: {
|
||||
location: item.location,
|
||||
location: normalizedOptions.nextLocation,
|
||||
sourceUnits: [{
|
||||
sourceKey: sourceKeyForLocation(item.location, currDocPage),
|
||||
text: item.text,
|
||||
locator: locatorForLocation(item.location, activeReaderType),
|
||||
sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage),
|
||||
text: normalizedOptions.nextText,
|
||||
locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const item of pendingPrefetches) {
|
||||
sourceUnits.push(...item.sourceUnits);
|
||||
}
|
||||
|
||||
const plan = planCanonicalTtsSegments(sourceUnits, {
|
||||
readerType: activeReaderType,
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||||
enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0,
|
||||
language: resolvedLanguage,
|
||||
});
|
||||
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 = plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey));
|
||||
if (planned.length > 0) {
|
||||
plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned);
|
||||
if (Array.isArray(normalizedOptions.upcomingLocations)) {
|
||||
for (const item of normalizedOptions.upcomingLocations) {
|
||||
if (item.location === undefined || !item.text?.trim()) continue;
|
||||
const provided = item.sourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? [];
|
||||
pendingPrefetches.push(provided.length > 0
|
||||
? {
|
||||
location: item.location,
|
||||
sourceUnits: provided,
|
||||
}
|
||||
: {
|
||||
location: item.location,
|
||||
sourceUnits: [{
|
||||
sourceKey: sourceKeyForLocation(item.location, currDocPage),
|
||||
text: item.text,
|
||||
locator: locatorForLocation(item.location, activeReaderType),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const item of pendingPrefetches) {
|
||||
sourceUnits.push(...item.sourceUnits);
|
||||
}
|
||||
}
|
||||
|
||||
currentSourceUnitRef.current = effectiveCurrentUnits[0] ?? null;
|
||||
currentSourceContextUnitsRef.current = contextSourceUnits;
|
||||
const plan = planCanonicalTtsSegments(sourceUnits, {
|
||||
readerType: activeReaderType,
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||||
enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0,
|
||||
language: resolvedLanguage,
|
||||
});
|
||||
currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey));
|
||||
newSentences = currentSegments.map((segment) => segment.text);
|
||||
|
||||
for (const item of pendingPrefetches) {
|
||||
const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey));
|
||||
const planned = plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey));
|
||||
if (planned.length > 0) {
|
||||
plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned);
|
||||
}
|
||||
}
|
||||
|
||||
currentSourceUnitRef.current = effectiveCurrentUnits[0] ?? null;
|
||||
currentSourceContextUnitsRef.current = contextSourceUnits;
|
||||
}
|
||||
|
||||
if (handleBlankSection(newSentences.join(' '))) return;
|
||||
|
||||
|
|
@ -1318,6 +1416,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
let startIndex = 0;
|
||||
if (resolution.kind === 'epub-resolved') {
|
||||
startIndex = resolution.index;
|
||||
// Cross-page jump hardening: the raw index was computed against the
|
||||
// sidebar's view. On the canonical path, re-anchor to the segment whose
|
||||
// per-segment charOffset matches the jump target so chapters with
|
||||
// repeated text land exactly.
|
||||
const jump = pendingEpubJumpRef.current;
|
||||
if (useCanonicalEpub && jump && typeof jump.charOffset === 'number') {
|
||||
const mapped = currentSegments.findIndex(
|
||||
(segment) => segment.ownerLocator?.charOffset === jump.charOffset,
|
||||
);
|
||||
if (mapped >= 0) startIndex = mapped;
|
||||
}
|
||||
setCurrentIndex(startIndex);
|
||||
pendingEpubJumpRef.current = null;
|
||||
pendingJumpTargetRef.current = null;
|
||||
|
|
@ -1329,11 +1438,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
if (isEPUB) {
|
||||
clearPendingEpubJump();
|
||||
}
|
||||
startIndex = isEPUB && shouldResumePlayback
|
||||
? resolveEpubBoundaryHandoffStartIndex(currentSegments, completedEpubBoundarySegmentRef.current)
|
||||
: 0;
|
||||
if (startIndex > 0) {
|
||||
completedEpubBoundarySegmentRef.current = null;
|
||||
if (useCanonicalEpub && shouldResumePlayback) {
|
||||
// Exact ordinal handoff: start at the first window segment past the
|
||||
// highest ordinal already spoken in this chapter.
|
||||
startIndex = resolveCanonicalStartIndex(
|
||||
currentSegments,
|
||||
normalizedOptions.canonicalSpine ?? null,
|
||||
lastPlayedCanonicalRef.current,
|
||||
);
|
||||
} else if (isEPUB && shouldResumePlayback) {
|
||||
startIndex = resolveEpubBoundaryHandoffStartIndex(currentSegments, completedEpubBoundarySegmentRef.current);
|
||||
if (startIndex > 0) {
|
||||
completedEpubBoundarySegmentRef.current = null;
|
||||
}
|
||||
} else {
|
||||
startIndex = 0;
|
||||
}
|
||||
setCurrentIndex(startIndex);
|
||||
}
|
||||
|
|
@ -1524,6 +1643,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
preloadGenerationSignatureRef.current = signature;
|
||||
clearPendingEpubJump();
|
||||
bumpEpubPreloadGeneration();
|
||||
// maxBlockLength/language changes re-shape the canonical plan (new ordinals),
|
||||
// so the previous handoff anchor is no longer comparable.
|
||||
lastPlayedCanonicalRef.current = null;
|
||||
}, [
|
||||
documentId,
|
||||
configProviderRef,
|
||||
|
|
@ -2163,6 +2285,27 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
}
|
||||
if (isEPUB) {
|
||||
completedEpubBoundarySegmentRef.current = completedEpubBoundarySegment(playbackSegment);
|
||||
// Track the highest canonical ordinal spoken in this spine item so
|
||||
// the next page's window can hand off at exactly ordinal + 1.
|
||||
const ownerLocator = playbackSegment?.ownerLocator;
|
||||
if (
|
||||
ownerLocator
|
||||
&& typeof ownerLocator.spineHref === 'string'
|
||||
&& typeof ownerLocator.spineIndex === 'number'
|
||||
&& typeof playbackSegment?.ordinal === 'number'
|
||||
) {
|
||||
const prev = lastPlayedCanonicalRef.current;
|
||||
const sameSpine = prev
|
||||
&& prev.spineHref === ownerLocator.spineHref
|
||||
&& prev.spineIndex === ownerLocator.spineIndex;
|
||||
lastPlayedCanonicalRef.current = {
|
||||
spineHref: ownerLocator.spineHref,
|
||||
spineIndex: ownerLocator.spineIndex,
|
||||
ordinal: sameSpine
|
||||
? Math.max(prev!.ordinal, playbackSegment.ordinal)
|
||||
: playbackSegment.ordinal,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (isPlaying) {
|
||||
advance();
|
||||
|
|
@ -2504,32 +2647,42 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
cfi: item.cfi,
|
||||
});
|
||||
|
||||
const upcomingUnits: CanonicalTtsSourceUnit[] = upcomingLocationItems.map((item) => ({
|
||||
sourceKey: sourceKeyForLocation(item.cfi, currDocPage),
|
||||
text: item.text,
|
||||
locator: locatorForWalkerItem(item),
|
||||
}));
|
||||
const liveContextUnits = currentSourceContextUnitsRef.current.length > 0
|
||||
? currentSourceContextUnitsRef.current
|
||||
: (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []);
|
||||
const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits(
|
||||
liveContextUnits,
|
||||
upcomingUnits,
|
||||
);
|
||||
// Prefer canonical per-chunk segments attached by the walker — these
|
||||
// are viewport-independent and mint identical keys/locators to what
|
||||
// playback will request, so warmed audio actually hits the cache.
|
||||
// Only chunks the walker couldn't canonicalize fall back to the
|
||||
// legacy preview-based plan.
|
||||
const fallbackItems = upcomingLocationItems.filter((item) => !item.segments?.length);
|
||||
let fallbackSegments: CanonicalTtsSegment[] = [];
|
||||
if (fallbackItems.length > 0) {
|
||||
const upcomingUnits: CanonicalTtsSourceUnit[] = fallbackItems.map((item) => ({
|
||||
sourceKey: sourceKeyForLocation(item.cfi, currDocPage),
|
||||
text: item.text,
|
||||
locator: locatorForWalkerItem(item),
|
||||
}));
|
||||
const liveContextUnits = currentSourceContextUnitsRef.current.length > 0
|
||||
? currentSourceContextUnitsRef.current
|
||||
: (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []);
|
||||
const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits(
|
||||
liveContextUnits,
|
||||
upcomingUnits,
|
||||
);
|
||||
fallbackSegments = planCanonicalTtsSegments(sourceUnits, {
|
||||
readerType: 'epub',
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'),
|
||||
language: resolvedLanguage,
|
||||
}).segments;
|
||||
}
|
||||
|
||||
const plan = planCanonicalTtsSegments(sourceUnits, {
|
||||
readerType: 'epub',
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'),
|
||||
language: resolvedLanguage,
|
||||
});
|
||||
const uniqueCandidates: Array<EpubLocationPreloadCandidate & { locator: TTSSegmentLocator }> = [];
|
||||
const seenCandidates = new Set<string>();
|
||||
for (const item of upcomingLocationItems) {
|
||||
const sourceKey = sourceKeyForLocation(item.cfi, currDocPage);
|
||||
const planned = plan.segments
|
||||
.filter((segment) => segment.ownerSourceKey === sourceKey)
|
||||
.slice(0, sentenceLookahead);
|
||||
const planned = (item.segments?.length
|
||||
? item.segments
|
||||
: fallbackSegments.filter((segment) => segment.ownerSourceKey === sourceKey)
|
||||
).slice(0, sentenceLookahead);
|
||||
for (let index = 0; index < planned.length; index += 1) {
|
||||
const segment = planned[index];
|
||||
const locator = segment.ownerLocator ?? locatorForWalkerItem(item);
|
||||
|
|
@ -2940,6 +3093,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
currentSourceUnitRef.current = null;
|
||||
currentSourceContextUnitsRef.current = [];
|
||||
completedEpubBoundarySegmentRef.current = null;
|
||||
lastPlayedCanonicalRef.current = null;
|
||||
pageFirstBlockFingerprintRef.current.clear();
|
||||
setIsPlaying(false);
|
||||
setCurrentIndex(0);
|
||||
|
|
@ -3016,11 +3170,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
unlockPlaybackOnUserGesture();
|
||||
if (isEPUB) {
|
||||
// CFI snapping makes locationKey unreliable; resolve via epoch on next setText.
|
||||
// Carry the target's per-segment charOffset so the canonical window can
|
||||
// re-anchor exactly (raw index is viewport-relative to the sidebar).
|
||||
pendingEpubJumpRef.current = {
|
||||
index: Math.max(0, index),
|
||||
epoch: epubJumpEpochRef.current,
|
||||
charOffset: typeof locator?.charOffset === 'number' ? locator.charOffset : undefined,
|
||||
};
|
||||
pendingJumpTargetRef.current = null;
|
||||
// A jump breaks ordinal continuity — drop the handoff anchor so the new
|
||||
// page plays from its resolved index rather than being trimmed.
|
||||
lastPlayedCanonicalRef.current = null;
|
||||
} else {
|
||||
pendingJumpTargetRef.current = {
|
||||
locationKey: normalizeLocationKey(resolvedLocation),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { planSpineSegments } from '@/lib/client/epub/epub-canonical-window';
|
||||
import {
|
||||
buildSegmentKeyPrefix,
|
||||
normalizeSegmentIdentityText,
|
||||
planCanonicalTtsSegments,
|
||||
type CanonicalTtsSegment,
|
||||
} from '@/lib/shared/tts-segment-plan';
|
||||
import type { TTSSegmentLocator } from '@/types/client';
|
||||
|
|
@ -15,6 +14,7 @@ export interface CanonicalizeEpubSegmentInput {
|
|||
cfi?: string;
|
||||
keyPrefix?: string;
|
||||
maxBlockLength?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface CanonicalizedEpubSegment {
|
||||
|
|
@ -74,30 +74,14 @@ function chooseByHintWindow(
|
|||
}
|
||||
|
||||
function buildCanonicalPlan(input: Omit<CanonicalizeEpubSegmentInput, 'segmentText' | 'hintCharOffset'>): CanonicalTtsSegment[] {
|
||||
if (!input.spineText.trim() || !input.spineHref.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceKey = `spine:${input.spineIndex}:${input.spineHref}`;
|
||||
const keyPrefix = input.keyPrefix ?? buildSegmentKeyPrefix('document', 'epub');
|
||||
const plan = planCanonicalTtsSegments(
|
||||
[{
|
||||
sourceKey,
|
||||
text: input.spineText,
|
||||
locator: {
|
||||
readerType: 'epub',
|
||||
spineHref: input.spineHref,
|
||||
spineIndex: input.spineIndex,
|
||||
charOffset: 0,
|
||||
},
|
||||
}],
|
||||
{
|
||||
readerType: 'epub',
|
||||
maxBlockLength: input.maxBlockLength,
|
||||
keyPrefix,
|
||||
},
|
||||
);
|
||||
return plan.segments;
|
||||
return planSpineSegments({
|
||||
spineText: input.spineText,
|
||||
spineHref: input.spineHref,
|
||||
spineIndex: input.spineIndex,
|
||||
keyPrefix: input.keyPrefix,
|
||||
maxBlockLength: input.maxBlockLength,
|
||||
language: input.language,
|
||||
});
|
||||
}
|
||||
|
||||
function toCanonicalized(
|
||||
|
|
|
|||
376
src/lib/client/epub/epub-canonical-window.ts
Normal file
376
src/lib/client/epub/epub-canonical-window.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import type { Book } from 'epubjs';
|
||||
|
||||
import {
|
||||
findSegmentOffset,
|
||||
getSpineItemPlainText,
|
||||
resolveSpineFromCfi,
|
||||
} from '@/lib/client/epub/spine-coordinates';
|
||||
import {
|
||||
buildSegmentKeyPrefix,
|
||||
normalizeSegmentIdentityText,
|
||||
planCanonicalTtsSegments,
|
||||
type CanonicalTtsSegment,
|
||||
} from '@/lib/shared/tts-segment-plan';
|
||||
import type { TTSSegmentLocator } from '@/types/client';
|
||||
|
||||
/**
|
||||
* Canonical "windowing" for EPUB TTS playback.
|
||||
*
|
||||
* The core invariant: a single EPUB spine item (chapter) is split into TTS
|
||||
* segments **exactly once**, with the greedy block grouping fixed from the
|
||||
* chapter start. Every rendered viewport page is then a contiguous *window*
|
||||
* into that one canonical sequence — selected by character offset, identified
|
||||
* by stable ordinal + content key.
|
||||
*
|
||||
* This is what lets a block that straddles a page break (starts on page A,
|
||||
* ends on page B) be the *same* canonical segment (same key, same ordinal) on
|
||||
* both pages, instead of two differently-grouped viewport blocks. Page A and
|
||||
* page B windows deliberately **overlap** by the straddling segments; the
|
||||
* playback layer (TTSContext) uses ordinal continuity to assign each straddler
|
||||
* to exactly the page where it is first heard. See
|
||||
* `tts-segment-plan.ts` for the planner and `spine-coordinates.ts` for the
|
||||
* offset helpers this builds on.
|
||||
*/
|
||||
|
||||
export interface SpinePlanParams {
|
||||
spineHref: string;
|
||||
spineIndex: number;
|
||||
keyPrefix?: string;
|
||||
maxBlockLength?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface CanonicalWindowResult {
|
||||
spineHref: string;
|
||||
spineIndex: number;
|
||||
/**
|
||||
* Segments overlapping the viewport, with each segment's `ownerLocator`
|
||||
* rewritten to carry its own per-segment `charOffset` (and the page-start
|
||||
* `cfi` as a soft jump hint). Cloned — the cached plan is never mutated.
|
||||
*/
|
||||
segments: CanonicalTtsSegment[];
|
||||
/** Global ordinal of the first segment in `segments`. */
|
||||
windowStartOrdinal: number;
|
||||
/** Global ordinal of the last segment in `segments`. */
|
||||
windowEndOrdinal: number;
|
||||
/** The full, pristine per-chapter canonical plan (shared reference, do not mutate). */
|
||||
plan: CanonicalTtsSegment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan one spine item's full text into canonical segments. Pure/sync — the
|
||||
* single source of truth shared by playback windowing, the sidebar, and
|
||||
* persistence canonicalization so all three mint identical segment keys.
|
||||
*/
|
||||
export function planSpineSegments(input: {
|
||||
spineText: string;
|
||||
spineHref: string;
|
||||
spineIndex: number;
|
||||
keyPrefix?: string;
|
||||
maxBlockLength?: number;
|
||||
language?: string;
|
||||
}): CanonicalTtsSegment[] {
|
||||
if (!input.spineText.trim() || !input.spineHref.trim()) return [];
|
||||
|
||||
const sourceKey = `spine:${input.spineIndex}:${input.spineHref}`;
|
||||
const keyPrefix = input.keyPrefix ?? buildSegmentKeyPrefix('document', 'epub');
|
||||
const plan = planCanonicalTtsSegments(
|
||||
[{
|
||||
sourceKey,
|
||||
text: input.spineText,
|
||||
locator: {
|
||||
readerType: 'epub',
|
||||
spineHref: input.spineHref,
|
||||
spineIndex: input.spineIndex,
|
||||
charOffset: 0,
|
||||
},
|
||||
}],
|
||||
{
|
||||
readerType: 'epub',
|
||||
maxBlockLength: input.maxBlockLength,
|
||||
keyPrefix,
|
||||
language: input.language,
|
||||
},
|
||||
);
|
||||
return plan.segments;
|
||||
}
|
||||
|
||||
// Per-Book cache of fully-planned chapters. Keyed by spine identity + the
|
||||
// settings that affect segmentation, so a settings change never reuses a stale
|
||||
// plan. GC'd automatically with the Book instance (WeakMap).
|
||||
const PLAN_CACHE = new WeakMap<Book, Map<string, CanonicalTtsSegment[]>>();
|
||||
|
||||
const planCacheKey = (p: SpinePlanParams): string =>
|
||||
`${p.spineIndex}:${p.spineHref}|mbl=${p.maxBlockLength ?? ''}|lang=${p.language ?? ''}|kp=${p.keyPrefix ?? ''}`;
|
||||
|
||||
/**
|
||||
* Build (or reuse) the cached canonical plan for one spine item. The plan is
|
||||
* computed once per (chapter, settings) so within-chapter page turns cost only
|
||||
* the windowing step, not a full re-split of the chapter.
|
||||
*/
|
||||
export async function buildSpineCanonicalPlan(
|
||||
book: Book,
|
||||
params: SpinePlanParams,
|
||||
): Promise<CanonicalTtsSegment[]> {
|
||||
let bucket = PLAN_CACHE.get(book);
|
||||
if (!bucket) {
|
||||
bucket = new Map();
|
||||
PLAN_CACHE.set(book, bucket);
|
||||
}
|
||||
const key = planCacheKey(params);
|
||||
const cached = bucket.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const spineText = await getSpineItemPlainText(book, params.spineHref);
|
||||
const segments = planSpineSegments({
|
||||
spineText,
|
||||
spineHref: params.spineHref,
|
||||
spineIndex: params.spineIndex,
|
||||
keyPrefix: params.keyPrefix,
|
||||
maxBlockLength: params.maxBlockLength,
|
||||
language: params.language,
|
||||
});
|
||||
bucket.set(key, segments);
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Slack (in normalized chars) for snapping the window start to a segment
|
||||
// boundary, absorbing tiny measurement jitter between independently measured
|
||||
// adjacent-page offsets. Far smaller than a block, so a straddler (whose head
|
||||
// on the previous page is substantial) is never pulled back in.
|
||||
const WINDOW_START_SNAP_TOLERANCE = 8;
|
||||
|
||||
/**
|
||||
* Select the canonical segments that **belong to** the page spanning the
|
||||
* character range [startOffset, endOffset). Returns inclusive array-index
|
||||
* bounds, or null when the range falls outside the plan.
|
||||
*
|
||||
* A block belongs to the page where it **starts** — exactly like PDF blocks.
|
||||
* This yields a clean, non-overlapping partition of the chapter across pages:
|
||||
*
|
||||
* - Start: the first segment that begins at/after `startOffset` (minus a small
|
||||
* snap tolerance). A block straddling *in* from the previous page begins
|
||||
* before `startOffset`, so it is excluded here — it belongs to the previous
|
||||
* page and is never duplicated on this one.
|
||||
* - End: the last segment that begins before `endOffset`. A block straddling
|
||||
* *out* to the next page begins on this page, so it is kept here (and
|
||||
* excluded from the next page by the same start rule).
|
||||
*
|
||||
* Because both the sidebar and manual skip read this exact list, navigation is
|
||||
* deterministic and a block is highlighted as the same segment on every visit.
|
||||
*/
|
||||
export function selectCanonicalWindow(
|
||||
plan: readonly CanonicalTtsSegment[],
|
||||
startOffset: number,
|
||||
endOffset: number,
|
||||
): { startIndex: number; endIndex: number } | null {
|
||||
if (plan.length === 0) return null;
|
||||
|
||||
const startThreshold = startOffset - WINDOW_START_SNAP_TOLERANCE;
|
||||
let startIndex = -1;
|
||||
for (let i = 0; i < plan.length; i += 1) {
|
||||
if (plan[i].startAnchor.offset >= startThreshold) {
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startIndex < 0) return null; // startOffset is past the end of the chapter
|
||||
|
||||
let endIndex = -1;
|
||||
for (let i = plan.length - 1; i >= startIndex; i -= 1) {
|
||||
if (plan[i].startAnchor.offset < endOffset) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endIndex < startIndex) endIndex = startIndex; // always yield at least one segment
|
||||
|
||||
return { startIndex, endIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-express a segment's anchors relative to a rendered viewport so the EPUB
|
||||
* highlight pipeline (resolveVisibleSegmentRange) can map it onto the page's
|
||||
* text map. The chapter plan anchors a segment in *spine-global* space
|
||||
* (sourceKey = `spine:…`, offset from the chapter start); highlighting needs
|
||||
* viewport-local space (sourceKey = the page's rendered-map key, offset within
|
||||
* the page text). Offsets are clamped to the page, so a block straddling in/out
|
||||
* highlights only its visible portion.
|
||||
*/
|
||||
export interface ViewportAnchorContext {
|
||||
/** Must equal the rendered text map's sourceKey for this page. */
|
||||
sourceKey: string;
|
||||
/** Normalized char offset of the page start within the spine text. */
|
||||
baseOffset: number;
|
||||
/** Normalized length of the page's visible text. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a [startIndex, endIndex] slice of a plan, rewriting each segment's
|
||||
* `ownerLocator` to a stable per-segment EPUB locator. The per-segment
|
||||
* `charOffset` is load-bearing: `buildLocatorRequestKey` keys EPUB audio as
|
||||
* `epub:${spineIndex}:${spineHref}:${charOffset}`, and persistence + the
|
||||
* sidebar manifest sort rely on a real offset (the planner leaves it 0 for the
|
||||
* whole-chapter source unit). `cfi` is attached as a non-identity jump hint.
|
||||
*
|
||||
* When `viewport` is provided, anchors are additionally rewritten to
|
||||
* viewport-local coordinates so the page's segments can be highlighted. Omit it
|
||||
* for prefetch/walker slices (not rendered on the current page).
|
||||
*/
|
||||
export function materializeWindowSegments(
|
||||
plan: readonly CanonicalTtsSegment[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
ctx: { spineHref: string; spineIndex: number; cfi?: string },
|
||||
viewport?: ViewportAnchorContext,
|
||||
): CanonicalTtsSegment[] {
|
||||
const out: CanonicalTtsSegment[] = [];
|
||||
const lo = Math.max(0, startIndex);
|
||||
const hi = Math.min(plan.length - 1, endIndex);
|
||||
const clampToViewport = (offset: number): number =>
|
||||
Math.max(0, Math.min(offset - viewport!.baseOffset, viewport!.length));
|
||||
for (let i = lo; i <= hi; i += 1) {
|
||||
const seg = plan[i];
|
||||
const locator: TTSSegmentLocator = {
|
||||
readerType: 'epub',
|
||||
spineHref: ctx.spineHref,
|
||||
spineIndex: ctx.spineIndex,
|
||||
charOffset: Math.max(0, seg.startAnchor.offset),
|
||||
};
|
||||
if (ctx.cfi) locator.cfi = ctx.cfi;
|
||||
const next: CanonicalTtsSegment = { ...seg, ownerLocator: locator };
|
||||
if (viewport) {
|
||||
next.startAnchor = { sourceKey: viewport.sourceKey, offset: clampToViewport(seg.startAnchor.offset) };
|
||||
next.endAnchor = { sourceKey: viewport.sourceKey, offset: clampToViewport(seg.endAnchor.offset) };
|
||||
// Keep ownerSourceKey in lock-step with the rewritten anchors: the word
|
||||
// highlighter (resolveAlignmentWordSourceRange) requires
|
||||
// startAnchor.sourceKey === ownerSourceKey to treat the segment as
|
||||
// anchored in the rendered page.
|
||||
next.ownerSourceKey = viewport.sourceKey;
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const buildResult = (
|
||||
plan: CanonicalTtsSegment[],
|
||||
range: { startIndex: number; endIndex: number },
|
||||
ctx: { spineHref: string; spineIndex: number; cfi?: string },
|
||||
viewport?: ViewportAnchorContext,
|
||||
): CanonicalWindowResult | null => {
|
||||
const segments = materializeWindowSegments(plan, range.startIndex, range.endIndex, ctx, viewport);
|
||||
if (segments.length === 0) return null;
|
||||
return {
|
||||
spineHref: ctx.spineHref,
|
||||
spineIndex: ctx.spineIndex,
|
||||
segments,
|
||||
windowStartOrdinal: plan[range.startIndex].ordinal,
|
||||
windowEndOrdinal: plan[range.endIndex].ordinal,
|
||||
plan,
|
||||
};
|
||||
};
|
||||
|
||||
export interface BuildEpubCanonicalWindowOptions {
|
||||
startCfi: string;
|
||||
viewportText: string;
|
||||
keyPrefix?: string;
|
||||
maxBlockLength?: number;
|
||||
language?: string;
|
||||
/**
|
||||
* Rendered text map sourceKey for this page. When provided, the returned
|
||||
* segments' anchors are rewritten to viewport-local coordinates so they can
|
||||
* be highlighted (resolveVisibleSegmentRange matches on this sourceKey).
|
||||
*/
|
||||
viewportAnchorSourceKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground path: build the canonical window for the currently rendered page
|
||||
* from its start CFI + visible text. Returns null when the page text can't be
|
||||
* located in the spine item (footnotes, nav docs, image-only pages) so the
|
||||
* caller can fall back to the legacy preview-based plan.
|
||||
*/
|
||||
export async function buildEpubCanonicalWindow(
|
||||
book: Book | null | undefined,
|
||||
options: BuildEpubCanonicalWindowOptions,
|
||||
): Promise<CanonicalWindowResult | null> {
|
||||
if (!book?.isOpen) return null;
|
||||
if (!options.viewportText.trim()) return null;
|
||||
|
||||
const spine = resolveSpineFromCfi(book, options.startCfi);
|
||||
if (!spine) return null;
|
||||
|
||||
const plan = await buildSpineCanonicalPlan(book, {
|
||||
spineHref: spine.href,
|
||||
spineIndex: spine.index,
|
||||
keyPrefix: options.keyPrefix,
|
||||
maxBlockLength: options.maxBlockLength,
|
||||
language: options.language,
|
||||
});
|
||||
if (plan.length === 0) return null;
|
||||
|
||||
const spineText = await getSpineItemPlainText(book, spine.href);
|
||||
// Explicit -1 detection — do NOT use buildEpubChunkAnchor here, which masks a
|
||||
// miss to offset 0 and would silently window from the chapter start.
|
||||
const startOffset = findSegmentOffset(spineText, options.viewportText, 0);
|
||||
if (startOffset < 0) return null;
|
||||
const viewportLength = normalizeSegmentIdentityText(options.viewportText).length;
|
||||
const endOffset = startOffset + viewportLength;
|
||||
|
||||
const range = selectCanonicalWindow(plan, startOffset, endOffset);
|
||||
if (!range) return null;
|
||||
const viewport: ViewportAnchorContext | undefined = options.viewportAnchorSourceKey
|
||||
? { sourceKey: options.viewportAnchorSourceKey, baseOffset: startOffset, length: viewportLength }
|
||||
: undefined;
|
||||
return buildResult(plan, range, {
|
||||
spineHref: spine.href,
|
||||
spineIndex: spine.index,
|
||||
cfi: options.startCfi,
|
||||
}, viewport);
|
||||
}
|
||||
|
||||
export interface BuildEpubCanonicalWindowFromChunkOptions {
|
||||
spineHref: string;
|
||||
spineIndex: number;
|
||||
/** Chunk start offset in normalized character space (from the walker). */
|
||||
chunkOffset: number;
|
||||
text: string;
|
||||
cfi?: string;
|
||||
keyPrefix?: string;
|
||||
maxBlockLength?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch/walker path: build a canonical window from already-resolved spine
|
||||
* coordinates (the walker reports spineHref/spineIndex/chunkOffset/text), so no
|
||||
* CFI resolution or range extraction is needed.
|
||||
*/
|
||||
export async function buildEpubCanonicalWindowFromChunk(
|
||||
book: Book | null | undefined,
|
||||
options: BuildEpubCanonicalWindowFromChunkOptions,
|
||||
): Promise<CanonicalWindowResult | null> {
|
||||
if (!book?.isOpen) return null;
|
||||
if (!options.text.trim()) return null;
|
||||
|
||||
const plan = await buildSpineCanonicalPlan(book, {
|
||||
spineHref: options.spineHref,
|
||||
spineIndex: options.spineIndex,
|
||||
keyPrefix: options.keyPrefix,
|
||||
maxBlockLength: options.maxBlockLength,
|
||||
language: options.language,
|
||||
});
|
||||
if (plan.length === 0) return null;
|
||||
|
||||
const startOffset = Math.max(0, options.chunkOffset);
|
||||
const endOffset = startOffset + normalizeSegmentIdentityText(options.text).length;
|
||||
const range = selectCanonicalWindow(plan, startOffset, endOffset);
|
||||
if (!range) return null;
|
||||
return buildResult(plan, range, {
|
||||
spineHref: options.spineHref,
|
||||
spineIndex: options.spineIndex,
|
||||
cfi: options.cfi,
|
||||
});
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
invalidateSpinePlainTextCache,
|
||||
} from '@/lib/client/epub/spine-coordinates';
|
||||
import { buildWalkerThemeRules, type WalkerThemeSnapshot } from '@/lib/client/epub/walker-theme';
|
||||
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
||||
|
||||
export interface RenderedLocationWalkItem {
|
||||
/** Page-start CFI from the rendition. Retained as a soft jump hint only. */
|
||||
|
|
@ -22,6 +23,13 @@ export interface RenderedLocationWalkItem {
|
|||
* viewports, so segments can be anchored to this base.
|
||||
*/
|
||||
chunkOffset: number;
|
||||
/**
|
||||
* Canonical segments for this chunk, windowed from the chapter's canonical
|
||||
* plan. Attached after the raw walk by `walkUpcomingRenderedLocations` (which
|
||||
* holds the live Book). Present means prefetch can use viewport-independent
|
||||
* segments that mint identical keys to playback; absent → preview fallback.
|
||||
*/
|
||||
segments?: CanonicalTtsSegment[];
|
||||
}
|
||||
|
||||
export interface RenderedLocationWalkRequest {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ interface EpubRenderedLocationWalkItem {
|
|||
* spine item's plain text. Stable across viewports.
|
||||
*/
|
||||
chunkOffset: number;
|
||||
/**
|
||||
* Canonical segments for this chunk, windowed from the chapter's canonical
|
||||
* plan and attached after the raw walk. Present → prefetch uses
|
||||
* viewport-independent segments with identical keys to playback; absent →
|
||||
* preview-based fallback planning.
|
||||
*/
|
||||
segments?: CanonicalTtsSegment[];
|
||||
}
|
||||
|
||||
export type EpubRenderedLocationWalker = (
|
||||
|
|
|
|||
298
tests/unit/epub-canonical-window.vitest.spec.ts
Normal file
298
tests/unit/epub-canonical-window.vitest.spec.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import type { Book } from 'epubjs';
|
||||
|
||||
import {
|
||||
buildEpubCanonicalWindow,
|
||||
buildEpubCanonicalWindowFromChunk,
|
||||
buildSpineCanonicalPlan,
|
||||
materializeWindowSegments,
|
||||
planSpineSegments,
|
||||
selectCanonicalWindow,
|
||||
} from '../../src/lib/client/epub/epub-canonical-window';
|
||||
import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan';
|
||||
|
||||
const SPINE_HREF = 'OEBPS/ch01.xhtml';
|
||||
const SPINE_INDEX = 1;
|
||||
const KEY_PREFIX = 'doc-1:epub:v1';
|
||||
const MAX_BLOCK = 80;
|
||||
|
||||
// A chapter long enough that an ~80-char block grouping yields many segments,
|
||||
// so a page break can be placed inside one of them.
|
||||
const SENTENCES = [
|
||||
'The star was particularly bright when the station lights switched off for cycle night.',
|
||||
'After losing his staring match, the night janitor muttered and walked on alone.',
|
||||
'You might have called it aqua, or perhaps a faded green under the old glass.',
|
||||
'A titch too purple for hot pink, it was still impossible to ignore at noon.',
|
||||
'Needing no pole or wire to hold them aloft, the banners drifted above the plaza.',
|
||||
'He would have been confused to hear that this was considered a calm evening here.',
|
||||
'The lift doors parted onto a corridor that smelled faintly of ozone and rain.',
|
||||
'Somewhere below, a generator coughed twice and then settled into its low hum.',
|
||||
];
|
||||
const SPINE_TEXT = SENTENCES.join('\n');
|
||||
|
||||
function makeFakeBook(text: string): Book {
|
||||
const section = {
|
||||
index: SPINE_INDEX,
|
||||
href: SPINE_HREF,
|
||||
cfiBase: '/6/4',
|
||||
load: async () => ({
|
||||
querySelector: (sel: string) => (sel === 'body' ? { textContent: text } : null),
|
||||
textContent: text,
|
||||
}),
|
||||
unload: () => {},
|
||||
};
|
||||
const get = (target: unknown) => {
|
||||
if (typeof target === 'number') return target === SPINE_INDEX ? section : null;
|
||||
if (typeof target === 'string') {
|
||||
if (target === SPINE_HREF || target.includes(SPINE_HREF)) return section;
|
||||
if (target.includes(section.cfiBase)) return section;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
isOpen: true,
|
||||
spine: { get, spineItems: [section] },
|
||||
load: () => Promise.resolve(undefined),
|
||||
} as unknown as Book;
|
||||
}
|
||||
|
||||
const plan = (): CanonicalTtsSegment[] =>
|
||||
planSpineSegments({
|
||||
spineText: SPINE_TEXT,
|
||||
spineHref: SPINE_HREF,
|
||||
spineIndex: SPINE_INDEX,
|
||||
keyPrefix: KEY_PREFIX,
|
||||
maxBlockLength: MAX_BLOCK,
|
||||
});
|
||||
|
||||
describe('planSpineSegments', () => {
|
||||
test('produces sequential ordinals and stable keys', () => {
|
||||
const segments = plan();
|
||||
expect(segments.length).toBeGreaterThanOrEqual(6);
|
||||
segments.forEach((segment, i) => {
|
||||
expect(segment.ordinal).toBe(i);
|
||||
expect(segment.key.startsWith(`${KEY_PREFIX}:`)).toBe(true);
|
||||
// Within-chapter single source unit → never a "source boundary".
|
||||
expect(segment.spansSourceBoundary).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectCanonicalWindow — clean partition', () => {
|
||||
test('a block straddling a page break belongs only to the page where it starts', () => {
|
||||
const segments = plan();
|
||||
// Pick a block in the middle and place the page break inside it (the
|
||||
// viewport for page B begins partway through this straddling block).
|
||||
const boundary = segments[Math.floor(segments.length / 2)];
|
||||
const splitOffset = Math.floor((boundary.startAnchor.offset + boundary.endAnchor.offset) / 2);
|
||||
const chapterEnd = segments[segments.length - 1].endAnchor.offset + 1;
|
||||
|
||||
const pageA = selectCanonicalWindow(segments, 0, splitOffset);
|
||||
const pageB = selectCanonicalWindow(segments, splitOffset, chapterEnd);
|
||||
expect(pageA).not.toBeNull();
|
||||
expect(pageB).not.toBeNull();
|
||||
|
||||
// The straddler is the LAST segment of page A and is NOT on page B.
|
||||
expect(pageA!.endIndex).toBe(boundary.ordinal);
|
||||
expect(pageB!.startIndex).toBe(boundary.ordinal + 1);
|
||||
});
|
||||
|
||||
test('page B starts exactly at boundary + 1 — no overlap, no gap (deterministic list)', () => {
|
||||
const segments = plan();
|
||||
const boundary = segments[Math.floor(segments.length / 2)];
|
||||
const splitOffset = Math.floor((boundary.startAnchor.offset + boundary.endAnchor.offset) / 2);
|
||||
const chapterEnd = segments[segments.length - 1].endAnchor.offset + 1;
|
||||
|
||||
const pageA = selectCanonicalWindow(segments, 0, splitOffset)!;
|
||||
const pageB = selectCanonicalWindow(segments, splitOffset, chapterEnd)!;
|
||||
|
||||
// The two pages partition the ordinals contiguously: …A.end | B.start…
|
||||
expect(pageB.startIndex).toBe(pageA.endIndex + 1);
|
||||
|
||||
// The sidebar/manual-skip list for page B never contains a page-A segment.
|
||||
const windowB = segments.slice(pageB.startIndex, pageB.endIndex + 1);
|
||||
expect(windowB.every((s) => s.ordinal > boundary.ordinal)).toBe(true);
|
||||
});
|
||||
|
||||
test('clean break (split between blocks) yields adjacent, non-overlapping ordinals', () => {
|
||||
const segments = plan();
|
||||
const before = segments[2];
|
||||
const after = segments[3];
|
||||
// Split exactly at the start of `after` (a clean sentence boundary).
|
||||
const splitOffset = after.startAnchor.offset;
|
||||
|
||||
const pageA = selectCanonicalWindow(segments, 0, splitOffset)!;
|
||||
const pageB = selectCanonicalWindow(segments, splitOffset, segments[segments.length - 1].endAnchor.offset + 1)!;
|
||||
|
||||
expect(pageA.endIndex).toBe(before.ordinal);
|
||||
expect(pageB.startIndex).toBe(after.ordinal);
|
||||
expect(pageB.startIndex).toBe(pageA.endIndex + 1);
|
||||
});
|
||||
|
||||
test('is idempotent — identical offsets give identical keys/ordinals (resize safety)', () => {
|
||||
const segments = plan();
|
||||
const a = selectCanonicalWindow(segments, 120, 360)!;
|
||||
const b = selectCanonicalWindow(segments, 120, 360)!;
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test('a block keeps its identity when the page break moves (resize / different sizes)', () => {
|
||||
const segments = plan();
|
||||
const s = segments[Math.floor(segments.length / 2)];
|
||||
const chapterEnd = segments[segments.length - 1].endAnchor.offset + 1;
|
||||
|
||||
// Narrow viewport: the break lands right after `s`, so `s` is the LAST
|
||||
// segment on its page (page base starts at the chapter start).
|
||||
const narrow = selectCanonicalWindow(segments, 0, s.startAnchor.offset + 1)!;
|
||||
const narrowSegs = materializeWindowSegments(segments, narrow.startIndex, narrow.endIndex,
|
||||
{ spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, cfi: 'cfiA' },
|
||||
{ sourceKey: 'pageA', baseOffset: 0, length: s.endAnchor.offset });
|
||||
const sInNarrow = narrowSegs[narrowSegs.length - 1];
|
||||
|
||||
// Wider/shifted viewport: the break lands right before `s`, so the SAME
|
||||
// block is now the FIRST segment on a later page (different base offset).
|
||||
const wide = selectCanonicalWindow(segments, s.startAnchor.offset, chapterEnd)!;
|
||||
const wideSegs = materializeWindowSegments(segments, wide.startIndex, wide.endIndex,
|
||||
{ spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, cfi: 'cfiB' },
|
||||
{ sourceKey: 'pageB', baseOffset: s.startAnchor.offset, length: chapterEnd - s.startAnchor.offset });
|
||||
const sInWide = wideSegs[0];
|
||||
|
||||
// Identity is viewport-independent: same key, same ordinal, same audio
|
||||
// locator charOffset — so audio cache, sidebar, and persistence all agree
|
||||
// no matter how the chapter re-paginates.
|
||||
expect(sInWide.key).toBe(sInNarrow.key);
|
||||
expect(sInWide.ordinal).toBe(sInNarrow.ordinal);
|
||||
expect(sInWide.ownerLocator?.charOffset).toBe(sInNarrow.ownerLocator?.charOffset);
|
||||
expect(sInWide.ownerLocator?.charOffset).toBe(s.startAnchor.offset);
|
||||
|
||||
// …but the highlight anchors are viewport-local, so they adapt to each page.
|
||||
expect(sInWide.startAnchor.sourceKey).toBe('pageB');
|
||||
expect(sInNarrow.startAnchor.sourceKey).toBe('pageA');
|
||||
expect(sInWide.startAnchor.offset).toBe(0); // first on its page → left edge
|
||||
});
|
||||
|
||||
test('returns null when the start offset is past the end of the chapter', () => {
|
||||
const segments = plan();
|
||||
const past = segments[segments.length - 1].endAnchor.offset + 1000;
|
||||
expect(selectCanonicalWindow(segments, past, past + 10)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('materializeWindowSegments', () => {
|
||||
test('rewrites each ownerLocator with its own charOffset + cfi hint', () => {
|
||||
const segments = plan();
|
||||
const out = materializeWindowSegments(segments, 1, 3, {
|
||||
spineHref: SPINE_HREF,
|
||||
spineIndex: SPINE_INDEX,
|
||||
cfi: 'epubcfi(/6/4!/0)',
|
||||
});
|
||||
expect(out).toHaveLength(3);
|
||||
out.forEach((seg, i) => {
|
||||
const original = segments[i + 1];
|
||||
expect(seg.key).toBe(original.key);
|
||||
expect(seg.ordinal).toBe(original.ordinal);
|
||||
expect(seg.ownerLocator?.readerType).toBe('epub');
|
||||
expect(seg.ownerLocator?.spineHref).toBe(SPINE_HREF);
|
||||
expect(seg.ownerLocator?.charOffset).toBe(original.startAnchor.offset);
|
||||
expect(seg.ownerLocator?.cfi).toBe('epubcfi(/6/4!/0)');
|
||||
});
|
||||
// Does not mutate the source plan's locators.
|
||||
expect(segments[1].ownerLocator?.charOffset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSpineCanonicalPlan (cached)', () => {
|
||||
test('returns the same plan reference for the same params and matches planSpineSegments', async () => {
|
||||
const book = makeFakeBook(SPINE_TEXT); // fresh Book → empty plan cache
|
||||
const first = await buildSpineCanonicalPlan(book, {
|
||||
spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, keyPrefix: KEY_PREFIX, maxBlockLength: MAX_BLOCK,
|
||||
});
|
||||
const second = await buildSpineCanonicalPlan(book, {
|
||||
spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, keyPrefix: KEY_PREFIX, maxBlockLength: MAX_BLOCK,
|
||||
});
|
||||
expect(second).toBe(first); // cache hit → identical reference
|
||||
expect(first.map((s) => s.key)).toEqual(plan().map((s) => s.key));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEpubCanonicalWindowFromChunk', () => {
|
||||
test('windows a chunk to canonical segments with playback-identical keys', async () => {
|
||||
const book = makeFakeBook(SPINE_TEXT);
|
||||
const segments = plan();
|
||||
const target = segments[3];
|
||||
const window = await buildEpubCanonicalWindowFromChunk(book, {
|
||||
spineHref: SPINE_HREF,
|
||||
spineIndex: SPINE_INDEX,
|
||||
chunkOffset: target.startAnchor.offset,
|
||||
text: target.text,
|
||||
cfi: 'epubcfi(/6/4!/8)',
|
||||
keyPrefix: KEY_PREFIX,
|
||||
maxBlockLength: MAX_BLOCK,
|
||||
});
|
||||
expect(window).not.toBeNull();
|
||||
expect(window!.segments[0].key).toBe(target.key);
|
||||
expect(window!.segments[0].ownerLocator?.charOffset).toBe(target.startAnchor.offset);
|
||||
expect(window!.windowStartOrdinal).toBe(target.ordinal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEpubCanonicalWindow (CFI path)', () => {
|
||||
test('windows the visible viewport text into the chapter plan', async () => {
|
||||
const book = makeFakeBook(SPINE_TEXT);
|
||||
const segments = plan();
|
||||
// Viewport shows two consecutive sentences.
|
||||
const viewportText = `${SENTENCES[2]} ${SENTENCES[3]}`;
|
||||
const window = await buildEpubCanonicalWindow(book, {
|
||||
startCfi: 'epubcfi(/6/4!/0)',
|
||||
viewportText,
|
||||
keyPrefix: KEY_PREFIX,
|
||||
maxBlockLength: MAX_BLOCK,
|
||||
});
|
||||
expect(window).not.toBeNull();
|
||||
expect(window!.spineHref).toBe(SPINE_HREF);
|
||||
// The window's keys are all drawn from the single chapter plan.
|
||||
const planKeys = new Set(segments.map((s) => s.key));
|
||||
window!.segments.forEach((s) => expect(planKeys.has(s.key)).toBe(true));
|
||||
});
|
||||
|
||||
test('rewrites anchors to viewport-local coordinates for highlighting', async () => {
|
||||
const book = makeFakeBook(SPINE_TEXT);
|
||||
const viewportText = `${SENTENCES[2]} ${SENTENCES[3]}`;
|
||||
const pageKey = 'page-sourcekey';
|
||||
const window = await buildEpubCanonicalWindow(book, {
|
||||
startCfi: 'epubcfi(/6/4!/0)',
|
||||
viewportText,
|
||||
keyPrefix: KEY_PREFIX,
|
||||
maxBlockLength: MAX_BLOCK,
|
||||
viewportAnchorSourceKey: pageKey,
|
||||
});
|
||||
expect(window).not.toBeNull();
|
||||
const viewportLen = viewportText.replace(/\s+/g, ' ').trim().length;
|
||||
window!.segments.forEach((seg) => {
|
||||
// Anchors point at the rendered map, not the spine.
|
||||
expect(seg.startAnchor.sourceKey).toBe(pageKey);
|
||||
expect(seg.endAnchor.sourceKey).toBe(pageKey);
|
||||
// ownerSourceKey is kept in lock-step so the word highlighter's
|
||||
// startAnchor.sourceKey === ownerSourceKey guard passes.
|
||||
expect(seg.ownerSourceKey).toBe(pageKey);
|
||||
// Offsets are viewport-local and clamped to the page.
|
||||
expect(seg.startAnchor.offset).toBeGreaterThanOrEqual(0);
|
||||
expect(seg.endAnchor.offset).toBeLessThanOrEqual(viewportLen);
|
||||
// ownerLocator (audio identity) stays spine-based.
|
||||
expect(seg.ownerLocator?.spineHref).toBe(SPINE_HREF);
|
||||
});
|
||||
// First visible segment starts at the page's left edge.
|
||||
expect(window!.segments[0].startAnchor.offset).toBe(0);
|
||||
});
|
||||
|
||||
test('returns null when viewport text is not indexable in the spine (fallback signal)', async () => {
|
||||
const book = makeFakeBook(SPINE_TEXT);
|
||||
const window = await buildEpubCanonicalWindow(book, {
|
||||
startCfi: 'epubcfi(/6/4!/0)',
|
||||
viewportText: 'Text that does not appear anywhere in this chapter at all.',
|
||||
keyPrefix: KEY_PREFIX,
|
||||
maxBlockLength: MAX_BLOCK,
|
||||
});
|
||||
expect(window).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue