Merge pull request #109 from richardr1126/fix/epub-canonical
fix(epub): canonical per-chapter TTS segmentation (deterministic page turns, highlighting, resize)
This commit is contained in:
commit
7d99011cf4
8 changed files with 1113 additions and 155 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;
|
||||
|
|
@ -242,6 +251,17 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
const { start, end } = location;
|
||||
if (!start?.cfi || !end?.cfi || !book || !book.isOpen || !rendition) return '';
|
||||
|
||||
// Guard against stale async completion: this function awaits range +
|
||||
// canonical-plan resolution, during which rapid page turns can move the
|
||||
// rendition on. If the live location no longer matches the page we
|
||||
// captured, bail before writing state so we don't overwrite the active
|
||||
// page's segments/highlights with a superseded page's.
|
||||
const capturedStartCfi = start.cfi;
|
||||
const isStale = (): boolean => {
|
||||
const live = rendition.location?.start?.cfi;
|
||||
return Boolean(live) && live !== capturedStartCfi;
|
||||
};
|
||||
|
||||
const rangeCfi = createRangeCfi(start.cfi, end.cfi);
|
||||
|
||||
const range = await book.getRange(rangeCfi);
|
||||
|
|
@ -249,22 +269,77 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
console.warn('Failed to get range from CFI:', rangeCfi);
|
||||
return '';
|
||||
}
|
||||
if (isStale()) return '';
|
||||
const textContent = range.toString().trim();
|
||||
setRenderedTextMaps(buildRenderedTextMaps(
|
||||
rendition,
|
||||
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),
|
||||
});
|
||||
|
||||
// The plan resolution above can load + segment a whole chapter; re-check
|
||||
// that we're still on the captured page before committing TTS state.
|
||||
if (isStale()) return '';
|
||||
|
||||
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 +348,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 +390,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 +400,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 +442,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
}
|
||||
: null;
|
||||
|
||||
return renderedLocationCloneManagerRef.current.walk({
|
||||
const items = await renderedLocationCloneManagerRef.current.walk({
|
||||
data: currDocData,
|
||||
startCfi,
|
||||
depth,
|
||||
|
|
@ -376,7 +452,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,
|
||||
|
|
|
|||
|
|
@ -220,28 +220,36 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const spine = resolveSpineFromCfi(book, currDocPage);
|
||||
if (!spine) {
|
||||
try {
|
||||
const spine = resolveSpineFromCfi(book, currDocPage);
|
||||
if (!spine) {
|
||||
if (!cancelled) setSynthRowCanonical([]);
|
||||
return;
|
||||
}
|
||||
const spineText = await getSpineItemPlainText(book, spine.href);
|
||||
if (cancelled) return;
|
||||
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
|
||||
const next = canonicalizeEpubSegmentsAgainstSpineText({
|
||||
segmentTexts: sentences,
|
||||
hintCharOffsets: offsets,
|
||||
spineText,
|
||||
spineHref: spine.href,
|
||||
spineIndex: spine.index,
|
||||
cfi: currDocPage,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
language: resolvedLanguage,
|
||||
});
|
||||
if (!cancelled) setSynthRowCanonical(next);
|
||||
} catch (error) {
|
||||
// Don't leave a previous page's canonical mapping in place if this
|
||||
// resolution fails — clear it so we fall back to non-canonical rows.
|
||||
console.warn('Failed to canonicalize EPUB sidebar segments:', error);
|
||||
if (!cancelled) setSynthRowCanonical([]);
|
||||
return;
|
||||
}
|
||||
const spineText = await getSpineItemPlainText(book, spine.href);
|
||||
if (cancelled) return;
|
||||
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
|
||||
const next = canonicalizeEpubSegmentsAgainstSpineText({
|
||||
segmentTexts: sentences,
|
||||
hintCharOffsets: offsets,
|
||||
spineText,
|
||||
spineHref: spine.href,
|
||||
spineIndex: spine.index,
|
||||
cfi: currDocPage,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
});
|
||||
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(
|
||||
|
|
|
|||
379
src/lib/client/epub/epub-canonical-window.ts
Normal file
379
src/lib/client/epub/epub-canonical-window.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
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.
|
||||
*
|
||||
* Viewport pages are **contiguous, non-overlapping** windows into that one
|
||||
* canonical sequence: each segment is owned exclusively by the page where its
|
||||
* start offset falls (see `selectCanonicalWindow`), exactly like PDF blocks. A
|
||||
* block that straddles a page break (starts on page A, ends on page B) is the
|
||||
* *same* canonical segment (same key, same ordinal) wherever it is referenced,
|
||||
* but it belongs only to page A — page B's window begins at the next segment.
|
||||
* This clean partition is what makes the sidebar list and manual skip
|
||||
* deterministic and keeps playback from repeating a straddler on the page turn.
|
||||
* The playback layer (TTSContext) additionally uses ordinal continuity to hand
|
||||
* off to ordinal + 1 across the seam. 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 = (
|
||||
|
|
|
|||
311
tests/unit/epub-canonical-window.vitest.spec.ts
Normal file
311
tests/unit/epub-canonical-window.vitest.spec.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
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));
|
||||
});
|
||||
|
||||
test('cache is language-aware: a different language is a cache miss', async () => {
|
||||
const book = makeFakeBook(SPINE_TEXT); // fresh Book → empty plan cache
|
||||
const base = {
|
||||
spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, keyPrefix: KEY_PREFIX, maxBlockLength: MAX_BLOCK,
|
||||
};
|
||||
const en = await buildSpineCanonicalPlan(book, { ...base, language: 'en' });
|
||||
const ja = await buildSpineCanonicalPlan(book, { ...base, language: 'ja' });
|
||||
// Distinct language → distinct cache key → distinct reference (no stale
|
||||
// reuse across languages). Same language returns the cached reference.
|
||||
expect(ja).not.toBe(en);
|
||||
expect(await buildSpineCanonicalPlan(book, { ...base, language: 'en' })).toBe(en);
|
||||
});
|
||||
});
|
||||
|
||||
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