Replace EPUB-specific word highlight mapping with shared token-sequence alignment logic used by HTML and PDF viewers. Remove bespoke alignment code and tests in favor of a single primitive (`locateAlignmentWordSpans`) that robustly maps spoken words to rendered text regions, tolerant of transcription and formatting differences. Update EPUB highlighting to cache per-segment word-region spans for efficient re-use. Add unit tests for the new alignment logic.
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
import type { Book } from 'epubjs';
|
|
|
|
import {
|
|
findSegmentOffset,
|
|
getSpineItemPlainText,
|
|
resolveSpineFromCfi,
|
|
} from '@/lib/client/epub/spine-coordinates';
|
|
import {
|
|
buildSegmentKeyPrefix,
|
|
normalizeSegmentIdentityText,
|
|
planCanonicalTtsSegments,
|
|
type CanonicalTtsSegment,
|
|
} from '@/lib/shared/tts-segment-plan';
|
|
import type { TTSSegmentLocator } from '@/types/client';
|
|
|
|
/**
|
|
* Canonical "windowing" for EPUB TTS playback.
|
|
*
|
|
* The core invariant: a single EPUB spine item (chapter) is split into TTS
|
|
* segments **exactly once**, with the greedy block grouping fixed from the
|
|
* chapter start. Every rendered viewport page is then a contiguous *window*
|
|
* into that one canonical sequence — selected by character offset, identified
|
|
* by stable ordinal + content key.
|
|
*
|
|
* Viewport pages are **contiguous, non-overlapping** windows into that one
|
|
* canonical sequence: each segment is owned exclusively by the page where its
|
|
* start offset falls (see `selectCanonicalWindow`), exactly like PDF blocks. A
|
|
* block that straddles a page break (starts on page A, ends on page B) is the
|
|
* *same* canonical segment (same key, same ordinal) wherever it is referenced,
|
|
* but it belongs only to page A — page B's window begins at the next segment.
|
|
* This clean partition is what makes the sidebar list and manual skip
|
|
* deterministic and keeps playback from repeating a straddler on the page turn.
|
|
* The playback layer (TTSContext) additionally uses ordinal continuity to hand
|
|
* off to ordinal + 1 across the seam. See `tts-segment-plan.ts` for the planner
|
|
* and `spine-coordinates.ts` for the offset helpers this builds on.
|
|
*/
|
|
|
|
export interface SpinePlanParams {
|
|
spineHref: string;
|
|
spineIndex: number;
|
|
keyPrefix?: string;
|
|
maxBlockLength?: number;
|
|
language?: string;
|
|
}
|
|
|
|
export interface CanonicalWindowResult {
|
|
spineHref: string;
|
|
spineIndex: number;
|
|
/**
|
|
* Segments overlapping the viewport, with each segment's `ownerLocator`
|
|
* rewritten to carry its own per-segment `charOffset` (and the page-start
|
|
* `cfi` as a soft jump hint). Cloned — the cached plan is never mutated.
|
|
*/
|
|
segments: CanonicalTtsSegment[];
|
|
/** Global ordinal of the first segment in `segments`. */
|
|
windowStartOrdinal: number;
|
|
/** Global ordinal of the last segment in `segments`. */
|
|
windowEndOrdinal: number;
|
|
/** The full, pristine per-chapter canonical plan (shared reference, do not mutate). */
|
|
plan: CanonicalTtsSegment[];
|
|
}
|
|
|
|
/**
|
|
* Plan one spine item's full text into canonical segments. Pure/sync — the
|
|
* single source of truth shared by playback windowing, the sidebar, and
|
|
* persistence canonicalization so all three mint identical segment keys.
|
|
*/
|
|
export function planSpineSegments(input: {
|
|
spineText: string;
|
|
spineHref: string;
|
|
spineIndex: number;
|
|
keyPrefix?: string;
|
|
maxBlockLength?: number;
|
|
language?: string;
|
|
}): CanonicalTtsSegment[] {
|
|
if (!input.spineText.trim() || !input.spineHref.trim()) return [];
|
|
|
|
const sourceKey = `spine:${input.spineIndex}:${input.spineHref}`;
|
|
const keyPrefix = input.keyPrefix ?? buildSegmentKeyPrefix('document', 'epub');
|
|
const plan = planCanonicalTtsSegments(
|
|
[{
|
|
sourceKey,
|
|
text: input.spineText,
|
|
locator: {
|
|
readerType: 'epub',
|
|
spineHref: input.spineHref,
|
|
spineIndex: input.spineIndex,
|
|
charOffset: 0,
|
|
},
|
|
}],
|
|
{
|
|
readerType: 'epub',
|
|
maxBlockLength: input.maxBlockLength,
|
|
keyPrefix,
|
|
language: input.language,
|
|
},
|
|
);
|
|
return plan.segments;
|
|
}
|
|
|
|
// Per-Book cache of fully-planned chapters. Keyed by spine identity + the
|
|
// settings that affect segmentation, so a settings change never reuses a stale
|
|
// plan. GC'd automatically with the Book instance (WeakMap).
|
|
const PLAN_CACHE = new WeakMap<Book, Map<string, CanonicalTtsSegment[]>>();
|
|
|
|
const planCacheKey = (p: SpinePlanParams): string =>
|
|
`${p.spineIndex}:${p.spineHref}|mbl=${p.maxBlockLength ?? ''}|lang=${p.language ?? ''}|kp=${p.keyPrefix ?? ''}`;
|
|
|
|
/**
|
|
* Build (or reuse) the cached canonical plan for one spine item. The plan is
|
|
* computed once per (chapter, settings) so within-chapter page turns cost only
|
|
* the windowing step, not a full re-split of the chapter.
|
|
*/
|
|
export async function buildSpineCanonicalPlan(
|
|
book: Book,
|
|
params: SpinePlanParams,
|
|
): Promise<CanonicalTtsSegment[]> {
|
|
let bucket = PLAN_CACHE.get(book);
|
|
if (!bucket) {
|
|
bucket = new Map();
|
|
PLAN_CACHE.set(book, bucket);
|
|
}
|
|
const key = planCacheKey(params);
|
|
const cached = bucket.get(key);
|
|
if (cached) return cached;
|
|
|
|
const spineText = await getSpineItemPlainText(book, params.spineHref);
|
|
const segments = planSpineSegments({
|
|
spineText,
|
|
spineHref: params.spineHref,
|
|
spineIndex: params.spineIndex,
|
|
keyPrefix: params.keyPrefix,
|
|
maxBlockLength: params.maxBlockLength,
|
|
language: params.language,
|
|
});
|
|
bucket.set(key, segments);
|
|
return segments;
|
|
}
|
|
|
|
// Slack (in normalized chars) for snapping the window start to a segment
|
|
// boundary, absorbing tiny measurement jitter between independently measured
|
|
// adjacent-page offsets. Far smaller than a block, so a straddler (whose head
|
|
// on the previous page is substantial) is never pulled back in.
|
|
const WINDOW_START_SNAP_TOLERANCE = 8;
|
|
|
|
/**
|
|
* Select the canonical segments that **belong to** the page spanning the
|
|
* character range [startOffset, endOffset). Returns inclusive array-index
|
|
* bounds, or null when the range falls outside the plan.
|
|
*
|
|
* A block belongs to the page where it **starts** — exactly like PDF blocks.
|
|
* This yields a clean, non-overlapping partition of the chapter across pages:
|
|
*
|
|
* - Start: the first segment that begins at/after `startOffset` (minus a small
|
|
* snap tolerance). A block straddling *in* from the previous page begins
|
|
* before `startOffset`, so it is excluded here — it belongs to the previous
|
|
* page and is never duplicated on this one.
|
|
* - End: the last segment that begins before `endOffset`. A block straddling
|
|
* *out* to the next page begins on this page, so it is kept here (and
|
|
* excluded from the next page by the same start rule).
|
|
*
|
|
* Because both the sidebar and manual skip read this exact list, navigation is
|
|
* deterministic and a block is highlighted as the same segment on every visit.
|
|
*/
|
|
export function selectCanonicalWindow(
|
|
plan: readonly CanonicalTtsSegment[],
|
|
startOffset: number,
|
|
endOffset: number,
|
|
): { startIndex: number; endIndex: number } | null {
|
|
if (plan.length === 0) return null;
|
|
|
|
const startThreshold = startOffset - WINDOW_START_SNAP_TOLERANCE;
|
|
let startIndex = -1;
|
|
for (let i = 0; i < plan.length; i += 1) {
|
|
if (plan[i].startAnchor.offset >= startThreshold) {
|
|
startIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
if (startIndex < 0) return null; // startOffset is past the end of the chapter
|
|
|
|
let endIndex = -1;
|
|
for (let i = plan.length - 1; i >= startIndex; i -= 1) {
|
|
if (plan[i].startAnchor.offset < endOffset) {
|
|
endIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
if (endIndex < startIndex) endIndex = startIndex; // always yield at least one segment
|
|
|
|
return { startIndex, endIndex };
|
|
}
|
|
|
|
/**
|
|
* Re-express a segment's anchors relative to a rendered viewport so the EPUB
|
|
* highlight pipeline (resolveVisibleSegmentRange) can map it onto the page's
|
|
* text map. The chapter plan anchors a segment in *spine-global* space
|
|
* (sourceKey = `spine:…`, offset from the chapter start); highlighting needs
|
|
* viewport-local space (sourceKey = the page's rendered-map key, offset within
|
|
* the page text). Offsets are clamped to the page, so a block straddling in/out
|
|
* highlights only its visible portion.
|
|
*/
|
|
export interface ViewportAnchorContext {
|
|
/** Must equal the rendered text map's sourceKey for this page. */
|
|
sourceKey: string;
|
|
/** Normalized char offset of the page start within the spine text. */
|
|
baseOffset: number;
|
|
/** Normalized length of the page's visible text. */
|
|
length: number;
|
|
}
|
|
|
|
/**
|
|
* Clone a [startIndex, endIndex] slice of a plan, rewriting each segment's
|
|
* `ownerLocator` to a stable per-segment EPUB locator. The per-segment
|
|
* `charOffset` is load-bearing: `buildLocatorRequestKey` keys EPUB audio as
|
|
* `epub:${spineIndex}:${spineHref}:${charOffset}`, and persistence + the
|
|
* sidebar manifest sort rely on a real offset (the planner leaves it 0 for the
|
|
* whole-chapter source unit). `cfi` is attached as a non-identity jump hint.
|
|
*
|
|
* When `viewport` is provided, anchors are additionally rewritten to
|
|
* viewport-local coordinates so the page's segments can be highlighted. Omit it
|
|
* for prefetch/walker slices (not rendered on the current page).
|
|
*/
|
|
export function materializeWindowSegments(
|
|
plan: readonly CanonicalTtsSegment[],
|
|
startIndex: number,
|
|
endIndex: number,
|
|
ctx: { spineHref: string; spineIndex: number; cfi?: string },
|
|
viewport?: ViewportAnchorContext,
|
|
): CanonicalTtsSegment[] {
|
|
const out: CanonicalTtsSegment[] = [];
|
|
const lo = Math.max(0, startIndex);
|
|
const hi = Math.min(plan.length - 1, endIndex);
|
|
const clampToViewport = (offset: number): number =>
|
|
Math.max(0, Math.min(offset - viewport!.baseOffset, viewport!.length));
|
|
for (let i = lo; i <= hi; i += 1) {
|
|
const seg = plan[i];
|
|
const locator: TTSSegmentLocator = {
|
|
readerType: 'epub',
|
|
spineHref: ctx.spineHref,
|
|
spineIndex: ctx.spineIndex,
|
|
charOffset: Math.max(0, seg.startAnchor.offset),
|
|
};
|
|
if (ctx.cfi) locator.cfi = ctx.cfi;
|
|
const next: CanonicalTtsSegment = { ...seg, ownerLocator: locator };
|
|
if (viewport) {
|
|
next.startAnchor = { sourceKey: viewport.sourceKey, offset: clampToViewport(seg.startAnchor.offset) };
|
|
next.endAnchor = { sourceKey: viewport.sourceKey, offset: clampToViewport(seg.endAnchor.offset) };
|
|
// Keep ownerSourceKey in lock-step with the rewritten anchors: the word
|
|
// highlighter requires startAnchor.sourceKey === ownerSourceKey to treat
|
|
// the segment as anchored in the rendered page.
|
|
next.ownerSourceKey = viewport.sourceKey;
|
|
}
|
|
out.push(next);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const buildResult = (
|
|
plan: CanonicalTtsSegment[],
|
|
range: { startIndex: number; endIndex: number },
|
|
ctx: { spineHref: string; spineIndex: number; cfi?: string },
|
|
viewport?: ViewportAnchorContext,
|
|
): CanonicalWindowResult | null => {
|
|
const segments = materializeWindowSegments(plan, range.startIndex, range.endIndex, ctx, viewport);
|
|
if (segments.length === 0) return null;
|
|
return {
|
|
spineHref: ctx.spineHref,
|
|
spineIndex: ctx.spineIndex,
|
|
segments,
|
|
windowStartOrdinal: plan[range.startIndex].ordinal,
|
|
windowEndOrdinal: plan[range.endIndex].ordinal,
|
|
plan,
|
|
};
|
|
};
|
|
|
|
export interface BuildEpubCanonicalWindowOptions {
|
|
startCfi: string;
|
|
viewportText: string;
|
|
keyPrefix?: string;
|
|
maxBlockLength?: number;
|
|
language?: string;
|
|
/**
|
|
* Rendered text map sourceKey for this page. When provided, the returned
|
|
* segments' anchors are rewritten to viewport-local coordinates so they can
|
|
* be highlighted (resolveVisibleSegmentRange matches on this sourceKey).
|
|
*/
|
|
viewportAnchorSourceKey?: string;
|
|
}
|
|
|
|
/**
|
|
* Foreground path: build the canonical window for the currently rendered page
|
|
* from its start CFI + visible text. Returns null when the page text can't be
|
|
* located in the spine item (footnotes, nav docs, image-only pages) so the
|
|
* caller can fall back to the legacy preview-based plan.
|
|
*/
|
|
export async function buildEpubCanonicalWindow(
|
|
book: Book | null | undefined,
|
|
options: BuildEpubCanonicalWindowOptions,
|
|
): Promise<CanonicalWindowResult | null> {
|
|
if (!book?.isOpen) return null;
|
|
if (!options.viewportText.trim()) return null;
|
|
|
|
const spine = resolveSpineFromCfi(book, options.startCfi);
|
|
if (!spine) return null;
|
|
|
|
const plan = await buildSpineCanonicalPlan(book, {
|
|
spineHref: spine.href,
|
|
spineIndex: spine.index,
|
|
keyPrefix: options.keyPrefix,
|
|
maxBlockLength: options.maxBlockLength,
|
|
language: options.language,
|
|
});
|
|
if (plan.length === 0) return null;
|
|
|
|
const spineText = await getSpineItemPlainText(book, spine.href);
|
|
// Explicit -1 detection — do NOT use buildEpubChunkAnchor here, which masks a
|
|
// miss to offset 0 and would silently window from the chapter start.
|
|
const startOffset = findSegmentOffset(spineText, options.viewportText, 0);
|
|
if (startOffset < 0) return null;
|
|
const viewportLength = normalizeSegmentIdentityText(options.viewportText).length;
|
|
const endOffset = startOffset + viewportLength;
|
|
|
|
const range = selectCanonicalWindow(plan, startOffset, endOffset);
|
|
if (!range) return null;
|
|
const viewport: ViewportAnchorContext | undefined = options.viewportAnchorSourceKey
|
|
? { sourceKey: options.viewportAnchorSourceKey, baseOffset: startOffset, length: viewportLength }
|
|
: undefined;
|
|
return buildResult(plan, range, {
|
|
spineHref: spine.href,
|
|
spineIndex: spine.index,
|
|
cfi: options.startCfi,
|
|
}, viewport);
|
|
}
|
|
|
|
export interface BuildEpubCanonicalWindowFromChunkOptions {
|
|
spineHref: string;
|
|
spineIndex: number;
|
|
/** Chunk start offset in normalized character space (from the walker). */
|
|
chunkOffset: number;
|
|
text: string;
|
|
cfi?: string;
|
|
keyPrefix?: string;
|
|
maxBlockLength?: number;
|
|
language?: string;
|
|
}
|
|
|
|
/**
|
|
* Prefetch/walker path: build a canonical window from already-resolved spine
|
|
* coordinates (the walker reports spineHref/spineIndex/chunkOffset/text), so no
|
|
* CFI resolution or range extraction is needed.
|
|
*/
|
|
export async function buildEpubCanonicalWindowFromChunk(
|
|
book: Book | null | undefined,
|
|
options: BuildEpubCanonicalWindowFromChunkOptions,
|
|
): Promise<CanonicalWindowResult | null> {
|
|
if (!book?.isOpen) return null;
|
|
if (!options.text.trim()) return null;
|
|
|
|
const plan = await buildSpineCanonicalPlan(book, {
|
|
spineHref: options.spineHref,
|
|
spineIndex: options.spineIndex,
|
|
keyPrefix: options.keyPrefix,
|
|
maxBlockLength: options.maxBlockLength,
|
|
language: options.language,
|
|
});
|
|
if (plan.length === 0) return null;
|
|
|
|
const startOffset = Math.max(0, options.chunkOffset);
|
|
const endOffset = startOffset + normalizeSegmentIdentityText(options.text).length;
|
|
const range = selectCanonicalWindow(plan, startOffset, endOffset);
|
|
if (!range) return null;
|
|
return buildResult(plan, range, {
|
|
spineHref: options.spineHref,
|
|
spineIndex: options.spineIndex,
|
|
cfi: options.cfi,
|
|
});
|
|
}
|