feat(tts,epub,db): enforce stable EPUB segment locators and add segmentKey identity
Implement strict validation for EPUB TTS segment locators, requiring stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) and rejecting legacy CFI-only locators. Introduce `segmentKey` as a normalized identity derived from segment text, used for robust merging and deduplication of synthesized and persisted segments. Update database schemas, API routes, and manifest logic to support the new locator format and segmentKey. Add helpers for locator resolution and segmentKey construction, with comprehensive unit tests for identity, normalization, and manifest grouping. BREAKING CHANGE: Persisted EPUB TTS segment locators must now include stable spine coordinates; legacy CFI-only locators are no longer accepted. Consumers must handle the new `segmentKey` field for segment identity and merging.
This commit is contained in:
parent
e8d4c11434
commit
f9adba791d
33 changed files with 4922 additions and 233 deletions
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "tts_segments" ADD COLUMN "segment_key" text;
|
||||
1301
drizzle/postgres/meta/0002_snapshot.json
Normal file
1301
drizzle/postgres/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,13 @@
|
|||
"when": 1777928320804,
|
||||
"tag": "0001_tts_segments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1778502597852,
|
||||
"tag": "0002_add_segment_key_to_tts_segments",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
1
drizzle/sqlite/0002_add_segment_key_to_tts_segments.sql
Normal file
1
drizzle/sqlite/0002_add_segment_key_to_tts_segments.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `tts_segments` ADD `segment_key` text;
|
||||
1257
drizzle/sqlite/meta/0002_snapshot.json
Normal file
1257
drizzle/sqlite/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,13 @@
|
|||
"when": 1777928320552,
|
||||
"tag": "0001_tts_segments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1778502597591,
|
||||
"tag": "0002_add_segment_key_to_tts_segments",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -359,6 +359,9 @@ export async function POST(request: NextRequest) {
|
|||
});
|
||||
|
||||
const segmentLocatorJson = canonicalLocatorJson(segment.locator);
|
||||
const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim()
|
||||
? segment.original.segmentKey.trim()
|
||||
: null;
|
||||
await db
|
||||
.insert(ttsSegments)
|
||||
.values({
|
||||
|
|
@ -368,6 +371,7 @@ export async function POST(request: NextRequest) {
|
|||
readerType: scope.readerType,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
segmentKey: segmentKeyForRow,
|
||||
locatorJson: segmentLocatorJson,
|
||||
settingsHash,
|
||||
settingsJson,
|
||||
|
|
@ -386,6 +390,7 @@ export async function POST(request: NextRequest) {
|
|||
readerType: scope.readerType,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
segmentKey: segmentKeyForRow,
|
||||
locatorJson: segmentLocatorJson,
|
||||
settingsHash,
|
||||
settingsJson,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
decodeManifestCursor,
|
||||
dedupeManifestVariants,
|
||||
encodeManifestCursor,
|
||||
locatorGroupKey,
|
||||
locatorIdentityKey,
|
||||
parseManifestPageSize,
|
||||
} from '@/lib/server/tts/segments-manifest';
|
||||
import type {
|
||||
|
|
@ -106,6 +106,7 @@ export async function GET(request: NextRequest) {
|
|||
readerType: string;
|
||||
documentVersion: number;
|
||||
segmentIndex: number;
|
||||
segmentKey: string | null;
|
||||
locatorJson: string | null;
|
||||
settingsHash: string;
|
||||
settingsJson: unknown;
|
||||
|
|
@ -127,18 +128,24 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
for (const row of rows) {
|
||||
const locator = parseLocator(row.locatorJson);
|
||||
const groupKey = `${row.segmentIndex}|${locatorGroupKey(locator)}`;
|
||||
// Use the per-row identity key (not the coarse sidebar group key) so two
|
||||
// persisted rows in the same chapter at different `charOffset`s remain
|
||||
// distinct entries instead of collapsing into one bucket whose locator
|
||||
// only reflects the first row seen.
|
||||
const groupKey = `${row.segmentIndex}|${locatorIdentityKey(locator)}`;
|
||||
|
||||
let entry = grouped.get(groupKey);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
segmentIndex: row.segmentIndex,
|
||||
segmentKey: row.segmentKey,
|
||||
locator,
|
||||
variants: [],
|
||||
};
|
||||
grouped.set(groupKey, entry);
|
||||
} else if (!entry.locator) {
|
||||
entry.locator = locator;
|
||||
} else {
|
||||
if (!entry.locator) entry.locator = locator;
|
||||
if (!entry.segmentKey && row.segmentKey) entry.segmentKey = row.segmentKey;
|
||||
}
|
||||
|
||||
let alignmentWordCount = 0;
|
||||
|
|
@ -182,6 +189,7 @@ export async function GET(request: NextRequest) {
|
|||
.map(([groupKey, segment]) => ({
|
||||
groupKey,
|
||||
segmentIndex: segment.segmentIndex,
|
||||
segmentKey: segment.segmentKey,
|
||||
locator: segment.locator,
|
||||
variants: dedupeManifestVariants(segment.variants),
|
||||
}))
|
||||
|
|
@ -206,6 +214,7 @@ export async function GET(request: NextRequest) {
|
|||
documentId,
|
||||
segments: page.map((segment) => ({
|
||||
segmentIndex: segment.segmentIndex,
|
||||
segmentKey: segment.segmentKey ?? null,
|
||||
locator: segment.locator,
|
||||
variants: segment.variants,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,23 @@ import { useTTS } from '@/contexts/TTSContext';
|
|||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||
import { compareSegmentLocators, locatorGroupKey, normalizeEpubLocationToken } from '@/lib/shared/tts-locator';
|
||||
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||||
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
||||
import { useEPUB } from '@/contexts/EPUBContext';
|
||||
import {
|
||||
canonicalizeEpubSegmentsAgainstSpineText,
|
||||
type CanonicalizedEpubSegment,
|
||||
} from '@/lib/client/epub/canonicalize-epub-segment';
|
||||
import {
|
||||
getSpineItemPlainText,
|
||||
resolveMonotonicSentenceOffsets,
|
||||
resolveSpineFromCfi,
|
||||
} from '@/lib/client/epub/spine-coordinates';
|
||||
import {
|
||||
isHtmlLocator,
|
||||
isPdfLocator,
|
||||
isStableEpubLocator,
|
||||
} from '@/types/client';
|
||||
import type {
|
||||
TTSSegmentLocator,
|
||||
TTSSegmentRow,
|
||||
|
|
@ -87,51 +103,39 @@ function statusColor(status: TTSSegmentVariant['status']): string {
|
|||
return 'bg-muted';
|
||||
}
|
||||
|
||||
function locatorMatchesCurrent(
|
||||
locator: TTSSegmentLocator | null,
|
||||
currentLocation: string | number,
|
||||
currentPageNumber: number,
|
||||
): boolean {
|
||||
if (!locator) return false;
|
||||
if (typeof locator.location === 'string' && locator.location.length > 0) {
|
||||
if (locator.readerType === 'epub') {
|
||||
if (typeof currentLocation !== 'string') return false;
|
||||
return normalizeEpubLocationToken(locator.location) === normalizeEpubLocationToken(currentLocation);
|
||||
}
|
||||
return String(locator.location) === String(currentLocation);
|
||||
}
|
||||
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
|
||||
return Math.floor(locator.page) === Math.floor(Number(currentPageNumber || 1));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string {
|
||||
if (!locator) return 'Unknown location';
|
||||
const parts: string[] = [];
|
||||
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
|
||||
parts.push(`Page ${Math.floor(locator.page)}`);
|
||||
if (isStableEpubLocator(locator)) {
|
||||
// Show the spine item filename as a recognisable chapter label. epubjs
|
||||
// hrefs look like "OEBPS/Chapter_03.xhtml" — strip the directory for the
|
||||
// primary label and keep the index for tie-breaks.
|
||||
const base = locator.spineHref.split('/').pop() || locator.spineHref;
|
||||
const stem = base.replace(/\.x?html?$/i, '');
|
||||
return `${stem} · EPUB`;
|
||||
}
|
||||
if (typeof locator.location === 'string' && locator.location) {
|
||||
parts.push(locator.location);
|
||||
if (isPdfLocator(locator)) {
|
||||
return `Page ${Math.floor(locator.page)} · PDF`;
|
||||
}
|
||||
if (locator.readerType) {
|
||||
parts.push(locator.readerType.toUpperCase());
|
||||
if (isHtmlLocator(locator)) {
|
||||
return `${locator.location} · HTML`;
|
||||
}
|
||||
return parts.join(' · ') || 'Unknown location';
|
||||
return 'Unknown location';
|
||||
}
|
||||
|
||||
function compareRows(a: TTSSegmentRow, b: TTSSegmentRow): number {
|
||||
const byLocator = compareSegmentLocators(a.locator, b.locator);
|
||||
if (byLocator !== 0) return byLocator;
|
||||
if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex;
|
||||
return locatorGroupKey(a.locator).localeCompare(locatorGroupKey(b.locator));
|
||||
return locatorIdentityKey(a.locator).localeCompare(locatorIdentityKey(b.locator));
|
||||
}
|
||||
|
||||
function mergeRows(existing: TTSSegmentRow[], incoming: TTSSegmentRow[]): TTSSegmentRow[] {
|
||||
const map = new Map<string, TTSSegmentRow>();
|
||||
const upsert = (row: TTSSegmentRow) => {
|
||||
const key = `${row.segmentIndex}|${locatorGroupKey(row.locator)}`;
|
||||
// Identity key (NOT the coarse sidebar group key) so that two rows in the
|
||||
// same chapter at different charOffsets stay as separate entries instead
|
||||
// of collapsing into one.
|
||||
const key = `${row.segmentIndex}|${locatorIdentityKey(row.locator)}`;
|
||||
const prev = map.get(key);
|
||||
if (!prev) {
|
||||
map.set(key, row);
|
||||
|
|
@ -176,8 +180,60 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
currDocPageNumber,
|
||||
isPlaying,
|
||||
playFromSegment,
|
||||
activeReaderType,
|
||||
} = useTTS();
|
||||
const { ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions, updateConfigKey } = useConfig();
|
||||
const {
|
||||
ttsProvider,
|
||||
ttsModel,
|
||||
voice,
|
||||
voiceSpeed,
|
||||
ttsInstructions,
|
||||
ttsSegmentMaxBlockLength,
|
||||
updateConfigKey,
|
||||
} = useConfig();
|
||||
const { bookRef } = useEPUB();
|
||||
|
||||
/**
|
||||
* Canonicalized per-sentence identities for the currently rendered page.
|
||||
* Each local sentence is mapped onto the spine-level canonical segment plan
|
||||
* (forward-only ordinal walk), so overlap-boundary local splits still resolve
|
||||
* to the same canonical `segmentKey`/`charOffset` as persisted rows.
|
||||
*/
|
||||
const [synthRowCanonical, setSynthRowCanonical] = useState<Array<CanonicalizedEpubSegment | null>>([]);
|
||||
useEffect(() => {
|
||||
if (typeof currDocPage !== 'string' || !currDocPage || sentences.length === 0) {
|
||||
setSynthRowCanonical([]);
|
||||
return;
|
||||
}
|
||||
const book = bookRef.current;
|
||||
if (!book?.isOpen) {
|
||||
setSynthRowCanonical([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
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,
|
||||
});
|
||||
if (!cancelled) setSynthRowCanonical(next);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [bookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]);
|
||||
|
||||
const [state, setState] = useState<FetchState>({ kind: 'idle' });
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
|
|
@ -345,72 +401,140 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
}, [playFromSegment]);
|
||||
|
||||
const rowsToRender = useMemo(() => {
|
||||
if (state.kind !== 'ready') return [] as Array<{
|
||||
type Entry = {
|
||||
segmentIndex: number;
|
||||
sentenceText: string;
|
||||
row: TTSSegmentRow;
|
||||
isCurrentLocation: boolean;
|
||||
groupKey: string;
|
||||
groupLabel: string;
|
||||
}>;
|
||||
const currentRowsFromManifest = state.data.filter((row) =>
|
||||
locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber),
|
||||
);
|
||||
const nonCurrentRows = state.data.filter((row) =>
|
||||
!locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber),
|
||||
);
|
||||
/**
|
||||
* Synthesized rows are produced locally from the currently rendered page's
|
||||
* sentences. They carry sentence text and the live-play highlight. Manifest
|
||||
* rows come from the server-side manifest and may overlap synthesized rows
|
||||
* for the current page; we render them as their own listings so the
|
||||
* sidebar can show the rest of the chapter (and other chapters) without
|
||||
* needing to re-derive page boundaries on the client.
|
||||
*/
|
||||
isSynthesized: boolean;
|
||||
};
|
||||
if (state.kind !== 'ready') return [] as Entry[];
|
||||
|
||||
const inferredCurrentLocator = (() => {
|
||||
const first = currentRowsFromManifest[0]?.locator;
|
||||
if (first) return first;
|
||||
// Fallback locator for the live viewport. Used when per-sentence
|
||||
// canonical resolution hasn't completed yet and for PDF/HTML, which don't
|
||||
// have a spine concept.
|
||||
const inferredCurrentLocator: TTSSegmentLocator | null = (() => {
|
||||
if (typeof currDocPage === 'string' && currDocPage.length > 0) {
|
||||
return {
|
||||
location: currDocPage,
|
||||
readerType: 'epub' as const,
|
||||
};
|
||||
const book = bookRef.current;
|
||||
const spine = book && book.isOpen ? resolveSpineFromCfi(book, currDocPage) : null;
|
||||
if (spine) {
|
||||
return {
|
||||
readerType: 'epub',
|
||||
spineHref: spine.href,
|
||||
spineIndex: spine.index,
|
||||
charOffset: 0,
|
||||
cfi: currDocPage,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof currDocPageNumber === 'number' && Number.isFinite(currDocPageNumber)) {
|
||||
return {
|
||||
page: Math.floor(currDocPageNumber),
|
||||
readerType: 'pdf' as const,
|
||||
};
|
||||
return { readerType: 'pdf', page: Math.floor(currDocPageNumber) };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const variantsByIndex = new Map<number, TTSSegmentVariant[]>();
|
||||
for (const row of currentRowsFromManifest) {
|
||||
if (!variantsByIndex.has(row.segmentIndex)) {
|
||||
variantsByIndex.set(row.segmentIndex, []);
|
||||
}
|
||||
const merged = variantsByIndex.get(row.segmentIndex)!;
|
||||
const seenIds = new Set(merged.map((variant) => variant.segmentId));
|
||||
for (const variant of row.variants ?? []) {
|
||||
if (seenIds.has(variant.segmentId)) continue;
|
||||
seenIds.add(variant.segmentId);
|
||||
merged.push(variant);
|
||||
// Index manifest rows by their content-stable segmentKey so we can attach
|
||||
// their variants to the matching synthesized current-page row. This
|
||||
// collapses the visible duplicates (same content showing up twice — once
|
||||
// as a synth row with sentence text, once as a manifest row with audio).
|
||||
const manifestBySegmentKey = new Map<string, TTSSegmentRow>();
|
||||
for (const row of state.data) {
|
||||
if (row.segmentKey) manifestBySegmentKey.set(row.segmentKey, row);
|
||||
}
|
||||
|
||||
const entries: Entry[] = [];
|
||||
const claimedManifestKeys = new Set<string>();
|
||||
const keyPrefix = buildSegmentKeyPrefix(documentId, activeReaderType);
|
||||
|
||||
// Synthesized rows: one per local sentence. Always tagged as
|
||||
// `isCurrentLocation: true` so the live-playback highlight can fire on
|
||||
// `currentSentenceIndex`. We compute each sentence's `segmentKey` the same
|
||||
// way TTSContext does for persistence; when a manifest row carries the
|
||||
// same key, we pull in its variants/audio but keep the locally-resolved
|
||||
// per-sentence locator for sort positioning.
|
||||
//
|
||||
// **Locator preference (drives sort order):**
|
||||
// 1. Canonicalized per-sentence locator (`synthRowCanonical[i]`) —
|
||||
// identity-stable with persisted manifest rows across resize and
|
||||
// boundary split variations.
|
||||
// 2. Matching manifest row's persisted locator — fallback while the
|
||||
// async resolution is still running.
|
||||
// 3. `inferredCurrentLocator` (chapter + 0) — final fallback.
|
||||
//
|
||||
// Variants/audio are always taken from the manifest match when present,
|
||||
// regardless of which locator wins.
|
||||
if (inferredCurrentLocator) {
|
||||
for (let segmentIndex = 0; segmentIndex < sentences.length; segmentIndex += 1) {
|
||||
const sentence = sentences[segmentIndex] ?? '';
|
||||
const canonical = synthRowCanonical[segmentIndex] ?? null;
|
||||
const segmentKey = canonical?.segmentKey
|
||||
?? (sentence ? buildSegmentKey(keyPrefix, sentence) : null);
|
||||
const manifestMatch = segmentKey ? manifestBySegmentKey.get(segmentKey) : undefined;
|
||||
if (segmentKey && manifestMatch) claimedManifestKeys.add(segmentKey);
|
||||
|
||||
const localPerSentence = canonical?.locator ?? null;
|
||||
const mergedLocator: TTSSegmentLocator =
|
||||
localPerSentence
|
||||
?? manifestMatch?.locator
|
||||
?? inferredCurrentLocator;
|
||||
const synthRow: TTSSegmentRow = {
|
||||
segmentIndex,
|
||||
segmentKey,
|
||||
locator: mergedLocator,
|
||||
variants: manifestMatch?.variants ?? [],
|
||||
};
|
||||
entries.push({
|
||||
segmentIndex,
|
||||
sentenceText: sentence,
|
||||
row: synthRow,
|
||||
isCurrentLocation: true,
|
||||
groupKey: locatorGroupKey(mergedLocator),
|
||||
groupLabel: formatLocatorGroupLabel(mergedLocator),
|
||||
isSynthesized: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const currentRows: TTSSegmentRow[] = sentences.map((_, segmentIndex) => ({
|
||||
segmentIndex,
|
||||
locator: inferredCurrentLocator,
|
||||
variants: variantsByIndex.get(segmentIndex) ?? [],
|
||||
}));
|
||||
|
||||
const mergedRows = [...currentRows, ...nonCurrentRows].sort(compareRows);
|
||||
return mergedRows.map((row) => {
|
||||
const isCurrentLocation = locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber);
|
||||
return {
|
||||
// Manifest rows not claimed by a synth row (i.e. content not currently on
|
||||
// screen) render as their own listings. They keep their original locator
|
||||
// and group, so they sort into their chapter bucket at their real
|
||||
// `charOffset` position.
|
||||
for (const row of state.data) {
|
||||
if (row.segmentKey && claimedManifestKeys.has(row.segmentKey)) continue;
|
||||
entries.push({
|
||||
segmentIndex: row.segmentIndex,
|
||||
sentenceText: isCurrentLocation ? (sentences[row.segmentIndex] ?? '') : '',
|
||||
sentenceText: '',
|
||||
row,
|
||||
isCurrentLocation,
|
||||
isCurrentLocation: false,
|
||||
groupKey: locatorGroupKey(row.locator),
|
||||
groupLabel: formatLocatorGroupLabel(row.locator),
|
||||
};
|
||||
isSynthesized: false,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((a, b) => {
|
||||
const byLocator = compareSegmentLocators(a.row.locator, b.row.locator);
|
||||
if (byLocator !== 0) return byLocator;
|
||||
// Within the same locator group, place synthesized (current-page) rows
|
||||
// first so the user's active reading position floats to the top of the
|
||||
// chapter group.
|
||||
if (a.isSynthesized !== b.isSynthesized) return a.isSynthesized ? -1 : 1;
|
||||
return a.segmentIndex - b.segmentIndex;
|
||||
});
|
||||
}, [state, currDocPage, currDocPageNumber, sentences]);
|
||||
|
||||
return entries;
|
||||
}, [state, currDocPage, currDocPageNumber, sentences, bookRef, documentId, activeReaderType, synthRowCanonical]);
|
||||
|
||||
const totalVariants = state.kind === 'ready'
|
||||
? rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0)
|
||||
|
|
@ -538,7 +662,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
)}
|
||||
{state.kind === 'ready' && rowsToRender.length > 0 && (
|
||||
<ul className="divide-y divide-offbase">
|
||||
{rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel }, rowIndex) => {
|
||||
{rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel, isSynthesized }, rowIndex) => {
|
||||
const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null;
|
||||
const showGroupHeader = previousGroupKey !== groupKey;
|
||||
const isCurrent = isCurrentLocation && segmentIndex === currentSentenceIndex;
|
||||
|
|
@ -562,7 +686,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
const playable = !!(activeVariant && activeVariant.audioPresignUrl);
|
||||
return (
|
||||
<li
|
||||
key={`${groupKey}::${segmentIndex}`}
|
||||
key={`${isSynthesized ? 'syn' : 'mfr'}::${groupKey}::${segmentIndex}::${rowIndex}`}
|
||||
data-active-segment={isCurrent ? 'true' : undefined}
|
||||
className={`relative px-4 py-3 ${isCurrent ? 'bg-offbase/40' : ''}`}
|
||||
>
|
||||
|
|
@ -700,9 +824,13 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
<Row label="locator">
|
||||
{row.locator ? (
|
||||
<span className="font-mono tabular-nums text-[11px] text-foreground break-all">
|
||||
{row.locator.page !== undefined ? `p.${row.locator.page} ` : ''}
|
||||
{row.locator.location ?? ''}
|
||||
{row.locator.readerType ? ` (${row.locator.readerType})` : ''}
|
||||
{isStableEpubLocator(row.locator)
|
||||
? `epub spine[${row.locator.spineIndex}] ${row.locator.spineHref} @${row.locator.charOffset}`
|
||||
: isPdfLocator(row.locator)
|
||||
? `pdf p.${row.locator.page}`
|
||||
: isHtmlLocator(row.locator)
|
||||
? `html ${row.locator.location}`
|
||||
: `${row.locator.readerType || '?'} (legacy)`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted text-[11px]">none</span>
|
||||
|
|
|
|||
|
|
@ -38,10 +38,12 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
highlightWordIndex,
|
||||
clearWordHighlights,
|
||||
walkUpcomingRenderedLocations,
|
||||
resolveEpubLocator,
|
||||
} = useEPUB();
|
||||
const {
|
||||
registerLocationChangeHandler,
|
||||
registerEpubLocationWalker,
|
||||
registerEpubLocatorResolver,
|
||||
pause,
|
||||
currentSegment,
|
||||
currentSentenceAlignment,
|
||||
|
|
@ -74,11 +76,20 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
useEffect(() => {
|
||||
registerLocationChangeHandler(handleLocationChanged);
|
||||
registerEpubLocationWalker(walkUpcomingRenderedLocations);
|
||||
registerEpubLocatorResolver(resolveEpubLocator);
|
||||
return () => {
|
||||
registerLocationChangeHandler(null);
|
||||
registerEpubLocationWalker(null);
|
||||
registerEpubLocatorResolver(null);
|
||||
};
|
||||
}, [registerLocationChangeHandler, registerEpubLocationWalker, handleLocationChanged, walkUpcomingRenderedLocations]);
|
||||
}, [
|
||||
registerLocationChangeHandler,
|
||||
registerEpubLocationWalker,
|
||||
registerEpubLocatorResolver,
|
||||
handleLocationChanged,
|
||||
walkUpcomingRenderedLocations,
|
||||
resolveEpubLocator,
|
||||
]);
|
||||
|
||||
// Handle highlighting
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
|||
import { getDocumentMetadata } from '@/lib/client/api/documents';
|
||||
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
||||
import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { canonicalizeEpubSegmentAgainstSpineText } from '@/lib/client/epub/canonicalize-epub-segment';
|
||||
import { buildEpubLocator, getSpineItemPlainText } from '@/lib/client/epub/spine-coordinates';
|
||||
import { useTTS, type EpubLocatorResolver } from '@/contexts/TTSContext';
|
||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
import { createRangeCfi } from '@/lib/client/epub';
|
||||
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
||||
|
|
@ -41,7 +43,8 @@ import type {
|
|||
TTSAudiobookFormat,
|
||||
TTSAudiobookChapter,
|
||||
} from '@/types/tts';
|
||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
||||
import { isStableEpubLocator } from '@/types/client';
|
||||
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
||||
|
||||
interface EPUBContextType {
|
||||
|
|
@ -54,6 +57,7 @@ interface EPUBContextType {
|
|||
clearCurrDoc: () => void;
|
||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||
walkUpcomingRenderedLocations: EpubRenderedLocationWalker;
|
||||
resolveEpubLocator: EpubLocatorResolver;
|
||||
createFullAudioBook: (
|
||||
onProgress: (progress: number) => void,
|
||||
signal?: AbortSignal,
|
||||
|
|
@ -536,6 +540,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
apiKey,
|
||||
baseUrl,
|
||||
ttsProvider,
|
||||
ttsSegmentMaxBlockLength,
|
||||
smartSentenceSplitting,
|
||||
epubTheme,
|
||||
epubHighlightEnabled,
|
||||
|
|
@ -544,6 +549,11 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
||||
const [currDocName, setCurrDocName] = useState<string>();
|
||||
const [currDocText, setCurrDocText] = useState<string>();
|
||||
// Mirror state into a ref so resolveEpubLocator (registered once with
|
||||
// TTSContext via a stable callback) can always read the latest page text
|
||||
// without forcing re-registration on every page turn.
|
||||
const currDocTextRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => { currDocTextRef.current = currDocText; }, [currDocText]);
|
||||
const [isAudioCombining] = useState(false);
|
||||
|
||||
// Add new refs
|
||||
|
|
@ -744,6 +754,57 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, [loadSpineSection]);
|
||||
|
||||
/**
|
||||
* Resolves a draft EPUB locator (typically `{ readerType: 'epub', location:
|
||||
* <CFI> }`) into stable spine coordinates using the live `Book` instance.
|
||||
* Registered with TTSContext so segment-persist payloads are normalised to
|
||||
* viewport-independent coordinates before they hit the server.
|
||||
*
|
||||
* Returns null when there's no live book or the CFI doesn't resolve.
|
||||
*/
|
||||
const resolveEpubLocator = useCallback<EpubLocatorResolver>(async (
|
||||
draft,
|
||||
segmentText,
|
||||
options,
|
||||
) => {
|
||||
const book = bookRef.current;
|
||||
if (!book || !book.isOpen) return null;
|
||||
const resolvedLocator = (() => {
|
||||
if (isStableEpubLocator(draft)) return Promise.resolve(draft);
|
||||
const cfi = (typeof draft.cfi === 'string' && draft.cfi)
|
||||
|| (typeof draft.location === 'string' && draft.location)
|
||||
|| '';
|
||||
if (!cfi) return Promise.resolve<TTSSegmentLocator | null>(null);
|
||||
// Pass the current rendered page's text as the chunk anchor so the
|
||||
// per-segment search starts at this page's position in the spine.
|
||||
const chunkText = currDocTextRef.current;
|
||||
return buildEpubLocator(book, cfi, segmentText, chunkText);
|
||||
})();
|
||||
|
||||
const stable = await resolvedLocator;
|
||||
if (!stable || !isStableEpubLocator(stable)) return null;
|
||||
|
||||
const spineText = await getSpineItemPlainText(book, stable.spineHref);
|
||||
const canonical = canonicalizeEpubSegmentAgainstSpineText({
|
||||
segmentText,
|
||||
spineText,
|
||||
spineHref: stable.spineHref,
|
||||
spineIndex: stable.spineIndex,
|
||||
hintCharOffset: stable.charOffset,
|
||||
cfi: stable.cfi,
|
||||
keyPrefix: options?.keyPrefix,
|
||||
maxBlockLength: options?.maxBlockLength ?? ttsSegmentMaxBlockLength,
|
||||
});
|
||||
if (!canonical) return null;
|
||||
|
||||
return {
|
||||
locator: canonical.locator,
|
||||
segmentKey: canonical.segmentKey,
|
||||
segmentIndex: canonical.segmentIndex,
|
||||
text: canonical.text,
|
||||
};
|
||||
}, [ttsSegmentMaxBlockLength]);
|
||||
|
||||
const walkUpcomingRenderedLocations = useCallback<EpubRenderedLocationWalker>(async (startCfi, depth, signal) => {
|
||||
if (!startCfi || depth <= 0 || signal.aborted) return [];
|
||||
const visibleRendition = renditionRef.current;
|
||||
|
|
@ -763,10 +824,24 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const width = Math.max(320, Math.floor(bounds?.width || 900));
|
||||
const height = Math.max(320, Math.floor(bounds?.height || 700));
|
||||
|
||||
const visibleContents = typeof visibleRendition.getContents === 'function'
|
||||
? visibleRendition.getContents()
|
||||
: [];
|
||||
const visibleContent = Array.isArray(visibleContents) ? visibleContents[0] : visibleContents;
|
||||
const contentDoc = (visibleContent as { document?: Document | null } | null | undefined)?.document ?? null;
|
||||
const contentBody = contentDoc?.body ?? null;
|
||||
const bodyStyle = contentBody ? getComputedStyle(contentBody) : null;
|
||||
|
||||
const theme = epubTheme
|
||||
? {
|
||||
foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'),
|
||||
base: getComputedStyle(document.documentElement).getPropertyValue('--base'),
|
||||
fontFamily: bodyStyle?.fontFamily || undefined,
|
||||
fontSize: bodyStyle?.fontSize || undefined,
|
||||
lineHeight: bodyStyle?.lineHeight || undefined,
|
||||
fontWeight: bodyStyle?.fontWeight || undefined,
|
||||
letterSpacing: bodyStyle?.letterSpacing || undefined,
|
||||
wordSpacing: bodyStyle?.wordSpacing || undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
|
|
@ -1078,6 +1153,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
clearCurrDoc,
|
||||
extractPageText,
|
||||
walkUpcomingRenderedLocations,
|
||||
resolveEpubLocator,
|
||||
createFullAudioBook,
|
||||
regenerateChapter,
|
||||
bookRef,
|
||||
|
|
@ -1102,6 +1178,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
clearCurrDoc,
|
||||
extractPageText,
|
||||
walkUpcomingRenderedLocations,
|
||||
resolveEpubLocator,
|
||||
createFullAudioBook,
|
||||
regenerateChapter,
|
||||
handleLocationChanged,
|
||||
|
|
|
|||
|
|
@ -38,17 +38,22 @@ import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/
|
|||
import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks';
|
||||
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
|
||||
import {
|
||||
buildSegmentKeyPrefix,
|
||||
planCanonicalTtsSegments,
|
||||
type CanonicalTtsSegment,
|
||||
type CanonicalTtsSourceUnit,
|
||||
} from '@/lib/shared/tts-segment-plan';
|
||||
import {
|
||||
buildWalkerPlanningSourceUnits,
|
||||
selectUpcomingWalkerItems,
|
||||
} from '@/lib/shared/tts-epub-preload';
|
||||
import {
|
||||
completedEpubBoundarySegment,
|
||||
resolveEpubBoundaryHandoffStartIndex,
|
||||
resolveEpubReplaySuppressionAction,
|
||||
type CompletedEpubBoundarySegment,
|
||||
} from '@/lib/shared/tts-epub-handoff';
|
||||
import { normalizeEpubLocationToken, normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
||||
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||
|
|
@ -61,10 +66,35 @@ import type {
|
|||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestHeaders,
|
||||
TTSSegmentInput,
|
||||
TTSSegmentLocator,
|
||||
TTSRetryOptions,
|
||||
TTSSegmentManifestItem,
|
||||
} from '@/types/client';
|
||||
import { isStableEpubLocator } from '@/types/client';
|
||||
|
||||
/**
|
||||
* Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub',
|
||||
* location: <CFI> }`) into a stable book coordinate. The resolver lives in
|
||||
* EPUBContext where the live `Book` instance is available; TTSContext calls it
|
||||
* just before posting segments to the server so what gets persisted is
|
||||
* viewport-independent. Returns null when the CFI can't be resolved.
|
||||
*/
|
||||
export type EpubLocatorResolver = (
|
||||
draft: TTSSegmentLocator,
|
||||
segmentText: string,
|
||||
options?: {
|
||||
segmentIndex: number;
|
||||
segmentKey?: string | null;
|
||||
keyPrefix: string;
|
||||
maxBlockLength: number;
|
||||
},
|
||||
) => Promise<{
|
||||
locator: TTSSegmentLocator;
|
||||
segmentKey?: string | null;
|
||||
segmentIndex?: number;
|
||||
text?: string;
|
||||
} | null>;
|
||||
import type { ReaderType } from '@/types/user-state';
|
||||
import {
|
||||
clampSegmentPreloadDepth,
|
||||
|
|
@ -110,8 +140,11 @@ interface TTSContextType extends TTSPlaybackState {
|
|||
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
|
||||
registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation
|
||||
registerEpubLocationWalker: (walker: EpubRenderedLocationWalker | null) => void;
|
||||
registerEpubLocatorResolver: (resolver: EpubLocatorResolver | null) => void; // EPUB-only: resolves CFI drafts to stable spine coords before persist
|
||||
registerVisualPageChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void;
|
||||
setIsEPUB: (isEPUB: boolean) => void;
|
||||
/** Effective reader type used to mint segmentKeys (see buildSegmentKeyPrefix). */
|
||||
activeReaderType: ReaderType;
|
||||
}
|
||||
|
||||
interface SetTextOptions {
|
||||
|
|
@ -214,6 +247,22 @@ const buildScopedSegmentCacheKey = (
|
|||
};
|
||||
|
||||
const buildLocatorRequestKey = (locator: TTSSegmentLocator): string => {
|
||||
// Stable EPUB locators: use spine identity + charOffset for a unique,
|
||||
// viewport-independent cache key. Falling through to `locator.location`
|
||||
// would yield the empty string for the new shape and collide across
|
||||
// segments.
|
||||
if (locator.readerType === 'epub') {
|
||||
if (
|
||||
typeof locator.spineHref === 'string'
|
||||
&& typeof locator.spineIndex === 'number'
|
||||
&& typeof locator.charOffset === 'number'
|
||||
) {
|
||||
return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`;
|
||||
}
|
||||
if (typeof locator.cfi === 'string' && locator.cfi) {
|
||||
return normalizeLocationKey(locator.cfi);
|
||||
}
|
||||
}
|
||||
if (typeof locator.location === 'string' && locator.location) {
|
||||
return normalizeLocationKey(locator.location);
|
||||
}
|
||||
|
|
@ -314,6 +363,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
isAtLimit,
|
||||
} = useAuthRateLimit();
|
||||
|
||||
// Get document ID and reader type from URL
|
||||
const { id } = useParams();
|
||||
const pathname = usePathname();
|
||||
const documentId = useMemo(() => {
|
||||
if (typeof id === 'string') return id;
|
||||
if (Array.isArray(id)) return id[0];
|
||||
return '';
|
||||
}, [id]);
|
||||
|
||||
const currentReaderType: ReaderType = useMemo(() => {
|
||||
if (pathname.startsWith('/epub/')) return 'epub';
|
||||
if (pathname.startsWith('/html/')) return 'html';
|
||||
return 'pdf';
|
||||
}, [pathname]);
|
||||
|
||||
// Add ref for location change handler
|
||||
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||||
const visualPageChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||||
|
|
@ -334,6 +398,77 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
epubLocationWalkerRef.current = walker;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Resolves a CFI + segment text into stable EPUB coordinates. Registered by
|
||||
* EPUBContext (which owns the live `Book` instance). Used at server-persist
|
||||
* time so the saved locator carries `spineHref`/`spineIndex`/`charOffset`
|
||||
* rather than the viewport-dependent CFI string. Returns null when the CFI
|
||||
* can't be resolved — callers should drop the segment from the persist
|
||||
* payload in that case to avoid writing legacy-shape locators.
|
||||
*/
|
||||
const epubLocatorResolverRef = useRef<EpubLocatorResolver | null>(null);
|
||||
|
||||
const registerEpubLocatorResolver = useCallback((resolver: EpubLocatorResolver | null) => {
|
||||
epubLocatorResolverRef.current = resolver;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Walks the segment payload that's about to be POSTed to
|
||||
* /api/tts/segments/ensure and canonicalizes EPUB entries through the
|
||||
* registered resolver. This normalizes both draft and already-stable locators
|
||||
* to a spine-level canonical segment identity (text/key/index/locator).
|
||||
* Non-EPUB locators are untouched.
|
||||
*
|
||||
* Segments whose EPUB locators can't be resolved are DROPPED from the
|
||||
* payload — we'd rather persist nothing than persist a viewport-dependent
|
||||
* locator that will misbehave across devices and resizes.
|
||||
*/
|
||||
const resolveSegmentsForPersist = useCallback(async (
|
||||
segments: TTSSegmentInput[],
|
||||
): Promise<TTSSegmentInput[]> => {
|
||||
const resolver = epubLocatorResolverRef.current;
|
||||
const out: TTSSegmentInput[] = [];
|
||||
for (const segment of segments) {
|
||||
const locator = segment.locator;
|
||||
if (!locator || locator.readerType !== 'epub') {
|
||||
out.push(segment);
|
||||
continue;
|
||||
}
|
||||
if (!resolver) {
|
||||
// No book available to resolve — drop. This can happen during early
|
||||
// boot before EPUBContext has mounted.
|
||||
continue;
|
||||
}
|
||||
const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub');
|
||||
try {
|
||||
const resolved = await resolver(locator, segment.text, {
|
||||
segmentIndex: segment.segmentIndex,
|
||||
segmentKey: segment.segmentKey ?? null,
|
||||
keyPrefix,
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
});
|
||||
if (resolved && isStableEpubLocator(resolved.locator)) {
|
||||
out.push({
|
||||
...segment,
|
||||
locator: resolved.locator,
|
||||
...(typeof resolved.segmentKey === 'string' && resolved.segmentKey.trim()
|
||||
? { segmentKey: resolved.segmentKey.trim() }
|
||||
: {}),
|
||||
...(typeof resolved.segmentIndex === 'number' && Number.isFinite(resolved.segmentIndex)
|
||||
? { segmentIndex: Math.max(0, Math.floor(resolved.segmentIndex)) }
|
||||
: {}),
|
||||
...(typeof resolved.text === 'string' && resolved.text.trim()
|
||||
? { text: resolved.text }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to resolve EPUB locator; dropping segment', error);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [documentId, ttsSegmentMaxBlockLength]);
|
||||
|
||||
/**
|
||||
* Registers a handler function for visual page changes in EPUB documents
|
||||
* This is only used for EPUB documents to handle visual page navigation
|
||||
|
|
@ -344,21 +479,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
visualPageChangeHandlerRef.current = handler;
|
||||
}, []);
|
||||
|
||||
// Get document ID from URL params
|
||||
const { id } = useParams();
|
||||
const pathname = usePathname();
|
||||
const documentId = useMemo(() => {
|
||||
if (typeof id === 'string') return id;
|
||||
if (Array.isArray(id)) return id[0];
|
||||
return '';
|
||||
}, [id]);
|
||||
|
||||
const currentReaderType: ReaderType = useMemo(() => {
|
||||
if (pathname.startsWith('/epub/')) return 'epub';
|
||||
if (pathname.startsWith('/html/')) return 'html';
|
||||
return 'pdf';
|
||||
}, [pathname]);
|
||||
|
||||
/**
|
||||
* State Management
|
||||
*/
|
||||
|
|
@ -366,6 +486,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const [isEPUB, setIsEPUB] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
/**
|
||||
* Resolved reader type for segment planning. Mirrors the `activeReaderType`
|
||||
* used inside `setText`/preload paths so external consumers (e.g. the
|
||||
* sidebar) can compute identical `segmentKey`s from local text and match
|
||||
* them against persisted manifest rows by content identity.
|
||||
*/
|
||||
const activeReaderType: ReaderType = useMemo(
|
||||
() => (isEPUB ? 'epub' : currentReaderType),
|
||||
[isEPUB, currentReaderType],
|
||||
);
|
||||
|
||||
const [currDocPage, setCurrDocPage] = useState<TTSLocation>(1);
|
||||
const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only
|
||||
const [currDocPages, setCurrDocPages] = useState<number>();
|
||||
|
|
@ -422,6 +553,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const currentIndexRef = useRef(0);
|
||||
const plannedSegmentsByLocationRef = useRef<Map<string, CanonicalTtsSegment[]>>(new Map());
|
||||
const currentSourceUnitRef = useRef<CanonicalTtsSourceUnit | null>(null);
|
||||
const currentSourceContextUnitsRef = useRef<CanonicalTtsSourceUnit[]>([]);
|
||||
const completedEpubBoundarySegmentRef = useRef<CompletedEpubBoundarySegment | null>(null);
|
||||
const pendingNextLocationRef = useRef<TTSLocation | undefined>(undefined);
|
||||
|
||||
|
|
@ -527,6 +659,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
}
|
||||
}, []);
|
||||
|
||||
const cacheCompletedManifestForCandidate = useCallback((
|
||||
cacheKey: string,
|
||||
segment: TTSSegmentManifestItem,
|
||||
alignmentEnabledForCurrentDoc: boolean,
|
||||
): boolean => {
|
||||
if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) {
|
||||
return false;
|
||||
}
|
||||
setSegmentManifestCache(cacheKey, segment);
|
||||
if (alignmentEnabledForCurrentDoc && segment.alignment) {
|
||||
sentenceAlignmentCacheRef.current.set(cacheKey, segment.alignment);
|
||||
}
|
||||
return true;
|
||||
}, [setSegmentManifestCache]);
|
||||
|
||||
const invalidatePlaybackRun = useCallback(() => {
|
||||
playbackRunIdRef.current += 1;
|
||||
playbackInFlightRef.current = false;
|
||||
|
|
@ -841,7 +988,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
? normalizedOptions.location
|
||||
: currDocPage;
|
||||
const resolvedLocationKey = normalizeLocationKey(resolvedLocation);
|
||||
const activeReaderType = isEPUB ? 'epub' : currentReaderType;
|
||||
|
||||
// Keep currDocPage aligned with whatever the caller declared as the viewport's
|
||||
// location. This is the canonical entry point for "the rendered page now shows
|
||||
// this content at this location" — the navigation flow (handleLocationChanged →
|
||||
// skipToLocation) already set it before calling setText, so this is a no-op
|
||||
// for next/prev/jump. The path that needs it is **resize**: EPUBViewer's
|
||||
// checkResize calls extractPageText directly (bypassing skipToLocation), so
|
||||
// without this, currDocPage would stay pinned to the pre-resize CFI even
|
||||
// though the page has repaginated to a new start CFI.
|
||||
if (normalizedOptions.location !== undefined && normalizedOptions.location !== currDocPage) {
|
||||
setCurrDocPage(normalizedOptions.location);
|
||||
}
|
||||
const currentSourceKey = sourceKeyForLocation(resolvedLocation, currDocPage);
|
||||
const currentSource: CanonicalTtsSourceUnit = {
|
||||
sourceKey: currentSourceKey,
|
||||
|
|
@ -849,10 +1007,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
locator: locatorForLocation(resolvedLocation, activeReaderType),
|
||||
};
|
||||
|
||||
const sourceUnits: CanonicalTtsSourceUnit[] = [];
|
||||
const contextSourceUnits: CanonicalTtsSourceUnit[] = [];
|
||||
if (smartSentenceSplitting && normalizedOptions.previousText?.trim()) {
|
||||
const previousLocation = normalizedOptions.previousLocation;
|
||||
sourceUnits.push({
|
||||
contextSourceUnits.push({
|
||||
sourceKey: previousLocation !== undefined
|
||||
? sourceKeyForLocation(previousLocation, currDocPage)
|
||||
: `previous:${currentSourceKey}`,
|
||||
|
|
@ -862,7 +1020,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
: null,
|
||||
});
|
||||
}
|
||||
sourceUnits.push(currentSource);
|
||||
contextSourceUnits.push(currentSource);
|
||||
const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits];
|
||||
|
||||
plannedSegmentsByLocationRef.current.clear();
|
||||
pendingNextLocationRef.current = normalizedOptions.nextLocation;
|
||||
|
|
@ -895,14 +1054,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const plan = planCanonicalTtsSegments(sourceUnits, {
|
||||
readerType: activeReaderType,
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
keyPrefix: `${documentId || 'document'}:${activeReaderType}:v1`,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||||
});
|
||||
const currentSegments = smartSentenceSplitting
|
||||
? plan.segments.filter((segment) => segment.ownerSourceKey === currentSourceKey)
|
||||
: planCanonicalTtsSegments([currentSource], {
|
||||
readerType: activeReaderType,
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
keyPrefix: `${documentId || 'document'}:${activeReaderType}:v1`,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||||
}).segments;
|
||||
const newSentences = currentSegments.map((segment) => segment.text);
|
||||
|
||||
|
|
@ -912,7 +1071,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
: planCanonicalTtsSegments([item], {
|
||||
readerType: activeReaderType,
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
keyPrefix: `${documentId || 'document'}:${activeReaderType}:v1`,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||||
}).segments;
|
||||
if (planned.length > 0) {
|
||||
plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned);
|
||||
|
|
@ -920,6 +1079,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
}
|
||||
|
||||
currentSourceUnitRef.current = currentSource;
|
||||
currentSourceContextUnitsRef.current = contextSourceUnits;
|
||||
|
||||
if (handleBlankSection(newSentences.join(' '))) return;
|
||||
|
||||
|
|
@ -1038,7 +1198,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
handleBlankSection,
|
||||
abortAudio,
|
||||
isEPUB,
|
||||
currentReaderType,
|
||||
activeReaderType,
|
||||
smartSentenceSplitting,
|
||||
invalidatePlaybackRun,
|
||||
currDocPage,
|
||||
|
|
@ -1272,6 +1432,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
try {
|
||||
onTTSStart();
|
||||
const persistSegments = await resolveSegmentsForPersist([
|
||||
{
|
||||
segmentIndex: sentenceIndex,
|
||||
...(segmentKey ? { segmentKey } : {}),
|
||||
text: sentence,
|
||||
locator,
|
||||
},
|
||||
]);
|
||||
if (persistSegments.length === 0) {
|
||||
if (!preload) setIsPlaying(false);
|
||||
return undefined;
|
||||
}
|
||||
const ensured = await withRetry(
|
||||
async () => ensureTtsSegments({
|
||||
documentId,
|
||||
|
|
@ -1282,14 +1454,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
nativeSpeed: effectiveNativeSpeed,
|
||||
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
|
||||
},
|
||||
segments: [
|
||||
{
|
||||
segmentIndex: sentenceIndex,
|
||||
...(segmentKey ? { segmentKey } : {}),
|
||||
text: sentence,
|
||||
locator,
|
||||
},
|
||||
],
|
||||
segments: persistSegments,
|
||||
}, reqHeaders, controller.signal),
|
||||
retryOptions,
|
||||
);
|
||||
|
|
@ -1383,11 +1548,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
currentReaderType,
|
||||
setSegmentManifestCache,
|
||||
isAbortLikeError,
|
||||
resolveSegmentsForPersist,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Processes and plays the current sentence
|
||||
*
|
||||
*
|
||||
* @param {string} sentence - The sentence to process
|
||||
* @param {boolean} [preload=false] - Whether this is a preload request
|
||||
* @returns {Promise<TTSSegmentPlaybackSource | null>} Prepared playback source metadata
|
||||
|
|
@ -2031,39 +2197,55 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
if (generationAtStart !== epubPreloadGenerationRef.current) return;
|
||||
if (!locationItems.length) return;
|
||||
|
||||
const currentToken = normalizeEpubLocationToken(String(currDocPage));
|
||||
const filteredLocationItems = locationItems.filter((item) =>
|
||||
normalizeEpubLocationToken(item.location) !== currentToken,
|
||||
const upcomingLocationItems = selectUpcomingWalkerItems(
|
||||
locationItems,
|
||||
String(currDocPage),
|
||||
maxDepth,
|
||||
);
|
||||
const targetDepth = Math.max(0, maxDepth - 1);
|
||||
const upcomingLocationItems = filteredLocationItems.slice(0, targetDepth);
|
||||
if (!upcomingLocationItems.length) return;
|
||||
|
||||
const sourceUnits: CanonicalTtsSourceUnit[] = [];
|
||||
if (smartSentenceSplitting && currentSourceUnitRef.current) {
|
||||
sourceUnits.push(currentSourceUnitRef.current);
|
||||
}
|
||||
sourceUnits.push(...upcomingLocationItems.map((item) => ({
|
||||
sourceKey: sourceKeyForLocation(item.location, currDocPage),
|
||||
// Build a stable EPUB locator for each rendered chunk from the
|
||||
// walker's spine coordinates. These are viewport-independent — the
|
||||
// same content yields the same locator across devices and resizes.
|
||||
const locatorForWalkerItem = (
|
||||
item: typeof upcomingLocationItems[number],
|
||||
): TTSSegmentLocator => ({
|
||||
readerType: 'epub',
|
||||
spineHref: item.spineHref,
|
||||
spineIndex: item.spineIndex,
|
||||
charOffset: item.chunkOffset,
|
||||
cfi: item.cfi,
|
||||
});
|
||||
|
||||
const upcomingUnits: CanonicalTtsSourceUnit[] = upcomingLocationItems.map((item) => ({
|
||||
sourceKey: sourceKeyForLocation(item.cfi, currDocPage),
|
||||
text: item.text,
|
||||
locator: { location: item.location, readerType: 'epub' as const },
|
||||
})));
|
||||
locator: locatorForWalkerItem(item),
|
||||
}));
|
||||
const liveContextUnits = currentSourceContextUnitsRef.current.length > 0
|
||||
? currentSourceContextUnitsRef.current
|
||||
: (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []);
|
||||
const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits(
|
||||
smartSentenceSplitting,
|
||||
liveContextUnits,
|
||||
upcomingUnits,
|
||||
);
|
||||
|
||||
const plan = planCanonicalTtsSegments(sourceUnits, {
|
||||
readerType: 'epub',
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
keyPrefix: `${documentId || 'document'}:epub:v1`,
|
||||
keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'),
|
||||
});
|
||||
const uniqueCandidates: EpubLocationPreloadCandidate[] = [];
|
||||
const uniqueCandidates: Array<EpubLocationPreloadCandidate & { locator: TTSSegmentLocator }> = [];
|
||||
const seenCandidates = new Set<string>();
|
||||
for (const item of upcomingLocationItems) {
|
||||
const sourceKey = sourceKeyForLocation(item.location, currDocPage);
|
||||
const sourceKey = sourceKeyForLocation(item.cfi, currDocPage);
|
||||
const planned = plan.segments
|
||||
.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 ?? { location: item.location, readerType: 'epub' as const };
|
||||
const locator = segment.ownerLocator ?? locatorForWalkerItem(item);
|
||||
const requestKey = buildSegmentRequestKey(locator, index, segment.text, segment.key);
|
||||
const cacheKey = buildScopedSegmentCacheKey(
|
||||
locator,
|
||||
|
|
@ -2083,9 +2265,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
sentence: segment.text,
|
||||
segmentKey: segment.key,
|
||||
segmentIndex: index,
|
||||
location: item.location,
|
||||
location: item.cfi,
|
||||
requestKey,
|
||||
cacheKey,
|
||||
locator,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2095,12 +2278,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
segmentIndex: candidate.segmentIndex,
|
||||
segmentKey: candidate.segmentKey,
|
||||
text: candidate.sentence,
|
||||
locator: { location: candidate.location, readerType: 'epub' as const },
|
||||
locator: candidate.locator,
|
||||
}));
|
||||
|
||||
const preloadPromise = (async (): Promise<void> => {
|
||||
onTTSStart();
|
||||
started = true;
|
||||
const persistPayload = await resolveSegmentsForPersist(payload);
|
||||
if (persistPayload.length === 0) return;
|
||||
const ensured = await withRetry(
|
||||
async () => ensureTtsSegments({
|
||||
documentId,
|
||||
|
|
@ -2111,7 +2296,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
nativeSpeed: effectiveNativeSpeed,
|
||||
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
|
||||
},
|
||||
segments: payload,
|
||||
segments: persistPayload,
|
||||
}, reqHeaders, controller.signal),
|
||||
retryOptions,
|
||||
);
|
||||
|
|
@ -2128,10 +2313,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
for (const candidate of uniqueCandidates) {
|
||||
const segment = segmentLookup.get(candidate.segmentKey);
|
||||
if (!segment) continue;
|
||||
setSegmentManifestCache(candidate.cacheKey, segment);
|
||||
if (alignmentEnabledForCurrentDoc && segment.alignment) {
|
||||
sentenceAlignmentCacheRef.current.set(candidate.cacheKey, segment.alignment);
|
||||
}
|
||||
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -2280,9 +2462,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
text: candidate.sentence,
|
||||
locator: candidate.locator,
|
||||
}));
|
||||
const candidateLookup = new Map<string, typeof uniqueCandidates[number]>();
|
||||
const candidateLookupKey = (segmentIndex: number, segmentKey?: string | null) =>
|
||||
`${segmentIndex}|${segmentKey || ''}`;
|
||||
for (const candidate of uniqueCandidates) {
|
||||
candidateLookup.set(candidateLookupKey(candidate.segmentIndex, candidate.segmentKey), candidate);
|
||||
}
|
||||
const preloadPromise = (async (): Promise<void> => {
|
||||
try {
|
||||
onTTSStart();
|
||||
const persistPayload = await resolveSegmentsForPersist(payload);
|
||||
if (persistPayload.length === 0) return;
|
||||
const ensured = await withRetry(
|
||||
async () => ensureTtsSegments({
|
||||
documentId,
|
||||
|
|
@ -2293,19 +2483,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
nativeSpeed: effectiveNativeSpeed,
|
||||
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
|
||||
},
|
||||
segments: payload,
|
||||
segments: persistPayload,
|
||||
}, reqHeaders, controller.signal),
|
||||
retryOptions,
|
||||
);
|
||||
|
||||
ensured.segments.forEach((segment, index) => {
|
||||
const candidate = uniqueCandidates[index];
|
||||
ensured.segments.forEach((segment) => {
|
||||
const candidate = candidateLookup.get(
|
||||
candidateLookupKey(segment.segmentIndex, segment.segmentKey),
|
||||
);
|
||||
if (!candidate) return;
|
||||
if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) return;
|
||||
setSegmentManifestCache(candidate.cacheKey, segment);
|
||||
if (alignmentEnabledForCurrentDoc && segment.alignment) {
|
||||
sentenceAlignmentCacheRef.current.set(candidate.cacheKey, segment.alignment);
|
||||
}
|
||||
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
|
||||
});
|
||||
} finally {
|
||||
activeAbortControllers.current.delete(controller);
|
||||
|
|
@ -2380,12 +2568,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
onTTSStart,
|
||||
onTTSComplete,
|
||||
processSentence,
|
||||
setSegmentManifestCache,
|
||||
cacheCompletedManifestForCandidate,
|
||||
isAbortLikeError,
|
||||
pdfHighlightEnabled,
|
||||
pdfWordHighlightEnabled,
|
||||
epubHighlightEnabled,
|
||||
epubWordHighlightEnabled,
|
||||
resolveSegmentsForPersist,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -2440,6 +2629,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
bumpEpubPreloadGeneration();
|
||||
plannedSegmentsByLocationRef.current.clear();
|
||||
currentSourceUnitRef.current = null;
|
||||
currentSourceContextUnitsRef.current = [];
|
||||
completedEpubBoundarySegmentRef.current = null;
|
||||
pageFirstBlockFingerprintRef.current.clear();
|
||||
setIsPlaying(false);
|
||||
|
|
@ -2478,6 +2668,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
const resolvedLocation: TTSLocation | undefined = (() => {
|
||||
if (!locator) return undefined;
|
||||
// Stable EPUB locators carry the jump-hint CFI in `cfi`, not `location`
|
||||
// (which is now reserved for HTML / legacy rows).
|
||||
if (locator.readerType === 'epub' && typeof locator.cfi === 'string' && locator.cfi) {
|
||||
return locator.cfi;
|
||||
}
|
||||
if (typeof locator.location === 'string' && locator.location) return locator.location;
|
||||
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) return Math.floor(locator.page);
|
||||
return undefined;
|
||||
|
|
@ -2664,8 +2859,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
registerEpubLocationWalker,
|
||||
registerEpubLocatorResolver,
|
||||
registerVisualPageChangeHandler,
|
||||
setIsEPUB
|
||||
setIsEPUB,
|
||||
activeReaderType,
|
||||
}), [
|
||||
isPlaying,
|
||||
isProcessing,
|
||||
|
|
@ -2692,10 +2889,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
registerEpubLocationWalker,
|
||||
registerEpubLocatorResolver,
|
||||
registerVisualPageChangeHandler,
|
||||
setIsEPUB,
|
||||
currentSentenceAlignment,
|
||||
currentWordIndex
|
||||
currentWordIndex,
|
||||
activeReaderType,
|
||||
]);
|
||||
|
||||
// Use media session hook
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export const ttsSegments = pgTable('tts_segments', {
|
|||
readerType: text('reader_type').notNull(),
|
||||
documentVersion: bigint('document_version', { mode: 'number' }).notNull(),
|
||||
segmentIndex: integer('segment_index').notNull(),
|
||||
segmentKey: text('segment_key'),
|
||||
locatorJson: text('locator_json'),
|
||||
settingsHash: text('settings_hash').notNull(),
|
||||
settingsJson: jsonb('settings_json').notNull(),
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export const ttsSegments = sqliteTable('tts_segments', {
|
|||
readerType: text('reader_type').notNull(),
|
||||
documentVersion: integer('document_version').notNull(),
|
||||
segmentIndex: integer('segment_index').notNull(),
|
||||
segmentKey: text('segment_key'),
|
||||
locatorJson: text('locator_json'),
|
||||
settingsHash: text('settings_hash').notNull(),
|
||||
settingsJson: text('settings_json').notNull(),
|
||||
|
|
|
|||
184
src/lib/client/epub/canonicalize-epub-segment.ts
Normal file
184
src/lib/client/epub/canonicalize-epub-segment.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import {
|
||||
buildSegmentKeyPrefix,
|
||||
normalizeSegmentIdentityText,
|
||||
planCanonicalTtsSegments,
|
||||
type CanonicalTtsSegment,
|
||||
} from '@/lib/shared/tts-segment-plan';
|
||||
import type { TTSSegmentLocator } from '@/types/client';
|
||||
|
||||
export interface CanonicalizeEpubSegmentInput {
|
||||
segmentText: string;
|
||||
spineText: string;
|
||||
spineHref: string;
|
||||
spineIndex: number;
|
||||
hintCharOffset?: number;
|
||||
cfi?: string;
|
||||
keyPrefix?: string;
|
||||
maxBlockLength?: number;
|
||||
}
|
||||
|
||||
export interface CanonicalizedEpubSegment {
|
||||
text: string;
|
||||
segmentKey: string;
|
||||
segmentIndex: number;
|
||||
locator: TTSSegmentLocator;
|
||||
}
|
||||
|
||||
export interface CanonicalizeEpubSegmentsInput extends Omit<CanonicalizeEpubSegmentInput, 'segmentText' | 'hintCharOffset'> {
|
||||
segmentTexts: readonly string[];
|
||||
hintCharOffsets?: readonly number[];
|
||||
}
|
||||
|
||||
function distanceToHint(segment: CanonicalTtsSegment, hint: number): number {
|
||||
return Math.abs(segment.startAnchor.offset - hint);
|
||||
}
|
||||
|
||||
function chooseClosestByHint(
|
||||
segments: CanonicalTtsSegment[],
|
||||
hint: number,
|
||||
): CanonicalTtsSegment | null {
|
||||
if (segments.length === 0) return null;
|
||||
return segments
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const byDistance = distanceToHint(a, hint) - distanceToHint(b, hint);
|
||||
if (byDistance !== 0) return byDistance;
|
||||
return a.ordinal - b.ordinal;
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
function chooseByHintWindow(
|
||||
segments: CanonicalTtsSegment[],
|
||||
hint: number,
|
||||
): CanonicalTtsSegment | null {
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const containing = segments.filter((segment) =>
|
||||
hint >= segment.startAnchor.offset && hint < segment.endAnchor.offset,
|
||||
);
|
||||
if (containing.length > 0) return chooseClosestByHint(containing, hint);
|
||||
|
||||
const forward = segments.filter((segment) => segment.startAnchor.offset >= hint);
|
||||
if (forward.length > 0) {
|
||||
return forward
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const byStart = a.startAnchor.offset - b.startAnchor.offset;
|
||||
if (byStart !== 0) return byStart;
|
||||
return a.ordinal - b.ordinal;
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
// Fallback: everything is before the hint; choose nearest by distance.
|
||||
return chooseClosestByHint(segments, hint);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function toCanonicalized(
|
||||
chosen: CanonicalTtsSegment,
|
||||
input: Omit<CanonicalizeEpubSegmentInput, 'segmentText' | 'hintCharOffset'>,
|
||||
): CanonicalizedEpubSegment {
|
||||
const locator: TTSSegmentLocator = {
|
||||
readerType: 'epub',
|
||||
spineHref: input.spineHref,
|
||||
spineIndex: input.spineIndex,
|
||||
charOffset: Math.max(0, chosen.startAnchor.offset),
|
||||
};
|
||||
if (input.cfi) locator.cfi = input.cfi;
|
||||
|
||||
return {
|
||||
text: chosen.text,
|
||||
segmentKey: chosen.key,
|
||||
segmentIndex: chosen.ordinal,
|
||||
locator,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a possibly viewport-shaped EPUB segment candidate against one
|
||||
* spine item's full text. Prefers exact normalized-text matches near the hint
|
||||
* offset; falls back to hint-window selection when boundaries differ.
|
||||
*/
|
||||
export function canonicalizeEpubSegmentAgainstSpineText(
|
||||
input: CanonicalizeEpubSegmentInput,
|
||||
): CanonicalizedEpubSegment | null {
|
||||
if (!input.segmentText.trim()) {
|
||||
return null;
|
||||
}
|
||||
const segments = buildCanonicalPlan(input);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const normalizedCandidate = normalizeSegmentIdentityText(input.segmentText);
|
||||
const hint = Math.max(0, Math.floor(input.hintCharOffset ?? 0));
|
||||
const exactMatches = segments.filter((segment) =>
|
||||
normalizeSegmentIdentityText(segment.text) === normalizedCandidate,
|
||||
);
|
||||
|
||||
const chosen = chooseClosestByHint(exactMatches, hint)
|
||||
?? chooseByHintWindow(segments, hint);
|
||||
if (!chosen) return null;
|
||||
|
||||
return toCanonicalized(chosen, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a sentence list against one spine item's full text with a
|
||||
* monotonic (forward-only) segment ordinal cursor. This prevents overlap
|
||||
* boundary rows from snapping backward to an earlier canonical segment when a
|
||||
* local boundary split changes sentence text.
|
||||
*/
|
||||
export function canonicalizeEpubSegmentsAgainstSpineText(
|
||||
input: CanonicalizeEpubSegmentsInput,
|
||||
): Array<CanonicalizedEpubSegment | null> {
|
||||
const output: Array<CanonicalizedEpubSegment | null> = input.segmentTexts.map(() => null);
|
||||
const segments = buildCanonicalPlan(input);
|
||||
if (segments.length === 0) return output;
|
||||
|
||||
let nextMinOrdinal = 0;
|
||||
for (let i = 0; i < input.segmentTexts.length; i += 1) {
|
||||
const rawText = input.segmentTexts[i] ?? '';
|
||||
if (!rawText.trim()) continue;
|
||||
const hint = Math.max(0, Math.floor(input.hintCharOffsets?.[i] ?? 0));
|
||||
const allowed = segments.filter((segment) => segment.ordinal >= nextMinOrdinal);
|
||||
if (allowed.length === 0) break;
|
||||
|
||||
const normalizedCandidate = normalizeSegmentIdentityText(rawText);
|
||||
const exactMatches = allowed.filter((segment) =>
|
||||
normalizeSegmentIdentityText(segment.text) === normalizedCandidate,
|
||||
);
|
||||
const chosen = chooseClosestByHint(exactMatches, hint)
|
||||
?? chooseByHintWindow(allowed, hint);
|
||||
if (!chosen) continue;
|
||||
|
||||
output[i] = toCanonicalized(chosen, input);
|
||||
nextMinOrdinal = chosen.ordinal + 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
|
@ -1,10 +1,27 @@
|
|||
import type { Book, Rendition } from 'epubjs';
|
||||
|
||||
import { createRangeCfi } from '@/lib/client/epub';
|
||||
import {
|
||||
buildEpubChunkAnchor,
|
||||
invalidateSpinePlainTextCache,
|
||||
} from '@/lib/client/epub/spine-coordinates';
|
||||
import { buildWalkerThemeRules, type WalkerThemeSnapshot } from '@/lib/client/epub/walker-theme';
|
||||
|
||||
export interface RenderedLocationWalkItem {
|
||||
location: string;
|
||||
/** Page-start CFI from the rendition. Retained as a soft jump hint only. */
|
||||
cfi: string;
|
||||
/** Plain-text content of the rendered page chunk. */
|
||||
text: string;
|
||||
/** Spine item href the chunk belongs to. Stable across viewports. */
|
||||
spineHref: string;
|
||||
/** Ordinal of the spine item within the book. Stable across viewports. */
|
||||
spineIndex: number;
|
||||
/**
|
||||
* Offset (in normalized character space — see normalizeSegmentIdentityText)
|
||||
* where this chunk begins inside the spine item's plain text. Stable across
|
||||
* viewports, so segments can be anchored to this base.
|
||||
*/
|
||||
chunkOffset: number;
|
||||
}
|
||||
|
||||
export interface RenderedLocationWalkRequest {
|
||||
|
|
@ -15,10 +32,7 @@ export interface RenderedLocationWalkRequest {
|
|||
width: number;
|
||||
height: number;
|
||||
spread?: string;
|
||||
theme?: {
|
||||
foreground: string;
|
||||
base: string;
|
||||
} | null;
|
||||
theme?: WalkerThemeSnapshot | null;
|
||||
}
|
||||
|
||||
type Session = {
|
||||
|
|
@ -136,6 +150,9 @@ export class EpubRenderedLocationCloneManager {
|
|||
const current = this.activeSession;
|
||||
this.activeSession = null;
|
||||
if (!current) return;
|
||||
try {
|
||||
invalidateSpinePlainTextCache(current.book);
|
||||
} catch {}
|
||||
try {
|
||||
current.rendition.destroy();
|
||||
} catch {}
|
||||
|
|
@ -191,12 +208,7 @@ export class EpubRenderedLocationCloneManager {
|
|||
|
||||
if (request.theme) {
|
||||
try {
|
||||
rendition.themes.registerRules('openreader-preload-theme', {
|
||||
body: {
|
||||
color: request.theme.foreground,
|
||||
'background-color': request.theme.base,
|
||||
},
|
||||
});
|
||||
rendition.themes.registerRules('openreader-preload-theme', buildWalkerThemeRules(request.theme));
|
||||
rendition.themes.select('openreader-preload-theme');
|
||||
} catch (error) {
|
||||
console.warn('Failed applying preload EPUB theme rules:', error);
|
||||
|
|
@ -261,9 +273,18 @@ export class EpubRenderedLocationCloneManager {
|
|||
const text = range?.toString()?.trim() || '';
|
||||
if (!text) continue;
|
||||
|
||||
const chunkAnchor = await Promise.race([
|
||||
buildEpubChunkAnchor(session.book, start, text),
|
||||
abortPromise,
|
||||
]);
|
||||
if (!chunkAnchor) continue;
|
||||
|
||||
results.push({
|
||||
location: start,
|
||||
cfi: start,
|
||||
text,
|
||||
spineHref: chunkAnchor.spineHref,
|
||||
spineIndex: chunkAnchor.spineIndex,
|
||||
chunkOffset: chunkAnchor.charOffset,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
283
src/lib/client/epub/spine-coordinates.ts
Normal file
283
src/lib/client/epub/spine-coordinates.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import type { Book } from 'epubjs';
|
||||
import type Section from 'epubjs/types/section';
|
||||
|
||||
import { normalizeSegmentIdentityText } from '@/lib/shared/tts-segment-plan';
|
||||
import type { TTSSegmentLocator } from '@/types/client';
|
||||
|
||||
/**
|
||||
* Stable book coordinates for an EPUB anchor (a page chunk or a sentence).
|
||||
*
|
||||
* `charOffset` is measured in the *normalized* spine-item text — i.e. the same
|
||||
* normalization used by segmentKey (see normalizeSegmentIdentityText). This is
|
||||
* deliberate: we want a coordinate that is stable across viewports and that
|
||||
* matches the identity space audio segments already live in. The coordinate is
|
||||
* NOT intended to round-trip back to a CFI; the optional `cfi` field on the
|
||||
* locator carries that jump hint separately.
|
||||
*/
|
||||
export interface EpubSpineCoord {
|
||||
spineHref: string;
|
||||
spineIndex: number;
|
||||
charOffset: number;
|
||||
}
|
||||
|
||||
interface SectionLike {
|
||||
index: number;
|
||||
href?: string;
|
||||
load?: ((request?: unknown) => Promise<Document> | Document) | undefined;
|
||||
unload?: () => void;
|
||||
}
|
||||
|
||||
const PLAIN_TEXT_CACHE = new WeakMap<Book, Map<string, string>>();
|
||||
|
||||
const getCacheBucket = (book: Book): Map<string, string> => {
|
||||
let bucket = PLAIN_TEXT_CACHE.get(book);
|
||||
if (!bucket) {
|
||||
bucket = new Map();
|
||||
PLAIN_TEXT_CACHE.set(book, bucket);
|
||||
}
|
||||
return bucket;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the cached plain-text-by-href map for a book. Call when the underlying
|
||||
* EPUB resource is destroyed/re-opened so we don't hand out text from an old
|
||||
* book instance.
|
||||
*/
|
||||
export function invalidateSpinePlainTextCache(book: Book): void {
|
||||
PLAIN_TEXT_CACHE.delete(book);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a CFI (or href, or spine index) to its spine item identity.
|
||||
* Returns null when the CFI can't be resolved by epub.js.
|
||||
*/
|
||||
export function resolveSpineFromCfi(
|
||||
book: Book,
|
||||
cfiOrHrefOrIndex: string | number | undefined | null,
|
||||
): { href: string; index: number } | null {
|
||||
if (cfiOrHrefOrIndex === null || cfiOrHrefOrIndex === undefined) return null;
|
||||
try {
|
||||
const section = book.spine.get(cfiOrHrefOrIndex as never) as Section | undefined;
|
||||
if (!section) return null;
|
||||
const href = section.href;
|
||||
const index = section.index;
|
||||
if (typeof href !== 'string' || !href || typeof index !== 'number' || !Number.isFinite(index)) {
|
||||
return null;
|
||||
}
|
||||
return { href, index };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the spine item identified by href and return its plain text content.
|
||||
* Results are memoised per Book instance so this is safe to call repeatedly.
|
||||
*
|
||||
* Returns the empty string on any failure — callers should treat that as
|
||||
* "couldn't resolve coordinates" and fall back.
|
||||
*/
|
||||
export async function getSpineItemPlainText(book: Book, href: string): Promise<string> {
|
||||
if (!href) return '';
|
||||
const cache = getCacheBucket(book);
|
||||
const hit = cache.get(href);
|
||||
if (hit !== undefined) return hit;
|
||||
|
||||
try {
|
||||
const section = book.spine.get(href) as Section | undefined;
|
||||
if (!section || typeof section.load !== 'function') {
|
||||
cache.set(href, '');
|
||||
return '';
|
||||
}
|
||||
// epub.js Section.load accepts a request function; book.load is the canonical resolver.
|
||||
//
|
||||
// IMPORTANT: `section.load()` resolves to the spine item's `<html>`
|
||||
// Element (NOT a Document). See epubjs/src/section.js — `this.contents =
|
||||
// xml.documentElement; loading.resolve(this.contents);`. So neither
|
||||
// `.body` nor `.documentElement` exists on the resolved value; pulling
|
||||
// text via those paths silently returns undefined and we end up with an
|
||||
// empty spine string. We have to query for `<body>` inside the element
|
||||
// (or fall back to the element's own textContent if there's no body).
|
||||
const loaded = await Promise.resolve(
|
||||
(section as unknown as SectionLike).load!(book.load.bind(book)),
|
||||
);
|
||||
const root = loaded as Element | Document | null | undefined;
|
||||
let raw = '';
|
||||
if (root) {
|
||||
// Handle both shapes — most epubjs versions resolve to an Element, but
|
||||
// be defensive in case a future version returns a Document.
|
||||
if ('body' in (root as Document) && (root as Document).body) {
|
||||
raw = (root as Document).body.textContent ?? '';
|
||||
} else if (typeof (root as Element).querySelector === 'function') {
|
||||
const body = (root as Element).querySelector('body');
|
||||
raw = body?.textContent ?? (root as Element).textContent ?? '';
|
||||
} else if ('textContent' in (root as Element)) {
|
||||
raw = (root as Element).textContent ?? '';
|
||||
}
|
||||
}
|
||||
const text = typeof raw === 'string' ? raw : '';
|
||||
cache.set(href, text);
|
||||
try {
|
||||
(section as unknown as SectionLike).unload?.();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
cache.set(href, '');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the character offset (in normalized space) of `segmentText` inside
|
||||
* `spineText`. The optional `hintNormalized` narrows the search to start at or
|
||||
* after that normalized offset, which disambiguates repeated phrases.
|
||||
*
|
||||
* Falls back to a from-start search when the forward search misses — useful
|
||||
* for single-shot resolution where the hint may be inaccurate (e.g. a walker
|
||||
* reporting a chunk start that doesn't quite align). **Do NOT use this in a
|
||||
* monotonic per-sentence walk** — the from-start fallback can return an
|
||||
* occurrence *before* the cursor, which silently reorders rows. Use
|
||||
* `resolveMonotonicSentenceOffsets` for that case.
|
||||
*
|
||||
* Returns -1 if the segment text is not found.
|
||||
*/
|
||||
export function findSegmentOffset(
|
||||
spineText: string,
|
||||
segmentText: string,
|
||||
hintNormalized: number = 0,
|
||||
): number {
|
||||
const haystack = normalizeSegmentIdentityText(spineText);
|
||||
const needle = normalizeSegmentIdentityText(segmentText);
|
||||
if (!haystack || !needle) return -1;
|
||||
const startFrom = Math.max(0, Math.min(haystack.length, Math.floor(hintNormalized)));
|
||||
const direct = haystack.indexOf(needle, startFrom);
|
||||
if (direct !== -1) return direct;
|
||||
// Fall back to a search from the beginning — the hint can be wrong if the
|
||||
// walker reported a chunk start that doesn't align with our segment.
|
||||
if (startFrom > 0) {
|
||||
const fromStart = haystack.indexOf(needle);
|
||||
if (fromStart !== -1) return fromStart;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve per-sentence character offsets within a spine item's plain text, in
|
||||
* document order. Searches forward only — each sentence's offset is found at
|
||||
* or after the previous match's end+1. If a sentence's text isn't found ahead
|
||||
* of the cursor (e.g. it contains a phrase that only recurs earlier in the
|
||||
* chapter), the current cursor value is reused so the result stays
|
||||
* **monotonically non-decreasing**.
|
||||
*
|
||||
* This guarantee is the whole point: it prevents the sidebar's sort from
|
||||
* pulling a later-on-the-page sentence backwards because a substring of it
|
||||
* happens to appear in a chapter heading or earlier passage. Using
|
||||
* `findSegmentOffset` in a loop would let that happen via its from-start
|
||||
* fallback — which is correct for single-shot lookups and wrong here.
|
||||
*
|
||||
* Returns an array of offsets, one per input sentence (empty/falsy sentences
|
||||
* get the current cursor value). All values are >= 0 and the sequence is
|
||||
* monotonic non-decreasing.
|
||||
*/
|
||||
export function resolveMonotonicSentenceOffsets(
|
||||
spineText: string,
|
||||
sentences: readonly string[],
|
||||
): number[] {
|
||||
const haystack = normalizeSegmentIdentityText(spineText);
|
||||
const offsets: number[] = [];
|
||||
let cursor = 0;
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence) {
|
||||
offsets.push(cursor);
|
||||
continue;
|
||||
}
|
||||
const needle = normalizeSegmentIdentityText(sentence);
|
||||
const found = needle && haystack ? haystack.indexOf(needle, cursor) : -1;
|
||||
if (found >= 0) {
|
||||
offsets.push(found);
|
||||
cursor = found + 1;
|
||||
} else {
|
||||
offsets.push(cursor);
|
||||
}
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a chunk-level anchor (e.g. for a rendered page or for the current
|
||||
* viewport's start CFI). Returns the spine identity plus the normalized
|
||||
* character offset where this chunk begins in the spine item's text.
|
||||
*
|
||||
* When the chunk text can't be located inside the spine item we still return
|
||||
* the spine identity with `chunkOffset = 0` so callers have *something* stable
|
||||
* to attach to.
|
||||
*/
|
||||
export async function buildEpubChunkAnchor(
|
||||
book: Book,
|
||||
chunkCfi: string,
|
||||
chunkText: string,
|
||||
): Promise<(EpubSpineCoord & { spineText: string }) | null> {
|
||||
const spine = resolveSpineFromCfi(book, chunkCfi);
|
||||
if (!spine) return null;
|
||||
const spineText = await getSpineItemPlainText(book, spine.href);
|
||||
const chunkOffset = chunkText
|
||||
? Math.max(0, findSegmentOffset(spineText, chunkText, 0))
|
||||
: 0;
|
||||
return {
|
||||
spineHref: spine.href,
|
||||
spineIndex: spine.index,
|
||||
charOffset: chunkOffset,
|
||||
spineText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the EPUB locator for a single segment, given an already-resolved chunk
|
||||
* anchor. Reuses the cached spine plain text via the anchor.
|
||||
*
|
||||
* `cfi` is recorded as a soft jump hint only and is intentionally NOT part of
|
||||
* the locator's identity.
|
||||
*/
|
||||
export function buildEpubLocatorFromChunk(
|
||||
anchor: EpubSpineCoord & { spineText: string },
|
||||
segmentText: string,
|
||||
cfi?: string,
|
||||
): TTSSegmentLocator {
|
||||
// Forward-only search from the chunk anchor. Critically, we do NOT use
|
||||
// `findSegmentOffset` here because its from-start fallback would let a
|
||||
// segment's offset jump *before* the chunk anchor — which means a
|
||||
// sentence from a later page that happens to contain text also present
|
||||
// earlier in the chapter (chapter heading, refrain, common phrase) would
|
||||
// be persisted with an offset somewhere in an earlier page, causing it
|
||||
// to interleave out-of-order in the sidebar's sort.
|
||||
const haystack = normalizeSegmentIdentityText(anchor.spineText);
|
||||
const needle = normalizeSegmentIdentityText(segmentText);
|
||||
const found = needle && haystack ? haystack.indexOf(needle, anchor.charOffset) : -1;
|
||||
const charOffset = found >= 0 ? found : anchor.charOffset;
|
||||
const locator: TTSSegmentLocator = {
|
||||
readerType: 'epub',
|
||||
spineHref: anchor.spineHref,
|
||||
spineIndex: anchor.spineIndex,
|
||||
charOffset,
|
||||
};
|
||||
if (cfi) locator.cfi = cfi;
|
||||
return locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot helper: resolve a single segment's locator without an anchor in
|
||||
* hand. Loads spine text on demand (cached). Returns null when the CFI doesn't
|
||||
* resolve to a spine item.
|
||||
*/
|
||||
export async function buildEpubLocator(
|
||||
book: Book,
|
||||
chunkCfi: string,
|
||||
segmentText: string,
|
||||
chunkText?: string,
|
||||
): Promise<TTSSegmentLocator | null> {
|
||||
const anchor = await buildEpubChunkAnchor(book, chunkCfi, chunkText ?? segmentText);
|
||||
if (!anchor) return null;
|
||||
return buildEpubLocatorFromChunk(anchor, segmentText, chunkCfi);
|
||||
}
|
||||
30
src/lib/client/epub/walker-theme.ts
Normal file
30
src/lib/client/epub/walker-theme.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export interface WalkerThemeSnapshot {
|
||||
foreground: string;
|
||||
base: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
lineHeight?: string;
|
||||
fontWeight?: string;
|
||||
letterSpacing?: string;
|
||||
wordSpacing?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build epub.js theme rules for the hidden preload walker using the same core
|
||||
* typography metrics as the visible rendition.
|
||||
*/
|
||||
export function buildWalkerThemeRules(theme: WalkerThemeSnapshot): Record<string, Record<string, string>> {
|
||||
const bodyRules: Record<string, string> = {
|
||||
color: theme.foreground,
|
||||
'background-color': theme.base,
|
||||
};
|
||||
|
||||
if (theme.fontFamily) bodyRules['font-family'] = theme.fontFamily;
|
||||
if (theme.fontSize) bodyRules['font-size'] = theme.fontSize;
|
||||
if (theme.lineHeight) bodyRules['line-height'] = theme.lineHeight;
|
||||
if (theme.fontWeight) bodyRules['font-weight'] = theme.fontWeight;
|
||||
if (theme.letterSpacing) bodyRules['letter-spacing'] = theme.letterSpacing;
|
||||
if (theme.wordSpacing) bodyRules['word-spacing'] = theme.wordSpacing;
|
||||
|
||||
return { body: bodyRules };
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { compareSegmentLocators, locatorGroupKey } from '@/lib/shared/tts-locator';
|
||||
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||||
import type {
|
||||
TTSSegmentLocator,
|
||||
TTSSegmentVariant,
|
||||
} from '@/types/client';
|
||||
|
||||
export { locatorGroupKey };
|
||||
export { locatorGroupKey, locatorIdentityKey };
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 150;
|
||||
export const MIN_PAGE_SIZE = 25;
|
||||
|
|
|
|||
|
|
@ -54,20 +54,54 @@ export function normalizeSegmentText(text: string): string {
|
|||
return preprocessSentenceForAudio(text || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and shape a locator for persistence. EPUB locators MUST carry the
|
||||
* stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) — the
|
||||
* legacy CFI-only shape is rejected (returns null) so we never store a
|
||||
* viewport-dependent locator. PDF and HTML branches are unchanged.
|
||||
*/
|
||||
export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSegmentLocator | null {
|
||||
if (!locator) return null;
|
||||
const normalized: TTSSegmentLocator = {};
|
||||
if (typeof locator.page === 'number' && Number.isFinite(locator.page)) {
|
||||
normalized.page = Math.max(1, Math.floor(locator.page));
|
||||
if (locator.readerType === 'pdf') {
|
||||
if (typeof locator.page !== 'number' || !Number.isFinite(locator.page)) return null;
|
||||
return {
|
||||
readerType: 'pdf',
|
||||
page: Math.max(1, Math.floor(locator.page)),
|
||||
};
|
||||
}
|
||||
if (typeof locator.location === 'string' && locator.location.trim()) {
|
||||
normalized.location = locator.location.trim();
|
||||
if (locator.readerType === 'html') {
|
||||
if (typeof locator.location !== 'string' || !locator.location.trim()) return null;
|
||||
return {
|
||||
readerType: 'html',
|
||||
location: locator.location.trim(),
|
||||
};
|
||||
}
|
||||
if (locator.readerType === 'pdf' || locator.readerType === 'epub' || locator.readerType === 'html') {
|
||||
normalized.readerType = locator.readerType;
|
||||
if (locator.readerType === 'epub') {
|
||||
const spineHref = typeof locator.spineHref === 'string' ? locator.spineHref.trim() : '';
|
||||
const spineIndex = typeof locator.spineIndex === 'number' && Number.isFinite(locator.spineIndex)
|
||||
? Math.max(0, Math.floor(locator.spineIndex))
|
||||
: -1;
|
||||
const charOffset = typeof locator.charOffset === 'number' && Number.isFinite(locator.charOffset)
|
||||
? Math.max(0, Math.floor(locator.charOffset))
|
||||
: -1;
|
||||
if (!spineHref || spineIndex < 0 || charOffset < 0) {
|
||||
// Reject draft/legacy EPUB locators that lack stable coordinates. The
|
||||
// client is expected to resolve these via the spine-coordinates helper
|
||||
// before posting.
|
||||
return null;
|
||||
}
|
||||
const normalized: TTSSegmentLocator = {
|
||||
readerType: 'epub',
|
||||
spineHref,
|
||||
spineIndex,
|
||||
charOffset,
|
||||
};
|
||||
if (typeof locator.cfi === 'string' && locator.cfi.trim()) {
|
||||
normalized.cfi = locator.cfi.trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
if (Object.keys(normalized).length === 0) return null;
|
||||
return normalized;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function locatorFingerprint(locator: TTSSegmentLocator | null): string {
|
||||
|
|
|
|||
38
src/lib/shared/tts-epub-preload.ts
Normal file
38
src/lib/shared/tts-epub-preload.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan';
|
||||
import { normalizeEpubLocationToken } from '@/lib/shared/tts-locator';
|
||||
|
||||
export interface EpubWalkerLocationItem {
|
||||
cfi: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the EPUB depth contract explicit:
|
||||
* - `maxDepth` counts the current location as depth 1
|
||||
* - upcoming preload targets are therefore `maxDepth - 1`
|
||||
*/
|
||||
export function selectUpcomingWalkerItems<T extends EpubWalkerLocationItem>(
|
||||
locationItems: readonly T[],
|
||||
currentCfi: string,
|
||||
maxDepth: number,
|
||||
): T[] {
|
||||
const currentToken = normalizeEpubLocationToken(String(currentCfi || ''));
|
||||
const filtered = locationItems.filter((item) =>
|
||||
normalizeEpubLocationToken(item.cfi) !== currentToken,
|
||||
);
|
||||
const targetDepth = Math.max(0, maxDepth - 1);
|
||||
return filtered.slice(0, targetDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical source-unit list used for walker-based EPUB planning.
|
||||
* When smart splitting is enabled we seed with live context units
|
||||
* (previous/current) so walker boundary behavior aligns with setText.
|
||||
*/
|
||||
export function buildWalkerPlanningSourceUnits(
|
||||
smartSentenceSplitting: boolean,
|
||||
contextUnits: readonly CanonicalTtsSourceUnit[],
|
||||
upcomingUnits: readonly CanonicalTtsSourceUnit[],
|
||||
): CanonicalTtsSourceUnit[] {
|
||||
if (!smartSentenceSplitting) return [...upcomingUnits];
|
||||
return [...contextUnits, ...upcomingUnits];
|
||||
}
|
||||
|
|
@ -1,11 +1,22 @@
|
|||
import type { TTSSegmentLocator } from '@/types/client';
|
||||
import {
|
||||
isHtmlLocator,
|
||||
isPdfLocator,
|
||||
isStableEpubLocator,
|
||||
type TTSSegmentLocator,
|
||||
} from '@/types/client';
|
||||
import type { TTSLocation } from '@/types/tts';
|
||||
|
||||
const naturalLocationCollator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
export function normalizeTtsLocationKey(location: TTSLocation): string {
|
||||
return typeof location === 'number' ? `num:${location}` : `str:${location}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an EPUB CFI string for whitespace and step-marker variations so two
|
||||
* CFIs from the same epub.js rendition can be compared by equality. This is
|
||||
* only useful for in-flight CFI deduplication (same device, same render); it
|
||||
* does NOT make CFIs stable across viewports — that's what the spine
|
||||
* coordinates on the locator are for.
|
||||
*/
|
||||
export function normalizeEpubLocationToken(location: string): string {
|
||||
return location
|
||||
.trim()
|
||||
|
|
@ -13,45 +24,100 @@ export function normalizeEpubLocationToken(location: string): string {
|
|||
.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export function normalizeTtsLocationKey(location: TTSLocation): string {
|
||||
return typeof location === 'number' ? `num:${location}` : `str:${location}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse grouping key used by the sidebar to bucket rows under a chapter
|
||||
* heading. EPUB groups by spine item (chapter/section), which is
|
||||
* viewport-independent. PDF groups by page. HTML groups by the free-form
|
||||
* location string. Multiple rows in the same chapter share this key — that's
|
||||
* the whole point.
|
||||
*
|
||||
* **Do NOT use for storage dedupe.** For that you want `locatorIdentityKey`,
|
||||
* which carries the full per-row identity (including `charOffset` for EPUB).
|
||||
*
|
||||
* Legacy EPUB rows that only carry a CFI (no spine coords) fall back to a
|
||||
* group keyed by the raw CFI string — they keep working in isolation but will
|
||||
* not co-group with new-shape rows. Such rows are expected to disappear once
|
||||
* the user clears the legacy manifest.
|
||||
*/
|
||||
export function locatorGroupKey(locator: TTSSegmentLocator | null): string {
|
||||
if (!locator) return 'none';
|
||||
const page = typeof locator.page === 'number' && Number.isFinite(locator.page)
|
||||
? String(Math.floor(locator.page))
|
||||
: '';
|
||||
const location = typeof locator.location === 'string' ? locator.location : '';
|
||||
const readerType = locator.readerType || '';
|
||||
return `p:${page}|l:${location}|r:${readerType}`;
|
||||
if (isStableEpubLocator(locator)) {
|
||||
return `epub:${locator.spineIndex}:${locator.spineHref}`;
|
||||
}
|
||||
if (isPdfLocator(locator)) {
|
||||
return `pdf:${Math.floor(locator.page)}`;
|
||||
}
|
||||
if (isHtmlLocator(locator)) {
|
||||
return `html:${locator.location}`;
|
||||
}
|
||||
// Legacy / draft fallback. Keeps the row identifiable but does not promise
|
||||
// cross-viewport stability.
|
||||
const readerType = locator.readerType || '?';
|
||||
const fallback = locator.location ?? (typeof locator.page === 'number' ? String(locator.page) : '');
|
||||
return `legacy:${readerType}:${fallback}`;
|
||||
}
|
||||
|
||||
export function compareLocationTokens(a: string, b: string): number {
|
||||
return naturalLocationCollator.compare(
|
||||
normalizeEpubLocationToken(a),
|
||||
normalizeEpubLocationToken(b),
|
||||
);
|
||||
/**
|
||||
* Per-row identity key. Unlike `locatorGroupKey`, this includes the full
|
||||
* locator-level identity (e.g. `charOffset` for EPUB) so two rows at different
|
||||
* positions inside the same spine item never collapse into one entry. Used by
|
||||
* the server-side manifest aggregator and anywhere we need "is this the same
|
||||
* persisted row?" — not for sidebar chapter buckets.
|
||||
*/
|
||||
export function locatorIdentityKey(locator: TTSSegmentLocator | null): string {
|
||||
if (!locator) return 'none';
|
||||
if (isStableEpubLocator(locator)) {
|
||||
return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`;
|
||||
}
|
||||
if (isPdfLocator(locator)) {
|
||||
return `pdf:${Math.floor(locator.page)}`;
|
||||
}
|
||||
if (isHtmlLocator(locator)) {
|
||||
return `html:${locator.location}`;
|
||||
}
|
||||
const readerType = locator.readerType || '?';
|
||||
const fallback = locator.location ?? (typeof locator.page === 'number' ? String(locator.page) : '');
|
||||
return `legacy:${readerType}:${fallback}`;
|
||||
}
|
||||
|
||||
function readerTypeRank(readerType: string | undefined): number {
|
||||
if (readerType === 'epub') return 0;
|
||||
if (readerType === 'pdf') return 1;
|
||||
if (readerType === 'html') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total order over locators. Within a readerType:
|
||||
* - EPUB (stable shape): (spineIndex, charOffset). Both numeric — stable
|
||||
* across viewports.
|
||||
* - PDF: page number.
|
||||
* - HTML: location string (lexicographic).
|
||||
* Across readerTypes (rarely mixed in practice): stable rank by readerType.
|
||||
* Null locators sort last.
|
||||
*/
|
||||
export function compareSegmentLocators(
|
||||
a: TTSSegmentLocator | null,
|
||||
b: TTSSegmentLocator | null,
|
||||
): number {
|
||||
const aPage = typeof a?.page === 'number' && Number.isFinite(a.page)
|
||||
? Math.floor(a.page)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const bPage = typeof b?.page === 'number' && Number.isFinite(b.page)
|
||||
? Math.floor(b.page)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
if (aPage !== bPage) return aPage - bPage;
|
||||
|
||||
const aLocation = typeof a?.location === 'string' ? a.location : '';
|
||||
const bLocation = typeof b?.location === 'string' ? b.location : '';
|
||||
const byLocation = compareLocationTokens(aLocation, bLocation);
|
||||
if (byLocation !== 0) return byLocation;
|
||||
|
||||
const aReaderType = a?.readerType || '';
|
||||
const bReaderType = b?.readerType || '';
|
||||
return aReaderType.localeCompare(bReaderType);
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
if (a.readerType !== b.readerType) {
|
||||
return readerTypeRank(a.readerType) - readerTypeRank(b.readerType);
|
||||
}
|
||||
if (isStableEpubLocator(a) && isStableEpubLocator(b)) {
|
||||
if (a.spineIndex !== b.spineIndex) return a.spineIndex - b.spineIndex;
|
||||
if (a.charOffset !== b.charOffset) return a.charOffset - b.charOffset;
|
||||
return a.spineHref.localeCompare(b.spineHref);
|
||||
}
|
||||
if (isPdfLocator(a) && isPdfLocator(b)) {
|
||||
return Math.floor(a.page) - Math.floor(b.page);
|
||||
}
|
||||
if (isHtmlLocator(a) && isHtmlLocator(b)) {
|
||||
return a.location.localeCompare(b.location);
|
||||
}
|
||||
// One or both are legacy/draft — fall back to grouped-key compare so the
|
||||
// sort is at least deterministic and self-consistent.
|
||||
return locatorGroupKey(a).localeCompare(locatorGroupKey(b));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const normalizeSourceText = (text: string): string =>
|
|||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const normalizeSegmentIdentityText = (text: string): string =>
|
||||
export const normalizeSegmentIdentityText = (text: string): string =>
|
||||
preprocessSentenceForAudio(text)
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
|
|
@ -72,7 +72,16 @@ const stableHash = (value: string): string => {
|
|||
return hash.toString(16).padStart(8, '0');
|
||||
};
|
||||
|
||||
const buildSegmentKey = (keyPrefix: string, text: string): string =>
|
||||
/**
|
||||
* Compose a key prefix in the canonical form used everywhere segmentKeys are
|
||||
* minted. Keep this in lock-step with the inline prefixes in TTSContext.
|
||||
*/
|
||||
export const buildSegmentKeyPrefix = (
|
||||
documentId: string | null | undefined,
|
||||
readerType: ReaderType,
|
||||
): string => `${documentId || 'document'}:${readerType}:v1`;
|
||||
|
||||
export const buildSegmentKey = (keyPrefix: string, text: string): string =>
|
||||
[
|
||||
keyPrefix,
|
||||
stableHash(normalizeSegmentIdentityText(text)),
|
||||
|
|
|
|||
|
|
@ -94,10 +94,78 @@ export interface TTSSegmentSettings {
|
|||
ttsInstructions?: string;
|
||||
}
|
||||
|
||||
export type TTSReaderType = 'pdf' | 'epub' | 'html';
|
||||
|
||||
/**
|
||||
* Locator describing where a TTS segment came from inside a document.
|
||||
*
|
||||
* Field usage by readerType:
|
||||
* - PDF: `page` (1-based).
|
||||
* - HTML: `location` (free-form fragment id / scroll anchor).
|
||||
* - EPUB: **stable book coordinates** — `spineHref`, `spineIndex`, `charOffset`.
|
||||
* `cfi` is a best-effort jump hint only; it is NOT used for identity,
|
||||
* grouping, sorting, or matching. The stable coordinates are the same
|
||||
* across devices and window sizes.
|
||||
*
|
||||
* The interface is structurally permissive for backwards compatibility with
|
||||
* in-flight code paths, but the server-side `normalizeLocator` enforces the
|
||||
* required fields per readerType before persisting. Use the
|
||||
* `isStableEpubLocator` / `isPdfLocator` / `isHtmlLocator` guards at read sites
|
||||
* that need a particular shape.
|
||||
*/
|
||||
export interface TTSSegmentLocator {
|
||||
readerType?: TTSReaderType;
|
||||
// PDF / legacy
|
||||
page?: number;
|
||||
// HTML / legacy EPUB CFI (kept for in-flight drafts; not persisted for EPUB)
|
||||
location?: string;
|
||||
readerType?: 'pdf' | 'epub' | 'html';
|
||||
// Stable EPUB coordinates
|
||||
spineHref?: string;
|
||||
spineIndex?: number;
|
||||
charOffset?: number;
|
||||
/** Best-effort jump hint for EPUB; not part of identity/sort/group. */
|
||||
cfi?: string;
|
||||
}
|
||||
|
||||
export function isPdfLocator(
|
||||
locator: TTSSegmentLocator | null | undefined,
|
||||
): locator is TTSSegmentLocator & { readerType: 'pdf'; page: number } {
|
||||
return !!locator
|
||||
&& locator.readerType === 'pdf'
|
||||
&& typeof locator.page === 'number'
|
||||
&& Number.isFinite(locator.page);
|
||||
}
|
||||
|
||||
export function isHtmlLocator(
|
||||
locator: TTSSegmentLocator | null | undefined,
|
||||
): locator is TTSSegmentLocator & { readerType: 'html'; location: string } {
|
||||
return !!locator
|
||||
&& locator.readerType === 'html'
|
||||
&& typeof locator.location === 'string'
|
||||
&& locator.location.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to a fully-stable EPUB locator. Returns false for EPUB drafts that
|
||||
* only carry a CFI — those must be resolved to spine coordinates before being
|
||||
* sent to the server.
|
||||
*/
|
||||
export function isStableEpubLocator(
|
||||
locator: TTSSegmentLocator | null | undefined,
|
||||
): locator is TTSSegmentLocator & {
|
||||
readerType: 'epub';
|
||||
spineHref: string;
|
||||
spineIndex: number;
|
||||
charOffset: number;
|
||||
} {
|
||||
return !!locator
|
||||
&& locator.readerType === 'epub'
|
||||
&& typeof locator.spineHref === 'string'
|
||||
&& locator.spineHref.length > 0
|
||||
&& typeof locator.spineIndex === 'number'
|
||||
&& Number.isFinite(locator.spineIndex)
|
||||
&& typeof locator.charOffset === 'number'
|
||||
&& Number.isFinite(locator.charOffset);
|
||||
}
|
||||
|
||||
export interface TTSSegmentInput {
|
||||
|
|
@ -145,6 +213,15 @@ export interface TTSSegmentVariant {
|
|||
|
||||
export interface TTSSegmentRow {
|
||||
segmentIndex: number;
|
||||
/**
|
||||
* Content-stable identity for this segment, derived from the normalized
|
||||
* sentence text on the client (see `buildSegmentKey` in
|
||||
* `lib/shared/tts-segment-plan.ts`). The sidebar uses this to merge
|
||||
* locally-synthesized current-page rows with persisted manifest rows of the
|
||||
* same content, so audio/variants attach to the visible text row instead of
|
||||
* showing as a separate listing.
|
||||
*/
|
||||
segmentKey: string | null;
|
||||
locator: TTSSegmentLocator | null;
|
||||
variants: TTSSegmentVariant[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,11 +55,27 @@ export interface TTSSentenceAlignment {
|
|||
words: TTSSentenceWord[];
|
||||
}
|
||||
|
||||
export interface EpubRenderedLocationWalkItem {
|
||||
/** Page-start CFI from the rendition — best-effort jump hint only. */
|
||||
cfi: string;
|
||||
/** Plain text content of the rendered page chunk. */
|
||||
text: string;
|
||||
/** Spine item href the chunk belongs to (stable across viewports). */
|
||||
spineHref: string;
|
||||
/** Ordinal of the spine item within the book (stable across viewports). */
|
||||
spineIndex: number;
|
||||
/**
|
||||
* Offset (in normalized character space) of this chunk's start inside the
|
||||
* spine item's plain text. Stable across viewports.
|
||||
*/
|
||||
chunkOffset: number;
|
||||
}
|
||||
|
||||
export type EpubRenderedLocationWalker = (
|
||||
startCfi: string,
|
||||
depth: number,
|
||||
signal: AbortSignal,
|
||||
) => Promise<Array<{ location: string; text: string }>>;
|
||||
) => Promise<EpubRenderedLocationWalkItem[]>;
|
||||
|
||||
// Supported output formats for generated audiobooks
|
||||
export type TTSAudiobookFormat = 'mp3' | 'm4b';
|
||||
|
|
|
|||
153
tests/unit/canonicalize-epub-segment.spec.ts
Normal file
153
tests/unit/canonicalize-epub-segment.spec.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
canonicalizeEpubSegmentAgainstSpineText,
|
||||
canonicalizeEpubSegmentsAgainstSpineText,
|
||||
} from '../../src/lib/client/epub/canonicalize-epub-segment';
|
||||
import { planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan';
|
||||
|
||||
test.describe('canonicalizeEpubSegmentAgainstSpineText', () => {
|
||||
test('maps an exact sentence to the canonical segment identity', () => {
|
||||
const spineText = [
|
||||
'First section sentence with enough words to stand alone.',
|
||||
'Second section sentence with enough words to stand alone.',
|
||||
'Third section sentence with enough words to stand alone.',
|
||||
].join('\n');
|
||||
|
||||
const result = canonicalizeEpubSegmentAgainstSpineText({
|
||||
segmentText: 'Second section sentence with enough words to stand alone.',
|
||||
spineText,
|
||||
spineHref: 'OEBPS/ch01.xhtml',
|
||||
spineIndex: 1,
|
||||
hintCharOffset: 70,
|
||||
keyPrefix: 'doc-1:epub:v1',
|
||||
maxBlockLength: 90,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.locator.readerType).toBe('epub');
|
||||
expect(result?.locator.spineHref).toBe('OEBPS/ch01.xhtml');
|
||||
expect(result?.locator.spineIndex).toBe(1);
|
||||
expect(result?.text).toContain('Second section sentence');
|
||||
expect(result?.segmentKey).toContain('doc-1:epub:v1:');
|
||||
});
|
||||
|
||||
test('uses hintCharOffset to choose the nearest repeated exact match', () => {
|
||||
const repeated = 'Echo phrase repeated with enough words to stand alone.';
|
||||
const spineText = [
|
||||
repeated,
|
||||
'Middle bridge sentence that separates repeated text clearly.',
|
||||
repeated,
|
||||
].join('\n');
|
||||
|
||||
const plan = planCanonicalTtsSegments(
|
||||
[{ sourceKey: 'spine:2:OEBPS/ch02.xhtml', text: spineText }],
|
||||
{ readerType: 'epub', maxBlockLength: 90, keyPrefix: 'doc-2:epub:v1' },
|
||||
);
|
||||
const matching = plan.segments.filter((segment) => segment.text === repeated);
|
||||
expect(matching.length).toBeGreaterThanOrEqual(2);
|
||||
const later = matching[matching.length - 1];
|
||||
|
||||
const result = canonicalizeEpubSegmentAgainstSpineText({
|
||||
segmentText: repeated,
|
||||
spineText,
|
||||
spineHref: 'OEBPS/ch02.xhtml',
|
||||
spineIndex: 2,
|
||||
hintCharOffset: later.startAnchor.offset,
|
||||
keyPrefix: 'doc-2:epub:v1',
|
||||
maxBlockLength: 90,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.segmentKey).toBe(later.key);
|
||||
expect(result?.locator.charOffset).toBe(later.startAnchor.offset);
|
||||
});
|
||||
|
||||
test('falls back to hint-window selection when text does not match exactly', () => {
|
||||
const spineText = [
|
||||
'Opening sentence that should map to block one.',
|
||||
'Middle sentence that should map to block two.',
|
||||
'Closing sentence that should map to block three.',
|
||||
].join('\n');
|
||||
|
||||
const plan = planCanonicalTtsSegments(
|
||||
[{ sourceKey: 'spine:3:OEBPS/ch03.xhtml', text: spineText }],
|
||||
{ readerType: 'epub', maxBlockLength: 90, keyPrefix: 'doc-3:epub:v1' },
|
||||
);
|
||||
const target = plan.segments.find((segment) =>
|
||||
segment.text.includes('Middle sentence'),
|
||||
);
|
||||
expect(target).toBeTruthy();
|
||||
|
||||
const result = canonicalizeEpubSegmentAgainstSpineText({
|
||||
segmentText: 'Non-matching walker boundary text fragment',
|
||||
spineText,
|
||||
spineHref: 'OEBPS/ch03.xhtml',
|
||||
spineIndex: 3,
|
||||
hintCharOffset: target!.startAnchor.offset,
|
||||
keyPrefix: 'doc-3:epub:v1',
|
||||
maxBlockLength: 90,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.segmentKey).toBe(target!.key);
|
||||
expect(result?.segmentIndex).toBe(target!.ordinal);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('canonicalizeEpubSegmentsAgainstSpineText', () => {
|
||||
test('maps overlap-boundary drift sentences to forward canonical segments', () => {
|
||||
const sourceSentences = [
|
||||
'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.',
|
||||
'You might have called it aqua, or perhaps a faded green under glass.',
|
||||
'A titch too purple for hot pink, it was still impossible to ignore.',
|
||||
'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.',
|
||||
];
|
||||
const spineText = sourceSentences.join('\n');
|
||||
const plan = planCanonicalTtsSegments(
|
||||
[{ sourceKey: 'spine:8:OEBPS/ch08.xhtml', text: spineText }],
|
||||
{ readerType: 'epub', maxBlockLength: 80, keyPrefix: 'doc-8:epub:v1' },
|
||||
);
|
||||
expect(plan.segments.length).toBeGreaterThanOrEqual(6);
|
||||
|
||||
const driftedLocalSentences = [
|
||||
plan.segments[0].text,
|
||||
plan.segments[1].text,
|
||||
plan.segments[2].text,
|
||||
plan.segments[3].text,
|
||||
// Mimics a resize boundary split that no longer matches canonical text.
|
||||
'Boundary drift fragment that does not exactly match any canonical segment.',
|
||||
plan.segments[5].text,
|
||||
];
|
||||
const hints = [
|
||||
plan.segments[0].startAnchor.offset,
|
||||
plan.segments[1].startAnchor.offset,
|
||||
plan.segments[2].startAnchor.offset,
|
||||
plan.segments[3].startAnchor.offset,
|
||||
plan.segments[3].startAnchor.offset + 1,
|
||||
plan.segments[5].startAnchor.offset,
|
||||
];
|
||||
|
||||
const mapped = canonicalizeEpubSegmentsAgainstSpineText({
|
||||
segmentTexts: driftedLocalSentences,
|
||||
hintCharOffsets: hints,
|
||||
spineText,
|
||||
spineHref: 'OEBPS/ch08.xhtml',
|
||||
spineIndex: 8,
|
||||
keyPrefix: 'doc-8:epub:v1',
|
||||
maxBlockLength: 80,
|
||||
});
|
||||
|
||||
expect(mapped[0]?.segmentKey).toBe(plan.segments[0].key);
|
||||
expect(mapped[1]?.segmentKey).toBe(plan.segments[1].key);
|
||||
expect(mapped[2]?.segmentKey).toBe(plan.segments[2].key);
|
||||
expect(mapped[3]?.segmentKey).toBe(plan.segments[3].key);
|
||||
// Core regression: despite hinting near segment 3, the mismatch sentence
|
||||
// maps to segment 4 (forward-only cursor), not backward to segment 3.
|
||||
expect(mapped[4]?.segmentIndex).toBe(4);
|
||||
expect(mapped[4]?.segmentKey).toBe(plan.segments[4].key);
|
||||
expect(mapped[5]?.segmentKey).toBe(plan.segments[5].key);
|
||||
});
|
||||
});
|
||||
408
tests/unit/epub-spine-coordinates.spec.ts
Normal file
408
tests/unit/epub-spine-coordinates.spec.ts
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Book } from 'epubjs';
|
||||
import {
|
||||
buildEpubLocator,
|
||||
buildEpubLocatorFromChunk,
|
||||
findSegmentOffset,
|
||||
getSpineItemPlainText,
|
||||
invalidateSpinePlainTextCache,
|
||||
resolveMonotonicSentenceOffsets,
|
||||
resolveSpineFromCfi,
|
||||
} from '../../src/lib/client/epub/spine-coordinates';
|
||||
import { isStableEpubLocator } from '../../src/types/client';
|
||||
|
||||
interface FakeSection {
|
||||
index: number;
|
||||
href: string;
|
||||
cfiBase: string;
|
||||
__text: string;
|
||||
// epubjs's real `section.load()` resolves to an `<html>` Element, but
|
||||
// historically the fake returned a Document. We accept both so the helper's
|
||||
// defensive shape-handling can be tested.
|
||||
load: (request?: unknown) => Promise<Element | Document>;
|
||||
unload: () => void;
|
||||
}
|
||||
|
||||
function makeFakeBook(items: Array<{ index: number; href: string; cfiBase: string; text: string }>) {
|
||||
// Mirror what epubjs's `Section.load()` actually does: resolve to the
|
||||
// spine item's `<html>` Element (NOT a Document). The helper has to query
|
||||
// `<body>` inside it — that's the contract this test fixture pins down.
|
||||
const sections: FakeSection[] = items.map((item) => ({
|
||||
index: item.index,
|
||||
href: item.href,
|
||||
cfiBase: item.cfiBase,
|
||||
__text: item.text,
|
||||
load: async () => {
|
||||
if (typeof document === 'undefined') {
|
||||
// Node fallback: hand back a shape with a `body` getter so the
|
||||
// helper's body-aware branch still works.
|
||||
return {
|
||||
querySelector: (sel: string) => (sel === 'body' ? { textContent: item.text } : null),
|
||||
textContent: item.text,
|
||||
} as unknown as Element;
|
||||
}
|
||||
const html = document.createElement('html');
|
||||
const body = document.createElement('body');
|
||||
body.textContent = item.text;
|
||||
html.appendChild(body);
|
||||
return html as unknown as Document;
|
||||
},
|
||||
unload: () => {},
|
||||
}));
|
||||
|
||||
const get = (target: unknown) => {
|
||||
if (typeof target === 'number') {
|
||||
return sections.find((s) => s.index === target) ?? null;
|
||||
}
|
||||
if (typeof target === 'string') {
|
||||
// Match by href substring or by cfiBase substring (epubcfi(/6/4!...) → '/6/4').
|
||||
const byHref = sections.find((s) => s.href === target || target.includes(s.href));
|
||||
if (byHref) return byHref;
|
||||
const byCfiBase = sections.find((s) => target.includes(s.cfiBase));
|
||||
if (byCfiBase) return byCfiBase;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const book = {
|
||||
spine: { get, spineItems: sections },
|
||||
load: () => Promise.resolve(undefined),
|
||||
} as unknown as Book;
|
||||
return { book, sections };
|
||||
}
|
||||
|
||||
test.describe('findSegmentOffset', () => {
|
||||
test('finds an offset for matching text', () => {
|
||||
const spineText = 'Hello world. This is a test paragraph for offsets.';
|
||||
const offset = findSegmentOffset(spineText, 'this is a test');
|
||||
expect(offset).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('uses the hint to disambiguate repeated phrases', () => {
|
||||
const spineText = 'apple banana apple banana apple banana';
|
||||
// First occurrence
|
||||
const first = findSegmentOffset(spineText, 'apple banana', 0);
|
||||
// Hint past the first occurrence
|
||||
const second = findSegmentOffset(spineText, 'apple banana', first + 1);
|
||||
expect(second).toBeGreaterThan(first);
|
||||
});
|
||||
|
||||
test('returns -1 when the segment text is not present', () => {
|
||||
expect(findSegmentOffset('hello world', 'goodbye')).toBe(-1);
|
||||
});
|
||||
|
||||
test('falls back to a from-start search when the hint overshoots', () => {
|
||||
const spineText = 'apple banana cherry';
|
||||
const offset = findSegmentOffset(spineText, 'apple', 100);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('resolveSpineFromCfi', () => {
|
||||
test('returns spine identity for a CFI inside a known spine', () => {
|
||||
const { book } = makeFakeBook([
|
||||
{ index: 2, href: 'OEBPS/ch02.xhtml', cfiBase: '/6/4', text: 'chapter 2 contents' },
|
||||
{ index: 3, href: 'OEBPS/ch03.xhtml', cfiBase: '/6/6', text: 'chapter 3 contents' },
|
||||
]);
|
||||
const resolved = resolveSpineFromCfi(book, 'epubcfi(/6/4!/4:0)');
|
||||
expect(resolved).toEqual({ href: 'OEBPS/ch02.xhtml', index: 2 });
|
||||
});
|
||||
|
||||
test('returns null when the spine.get call throws', () => {
|
||||
const broken = {
|
||||
spine: {
|
||||
get: () => { throw new Error('boom'); },
|
||||
spineItems: [],
|
||||
},
|
||||
} as unknown as Book;
|
||||
expect(resolveSpineFromCfi(broken, 'epubcfi(/6/4!/4:0)')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for null/undefined input', () => {
|
||||
const { book } = makeFakeBook([]);
|
||||
expect(resolveSpineFromCfi(book, null)).toBeNull();
|
||||
expect(resolveSpineFromCfi(book, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('buildEpubLocator', () => {
|
||||
test('produces a stable locator with charOffset within the spine item', async () => {
|
||||
const { book } = makeFakeBook([
|
||||
{
|
||||
index: 2,
|
||||
href: 'OEBPS/ch02.xhtml',
|
||||
cfiBase: '/6/4',
|
||||
text: 'First paragraph. The quick brown fox jumps over the lazy dog. Final words here.',
|
||||
},
|
||||
]);
|
||||
|
||||
invalidateSpinePlainTextCache(book);
|
||||
const locator = await buildEpubLocator(
|
||||
book,
|
||||
'epubcfi(/6/4!/4:0)',
|
||||
'The quick brown fox',
|
||||
);
|
||||
|
||||
expect(locator).not.toBeNull();
|
||||
expect(isStableEpubLocator(locator)).toBe(true);
|
||||
if (!locator || !isStableEpubLocator(locator)) return; // narrow for TS
|
||||
expect(locator.spineHref).toBe('OEBPS/ch02.xhtml');
|
||||
expect(locator.spineIndex).toBe(2);
|
||||
expect(locator.charOffset).toBeGreaterThan(0);
|
||||
expect(locator.cfi).toBe('epubcfi(/6/4!/4:0)');
|
||||
});
|
||||
|
||||
test('returns null when the CFI does not resolve to any spine item', async () => {
|
||||
const { book } = makeFakeBook([
|
||||
{ index: 0, href: 'OEBPS/ch00.xhtml', cfiBase: '/6/2', text: 'only chapter' },
|
||||
]);
|
||||
const locator = await buildEpubLocator(book, 'epubcfi(/99/99!/4:0)', 'nope');
|
||||
expect(locator).toBeNull();
|
||||
});
|
||||
|
||||
test('same content text yields identical locators across two calls (stability)', async () => {
|
||||
const { book } = makeFakeBook([
|
||||
{
|
||||
index: 4,
|
||||
href: 'OEBPS/ch04.xhtml',
|
||||
cfiBase: '/6/8',
|
||||
text: 'Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu.',
|
||||
},
|
||||
]);
|
||||
const a = await buildEpubLocator(book, 'epubcfi(/6/8!/4:0)', 'gamma delta epsilon');
|
||||
const b = await buildEpubLocator(book, 'epubcfi(/6/8!/4:0)', 'gamma delta epsilon');
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('buildEpubLocatorFromChunk', () => {
|
||||
test('uses the chunk anchor offset as a hint to disambiguate repeated text', () => {
|
||||
const spineText = 'foo bar foo bar foo bar';
|
||||
const anchorEarly = {
|
||||
spineHref: 'a.xhtml',
|
||||
spineIndex: 0,
|
||||
charOffset: 0,
|
||||
spineText,
|
||||
};
|
||||
const anchorLate = {
|
||||
spineHref: 'a.xhtml',
|
||||
spineIndex: 0,
|
||||
charOffset: 8, // past the first "foo bar"
|
||||
spineText,
|
||||
};
|
||||
const early = buildEpubLocatorFromChunk(anchorEarly, 'foo bar');
|
||||
const late = buildEpubLocatorFromChunk(anchorLate, 'foo bar');
|
||||
expect(early.charOffset).toBeLessThan(late.charOffset ?? Infinity);
|
||||
});
|
||||
|
||||
test('NEVER returns an offset before the chunk anchor (forward-only)', () => {
|
||||
// Regression: a segment text that recurs earlier in the chapter must not
|
||||
// get persisted with the earlier offset. The forward-only contract is
|
||||
// what keeps later-page rows from interleaving with earlier pages in the
|
||||
// sidebar's sort.
|
||||
const spineText = 'Yes I said. ' + 'Filler. '.repeat(20) + 'Yes I said. The actual page sentence.';
|
||||
const earlyOccurrence = spineText.indexOf('Yes I said.');
|
||||
const lateOccurrence = spineText.indexOf('Yes I said.', earlyOccurrence + 1);
|
||||
expect(earlyOccurrence).toBeLessThan(lateOccurrence); // sanity
|
||||
|
||||
const anchor = {
|
||||
spineHref: 'a.xhtml',
|
||||
spineIndex: 0,
|
||||
// Anchored ~at the actual page (past the early occurrence).
|
||||
charOffset: lateOccurrence - 5,
|
||||
spineText,
|
||||
};
|
||||
const locator = buildEpubLocatorFromChunk(anchor, 'Yes I said.');
|
||||
expect(locator.charOffset).toBeGreaterThanOrEqual(anchor.charOffset);
|
||||
// Specifically: must be at the late occurrence, NOT the early one.
|
||||
expect(locator.charOffset).toBeGreaterThan(earlyOccurrence);
|
||||
});
|
||||
|
||||
test('holds the anchor offset when segment text is not found ahead', () => {
|
||||
const anchor = {
|
||||
spineHref: 'a.xhtml',
|
||||
spineIndex: 0,
|
||||
charOffset: 100,
|
||||
spineText: 'short text here',
|
||||
};
|
||||
const locator = buildEpubLocatorFromChunk(anchor, 'definitely not present');
|
||||
expect(locator.charOffset).toBe(100); // holds at anchor, never goes earlier
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('buildEpubLocator anchored-search regression', () => {
|
||||
// Pins the contract for the server-side resolver path: when called with
|
||||
// a `chunkText` argument representing the current rendered page, the
|
||||
// returned locator's `charOffset` is **at or after** the chunk's position
|
||||
// in the spine — never the segment's earliest occurrence in the chapter.
|
||||
test('locator for a recurring sentence sits at the page anchor, not the chapter-early echo', async () => {
|
||||
if (typeof document === 'undefined') return; // DOM-only via fixture
|
||||
const earlyEcho = 'The Lighthouse.';
|
||||
const pageText = 'On Tuesday, the visitors arrived. The Lighthouse. They marveled at the height.';
|
||||
const chapterText = `${earlyEcho} ${'Filler sentence. '.repeat(40)}${pageText}`;
|
||||
const { book } = makeFakeBook([
|
||||
{ index: 0, href: 'ch.xhtml', cfiBase: '/6/2', text: chapterText },
|
||||
]);
|
||||
invalidateSpinePlainTextCache(book);
|
||||
// Search the SAME segment text. The resolver passes the page text as
|
||||
// chunkText, so the anchor sits at the page's start — not at the
|
||||
// earlier `earlyEcho` occurrence.
|
||||
const locator = await buildEpubLocator(
|
||||
book,
|
||||
'epubcfi(/6/2!/4:0)',
|
||||
'The Lighthouse.',
|
||||
pageText,
|
||||
);
|
||||
expect(locator).not.toBeNull();
|
||||
if (!locator || !isStableEpubLocator(locator)) return;
|
||||
// The returned offset must be past where the early echo lives.
|
||||
const earlyEchoIndex = chapterText.toLowerCase().indexOf(earlyEcho.toLowerCase());
|
||||
expect(locator.charOffset).toBeGreaterThan(earlyEchoIndex);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('findSegmentOffset fallback contract', () => {
|
||||
// These tests pin the documented behavior of `findSegmentOffset`. The
|
||||
// from-start fallback is correct for single-shot lookups but **wrong** in
|
||||
// a monotonic per-sentence walk — `resolveMonotonicSentenceOffsets` exists
|
||||
// precisely to avoid this. Keep these tests so the two helpers' contracts
|
||||
// stay distinct.
|
||||
test('returns the earliest occurrence when the hint overshoots', () => {
|
||||
expect(findSegmentOffset('Yes. No. Maybe.', 'yes.', /* hint */ 50)).toBe(0);
|
||||
});
|
||||
|
||||
test('falls back to from-start when the forward search misses', () => {
|
||||
// "echo" appears at index 0. Hint past the only occurrence should still
|
||||
// find it via the from-start fallback.
|
||||
expect(findSegmentOffset('echo and silence', 'echo', /* hint */ 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('resolveMonotonicSentenceOffsets', () => {
|
||||
test('returns monotonically non-decreasing offsets for sentences in order', () => {
|
||||
const spineText = 'The cat sat on the mat. The dog barked loudly. Then it was quiet.';
|
||||
const sentences = [
|
||||
'The cat sat on the mat.',
|
||||
'The dog barked loudly.',
|
||||
'Then it was quiet.',
|
||||
];
|
||||
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
|
||||
expect(offsets).toHaveLength(3);
|
||||
for (let i = 1; i < offsets.length; i += 1) {
|
||||
expect(offsets[i]).toBeGreaterThanOrEqual(offsets[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
test('does NOT jump backwards when a later sentence echoes earlier text', () => {
|
||||
// Regression: a sentence on the current page that happens to contain a
|
||||
// phrase also present earlier in the chapter (chapter title, refrain,
|
||||
// etc.) used to take the earlier occurrence's offset via
|
||||
// `findSegmentOffset`'s from-start fallback, which silently reordered
|
||||
// the sidebar's synth rows. The monotonic helper must never do that.
|
||||
const spineText = 'The Lighthouse. Several pages of narrative go here. The Lighthouse stood tall.';
|
||||
const sentences = [
|
||||
'Several pages of narrative go here.', // ~at offset 16
|
||||
'The Lighthouse stood tall.', // recurs — must NOT jump back to offset 0
|
||||
];
|
||||
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
|
||||
expect(offsets[1]).toBeGreaterThan(offsets[0]);
|
||||
});
|
||||
|
||||
test('holds the cursor for a sentence that cannot be found ahead', () => {
|
||||
const spineText = 'alpha beta gamma delta';
|
||||
const sentences = ['gamma', 'something not present', 'delta'];
|
||||
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
|
||||
// gamma found, missing sentence holds cursor, delta found ahead of it.
|
||||
expect(offsets[0]).toBeGreaterThan(0);
|
||||
expect(offsets[1]).toBeGreaterThanOrEqual(offsets[0]); // monotonic
|
||||
expect(offsets[2]).toBeGreaterThan(offsets[1]); // delta is after gamma
|
||||
});
|
||||
|
||||
test('handles empty inputs gracefully', () => {
|
||||
expect(resolveMonotonicSentenceOffsets('', ['anything'])).toEqual([0]);
|
||||
expect(resolveMonotonicSentenceOffsets('hello world', [])).toEqual([]);
|
||||
expect(resolveMonotonicSentenceOffsets('hello world', ['', '', ''])).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
test('repeated phrase across the page is resolved at successive occurrences', () => {
|
||||
// Three "yes." sentences in a row should each pick the next occurrence,
|
||||
// not all pile onto the first one.
|
||||
const spineText = 'yes. yes. yes. end.';
|
||||
const sentences = ['yes.', 'yes.', 'yes.'];
|
||||
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
|
||||
expect(new Set(offsets).size).toBe(3); // all distinct
|
||||
expect(offsets[0]).toBeLessThan(offsets[1]);
|
||||
expect(offsets[1]).toBeLessThan(offsets[2]);
|
||||
});
|
||||
|
||||
test('normalizes whitespace and casing the same as segmentKey identity', () => {
|
||||
// The helper uses `normalizeSegmentIdentityText` internally so it agrees
|
||||
// with the segmentKey hash space. Different casing / whitespace must
|
||||
// still locate the sentence.
|
||||
const spineText = 'Hello World. More content here.';
|
||||
const sentences = ['hello world.', 'MORE content here.'];
|
||||
const offsets = resolveMonotonicSentenceOffsets(spineText, sentences);
|
||||
expect(offsets[0]).toBe(0);
|
||||
expect(offsets[1]).toBeGreaterThan(offsets[0]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('getSpineItemPlainText shape handling', () => {
|
||||
// Regression: epubjs's `Section.load()` resolves to the spine item's
|
||||
// `<html>` Element (NOT a Document). Reading `loaded.body` directly was
|
||||
// returning undefined for every spine item, so spineText came back ''
|
||||
// and every `findSegmentOffset` returned -1 → every persisted charOffset
|
||||
// ended up at 0. These tests pin both shapes so a future epubjs upgrade
|
||||
// (or a defensive rewrite) doesn't quietly break extraction again.
|
||||
test('extracts text when section.load resolves to an <html> Element', async () => {
|
||||
// Use the harness fixture which already returns a real <html> element
|
||||
// when DOM is available.
|
||||
if (typeof document === 'undefined') {
|
||||
// Skip in non-DOM environments.
|
||||
return;
|
||||
}
|
||||
const { book } = makeFakeBook([
|
||||
{ index: 0, href: 'ch.xhtml', cfiBase: '/6/2', text: 'real text from html element' },
|
||||
]);
|
||||
invalidateSpinePlainTextCache(book);
|
||||
const text = await getSpineItemPlainText(book, 'ch.xhtml');
|
||||
expect(text).toContain('real text');
|
||||
});
|
||||
|
||||
test('extracts text when section.load resolves to a Document-shaped object', async () => {
|
||||
// Defensive: if a future epubjs version (or a custom shim) returns a
|
||||
// Document instead of an Element, the helper should still work.
|
||||
const fakeBook = {
|
||||
spine: {
|
||||
get: () => ({
|
||||
href: 'ch.xhtml',
|
||||
load: async () => ({
|
||||
body: { textContent: 'doc-shaped content' },
|
||||
documentElement: { textContent: 'doc-shaped content' },
|
||||
}),
|
||||
unload: () => {},
|
||||
}),
|
||||
spineItems: [],
|
||||
},
|
||||
load: () => Promise.resolve(undefined),
|
||||
} as unknown as Parameters<typeof getSpineItemPlainText>[0];
|
||||
const text = await getSpineItemPlainText(fakeBook, 'ch.xhtml');
|
||||
expect(text).toBe('doc-shaped content');
|
||||
});
|
||||
|
||||
test('returns empty string when section.load resolves to null/undefined', async () => {
|
||||
const fakeBook = {
|
||||
spine: {
|
||||
get: () => ({
|
||||
href: 'ch.xhtml',
|
||||
load: async () => null,
|
||||
unload: () => {},
|
||||
}),
|
||||
spineItems: [],
|
||||
},
|
||||
load: () => Promise.resolve(undefined),
|
||||
} as unknown as Parameters<typeof getSpineItemPlainText>[0];
|
||||
const text = await getSpineItemPlainText(fakeBook, 'ch.xhtml');
|
||||
expect(text).toBe('');
|
||||
});
|
||||
});
|
||||
64
tests/unit/tts-epub-preload.spec.ts
Normal file
64
tests/unit/tts-epub-preload.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import type { CanonicalTtsSourceUnit } from '../../src/lib/shared/tts-segment-plan';
|
||||
import {
|
||||
buildWalkerPlanningSourceUnits,
|
||||
selectUpcomingWalkerItems,
|
||||
} from '../../src/lib/shared/tts-epub-preload';
|
||||
|
||||
test.describe('EPUB walker preload helpers', () => {
|
||||
test('selectUpcomingWalkerItems honors depth as current + (depth-1) upcoming', () => {
|
||||
const items = [
|
||||
{ cfi: 'epubcfi(/6/2[;s=a]!/4/2)' }, // same as current after normalization
|
||||
{ cfi: 'epubcfi(/6/4!/4/2)' },
|
||||
{ cfi: 'epubcfi(/6/6!/4/2)' },
|
||||
{ cfi: 'epubcfi(/6/8!/4/2)' },
|
||||
];
|
||||
|
||||
const selected = selectUpcomingWalkerItems(items, 'epubcfi(/6/2!/4/2)', 2);
|
||||
expect(selected.map((item) => item.cfi)).toEqual([
|
||||
'epubcfi(/6/4!/4/2)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('selectUpcomingWalkerItems returns empty list when depth <= 1', () => {
|
||||
const items = [
|
||||
{ cfi: 'epubcfi(/6/4!/4/2)' },
|
||||
{ cfi: 'epubcfi(/6/6!/4/2)' },
|
||||
];
|
||||
expect(selectUpcomingWalkerItems(items, 'epubcfi(/6/2!/4/2)', 1)).toEqual([]);
|
||||
expect(selectUpcomingWalkerItems(items, 'epubcfi(/6/2!/4/2)', 0)).toEqual([]);
|
||||
});
|
||||
|
||||
test('buildWalkerPlanningSourceUnits includes live context when smart splitting is enabled', () => {
|
||||
const contextUnits: CanonicalTtsSourceUnit[] = [
|
||||
{ sourceKey: 'previous:page-a', text: 'prev sentence', locator: null },
|
||||
{ sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } },
|
||||
];
|
||||
const upcomingUnits: CanonicalTtsSourceUnit[] = [
|
||||
{ sourceKey: 'page-b', text: 'upcoming one', locator: { readerType: 'epub', location: 'page-b' } },
|
||||
{ sourceKey: 'page-c', text: 'upcoming two', locator: { readerType: 'epub', location: 'page-c' } },
|
||||
];
|
||||
|
||||
const planned = buildWalkerPlanningSourceUnits(true, contextUnits, upcomingUnits);
|
||||
expect(planned.map((item) => item.sourceKey)).toEqual([
|
||||
'previous:page-a',
|
||||
'page-a',
|
||||
'page-b',
|
||||
'page-c',
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildWalkerPlanningSourceUnits uses only upcoming units when smart splitting is disabled', () => {
|
||||
const contextUnits: CanonicalTtsSourceUnit[] = [
|
||||
{ sourceKey: 'previous:page-a', text: 'prev sentence', locator: null },
|
||||
{ sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } },
|
||||
];
|
||||
const upcomingUnits: CanonicalTtsSourceUnit[] = [
|
||||
{ sourceKey: 'page-b', text: 'upcoming one', locator: { readerType: 'epub', location: 'page-b' } },
|
||||
];
|
||||
|
||||
const planned = buildWalkerPlanningSourceUnits(false, contextUnits, upcomingUnits);
|
||||
expect(planned.map((item) => item.sourceKey)).toEqual(['page-b']);
|
||||
});
|
||||
});
|
||||
150
tests/unit/tts-locator.spec.ts
Normal file
150
tests/unit/tts-locator.spec.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
compareSegmentLocators,
|
||||
locatorGroupKey,
|
||||
locatorIdentityKey,
|
||||
normalizeEpubLocationToken,
|
||||
} from '../../src/lib/shared/tts-locator';
|
||||
import {
|
||||
isHtmlLocator,
|
||||
isPdfLocator,
|
||||
isStableEpubLocator,
|
||||
type TTSSegmentLocator,
|
||||
} from '../../src/types/client';
|
||||
|
||||
const epubLocator = (
|
||||
spineHref: string,
|
||||
spineIndex: number,
|
||||
charOffset: number,
|
||||
cfi?: string,
|
||||
): TTSSegmentLocator => ({
|
||||
readerType: 'epub',
|
||||
spineHref,
|
||||
spineIndex,
|
||||
charOffset,
|
||||
...(cfi ? { cfi } : {}),
|
||||
});
|
||||
|
||||
test.describe('locatorGroupKey', () => {
|
||||
test('groups stable EPUB locators by spine index and href', () => {
|
||||
expect(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0))).toBe('epub:2:OEBPS/ch02.xhtml');
|
||||
// Different charOffset, same spine → same group (sidebar bucket is chapter-sized).
|
||||
expect(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 1024)))
|
||||
.toBe(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0)));
|
||||
// Different spine → different group.
|
||||
expect(locatorGroupKey(epubLocator('OEBPS/ch03.xhtml', 3, 0)))
|
||||
.not.toBe(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0)));
|
||||
});
|
||||
|
||||
test('groups PDF locators by page', () => {
|
||||
expect(locatorGroupKey({ readerType: 'pdf', page: 7 })).toBe('pdf:7');
|
||||
});
|
||||
|
||||
test('groups HTML locators by location', () => {
|
||||
expect(locatorGroupKey({ readerType: 'html', location: '#anchor' })).toBe('html:#anchor');
|
||||
});
|
||||
|
||||
test('returns "none" for null locators', () => {
|
||||
expect(locatorGroupKey(null)).toBe('none');
|
||||
});
|
||||
|
||||
test('falls back to a legacy group key for EPUB drafts missing spine coords', () => {
|
||||
// A draft EPUB locator (just a CFI) should not co-group with stable rows.
|
||||
const draft: TTSSegmentLocator = { readerType: 'epub', location: 'epubcfi(/6/8!/4:0)' };
|
||||
expect(locatorGroupKey(draft)).toContain('legacy');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('locatorIdentityKey', () => {
|
||||
test('two EPUB rows in the same chapter at different charOffsets get distinct identity keys', () => {
|
||||
// This is the bug that caused page-N rows to swallow page-(N+1) rows in
|
||||
// the server-side manifest aggregator: the previous code keyed by the
|
||||
// chapter-coarse groupKey, collapsing distinct rows into a single bucket.
|
||||
const a = epubLocator('OEBPS/ch02.xhtml', 2, 0);
|
||||
const b = epubLocator('OEBPS/ch02.xhtml', 2, 1024);
|
||||
expect(locatorIdentityKey(a)).not.toBe(locatorIdentityKey(b));
|
||||
});
|
||||
|
||||
test('two EPUB rows at the same coordinate get equal identity keys', () => {
|
||||
// Identity is content-of-locator only; the optional cfi jump hint is
|
||||
// intentionally not part of identity.
|
||||
const a = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:0)');
|
||||
const b = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:8)');
|
||||
expect(locatorIdentityKey(a)).toBe(locatorIdentityKey(b));
|
||||
});
|
||||
|
||||
test('chapter-coarse group key collides across rows that identity-key keeps distinct', () => {
|
||||
// Sanity-check the relationship between the two helpers.
|
||||
const a = epubLocator('OEBPS/ch02.xhtml', 2, 0);
|
||||
const b = epubLocator('OEBPS/ch02.xhtml', 2, 1024);
|
||||
expect(locatorGroupKey(a)).toBe(locatorGroupKey(b)); // same chapter bucket
|
||||
expect(locatorIdentityKey(a)).not.toBe(locatorIdentityKey(b)); // distinct rows
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('compareSegmentLocators', () => {
|
||||
test('orders EPUB rows by spineIndex then charOffset numerically', () => {
|
||||
// Spine 10 must come AFTER spine 2 — a lexicographic compare would
|
||||
// wrongly place "10" before "2".
|
||||
const a = epubLocator('a.xhtml', 10, 0);
|
||||
const b = epubLocator('b.xhtml', 2, 0);
|
||||
expect(compareSegmentLocators(a, b)).toBeGreaterThan(0);
|
||||
expect(compareSegmentLocators(b, a)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('within the same spine, orders by charOffset', () => {
|
||||
const a = epubLocator('OEBPS/ch02.xhtml', 2, 50);
|
||||
const b = epubLocator('OEBPS/ch02.xhtml', 2, 1000);
|
||||
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
|
||||
expect(compareSegmentLocators(b, a)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('treats equal stable EPUB locators as equal regardless of optional cfi hint', () => {
|
||||
const a = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:0)');
|
||||
const b = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:8)');
|
||||
// cfi is a soft jump hint, not part of identity/sort.
|
||||
expect(compareSegmentLocators(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test('orders PDF locators by page numerically', () => {
|
||||
const a: TTSSegmentLocator = { readerType: 'pdf', page: 2 };
|
||||
const b: TTSSegmentLocator = { readerType: 'pdf', page: 10 };
|
||||
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('null locators sort last', () => {
|
||||
expect(compareSegmentLocators(null, { readerType: 'pdf', page: 1 })).toBeGreaterThan(0);
|
||||
expect(compareSegmentLocators({ readerType: 'pdf', page: 1 }, null)).toBeLessThan(0);
|
||||
expect(compareSegmentLocators(null, null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('type guards', () => {
|
||||
test('isStableEpubLocator requires all spine fields', () => {
|
||||
expect(isStableEpubLocator(epubLocator('a.xhtml', 0, 0))).toBe(true);
|
||||
expect(isStableEpubLocator({ readerType: 'epub', spineHref: 'a.xhtml' })).toBe(false);
|
||||
expect(isStableEpubLocator({ readerType: 'epub', location: 'epubcfi(...)' })).toBe(false);
|
||||
expect(isStableEpubLocator(null)).toBe(false);
|
||||
expect(isStableEpubLocator({ readerType: 'pdf', page: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
test('isPdfLocator narrows PDF rows', () => {
|
||||
expect(isPdfLocator({ readerType: 'pdf', page: 3 })).toBe(true);
|
||||
expect(isPdfLocator({ readerType: 'pdf' })).toBe(false);
|
||||
expect(isPdfLocator(epubLocator('a.xhtml', 0, 0))).toBe(false);
|
||||
});
|
||||
|
||||
test('isHtmlLocator narrows HTML rows', () => {
|
||||
expect(isHtmlLocator({ readerType: 'html', location: '#x' })).toBe(true);
|
||||
expect(isHtmlLocator({ readerType: 'html', location: '' })).toBe(false);
|
||||
expect(isHtmlLocator({ readerType: 'pdf', page: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('normalizeEpubLocationToken', () => {
|
||||
test('strips whitespace and step markers but preserves identity', () => {
|
||||
const a = normalizeEpubLocationToken('epubcfi(/6/8!/4:0)');
|
||||
const b = normalizeEpubLocationToken(' epubcfi(/6/8[;s=a]!/4:0) ');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan';
|
||||
import { buildSegmentKey, buildSegmentKeyPrefix, planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan';
|
||||
|
||||
test.describe('planCanonicalTtsSegments', () => {
|
||||
test('emits a cross-boundary segment once and assigns it to the source where it starts', () => {
|
||||
|
|
@ -165,3 +165,36 @@ test.describe('planCanonicalTtsSegments', () => {
|
|||
expect(plan.segments[0].ownerSourceKey).toBe('page:1');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('buildSegmentKeyPrefix / buildSegmentKey contract', () => {
|
||||
// These keys are the bridge between persistence and the sidebar's merge of
|
||||
// synth rows with manifest rows. Both sides must produce identical keys for
|
||||
// the same `(documentId, readerType, text)` triple, or the sidebar will
|
||||
// show duplicates.
|
||||
|
||||
test('prefix shape is `${documentId}:${readerType}:v1` (or "document" when documentId is falsy)', () => {
|
||||
expect(buildSegmentKeyPrefix('abc123', 'epub')).toBe('abc123:epub:v1');
|
||||
expect(buildSegmentKeyPrefix(null, 'pdf')).toBe('document:pdf:v1');
|
||||
expect(buildSegmentKeyPrefix('', 'epub')).toBe('document:epub:v1');
|
||||
});
|
||||
|
||||
test('same prefix + same text → same key (sidebar can match synth to manifest)', () => {
|
||||
const prefix = buildSegmentKeyPrefix('doc-1', 'epub');
|
||||
const k1 = buildSegmentKey(prefix, 'Hello world.');
|
||||
const k2 = buildSegmentKey(prefix, 'Hello world.');
|
||||
expect(k1).toBe(k2);
|
||||
});
|
||||
|
||||
test('normalization makes whitespace + casing differences identity-equivalent', () => {
|
||||
const prefix = buildSegmentKeyPrefix('doc-1', 'epub');
|
||||
const a = buildSegmentKey(prefix, ' Hello world. ');
|
||||
const b = buildSegmentKey(prefix, 'hello world.');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test('different readerType yields different keys for the same text', () => {
|
||||
const a = buildSegmentKey(buildSegmentKeyPrefix('doc-1', 'epub'), 'Same text.');
|
||||
const b = buildSegmentKey(buildSegmentKeyPrefix('doc-1', 'pdf'), 'Same text.');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -86,27 +86,74 @@ test.describe('tts segments manifest helpers', () => {
|
|||
expect(variants[0].status).toBe('completed');
|
||||
});
|
||||
|
||||
test('sorts segments deterministically by page, location, and segment index', () => {
|
||||
test('sorts PDF segments deterministically by page and segment index', () => {
|
||||
const rows = [
|
||||
{ groupKey: 'c', segmentIndex: 4, locator: { page: 2, location: 'z' } },
|
||||
{ groupKey: 'a', segmentIndex: 1, locator: { page: 1, location: 'b' } },
|
||||
{ groupKey: 'b', segmentIndex: 0, locator: { page: 1, location: 'a' } },
|
||||
{ groupKey: 'd', segmentIndex: 2, locator: { page: 1, location: 'a' } },
|
||||
{ groupKey: 'p2-i4', segmentIndex: 4, locator: { readerType: 'pdf' as const, page: 2 } },
|
||||
{ groupKey: 'p1-i1', segmentIndex: 1, locator: { readerType: 'pdf' as const, page: 1 } },
|
||||
{ groupKey: 'p1-i0', segmentIndex: 0, locator: { readerType: 'pdf' as const, page: 1 } },
|
||||
{ groupKey: 'p1-i2', segmentIndex: 2, locator: { readerType: 'pdf' as const, page: 1 } },
|
||||
];
|
||||
|
||||
const sorted = rows.sort(compareManifestSegments);
|
||||
expect(sorted.map((row) => row.groupKey)).toEqual(['b', 'd', 'a', 'c']);
|
||||
expect(sorted.map((row) => row.groupKey)).toEqual(['p1-i0', 'p1-i1', 'p1-i2', 'p2-i4']);
|
||||
});
|
||||
|
||||
test('sorts EPUB CFI locations naturally instead of lexicographically', () => {
|
||||
test('sorts EPUB locators by spineIndex then charOffset (numeric, viewport-stable)', () => {
|
||||
// The order below is intentionally jumbled, with spineIndex 10 placed
|
||||
// first so that a lexicographic compare on the spine string would put
|
||||
// "10" before "2". A correct numeric compare must surface spine 2 first.
|
||||
const rows = [
|
||||
{ groupKey: 'ten', segmentIndex: 0, locator: { location: 'epubcfi(/6/10!/4/2)', readerType: 'epub' as const } },
|
||||
{ groupKey: 'two-b', segmentIndex: 1, locator: { location: 'epubcfi(/6/2!/4/2)', readerType: 'epub' as const } },
|
||||
{ groupKey: 'two-a', segmentIndex: 0, locator: { location: 'epubcfi(/6/2!/4/2)', readerType: 'epub' as const } },
|
||||
{ groupKey: 'spine-10-off-0', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch10.xhtml', spineIndex: 10, charOffset: 0 } },
|
||||
{ groupKey: 'spine-2-off-200', segmentIndex: 1, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 200 } },
|
||||
{ groupKey: 'spine-2-off-50', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 50 } },
|
||||
];
|
||||
|
||||
const sorted = rows.sort(compareManifestSegments);
|
||||
expect(sorted.map((row) => row.groupKey)).toEqual(['two-a', 'two-b', 'ten']);
|
||||
expect(sorted.map((row) => row.groupKey)).toEqual(['spine-2-off-50', 'spine-2-off-200', 'spine-10-off-0']);
|
||||
});
|
||||
|
||||
test('groups EPUB rows by spine identity, not by raw CFI', () => {
|
||||
// Two rows in the same spine item but at different char offsets must share
|
||||
// a groupKey (so the sidebar buckets them together as one chapter), while
|
||||
// a row in a different spine sits in its own group.
|
||||
const rows = [
|
||||
{ groupKey: 'a', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 0, cfi: 'epubcfi(/6/4!/2:0)' } },
|
||||
{ groupKey: 'b', segmentIndex: 1, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 1024, cfi: 'epubcfi(/6/4!/4:0)' } },
|
||||
{ groupKey: 'c', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch03.xhtml', spineIndex: 3, charOffset: 0, cfi: 'epubcfi(/6/6!/2:0)' } },
|
||||
];
|
||||
|
||||
const sorted = rows.sort(compareManifestSegments);
|
||||
// Recompute groupKey using the production helper to assert the actual
|
||||
// grouping behavior rather than the test's hand-labeled groupKey.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { locatorGroupKey } = require('../../src/lib/shared/tts-locator');
|
||||
const groupKeys = sorted.map((row) => locatorGroupKey(row.locator));
|
||||
expect(groupKeys[0]).toBe(groupKeys[1]); // both ch02
|
||||
expect(groupKeys[2]).not.toBe(groupKeys[0]); // ch03 is its own group
|
||||
});
|
||||
|
||||
test('manifest aggregator distinguishes rows by identity (regression: per-page rows collapsed by chapter bucket)', () => {
|
||||
// Bug reproduction: previously the server-side aggregator keyed rows by
|
||||
// `${segmentIndex}|${locatorGroupKey(locator)}`. Because the new EPUB
|
||||
// groupKey is chapter-coarse, two persisted rows in different *pages* of
|
||||
// the same chapter that happened to share segmentIndex (which is
|
||||
// page-relative) collapsed into a single entry — making earlier pages'
|
||||
// rows visually "disappear" when later pages were generated.
|
||||
//
|
||||
// The fix uses `locatorIdentityKey` which includes charOffset for EPUB.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { locatorIdentityKey, locatorGroupKey } = require('../../src/lib/shared/tts-locator');
|
||||
|
||||
const page1Row0 = { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 0 };
|
||||
const page2Row0 = { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 2048 };
|
||||
|
||||
// Same chapter bucket (sidebar grouping should put them under one chapter header).
|
||||
expect(locatorGroupKey(page1Row0)).toBe(locatorGroupKey(page2Row0));
|
||||
// Different storage identity (server aggregator must NOT collapse them).
|
||||
const segmentIndex = 0;
|
||||
const k1 = `${segmentIndex}|${locatorIdentityKey(page1Row0)}`;
|
||||
const k2 = `${segmentIndex}|${locatorIdentityKey(page2Row0)}`;
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
|
||||
test('encodes and decodes cursors', () => {
|
||||
|
|
|
|||
|
|
@ -95,12 +95,36 @@ test.describe('tts segment helpers', () => {
|
|||
expect(hash).toHaveLength(64);
|
||||
});
|
||||
|
||||
test('normalizes locators and creates fingerprints', () => {
|
||||
const locator = normalizeLocator({ page: 2.9, location: ' cfi(1) ', readerType: 'epub' });
|
||||
expect(locator).toEqual({ page: 2, location: 'cfi(1)', readerType: 'epub' });
|
||||
test('normalizes PDF locators and creates fingerprints', () => {
|
||||
const locator = normalizeLocator({ readerType: 'pdf', page: 2.9 });
|
||||
expect(locator).toEqual({ readerType: 'pdf', page: 2 });
|
||||
expect(locatorFingerprint(locator)).toHaveLength(64);
|
||||
});
|
||||
|
||||
test('normalizes stable EPUB locators and rejects legacy CFI-only drafts', () => {
|
||||
// Stable shape: passes through with floors and trims applied.
|
||||
const stable = normalizeLocator({
|
||||
readerType: 'epub',
|
||||
spineHref: ' OEBPS/ch02.xhtml ',
|
||||
spineIndex: 2.7,
|
||||
charOffset: 128.4,
|
||||
cfi: ' epubcfi(/6/4!/4:0) ',
|
||||
});
|
||||
expect(stable).toEqual({
|
||||
readerType: 'epub',
|
||||
spineHref: 'OEBPS/ch02.xhtml',
|
||||
spineIndex: 2,
|
||||
charOffset: 128,
|
||||
cfi: 'epubcfi(/6/4!/4:0)',
|
||||
});
|
||||
expect(locatorFingerprint(stable)).toHaveLength(64);
|
||||
|
||||
// Legacy/draft EPUB shape (CFI in `location` but no spine coords) is
|
||||
// rejected so we never persist viewport-dependent locators.
|
||||
const legacy = normalizeLocator({ readerType: 'epub', location: 'epubcfi(/6/4!/4:0)' });
|
||||
expect(legacy).toBeNull();
|
||||
});
|
||||
|
||||
test('builds proportional alignment preserving order', () => {
|
||||
const alignment = buildProportionalAlignment({
|
||||
sentence: normalizeSegmentText('Hello world again'),
|
||||
|
|
|
|||
47
tests/unit/walker-theme.spec.ts
Normal file
47
tests/unit/walker-theme.spec.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { buildWalkerThemeRules } from '../../src/lib/client/epub/walker-theme';
|
||||
|
||||
test.describe('walker theme rules', () => {
|
||||
test('always includes foreground/background colors', () => {
|
||||
const rules = buildWalkerThemeRules({
|
||||
foreground: 'rgb(1, 2, 3)',
|
||||
base: 'rgb(9, 8, 7)',
|
||||
});
|
||||
|
||||
expect(rules.body.color).toBe('rgb(1, 2, 3)');
|
||||
expect(rules.body['background-color']).toBe('rgb(9, 8, 7)');
|
||||
});
|
||||
|
||||
test('includes typography metrics when provided', () => {
|
||||
const rules = buildWalkerThemeRules({
|
||||
foreground: '#111',
|
||||
base: '#fff',
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '18px',
|
||||
lineHeight: '1.7',
|
||||
fontWeight: '400',
|
||||
letterSpacing: '0.01em',
|
||||
wordSpacing: '0.03em',
|
||||
});
|
||||
|
||||
expect(rules.body['font-family']).toBe('Georgia, serif');
|
||||
expect(rules.body['font-size']).toBe('18px');
|
||||
expect(rules.body['line-height']).toBe('1.7');
|
||||
expect(rules.body['font-weight']).toBe('400');
|
||||
expect(rules.body['letter-spacing']).toBe('0.01em');
|
||||
expect(rules.body['word-spacing']).toBe('0.03em');
|
||||
});
|
||||
|
||||
test('omits typography keys when missing', () => {
|
||||
const rules = buildWalkerThemeRules({
|
||||
foreground: '#111',
|
||||
base: '#fff',
|
||||
fontFamily: '',
|
||||
lineHeight: '',
|
||||
});
|
||||
|
||||
expect('font-family' in rules.body).toBe(false);
|
||||
expect('line-height' in rules.body).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue