refactor(pdf-layout): remove compute "none" mode and unsupported parse status
Eliminate the "none" compute mode and all related code paths, including the NoneComputeBackend, "unsupported" parse status, and PDF margin settings. Parsing is now always available if the app starts successfully, and configuration is limited to "local" or "worker" compute modes. Update types, API routes, client adapters, and documentation to reflect this simplification. BREAKING CHANGE: "none" is no longer a valid COMPUTE_MODE; only "local" and "worker" are supported. "unsupported" parse status and PDF margin settings are removed.
This commit is contained in:
parent
bab3416a36
commit
f5d7408f17
24 changed files with 343 additions and 452 deletions
|
|
@ -79,7 +79,6 @@ IMPORT_LIBRARY_DIRS=
|
|||
|
||||
# Heavy compute backend mode for ONNX whisper alignment + PDF layout parsing.
|
||||
# local = run compute in-process (default)
|
||||
# none = disable both capabilities (good for preview/serverless)
|
||||
# worker = external durable worker mode (requires Redis-backed compute-worker service)
|
||||
COMPUTE_MODE=local
|
||||
# Required when COMPUTE_MODE=worker
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in
|
|||
# Heavy compute (recommended on Vercel in v1)
|
||||
# local = requires native binaries/models in-process (not recommended on Vercel)
|
||||
# worker = external durable compute worker (recommended)
|
||||
# none = disable ONNX whisper alignment + PDF layout parsing
|
||||
COMPUTE_MODE=worker
|
||||
COMPUTE_WORKER_URL=https://your-compute-worker.example.com
|
||||
COMPUTE_WORKER_TOKEN=...
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
|
|||
| `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass |
|
||||
| `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root |
|
||||
| `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) |
|
||||
| `COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable ONNX word alignment + PDF layout parsing |
|
||||
| `COMPUTE_MODE` | Heavy compute backend | `local` | Select `local` (in-process) or `worker` (external worker service) |
|
||||
| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Required when `COMPUTE_MODE=worker`; base URL for external compute worker |
|
||||
| `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Required bearer token for external compute worker auth |
|
||||
| `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` |
|
||||
|
|
@ -358,7 +358,6 @@ Selects the backend for heavy compute features (ONNX word alignment + PDF layout
|
|||
- Supported in v1:
|
||||
- `local`: run compute in-process on the app server
|
||||
- `worker`: enqueue async jobs in an external durable compute worker (Redis + BullMQ)
|
||||
- `none`: disable these features cleanly
|
||||
- `worker` requires `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN`
|
||||
- `worker` assumes the external worker can directly reach shared object storage (S3-compatible endpoint)
|
||||
- `worker` is not compatible with non-exposed embedded `weed mini` storage topologies
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ Monorepo packages under `compute/`:
|
|||
- Storage: AWS SDK v3 S3 client for reading/writing blobs
|
||||
- Logging: [Pino](https://getpino.io/)
|
||||
- Validation: [Zod](https://zod.dev/)
|
||||
- Compute mode is controlled by `COMPUTE_MODE` env var: `local` (in-process), `worker` (remote queue via HTTP + Redis), or disabled
|
||||
- Compute mode is controlled by `COMPUTE_MODE` env var: `local` (in-process) or `worker` (remote queue via HTTP + Redis)
|
||||
|
||||
## Tooling and testing
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import dynamic from 'next/dynamic';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react';
|
||||
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { DocumentSettings } from '@/components/documents/DocumentSettings';
|
||||
|
|
@ -19,6 +19,7 @@ import { resolveDocumentId } from '@/lib/client/dexie';
|
|||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||
import { LoadingSpinner } from '@/components/Spinner';
|
||||
import { usePdfDocument } from './usePdfDocument';
|
||||
|
||||
// Dynamic import for client-side rendering only
|
||||
|
|
@ -32,7 +33,6 @@ const PDFViewer = dynamic(
|
|||
|
||||
export default function PDFViewerPage() {
|
||||
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||
const computeAvailable = useFeatureFlag('computeAvailable');
|
||||
const { id } = useParams();
|
||||
const router = useRouter();
|
||||
const pdfState = usePdfDocument();
|
||||
|
|
@ -60,6 +60,11 @@ export default function PDFViewerPage() {
|
|||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||
const inFlightDocIdRef = useRef<string | null>(null);
|
||||
const loadedDocIdRef = useRef<string | null>(null);
|
||||
const backNavTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clearCurrDocRef = useRef(clearCurrDoc);
|
||||
const [isNavigatingBack, setIsNavigatingBack] = useState(false);
|
||||
const parseState = parseStatus ?? 'pending';
|
||||
const isParseReady = parseState === 'ready';
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -115,6 +120,25 @@ export default function PDFViewerPage() {
|
|||
loadDocument();
|
||||
}, [loadDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
clearCurrDocRef.current = clearCurrDoc;
|
||||
}, [clearCurrDoc]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (backNavTimeoutRef.current) {
|
||||
clearTimeout(backNavTimeoutRef.current);
|
||||
}
|
||||
clearCurrDocRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (isParseReady) return;
|
||||
stop();
|
||||
}, [isLoading, isParseReady, stop]);
|
||||
|
||||
// Compute available height = viewport - (header height + tts bar height)
|
||||
useEffect(() => {
|
||||
const compute = () => {
|
||||
|
|
@ -134,14 +158,33 @@ export default function PDFViewerPage() {
|
|||
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300));
|
||||
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
||||
|
||||
const handleBackToDocuments = useCallback((event?: MouseEvent) => {
|
||||
event?.preventDefault();
|
||||
if (isNavigatingBack) return;
|
||||
setIsNavigatingBack(true);
|
||||
stop();
|
||||
const hadOpenSidebar = activeSidebar !== null;
|
||||
setActiveSidebar(null);
|
||||
const delayMs = hadOpenSidebar ? 220 : 0;
|
||||
if (backNavTimeoutRef.current) {
|
||||
clearTimeout(backNavTimeoutRef.current);
|
||||
}
|
||||
backNavTimeoutRef.current = setTimeout(() => {
|
||||
router.push('/app');
|
||||
}, delayMs);
|
||||
}, [isNavigatingBack, stop, activeSidebar, router]);
|
||||
|
||||
const handleGenerateAudiobook = useCallback(async (
|
||||
onProgress: (progress: number) => void,
|
||||
signal: AbortSignal,
|
||||
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||
settings: AudiobookGenerationSettings
|
||||
) => {
|
||||
if (!isParseReady) {
|
||||
throw new Error('PDF parsing is not ready yet.');
|
||||
}
|
||||
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings);
|
||||
}, [createPDFAudioBook, id]);
|
||||
}, [createPDFAudioBook, id, isParseReady]);
|
||||
|
||||
const handleRegenerateChapter = useCallback(async (
|
||||
chapterIndex: number,
|
||||
|
|
@ -149,8 +192,11 @@ export default function PDFViewerPage() {
|
|||
settings: AudiobookGenerationSettings,
|
||||
signal: AbortSignal
|
||||
) => {
|
||||
if (!isParseReady) {
|
||||
throw new Error('PDF parsing is not ready yet.');
|
||||
}
|
||||
return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings);
|
||||
}, [regeneratePDFChapter]);
|
||||
}, [regeneratePDFChapter, isParseReady]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -158,7 +204,7 @@ export default function PDFViewerPage() {
|
|||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Link
|
||||
href="/app"
|
||||
onClick={() => { clearCurrDoc(); }}
|
||||
onClick={handleBackToDocuments}
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -170,13 +216,42 @@ export default function PDFViewerPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const renderPdfStatusLoader = () => {
|
||||
let statusText = 'Loading PDF...';
|
||||
if (!isLoading) {
|
||||
if (parseState === 'pending') {
|
||||
statusText = 'Preparing PDF layout...';
|
||||
} else if (parseState === 'running') {
|
||||
statusText = 'Parsing PDF layout blocks...';
|
||||
} else if (parseState === 'failed') {
|
||||
statusText = 'PDF parsing failed. Retry to continue.';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center gap-4 bg-base">
|
||||
<LoadingSpinner className="w-8 h-8 text-accent" />
|
||||
<p className="text-sm text-muted animate-pulse">{statusText}</p>
|
||||
{!isLoading && parseState === 'failed' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => forceReparseParsedPdf()}
|
||||
className="inline-flex items-center rounded-md border border-offbase bg-offbase px-2.5 py-1 text-xs text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
Retry Parse
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
left={
|
||||
<Link
|
||||
href="/app"
|
||||
onClick={() => clearCurrDoc()}
|
||||
onClick={handleBackToDocuments}
|
||||
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
aria-label="Back to documents"
|
||||
>
|
||||
|
|
@ -207,11 +282,8 @@ export default function PDFViewerPage() {
|
|||
}
|
||||
/>
|
||||
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-4">
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
{isLoading || !isParseReady ? (
|
||||
renderPdfStatusLoader()
|
||||
) : (
|
||||
<PDFViewer zoomLevel={zoomLevel} pdfState={pdfState} />
|
||||
)}
|
||||
|
|
@ -233,14 +305,13 @@ export default function PDFViewerPage() {
|
|||
<RateLimitBanner />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : isParseReady ? (
|
||||
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
|
||||
)}
|
||||
) : null}
|
||||
<DocumentSettings
|
||||
isOpen={activeSidebar === 'settings'}
|
||||
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
|
||||
pdf={{
|
||||
computeAvailable,
|
||||
parseStatus,
|
||||
parsedOverlayEnabled,
|
||||
skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [],
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@ import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
|||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import {
|
||||
extractTextFromPDF,
|
||||
highlightPattern,
|
||||
clearHighlights,
|
||||
clearWordHighlights,
|
||||
highlightWordIndex,
|
||||
} from '@/lib/client/pdf';
|
||||
import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text';
|
||||
import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan';
|
||||
import {
|
||||
DEFAULT_DOCUMENT_SETTINGS,
|
||||
|
|
@ -113,9 +113,6 @@ export interface PdfDocumentState {
|
|||
isAudioCombining: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_TEXT_RETRY_DELAY_MS = 120;
|
||||
const EMPTY_TEXT_MAX_RETRIES = 6;
|
||||
|
||||
/**
|
||||
* Main PDF route hook.
|
||||
*/
|
||||
|
|
@ -130,10 +127,6 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
registerVisualPageChangeHandler,
|
||||
} = useTTS();
|
||||
const {
|
||||
headerMargin,
|
||||
footerMargin,
|
||||
leftMargin,
|
||||
rightMargin,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerRef,
|
||||
|
|
@ -154,18 +147,11 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
const [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false);
|
||||
const [isAudioCombining] = useState(false);
|
||||
const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({
|
||||
pdfDocument,
|
||||
parsed: parsedDocument ?? undefined,
|
||||
settings: documentSettings,
|
||||
margins: {
|
||||
header: documentSettings.pdf?.margins?.header ?? headerMargin,
|
||||
footer: documentSettings.pdf?.margins?.footer ?? footerMargin,
|
||||
left: documentSettings.pdf?.margins?.left ?? leftMargin,
|
||||
right: documentSettings.pdf?.margins?.right ?? rightMargin,
|
||||
},
|
||||
smartSentenceSplitting,
|
||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||
}), [pdfDocument, parsedDocument, documentSettings, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]);
|
||||
}), [pdfDocument, parsedDocument, documentSettings, smartSentenceSplitting, ttsSegmentMaxBlockLength]);
|
||||
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
||||
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
||||
|
||||
|
|
@ -174,7 +160,6 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
const pdfDocGenerationRef = useRef(0);
|
||||
const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined);
|
||||
const loadSeqRef = useRef(0);
|
||||
const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType<typeof setTimeout> | null } | null>(null);
|
||||
|
||||
// Guards for setCurrentDocument to prevent stale loads from overwriting newer selections.
|
||||
const docLoadSeqRef = useRef(0);
|
||||
|
|
@ -190,10 +175,6 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
// document backfills parse output via the parsed endpoint polling path.
|
||||
const effectiveInitialStatus: PdfParseStatus = initialStatus ?? 'pending';
|
||||
setParseStatus(effectiveInitialStatus);
|
||||
if (effectiveInitialStatus === 'unsupported') {
|
||||
setParsedDocument(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAttempts = 25;
|
||||
const delayMs = 1200;
|
||||
|
|
@ -210,7 +191,7 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
return;
|
||||
}
|
||||
setParseStatus(result.status);
|
||||
if (result.status === 'failed' || result.status === 'unsupported') {
|
||||
if (result.status === 'failed') {
|
||||
setParsedDocument(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -262,7 +243,7 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
|
||||
/**
|
||||
* Loads and processes text from the current document page
|
||||
* Extracts text from the PDF and updates both document text and TTS text states
|
||||
* Uses parsed PDF blocks only and updates both document text and TTS text states.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
|
|
@ -274,30 +255,14 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
const seq = ++loadSeqRef.current;
|
||||
const pageNumber = currDocPageNumber;
|
||||
|
||||
const existingRetry = emptyRetryRef.current;
|
||||
if (existingRetry?.timer) {
|
||||
clearTimeout(existingRetry.timer);
|
||||
}
|
||||
emptyRetryRef.current =
|
||||
existingRetry && existingRetry.page === pageNumber
|
||||
? { ...existingRetry, timer: null }
|
||||
: null;
|
||||
|
||||
const margins = {
|
||||
header: documentSettings.pdf?.margins?.header ?? headerMargin,
|
||||
footer: documentSettings.pdf?.margins?.footer ?? footerMargin,
|
||||
left: documentSettings.pdf?.margins?.left ?? leftMargin,
|
||||
right: documentSettings.pdf?.margins?.right ?? rightMargin
|
||||
};
|
||||
|
||||
const pageFromParsed = (pageNum: number): ParsedPdfPage | undefined =>
|
||||
parsedDocument?.pages.find((page) => page.pageNumber === pageNum);
|
||||
|
||||
const hasUsableParsedBlocks = (page: ParsedPdfPage | undefined): page is ParsedPdfPage => {
|
||||
if (!page) return false;
|
||||
const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []);
|
||||
return page.blocks.some((block) => !skipKinds.has(block.kind) && block.text.trim().length > 0);
|
||||
};
|
||||
if (parseStatus !== 'ready' || !parsedDocument) {
|
||||
setCurrDocText(undefined);
|
||||
setTTSText('', { location: currDocPageNumber });
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => {
|
||||
const page = pageFromParsed(pageNum);
|
||||
|
|
@ -332,14 +297,9 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
}
|
||||
|
||||
const parsedPage = pageFromParsed(pageNumber);
|
||||
const useParsedPage = hasUsableParsedBlocks(parsedPage) ? parsedPage : undefined;
|
||||
const extracted = await extractTextFromPDF(
|
||||
currentPdf,
|
||||
pageNumber,
|
||||
margins,
|
||||
useParsedPage,
|
||||
useParsedPage ? documentSettings.pdf?.skipBlockKinds : undefined,
|
||||
);
|
||||
const extracted = parsedPage
|
||||
? buildPageTextFromBlocks(parsedPage, documentSettings.pdf?.skipBlockKinds ?? [])
|
||||
: '';
|
||||
|
||||
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||
throw new DOMException('Stale PDF extraction', 'AbortError');
|
||||
|
|
@ -383,44 +343,15 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
return;
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
const prevAttempt = emptyRetryRef.current?.page === pageNumber ? emptyRetryRef.current.attempt : 0;
|
||||
const attempt = prevAttempt + 1;
|
||||
|
||||
// Avoid pushing empty text into TTS immediately; transient empty extractions can happen
|
||||
// during page turns or react-pdf worker churn. Retry a few times before treating it as
|
||||
// a truly blank page.
|
||||
if (attempt <= EMPTY_TEXT_MAX_RETRIES) {
|
||||
const timer = setTimeout(() => {
|
||||
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||
return;
|
||||
}
|
||||
if (pageNumber !== currDocPageNumber) {
|
||||
return;
|
||||
}
|
||||
void loadCurrDocText();
|
||||
}, EMPTY_TEXT_RETRY_DELAY_MS);
|
||||
|
||||
emptyRetryRef.current = { page: pageNumber, attempt, timer };
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
emptyRetryRef.current = null;
|
||||
}
|
||||
|
||||
if (text !== currDocText || text === '') {
|
||||
setCurrDocText(text);
|
||||
const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber);
|
||||
const useBlockStructuredPlanning = sourceUnits.length > 0;
|
||||
setTTSText(text, {
|
||||
location: currDocPageNumber,
|
||||
...(!useBlockStructuredPlanning ? {
|
||||
previousText: prevText,
|
||||
nextLocation: nextPageNumber,
|
||||
nextText: nextText,
|
||||
upcomingLocations: additionalUpcoming,
|
||||
} : {}),
|
||||
previousText: prevText,
|
||||
nextLocation: nextPageNumber,
|
||||
nextText: nextText,
|
||||
upcomingLocations: additionalUpcoming,
|
||||
...(sourceUnits.length > 0 ? { sourceUnits } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -435,12 +366,9 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
currDocPages,
|
||||
setTTSText,
|
||||
currDocText,
|
||||
headerMargin,
|
||||
footerMargin,
|
||||
leftMargin,
|
||||
rightMargin,
|
||||
segmentPreloadDepthPages,
|
||||
parsedDocument,
|
||||
parseStatus,
|
||||
documentSettings,
|
||||
]);
|
||||
|
||||
|
|
@ -474,10 +402,6 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
// or fast refresh.
|
||||
pdfDocGenerationRef.current += 1;
|
||||
loadSeqRef.current += 1;
|
||||
if (emptyRetryRef.current?.timer) {
|
||||
clearTimeout(emptyRetryRef.current.timer);
|
||||
}
|
||||
emptyRetryRef.current = null;
|
||||
parsePollAbortRef.current?.abort();
|
||||
parsePollAbortRef.current = null;
|
||||
pageTextCacheRef.current.clear();
|
||||
|
|
@ -570,10 +494,6 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
docLoadAbortRef.current = null;
|
||||
parsePollAbortRef.current?.abort();
|
||||
parsePollAbortRef.current = null;
|
||||
if (emptyRetryRef.current?.timer) {
|
||||
clearTimeout(emptyRetryRef.current.timer);
|
||||
}
|
||||
emptyRetryRef.current = null;
|
||||
setCurrDocId(undefined);
|
||||
setCurrDocName(undefined);
|
||||
setCurrDocData(undefined);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean {
|
|||
return doc.pages.some((page) => Array.isArray(page.blocks) && page.blocks.length > 0);
|
||||
}
|
||||
|
||||
function normalizeParseStatus(
|
||||
status: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null,
|
||||
): 'pending' | 'running' | 'ready' | 'failed' {
|
||||
if (status === 'unsupported' || status === null) return 'pending';
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
if (!isS3Configured()) return s3NotConfiguredResponse();
|
||||
|
|
@ -60,7 +67,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (row.parseStatus === 'failed' && retryFailed) {
|
||||
if (row.parseStatus === 'unsupported') {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ parseStatus: 'pending' })
|
||||
|
|
@ -73,15 +80,30 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
return NextResponse.json({ parseStatus: 'pending' }, { status: 202 });
|
||||
}
|
||||
|
||||
if (row.parseStatus !== 'ready') {
|
||||
if (row.parseStatus === 'pending' || row.parseStatus === 'running' || row.parseStatus === null) {
|
||||
const effectiveStatus = normalizeParseStatus(row.parseStatus);
|
||||
|
||||
if (effectiveStatus === 'failed' && retryFailed) {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ parseStatus: 'pending' })
|
||||
.where(and(eq(documents.id, id), eq(documents.userId, row.userId)));
|
||||
enqueueParsePdfJob({
|
||||
documentId: id,
|
||||
userId: row.userId,
|
||||
namespace: testNamespace,
|
||||
});
|
||||
return NextResponse.json({ parseStatus: 'pending' }, { status: 202 });
|
||||
}
|
||||
|
||||
if (effectiveStatus !== 'ready') {
|
||||
if (effectiveStatus === 'pending' || effectiveStatus === 'running') {
|
||||
enqueueParsePdfJob({
|
||||
documentId: id,
|
||||
userId: row.userId,
|
||||
namespace: testNamespace,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ parseStatus: row.parseStatus ?? 'pending' }, { status: 202 });
|
||||
return NextResponse.json({ parseStatus: effectiveStatus }, { status: 202 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -161,7 +183,9 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (row.parseStatus !== 'running') {
|
||||
const effectiveStatus = normalizeParseStatus(row.parseStatus);
|
||||
|
||||
if (effectiveStatus !== 'running') {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ parseStatus: 'pending' })
|
||||
|
|
@ -175,7 +199,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ parseStatus: row.parseStatus === 'running' ? 'running' : 'pending' },
|
||||
{ parseStatus: effectiveStatus === 'running' ? 'running' : 'pending' },
|
||||
{ status: 202 },
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ type RegisterDocument = {
|
|||
lastModified: number;
|
||||
};
|
||||
|
||||
function normalizeParseStatus(
|
||||
status: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null,
|
||||
): 'pending' | 'running' | 'ready' | 'failed' | null {
|
||||
if (status === 'unsupported') return 'pending';
|
||||
return status;
|
||||
}
|
||||
|
||||
function s3NotConfiguredResponse(): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
|
||||
|
|
@ -245,7 +252,7 @@ export async function GET(req: NextRequest) {
|
|||
size: Number(doc.size),
|
||||
lastModified: Number(doc.lastModified),
|
||||
type,
|
||||
parseStatus: doc.parseStatus,
|
||||
parseStatus: normalizeParseStatus(doc.parseStatus),
|
||||
parsedJsonKey: doc.parsedJsonKey,
|
||||
scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, type ChangeEvent } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||
import {
|
||||
|
|
@ -15,7 +15,6 @@ import {
|
|||
clampSegmentPreloadSentenceLookahead,
|
||||
clampTtsSegmentMaxBlockLength,
|
||||
} from '@/types/config';
|
||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||
import { Section, ToggleRow, CheckItem, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
|
||||
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
||||
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
||||
|
|
@ -52,8 +51,6 @@ const viewTypeTextMapping = [
|
|||
|
||||
const rangeInputClassName = 'w-full bg-offbase rounded-md appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-md [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-md [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent';
|
||||
|
||||
type MarginKey = 'header' | 'footer' | 'left' | 'right';
|
||||
|
||||
type RangeSettingProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
|
|
@ -103,7 +100,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
epub?: boolean,
|
||||
html?: boolean,
|
||||
pdf?: {
|
||||
computeAvailable: boolean;
|
||||
parseStatus: PdfParseStatus | null;
|
||||
parsedOverlayEnabled: boolean;
|
||||
skipBlockKinds: ParsedPdfBlockKind[];
|
||||
|
|
@ -114,8 +110,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
onForceReparse: () => void;
|
||||
}
|
||||
}) {
|
||||
const computeAvailable = useFeatureFlag('computeAvailable');
|
||||
const canWordHighlight = computeAvailable;
|
||||
const canWordHighlight = true;
|
||||
const {
|
||||
viewType,
|
||||
skipBlank,
|
||||
|
|
@ -124,10 +119,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
segmentPreloadDepthPages,
|
||||
segmentPreloadSentenceLookahead,
|
||||
ttsSegmentMaxBlockLength,
|
||||
headerMargin,
|
||||
footerMargin,
|
||||
leftMargin,
|
||||
rightMargin,
|
||||
updateConfigKey,
|
||||
pdfHighlightEnabled,
|
||||
epubHighlightEnabled,
|
||||
|
|
@ -136,31 +127,11 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
htmlHighlightEnabled,
|
||||
htmlWordHighlightEnabled,
|
||||
} = useConfig();
|
||||
const [localMargins, setLocalMargins] = useState({
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
});
|
||||
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
||||
const isPdfMode = !epub && !html && !!pdf;
|
||||
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
|
||||
const [localSentenceLookahead, setLocalSentenceLookahead] = useState(segmentPreloadSentenceLookahead);
|
||||
const [localMaxBlockLength, setLocalMaxBlockLength] = useState(ttsSegmentMaxBlockLength);
|
||||
const marginValues: Record<MarginKey, number> = {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalMargins({
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
});
|
||||
}, [headerMargin, footerMargin, leftMargin, rightMargin]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPreloadDepth(segmentPreloadDepthPages);
|
||||
|
|
@ -174,22 +145,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
setLocalMaxBlockLength(ttsSegmentMaxBlockLength);
|
||||
}, [ttsSegmentMaxBlockLength]);
|
||||
|
||||
// Handler for slider release
|
||||
const handleMarginChangeComplete = (margin: MarginKey) => () => {
|
||||
const value = localMargins[margin];
|
||||
const configKey = `${margin}Margin`;
|
||||
if (value !== marginValues[margin]) {
|
||||
updateConfigKey(configKey as 'headerMargin' | 'footerMargin' | 'leftMargin' | 'rightMargin', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarginSliderChange = (margin: MarginKey) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalMargins((previous) => ({
|
||||
...previous,
|
||||
[margin]: Number(event.target.value),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ReaderSidebarShell
|
||||
isOpen={isOpen}
|
||||
|
|
@ -201,6 +156,116 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
panelClassName="w-full sm:w-[30rem]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{isPdfMode && pdf && (
|
||||
<Section
|
||||
title="PDF Essentials"
|
||||
subtitle="Critical parsing and playback controls."
|
||||
variant="flat"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Page mode"
|
||||
className={`${segmentedGroupClass} grid-cols-3`}
|
||||
>
|
||||
{viewTypeTextMapping.map((view) => {
|
||||
const active = selectedView.id === view.id;
|
||||
return (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => updateConfigKey('viewType', view.id as ViewType)}
|
||||
className={segmentedButtonClass(active)}
|
||||
>
|
||||
{view.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedView.id === 'scroll' ? (
|
||||
<p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ToggleRow
|
||||
label="Highlight text during playback"
|
||||
description="Highlight the current sentence in PDF."
|
||||
checked={pdfHighlightEnabled}
|
||||
onChange={(checked) => updateConfigKey('pdfHighlightEnabled', checked)}
|
||||
variant="flat"
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Word-by-word highlighting"
|
||||
description={`Highlight words using timing data${!canWordHighlight ? ' (not available on this server)' : ''}.`}
|
||||
checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
|
||||
disabled={!pdfHighlightEnabled || !canWordHighlight}
|
||||
onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)}
|
||||
variant="flat"
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Use detected sections as chapters"
|
||||
description="Build audiobook chapters from section headers."
|
||||
checked={pdf.chaptersFromSections}
|
||||
onChange={pdf.onToggleChaptersFromSections}
|
||||
variant="flat"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{isPdfMode && pdf && (
|
||||
<Section
|
||||
title="PDF Advanced"
|
||||
subtitle="Optional visual and structural tuning."
|
||||
variant="flat"
|
||||
action={
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="flex items-center gap-1 text-muted">
|
||||
<SparkleIcon className="h-3 w-3 text-accent/70" />
|
||||
<span className="text-xs">PP-DocLayout-V3</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted">
|
||||
<span>{pdf.parseStatus ?? 'pending'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 hover:scale-[1.1] shrink-0' })}
|
||||
onClick={pdf.onForceReparse}
|
||||
disabled={pdf.parseStatus === 'running' || pdf.parseStatus === 'pending'}
|
||||
title="Force reparse"
|
||||
>
|
||||
<RefreshIcon className={`h-3 w-3 ${pdf.parseStatus === 'running' || pdf.parseStatus === 'pending' ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ToggleRow
|
||||
label="Show block overlay"
|
||||
description="Render detected block boxes and labels on the page."
|
||||
checked={pdf.parsedOverlayEnabled}
|
||||
onChange={pdf.onToggleOverlay}
|
||||
disabled={pdf.parseStatus !== 'ready'}
|
||||
variant="flat"
|
||||
/>
|
||||
<details className="rounded-md border border-offbase bg-base/50 px-3 py-2">
|
||||
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Skip Block Kinds While Reading Aloud
|
||||
</summary>
|
||||
<div className="grid grid-cols-2 gap-x-3 pt-2">
|
||||
{PDF_SKIP_KIND_OPTIONS.map((option) => (
|
||||
<CheckItem
|
||||
key={option.kind}
|
||||
label={option.label}
|
||||
checked={pdf.skipBlockKinds.includes(option.kind)}
|
||||
onChange={(enabled) => pdf.onToggleSkipKind(option.kind, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section
|
||||
title="Playback Flow"
|
||||
subtitle="Segment and queue behavior."
|
||||
|
|
@ -273,95 +338,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
{!epub && !html && (
|
||||
<Section
|
||||
title="PDF Layout & Extraction"
|
||||
subtitle="Page mode and extraction bounds."
|
||||
variant="flat"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Page mode"
|
||||
className={`${segmentedGroupClass} grid-cols-3`}
|
||||
>
|
||||
{viewTypeTextMapping.map((view) => {
|
||||
const active = selectedView.id === view.id;
|
||||
return (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => updateConfigKey('viewType', view.id as ViewType)}
|
||||
className={segmentedButtonClass(active)}
|
||||
>
|
||||
{view.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedView.id === 'scroll' ? (
|
||||
<p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted">Text extraction margins</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Ignore edge content before extraction.
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => (
|
||||
<div key={margin} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="capitalize text-foreground">{margin}</span>
|
||||
<span className="font-semibold text-foreground">{Math.round(localMargins[margin] * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="0.2"
|
||||
step="0.01"
|
||||
value={localMargins[margin]}
|
||||
onChange={handleMarginSliderChange(margin)}
|
||||
onMouseUp={handleMarginChangeComplete(margin)}
|
||||
onKeyUp={handleMarginChangeComplete(margin)}
|
||||
onTouchEnd={handleMarginChangeComplete(margin)}
|
||||
className={rangeInputClassName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{!epub && !html && (
|
||||
<Section
|
||||
title="PDF Highlighting"
|
||||
subtitle="Playback highlighting in PDF mode."
|
||||
variant="flat"
|
||||
>
|
||||
<ToggleRow
|
||||
label="Highlight text during playback"
|
||||
description="Highlight the current sentence in PDF."
|
||||
checked={pdfHighlightEnabled}
|
||||
onChange={(checked) => updateConfigKey('pdfHighlightEnabled', checked)}
|
||||
variant="flat"
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Word-by-word highlighting"
|
||||
description={`Highlight words using timing data${!canWordHighlight ? ' (not available on this server)' : ''}.`}
|
||||
checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
|
||||
disabled={!pdfHighlightEnabled || !canWordHighlight}
|
||||
onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)}
|
||||
variant="flat"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{epub && (
|
||||
<Section
|
||||
title="EPUB Appearance"
|
||||
|
|
@ -416,64 +392,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{!epub && !html && pdf && (
|
||||
<Section
|
||||
title="PDF Structure"
|
||||
subtitle="Layout-aware parsing controls."
|
||||
variant="flat"
|
||||
action={
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="flex items-center gap-1 text-muted">
|
||||
<SparkleIcon className="h-3 w-3 text-accent/70" />
|
||||
<span className="text-xs">PP-DocLayout-V3</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted">
|
||||
<span>{pdf.parseStatus ?? 'pending'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 hover:scale-[1.1] shrink-0' })}
|
||||
onClick={pdf.onForceReparse}
|
||||
disabled={!computeAvailable || pdf.parseStatus === 'running' || pdf.parseStatus === 'pending'}
|
||||
title="Force reparse"
|
||||
>
|
||||
<RefreshIcon className={`h-3 w-3 ${pdf.parseStatus === 'running' || pdf.parseStatus === 'pending' ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ToggleRow
|
||||
label="Show block overlay"
|
||||
description="Render detected block boxes and labels on the page."
|
||||
checked={pdf.parsedOverlayEnabled}
|
||||
onChange={pdf.onToggleOverlay}
|
||||
disabled={pdf.parseStatus !== 'ready'}
|
||||
variant="flat"
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Use detected sections as chapters"
|
||||
description={computeAvailable ? 'Build audiobook chapters from section headers.' : 'Not available on this server.'}
|
||||
checked={pdf.chaptersFromSections}
|
||||
onChange={pdf.onToggleChaptersFromSections}
|
||||
disabled={!computeAvailable}
|
||||
variant="flat"
|
||||
/>
|
||||
<div className="pt-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted pb-1.5">Skip while reading aloud</p>
|
||||
<div className="grid grid-cols-2 gap-x-3">
|
||||
{PDF_SKIP_KIND_OPTIONS.map((option) => (
|
||||
<CheckItem
|
||||
key={option.kind}
|
||||
label={option.label}
|
||||
checked={pdf.skipBlockKinds.includes(option.kind)}
|
||||
onChange={(enabled) => pdf.onToggleSkipKind(option.kind, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</ReaderSidebarShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -119,6 +119,13 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) {
|
|||
wordHighlightTimeoutsRef.current.push(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearHighlights();
|
||||
clearWordHighlights();
|
||||
};
|
||||
}, [clearHighlights, clearWordHighlights]);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
* Handles highlighting the current sentence being read by TTS.
|
||||
|
|
@ -146,6 +153,12 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Root-cause guard: do not repaint highlights while react-pdf is still
|
||||
// replacing page/text layers for a new page or viewport layout.
|
||||
if (isPageRendering) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++sentenceHighlightSeqRef.current;
|
||||
const isLayoutChange = layoutKey !== lastSentenceLayoutKeyRef.current;
|
||||
lastSentenceLayoutKeyRef.current = layoutKey;
|
||||
|
|
@ -156,38 +169,40 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) {
|
|||
&& typeof activeLocator.blockId === 'string'
|
||||
&& activeLocator.blockId.length > 0;
|
||||
|
||||
if (isLayoutChange) {
|
||||
if (isLayoutChange || !hasParsedBlockLocator) {
|
||||
clearHighlights();
|
||||
}
|
||||
|
||||
if (!hasParsedBlockLocator) {
|
||||
return;
|
||||
}
|
||||
|
||||
const useBlockGeometryOnly = !pdfWordHighlightEnabled;
|
||||
|
||||
const tryApply = (attempt: number) => {
|
||||
if (seq !== sentenceHighlightSeqRef.current) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
if (hasParsedBlockLocator) {
|
||||
highlightPattern(currDocText, currentSentence, containerRef as RefObject<HTMLDivElement>, {
|
||||
parsedDocument,
|
||||
locator: activeLocator,
|
||||
useBlockGeometryOnly: !pdfWordHighlightEnabled,
|
||||
});
|
||||
return;
|
||||
if (!useBlockGeometryOnly) {
|
||||
const spans = container.querySelectorAll('.react-pdf__Page__textContent span');
|
||||
if (!spans.length) {
|
||||
if (attempt < 1) scheduleSentenceTimeout(() => tryApply(attempt + 1), 90);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const spans = container.querySelectorAll('.react-pdf__Page__textContent span');
|
||||
if (!spans.length) {
|
||||
if (attempt < 10) scheduleSentenceTimeout(() => tryApply(attempt + 1), 75);
|
||||
return;
|
||||
}
|
||||
|
||||
highlightPattern(currDocText, currentSentence, containerRef as RefObject<HTMLDivElement>);
|
||||
highlightPattern(currDocText, currentSentence, containerRef as RefObject<HTMLDivElement>, {
|
||||
parsedDocument,
|
||||
locator: activeLocator,
|
||||
useBlockGeometryOnly,
|
||||
});
|
||||
};
|
||||
|
||||
scheduleSentenceTimeout(() => tryApply(0), 200);
|
||||
scheduleSentenceTimeout(() => tryApply(0), useBlockGeometryOnly ? 80 : 120);
|
||||
|
||||
return () => {
|
||||
clearSentenceHighlightTimeouts();
|
||||
clearHighlights();
|
||||
};
|
||||
}, [
|
||||
currDocText,
|
||||
|
|
@ -199,6 +214,7 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) {
|
|||
pdfWordHighlightEnabled,
|
||||
parsedDocument,
|
||||
layoutKey,
|
||||
isPageRendering,
|
||||
clearSentenceHighlightTimeouts,
|
||||
scheduleSentenceTimeout
|
||||
]);
|
||||
|
|
@ -228,6 +244,10 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (isPageRendering) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++wordHighlightSeqRef.current;
|
||||
const isLayoutChange = layoutKey !== lastWordLayoutKeyRef.current;
|
||||
lastWordLayoutKeyRef.current = layoutKey;
|
||||
|
|
@ -276,7 +296,8 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) {
|
|||
highlightWordIndex,
|
||||
layoutKey,
|
||||
clearWordHighlightTimeouts,
|
||||
scheduleWordTimeout
|
||||
scheduleWordTimeout,
|
||||
isPageRendering
|
||||
]);
|
||||
|
||||
// Add page dimensions state
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export async function getDocumentMetadata(id: string, options?: { signal?: Abort
|
|||
export async function getParsedPdfDocument(
|
||||
id: string,
|
||||
options?: { signal?: AbortSignal; retryFailed?: boolean },
|
||||
): Promise<{ status: 'ready'; parsed: ParsedPdfDocument } | { status: 'pending' | 'running' | 'failed' | 'unsupported' }> {
|
||||
): Promise<{ status: 'ready'; parsed: ParsedPdfDocument } | { status: 'pending' | 'running' | 'failed' }> {
|
||||
const query = options?.retryFailed ? '?retry=1' : '';
|
||||
const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed${query}`, {
|
||||
signal: options?.signal,
|
||||
|
|
@ -91,7 +91,7 @@ export async function getParsedPdfDocument(
|
|||
if (res.status === 202) {
|
||||
const data = (await res.json().catch(() => null)) as { parseStatus?: string } | null;
|
||||
const parseStatus = data?.parseStatus;
|
||||
if (parseStatus === 'pending' || parseStatus === 'running' || parseStatus === 'failed' || parseStatus === 'unsupported') {
|
||||
if (parseStatus === 'pending' || parseStatus === 'running' || parseStatus === 'failed') {
|
||||
return { status: parseStatus };
|
||||
}
|
||||
return { status: 'pending' };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
|
||||
import type { AudiobookSourceAdapter, PreparedAudiobookChapter } from '@/lib/client/audiobooks/pipeline';
|
||||
import { normalizeTextForTts } from '@/lib/shared/nlp';
|
||||
import type { ParsedPdfDocument, ParsedPdfBlock } from '@/types/parsed-pdf';
|
||||
|
|
@ -7,15 +5,8 @@ import type { DocumentSettings } from '@/types/document-settings';
|
|||
import { DEFAULT_DOCUMENT_SETTINGS } from '@/types/document-settings';
|
||||
|
||||
interface PdfAudiobookAdapterOptions {
|
||||
pdfDocument?: PDFDocumentProxy;
|
||||
parsed?: ParsedPdfDocument;
|
||||
settings?: DocumentSettings;
|
||||
margins: {
|
||||
header: number;
|
||||
footer: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
smartSentenceSplitting: boolean;
|
||||
maxBlockLength?: number;
|
||||
}
|
||||
|
|
@ -90,47 +81,21 @@ function prepareParsedChapters({
|
|||
}
|
||||
|
||||
async function extractPreparedPdfChapters({
|
||||
pdfDocument,
|
||||
parsed,
|
||||
settings = DEFAULT_DOCUMENT_SETTINGS,
|
||||
margins,
|
||||
smartSentenceSplitting,
|
||||
maxBlockLength,
|
||||
}: PdfAudiobookAdapterOptions): Promise<PreparedAudiobookChapter[]> {
|
||||
if (parsed) {
|
||||
const parsedChapters = prepareParsedChapters({
|
||||
parsed,
|
||||
settings,
|
||||
smartSentenceSplitting,
|
||||
maxBlockLength,
|
||||
});
|
||||
if (parsedChapters.length > 0) {
|
||||
return parsedChapters;
|
||||
}
|
||||
if (!parsed) {
|
||||
throw new Error('PDF parsing is not ready yet.');
|
||||
}
|
||||
|
||||
if (!pdfDocument) {
|
||||
throw new Error('No PDF document loaded');
|
||||
}
|
||||
|
||||
const { extractTextFromPDF } = await import('@/lib/client/pdf');
|
||||
|
||||
const chapters: PreparedAudiobookChapter[] = [];
|
||||
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
|
||||
const rawText = await extractTextFromPDF(pdfDocument, pageNum, margins);
|
||||
const trimmedText = rawText.trim();
|
||||
if (!trimmedText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
chapters.push({
|
||||
index: chapters.length,
|
||||
title: `Page ${chapters.length + 1}`,
|
||||
text: smartSentenceSplitting ? normalizeTextForTts(trimmedText, { maxBlockLength }) : trimmedText,
|
||||
});
|
||||
}
|
||||
|
||||
return chapters;
|
||||
return prepareParsedChapters({
|
||||
parsed,
|
||||
settings,
|
||||
smartSentenceSplitting,
|
||||
maxBlockLength,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPdfAudiobookSourceAdapter(options: PdfAudiobookAdapterOptions): AudiobookSourceAdapter {
|
||||
|
|
|
|||
|
|
@ -559,23 +559,20 @@ export function highlightPattern(
|
|||
const parsedDocument = options?.parsedDocument ?? null;
|
||||
const locator = options?.locator ?? null;
|
||||
|
||||
if (
|
||||
parsedDocument
|
||||
&& locator
|
||||
&& options?.useBlockGeometryOnly
|
||||
&& highlightParsedBlockGeometry(containerRef, parsedDocument, locator)
|
||||
) {
|
||||
// Canonical path: parsed block locator is required for PDF sentence
|
||||
// highlighting. Avoid broad full-page text matching fallbacks.
|
||||
if (!parsedDocument || !locator || locator.readerType !== 'pdf' || !locator.blockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spanNodes = (
|
||||
parsedDocument && locator
|
||||
? (collectSpanNodesForParsedBlock(container, parsedDocument, locator)
|
||||
?? Array.from(container.querySelectorAll('.react-pdf__Page__textContent span')) as HTMLElement[])
|
||||
: Array.from(container.querySelectorAll('.react-pdf__Page__textContent span')) as HTMLElement[]
|
||||
);
|
||||
const spanNodes = collectSpanNodesForParsedBlock(container, parsedDocument, locator) ?? [];
|
||||
|
||||
if (!spanNodes.length) return;
|
||||
if (!spanNodes.length) {
|
||||
if (options?.useBlockGeometryOnly) {
|
||||
highlightParsedBlockGeometry(containerRef, parsedDocument, locator);
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastSpanNodes = spanNodes;
|
||||
|
||||
const tokens: PDFToken[] = [];
|
||||
|
|
@ -604,6 +601,15 @@ export function highlightPattern(
|
|||
if (!tokens.length) return;
|
||||
lastTokens = tokens;
|
||||
|
||||
if (options?.useBlockGeometryOnly) {
|
||||
lastSentenceTokenWindow = {
|
||||
start: 0,
|
||||
end: tokens.length - 1,
|
||||
};
|
||||
highlightParsedBlockGeometry(containerRef, parsedDocument, locator);
|
||||
return;
|
||||
}
|
||||
|
||||
const patternLen = cleanPattern.length;
|
||||
|
||||
// Core application of highlight logic once we know the best token window (if any)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types';
|
||||
import { NoneComputeBackend } from '@/lib/server/compute/none';
|
||||
import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode';
|
||||
import { WorkerComputeBackend } from '@/lib/server/compute/worker';
|
||||
|
||||
|
|
@ -7,10 +6,9 @@ let backend: ComputeBackend | null = null;
|
|||
|
||||
function createBackend(): ComputeBackend {
|
||||
const mode: ComputeMode = readComputeMode();
|
||||
if (mode === 'none') return new NoneComputeBackend();
|
||||
if (mode === 'worker') return new WorkerComputeBackend();
|
||||
// Intentionally lazy-load local compute so COMPUTE_MODE=none builds
|
||||
// can avoid tracing heavy ONNX dependencies.
|
||||
// Intentionally lazy-load local compute to avoid tracing heavy ONNX
|
||||
// dependencies unless the backend is actually local.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { LocalComputeBackend } = require('@/lib/server/compute/local') as typeof import('@/lib/server/compute/local');
|
||||
return new LocalComputeBackend();
|
||||
|
|
@ -22,5 +20,5 @@ export function getCompute(): ComputeBackend {
|
|||
}
|
||||
|
||||
export function isComputeAvailable(): boolean {
|
||||
return isComputeModeAvailable(readComputeMode());
|
||||
return isComputeModeAvailable();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import type { ComputeMode } from '@/lib/server/compute/types';
|
||||
|
||||
export function readComputeMode(): ComputeMode {
|
||||
const raw = (process.env.COMPUTE_MODE || 'local').trim().toLowerCase();
|
||||
if (raw === 'local' || raw === 'none' || raw === 'worker') return raw;
|
||||
return 'local';
|
||||
const envValue = process.env.COMPUTE_MODE;
|
||||
const raw = (envValue || '').trim().toLowerCase();
|
||||
if (!raw) return 'local';
|
||||
if (raw === 'local' || raw === 'worker') return raw;
|
||||
throw new Error(
|
||||
`Invalid COMPUTE_MODE="${envValue}". Expected "local" or "worker".`,
|
||||
);
|
||||
}
|
||||
|
||||
export function isComputeModeAvailable(mode: ComputeMode): boolean {
|
||||
return mode !== 'none';
|
||||
export function isComputeModeAvailable(): boolean {
|
||||
// Throws on invalid values so startup/runtime config fails fast.
|
||||
readComputeMode();
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types';
|
||||
import { UnsupportedComputeError } from '@/lib/server/compute/types';
|
||||
import type { ParsedPdfDocument } from '@/types/parsed-pdf';
|
||||
|
||||
export class NoneComputeBackend implements ComputeBackend {
|
||||
readonly mode = 'none' as const;
|
||||
|
||||
async alignWords(input: WhisperAlignInput): Promise<WhisperAlignResult> {
|
||||
void input;
|
||||
throw new UnsupportedComputeError('Word alignment is unavailable: COMPUTE_MODE=none');
|
||||
}
|
||||
|
||||
async parsePdfLayout(input: PdfLayoutInput): Promise<ParsedPdfDocument> {
|
||||
void input;
|
||||
throw new UnsupportedComputeError('PDF layout parsing is unavailable: COMPUTE_MODE=none');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { TTSAudioBuffer, TTSSentenceAlignment, ParsedPdfDocument } from '@openreader/compute-core';
|
||||
|
||||
export type ComputeMode = 'local' | 'worker' | 'none';
|
||||
export type ComputeMode = 'local' | 'worker';
|
||||
|
||||
export interface WhisperAlignInput {
|
||||
audioBuffer?: TTSAudioBuffer;
|
||||
|
|
@ -26,10 +26,3 @@ export interface ComputeBackend {
|
|||
alignWords(input: WhisperAlignInput): Promise<WhisperAlignResult>;
|
||||
parsePdfLayout(input: PdfLayoutInput): Promise<ParsedPdfDocument>;
|
||||
}
|
||||
|
||||
export class UnsupportedComputeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'UnsupportedComputeError';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { documents } from '@/db/schema';
|
||||
import { UnsupportedComputeError } from '@/lib/server/compute/types';
|
||||
import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore';
|
||||
import { getCompute } from '@/lib/server/compute';
|
||||
import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache';
|
||||
|
|
@ -58,7 +57,7 @@ export async function parsePdfJob(input: ParsePdfJobInput): Promise<void> {
|
|||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
const cause = error instanceof Error ? error.cause : undefined;
|
||||
const parseStatus = error instanceof UnsupportedComputeError ? 'unsupported' : 'failed';
|
||||
const parseStatus = 'failed';
|
||||
try {
|
||||
await db
|
||||
.update(documents)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
type RuntimeConfigKey,
|
||||
type RuntimeConfigSource,
|
||||
} from '@/lib/server/admin/settings';
|
||||
import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode';
|
||||
import { isComputeModeAvailable } from '@/lib/server/compute/mode';
|
||||
|
||||
export type ResolvedRuntimeConfig = RuntimeConfig & {
|
||||
computeAvailable: boolean;
|
||||
|
|
@ -29,7 +29,7 @@ export async function getResolvedRuntimeConfig(): Promise<ResolvedRuntimeConfig>
|
|||
const values = await getRuntimeConfig();
|
||||
return {
|
||||
...values,
|
||||
computeAvailable: isComputeModeAvailable(readComputeMode()),
|
||||
computeAvailable: isComputeModeAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,19 +16,6 @@ function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] {
|
|||
return Array.from(new Set(out));
|
||||
}
|
||||
|
||||
type PdfMargins = { header: number; footer: number; left: number; right: number };
|
||||
|
||||
function normalizeMargins(value: unknown): PdfMargins | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const rec = value as Record<string, unknown>;
|
||||
const header = Number(rec.header);
|
||||
const footer = Number(rec.footer);
|
||||
const left = Number(rec.left);
|
||||
const right = Number(rec.right);
|
||||
if (![header, footer, left, right].every((n) => Number.isFinite(n))) return undefined;
|
||||
return { header, footer, left, right };
|
||||
}
|
||||
|
||||
export function mergeDocumentSettings(
|
||||
defaults: DocumentSettings = DEFAULT_DOCUMENT_SETTINGS,
|
||||
stored: unknown,
|
||||
|
|
@ -38,7 +25,6 @@ export function mergeDocumentSettings(
|
|||
pdf: {
|
||||
skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])],
|
||||
chaptersFromSections: defaults.pdf?.chaptersFromSections ?? true,
|
||||
...(defaults.pdf?.margins ? { margins: defaults.pdf.margins } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -56,7 +42,6 @@ export function mergeDocumentSettings(
|
|||
typeof pdfRec.chaptersFromSections === 'boolean'
|
||||
? pdfRec.chaptersFromSections
|
||||
: (base.pdf?.chaptersFromSections ?? true),
|
||||
...(normalizeMargins(pdfRec.margins) ? { margins: normalizeMargins(pdfRec.margins) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ export interface DocumentSettings {
|
|||
schemaVersion: 1;
|
||||
pdf?: {
|
||||
skipBlockKinds: ParsedPdfBlockKind[];
|
||||
margins?: { header: number; footer: number; left: number; right: number };
|
||||
chaptersFromSections: boolean;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export interface BaseDocument {
|
|||
size: number;
|
||||
lastModified: number;
|
||||
type: DocumentType;
|
||||
parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null;
|
||||
parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null;
|
||||
parsedJsonKey?: string | null;
|
||||
scope?: 'user' | 'unclaimed';
|
||||
folderId?: string;
|
||||
|
|
|
|||
|
|
@ -76,4 +76,4 @@ export interface ParsedPdfDocument {
|
|||
pages: ParsedPdfPage[];
|
||||
}
|
||||
|
||||
export type PdfParseStatus = 'pending' | 'running' | 'ready' | 'failed' | 'unsupported';
|
||||
export type PdfParseStatus = 'pending' | 'running' | 'ready' | 'failed';
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ test.describe('pdf audiobook adapter', () => {
|
|||
const adapter = createPdfAudiobookSourceAdapter({
|
||||
parsed,
|
||||
settings,
|
||||
margins: { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 },
|
||||
smartSentenceSplitting: false,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue