openreader/src/lib/documentPreview.ts
Richard R 4a5f3060f2 refactor(documents): migrate from IndexedDB to server-backed storage with caching
- Replace client-side IndexedDB with server-first document storage
- Add document caching layer for offline capability and performance
- Implement document transfer during anonymous-to-authenticated account linking
- Add database indexes for improved query performance
- Update document contexts to use new caching system
- Refactor document upload and deletion APIs
- Add audiobook pruning for missing files
- Improve test isolation with namespace support
- Add unit tests for document cache and transfer functions
2026-02-08 11:15:57 -07:00

150 lines
4.4 KiB
TypeScript

import { pdfjs } from 'react-pdf';
import ePub from 'epubjs';
export { extractRawTextSnippet, extractTextSnippet } from '@/lib/text-snippets';
function shouldUseLegacyPdfWorker(): boolean {
if (typeof window === 'undefined') return false;
const ua = window.navigator.userAgent;
const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
if (!isSafari) return false;
const match = ua.match(/Version\/(\d+)/i);
if (!match?.[1]) return true;
const version = Number.parseInt(match[1], 10);
return Number.isFinite(version) ? version < 18 : true;
}
export function ensurePdfWorker(): void {
if (typeof window === 'undefined') return;
if (pdfjs.GlobalWorkerOptions.workerSrc) return;
const useLegacy = shouldUseLegacyPdfWorker();
const workerSrc = useLegacy
? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href
: new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href;
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
pdfjs.GlobalWorkerOptions.workerPort = null;
}
async function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read blob'));
reader.onload = () => resolve(String(reader.result));
reader.readAsDataURL(blob);
});
}
async function scaleImageBlobToDataUrl(blob: Blob, targetWidth: number): Promise<string> {
if (typeof window === 'undefined') {
return blobToDataUrl(blob);
}
if (typeof createImageBitmap !== 'function') {
return blobToDataUrl(blob);
}
const bitmap = await createImageBitmap(blob);
try {
const scale = targetWidth > 0 ? targetWidth / bitmap.width : 1;
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) {
return blobToDataUrl(blob);
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(bitmap, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
return dataUrl;
} finally {
bitmap.close();
}
}
export async function renderPdfFirstPageToDataUrl(
data: ArrayBuffer,
targetWidth: number,
): Promise<string> {
if (typeof window === 'undefined') {
throw new Error('PDF thumbnail rendering must run in the browser');
}
ensurePdfWorker();
const loadingTask = pdfjs.getDocument({ data });
const pdf = await loadingTask.promise;
try {
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1 });
const scale = targetWidth > 0 ? targetWidth / viewport.width : 1;
const scaledViewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.floor(scaledViewport.width));
canvas.height = Math.max(1, Math.floor(scaledViewport.height));
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) {
throw new Error('Failed to create canvas context');
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const renderTask = page.render({
canvasContext: ctx,
viewport: scaledViewport,
intent: 'display',
});
await renderTask.promise;
return canvas.toDataURL('image/jpeg', 0.82);
} finally {
await pdf.destroy().catch(() => undefined);
await loadingTask.destroy().catch(() => undefined);
}
}
export async function extractEpubCoverToDataUrl(
data: ArrayBuffer,
targetWidth: number,
): Promise<string | null> {
if (typeof window === 'undefined') {
return null;
}
const book = ePub(data);
const opened = book.opened.catch(() => undefined);
try {
const coverObjectUrl = await book.coverUrl();
if (!coverObjectUrl) return null;
const res = await fetch(coverObjectUrl);
const blob = await res.blob();
if (coverObjectUrl.startsWith('blob:')) {
URL.revokeObjectURL(coverObjectUrl);
}
return await scaleImageBlobToDataUrl(blob, targetWidth);
} catch {
return null;
} finally {
void opened.finally(() => {
try {
book.destroy();
} catch {
// ignore
}
});
}
}
// Note: text snippet helpers live in `src/lib/text-snippets.ts` to stay server-safe.