Migrate PDF parsing to use ONNX Docling layout model for structured block extraction, enabling improved TTS segmentation and chaptering. Add compute backend abstraction for heavy tasks (alignment, layout parsing) with configuration via `OPENREADER_COMPUTE_MODE`. Integrate block-level locators and document settings for per-document PDF parsing options. Update S3 storage for parsed PDF JSON, add migration and schema changes, and extend client and server APIs for parsed data and settings. Remove legacy word highlight flag in favor of compute capability detection. - Add ONNX model download, local/none compute modes, and `onnxruntime-node` dependency - Update PDF viewer and TTS pipeline to use parsed blocks and block-level locators - Add document settings storage and APIs for per-document PDF options - Update S3 storage layout for parsed PDF JSON - Add admin/config/docs updates for new compute and parsing features - Remove obsolete word highlight runtime flag and UI BREAKING CHANGE: PDF parsing and word highlighting now require `OPENREADER_COMPUTE_MODE=local` and ONNX model; document settings and S3 layout updated; legacy word highlight flag removed.
138 lines
5.4 KiB
TypeScript
138 lines
5.4 KiB
TypeScript
import {
|
|
isHtmlLocator,
|
|
isPdfLocator,
|
|
isStableEpubLocator,
|
|
type TTSSegmentLocator,
|
|
} from '@/types/client';
|
|
import type { TTSLocation } from '@/types/tts';
|
|
|
|
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()
|
|
.replace(/\[;s=[ab]\]/gi, '')
|
|
.replace(/\s+/g, '');
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
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}`;
|
|
}
|
|
|
|
/**
|
|
* 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)) {
|
|
const blockPart = typeof locator.blockId === 'string' && locator.blockId.trim()
|
|
? `:${locator.blockId.trim()}`
|
|
: '';
|
|
return `pdf:${Math.floor(locator.page)}${blockPart}`;
|
|
}
|
|
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 {
|
|
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)) {
|
|
const pageCmp = Math.floor(a.page) - Math.floor(b.page);
|
|
if (pageCmp !== 0) return pageCmp;
|
|
const aBlock = typeof a.blockId === 'string' ? a.blockId : '';
|
|
const bBlock = typeof b.blockId === 'string' ? b.blockId : '';
|
|
return aBlock.localeCompare(bBlock);
|
|
}
|
|
if (isHtmlLocator(a) && isHtmlLocator(b)) {
|
|
// When both locations look like positive integers (HTML reader blocks),
|
|
// compare numerically so "10" sorts after "2" instead of between "1" and
|
|
// "2". Falls back to lexicographic for legacy free-form locations.
|
|
const an = /^\d+$/.test(a.location) ? Number(a.location) : NaN;
|
|
const bn = /^\d+$/.test(b.location) ? Number(b.location) : NaN;
|
|
if (Number.isFinite(an) && Number.isFinite(bn)) {
|
|
return an - bn;
|
|
}
|
|
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));
|
|
}
|