openreader/src/lib/pdf.ts
Richard R 50c3829f98 update(auth): enhance rate limiting with device/IP backstops and improve UI
- Add device ID and IP-based rate limiting to prevent abuse
- Refactor UI components for better header menus and settings
- Update privacy popup with detailed data usage info
- Improve PDF handling and caching to prevent react-pdf warnings
- Update README with new Docker instructions and environment variables
2026-01-25 14:38:31 -07:00

771 lines
23 KiB
TypeScript

import { pdfjs } from 'react-pdf';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist';
import "core-js/proposals/promise-with-resolvers";
import type { TTSSentenceAlignment } from '@/types/tts';
import { CmpStr } from 'cmpstr';
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
// Worker coordination for offloading highlight token matching
interface HighlightTokenMatchRequest {
id: string;
type: 'tokenMatch';
pattern: string;
tokenTexts: string[];
}
interface HighlightTokenMatchResponse {
id: string;
type: 'tokenMatchResult';
bestStart: number;
bestEnd: number;
rating: number;
lengthDiff: number;
}
let highlightWorker: Worker | null = null;
function getHighlightWorker(): Worker | null {
if (typeof window === 'undefined') return null;
if (highlightWorker) return highlightWorker;
try {
highlightWorker = new Worker(
new URL('pdfHighlightWorker.ts', import.meta.url),
{ type: 'module' }
);
return highlightWorker;
} catch (e) {
console.error('Failed to initialize PDF highlight worker:', e);
highlightWorker = null;
return null;
}
}
function runHighlightTokenMatch(
pattern: string,
tokenTexts: string[]
): Promise<HighlightTokenMatchResponse | null> {
const worker = getHighlightWorker();
if (!worker) {
return Promise.resolve(null);
}
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
return new Promise((resolve) => {
const handleMessage = (event: MessageEvent) => {
const data = event.data as HighlightTokenMatchResponse;
if (!data || data.id !== id || data.type !== 'tokenMatchResult') {
return;
}
worker.removeEventListener('message', handleMessage as EventListener);
resolve(data);
};
worker.addEventListener('message', handleMessage as EventListener);
const message: HighlightTokenMatchRequest = {
id,
type: 'tokenMatch',
pattern,
tokenTexts,
};
worker.postMessage(message);
});
}
// Function to detect if we need to use legacy build
function shouldUseLegacyBuild() {
try {
if (typeof window === 'undefined') return false;
const ua = window.navigator.userAgent;
const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
console.log(isSafari ? 'Running on Safari' : 'Not running on Safari');
if (!isSafari) return false;
// Extract Safari version - matches "Version/18" format
const match = ua.match(/Version\/(\d+)/i);
console.log('Safari version:', match);
if (!match || !match[1]) return true; // If we can't determine version, use legacy to be safe
const version = parseInt(match[1]);
return version < 18; // Use legacy build for Safari versions equal or below 18
} catch (e) {
console.error('Error detecting Safari version:', e);
return false;
}
}
// Function to initialize PDF worker
function initPDFWorker() {
try {
if (typeof window !== 'undefined') {
const useLegacy = shouldUseLegacyBuild();
// Use local worker file instead of unpkg
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;
console.log('Setting PDF worker to:', workerSrc);
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
pdfjs.GlobalWorkerOptions.workerPort = null;
}
} catch (e) {
console.error('Error setting PDF worker:', e);
}
}
// Initialize the worker
initPDFWorker();
// Patch TextLayer.render to treat cancelled renders as non-errors
try {
const textLayerProto = TextLayer?.prototype;
const originalRender = textLayerProto?.render;
if (typeof originalRender === 'function') {
textLayerProto.render = async function patchedRender(...args) {
const task = originalRender.apply(this, args);
if (!task || typeof task.then !== 'function') return task;
return task.catch((error) => {
if (error && (error.name === 'AbortException' || error.name === 'RenderingCancelledException')) {
return;
}
throw error;
});
};
}
} catch (e) {
console.error('Error patching TextLayer.render:', e);
}
type PDFToken = {
spanIndex: number;
textNode: Text;
text: string;
startOffset: number;
endOffset: number;
};
let lastSpanNodes: HTMLElement[] = [];
let lastTokens: PDFToken[] = [];
let lastSentenceTokenWindow: { start: number; end: number } | null = null;
let lastSentencePattern: string | null = null;
let lastSentenceWordToTokenMap: number[] | null = null;
function getOrCreateHighlightLayer(span: HTMLElement): {
layer: HTMLElement;
pageElement: HTMLElement;
pageRect: DOMRect;
} | null {
const pageElement = span.closest('.react-pdf__Page') as HTMLElement | null;
if (!pageElement) return null;
let layer = pageElement.querySelector('.pdf-highlight-layer') as HTMLElement | null;
if (!layer) {
layer = document.createElement('div');
layer.className = 'pdf-highlight-layer';
pageElement.appendChild(layer);
}
layer.style.position = 'absolute';
layer.style.inset = '0';
layer.style.pointerEvents = 'none';
layer.style.zIndex = '4';
layer.style.overflow = 'hidden';
// Force a compositor layer to avoid Safari occasionally not painting
// newly-added positioned overlays.
layer.style.transform = 'translateZ(0)';
return { layer, pageElement, pageRect: pageElement.getBoundingClientRect() };
}
const normalizeWordForMatch = (text: string): string =>
text
.trim()
.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '')
.toLowerCase();
// Text Processing functions
export async function extractTextFromPDF(
pdf: PDFDocumentProxy,
pageNumber: number,
margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }
): Promise<string> {
try {
// Log pdf worker version
//console.log('PDF worker version:', pdfjs.GlobalWorkerOptions.workerSrc);
const page = await pdf.getPage(pageNumber);
const textContent = await page.getTextContent();
const viewport = page.getViewport({ scale: 1.0 });
const pageHeight = viewport.height;
const pageWidth = viewport.width;
const textItems = textContent.items.filter((item): item is TextItem => {
if (!('str' in item && 'transform' in item)) return false;
const [scaleX, skewX, skewY, scaleY, x, y] = item.transform;
// Basic text filtering
if (Math.abs(scaleX) < 1 || Math.abs(scaleX) > 20) return false;
if (Math.abs(scaleY) < 1 || Math.abs(scaleY) > 20) return false;
if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false;
// Calculate margins in PDF coordinate space (y=0 is at bottom)
const headerY = pageHeight * (1 - margins.header); // Convert from top margin to bottom-based Y
const footerY = pageHeight * margins.footer; // Footer Y stays as is since it's already bottom-based
const leftX = pageWidth * margins.left;
const rightX = pageWidth * (1 - margins.right);
// Check margins - remember y=0 is at bottom of page in PDF coordinates
if (y > headerY || y < footerY) { // Y greater than headerY means it's in header area, less than footerY means footer area
return false;
}
// Check horizontal margins
if (x < leftX || x > rightX) {
return false;
}
// Sanity check for coordinates
if (x < 0 || x > pageWidth) return false;
return item.str.trim().length > 0;
});
//console.log('Filtered text items:', textItems);
const tolerance = 2;
const lines: TextItem[][] = [];
let currentLine: TextItem[] = [];
let currentY: number | null = null;
textItems.forEach((item) => {
const y = item.transform[5];
if (currentY === null) {
currentY = y;
currentLine.push(item);
} else if (Math.abs(y - currentY) < tolerance) {
currentLine.push(item);
} else {
lines.push(currentLine);
currentLine = [item];
currentY = y;
}
});
lines.push(currentLine);
let pageText = '';
for (const line of lines) {
line.sort((a, b) => a.transform[4] - b.transform[4]);
let lineText = '';
let prevItem: TextItem | null = null;
for (const item of line) {
if (!prevItem) {
lineText = item.str;
} else {
const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0);
const currentStartX = item.transform[4];
const space = currentStartX - prevEndX;
// Get average character width as fallback
const avgCharWidth = (item.width ?? 0) / Math.max(1, item.str.length);
// Multiple conditions for space detection
const needsSpace =
// Primary check: significant gap between items
space > Math.max(avgCharWidth * 0.3, 2) ||
// Secondary check: natural word boundary
(!/^\W/.test(item.str) && !/\W$/.test(prevItem.str)) ||
// Tertiary check: items are far enough apart relative to their size
(space > ((prevItem.width ?? 0) * 0.25));
if (needsSpace) {
lineText += ' ' + item.str;
} else {
lineText += item.str;
}
}
prevItem = item;
}
pageText += lineText + ' ';
}
return pageText.replace(/\s+/g, ' ').trim();
} catch (error) {
// During Next.js fast refresh / route transitions, react-pdf can tear down the
// underlying worker and pdf.js may throw a TypeError like:
// "null is not an object (evaluating 'this.messageHandler.sendWithPromise')".
// Treat this as a cancellation so the app can ignore it.
if (
error instanceof TypeError &&
typeof error.message === 'string' &&
error.message.includes('messageHandler') &&
error.message.includes('sendWithPromise')
) {
throw new DOMException('PDF worker torn down', 'AbortError');
}
console.error('Error extracting text from PDF:', error);
// Preserve the original error so callers can decide whether to retry/ignore.
throw error;
}
}
// Highlighting functions
let highlightPatternSeq = 0;
export function clearHighlights() {
const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span');
textNodes.forEach((node) => {
const element = node as HTMLElement;
element.style.backgroundColor = '';
element.style.opacity = '1';
});
const overlays = document.querySelectorAll('.pdf-text-highlight-overlay');
overlays.forEach((node) => {
const element = node as HTMLElement;
if (element.parentElement) {
element.parentElement.removeChild(element);
}
});
const wordOverlays = document.querySelectorAll('.pdf-word-highlight-overlay');
wordOverlays.forEach((node) => {
const element = node as HTMLElement;
if (element.parentElement) {
element.parentElement.removeChild(element);
}
});
}
export function clearWordHighlights() {
const wordOverlays = document.querySelectorAll('.pdf-word-highlight-overlay');
wordOverlays.forEach((node) => {
const element = node as HTMLElement;
if (element.parentElement) {
element.parentElement.removeChild(element);
}
});
}
export function highlightPattern(
text: string,
pattern: string,
containerRef: React.RefObject<HTMLDivElement>
) {
const seq = ++highlightPatternSeq;
clearHighlights();
if (!pattern?.trim()) return;
const container = containerRef.current;
if (!container) return;
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
if (!cleanPattern) return;
lastSentencePattern = cleanPattern;
lastSentenceWordToTokenMap = null;
lastSentenceTokenWindow = null;
const spanNodes = Array.from(
container.querySelectorAll('.react-pdf__Page__textContent span')
) as HTMLElement[];
if (!spanNodes.length) return;
lastSpanNodes = spanNodes;
const tokens: PDFToken[] = [];
spanNodes.forEach((span, spanIndex) => {
const node = span.firstChild;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
const textNode = node as Text;
const textContent = textNode.textContent || '';
const wordRegex = /\S+/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(textContent)) !== null) {
const word = match[0];
tokens.push({
spanIndex,
textNode,
text: word,
startOffset: match.index,
endOffset: match.index + word.length,
});
}
});
if (!tokens.length) return;
lastTokens = tokens;
const patternLen = cleanPattern.length;
// Core application of highlight logic once we know the best token window (if any)
const applyHighlightFromTokens = (
tokenMatch:
| {
bestStart: number;
bestEnd: number;
rating: number;
lengthDiff: number;
}
| null
) => {
const highlightRanges: Array<{
textNode: Text;
startOffset: number;
endOffset: number;
span: HTMLElement;
}> = [];
let bestStart = -1;
let bestEnd = -1;
let bestRating = 0;
let bestLengthDiff = Infinity;
if (tokenMatch) {
bestStart = tokenMatch.bestStart;
bestEnd = tokenMatch.bestEnd;
bestRating = tokenMatch.rating;
bestLengthDiff = tokenMatch.lengthDiff;
}
const hasTokenMatch = bestStart !== -1;
const similarityThreshold =
bestLengthDiff < patternLen * 0.3 ? 0.3 : 0.5;
if (hasTokenMatch && bestRating >= similarityThreshold) {
lastSentenceTokenWindow = {
start: bestStart,
end: bestEnd,
};
const rangesBySpan = new Map<
number,
{ startOffset: number; endOffset: number }
>();
for (let i = bestStart; i <= bestEnd; i++) {
const token = tokens[i];
const existing = rangesBySpan.get(token.spanIndex);
if (!existing) {
rangesBySpan.set(token.spanIndex, {
startOffset: token.startOffset,
endOffset: token.endOffset,
});
} else {
existing.startOffset = Math.min(
existing.startOffset,
token.startOffset
);
existing.endOffset = Math.max(
existing.endOffset,
token.endOffset
);
}
}
rangesBySpan.forEach(({ startOffset, endOffset }, spanIndex) => {
const span = spanNodes[spanIndex];
const node = span.firstChild;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
highlightRanges.push({
textNode: node as Text,
startOffset,
endOffset,
span,
});
});
}
if (!highlightRanges.length) return;
// Create overlay rectangles for each range, relative to its page text layer
const scrollIntoViewRects: DOMRect[] = [];
highlightRanges.forEach(({ textNode, startOffset, endOffset, span }) => {
try {
const range = document.createRange();
range.setStart(textNode, startOffset);
range.setEnd(textNode, endOffset);
const highlightTarget = getOrCreateHighlightLayer(span);
if (!highlightTarget) return;
const { layer: highlightLayer, pageRect } = highlightTarget;
const rects = Array.from(range.getClientRects());
rects.forEach((rect) => {
const highlight = document.createElement('div');
highlight.className = 'pdf-text-highlight-overlay';
highlight.style.position = 'absolute';
highlight.style.backgroundColor = 'grey';
highlight.style.opacity = '0.4';
highlight.style.pointerEvents = 'none';
highlight.style.zIndex = '1';
highlight.style.left = `${rect.left - pageRect.left}px`;
highlight.style.top = `${rect.top - pageRect.top}px`;
highlight.style.width = `${rect.width}px`;
highlight.style.height = `${rect.height}px`;
highlightLayer.appendChild(highlight);
scrollIntoViewRects.push(rect);
});
} catch {
// If range creation fails for any reason, skip this segment
}
});
if (!scrollIntoViewRects.length) return;
// Scroll the first highlighted rect into view if needed
const containerRect = container.getBoundingClientRect();
const visibleTop = container.scrollTop;
const visibleBottom = visibleTop + containerRect.height;
const firstRect = scrollIntoViewRects[0];
const elementTop =
firstRect.top - containerRect.top + container.scrollTop;
if (elementTop < visibleTop || elementTop > visibleBottom) {
container.scrollTo({
top: elementTop - containerRect.height / 3,
behavior: 'smooth',
});
}
};
const tokenTexts = tokens.map((t) => t.text);
// Fire-and-forget async worker call; UI thread returns immediately
runHighlightTokenMatch(cleanPattern, tokenTexts)
.then((result) => {
if (seq !== highlightPatternSeq) return;
if (!result || result.bestStart === -1) {
// No worker result or no good match; nothing to highlight
applyHighlightFromTokens(null);
} else {
applyHighlightFromTokens({
bestStart: result.bestStart,
bestEnd: result.bestEnd,
rating: result.rating,
lengthDiff: result.lengthDiff,
});
}
})
.catch((error) => {
if (seq !== highlightPatternSeq) return;
console.error(
'Error in PDF highlight worker; no highlights applied:',
error
);
applyHighlightFromTokens(null);
});
}
export function highlightWordIndex(
alignment: TTSSentenceAlignment | undefined,
wordIndex: number | null | undefined,
sentence: string | null | undefined,
containerRef: React.RefObject<HTMLDivElement>
) {
clearWordHighlights();
if (!alignment) return;
if (wordIndex === null || wordIndex === undefined || wordIndex < 0) {
return;
}
const words = alignment.words || [];
if (!words.length || wordIndex >= words.length) return;
const container = containerRef.current;
if (!container) return;
if (!lastSentenceTokenWindow) return;
if (!lastTokens.length || !lastSpanNodes.length) return;
const cleanSentence =
sentence && sentence.trim()
? sentence.trim().replace(/\s+/g, ' ')
: null;
if (!cleanSentence || !lastSentencePattern) return;
if (cleanSentence !== lastSentencePattern) return;
const start = lastSentenceTokenWindow.start;
const end = lastSentenceTokenWindow.end;
if (end < start) return;
// Lazily build or refresh the mapping from alignment word
// indices to PDF token indices for this sentence window.
if (
!lastSentenceWordToTokenMap ||
lastSentenceWordToTokenMap.length !== words.length
) {
const pdfFiltered: { tokenIndex: number; norm: string }[] = [];
for (let i = start; i <= end; i++) {
const norm = normalizeWordForMatch(lastTokens[i].text);
if (!norm) continue;
pdfFiltered.push({ tokenIndex: i, norm });
}
const ttsFiltered: { wordIndex: number; norm: string }[] = [];
for (let i = 0; i < words.length; i++) {
const norm = normalizeWordForMatch(words[i].text);
if (!norm) continue;
ttsFiltered.push({ wordIndex: i, norm });
}
const wordToToken = new Array<number>(words.length).fill(-1);
const m = pdfFiltered.length;
const n = ttsFiltered.length;
if (m && n) {
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(Number.POSITIVE_INFINITY)
);
const bt: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0)
); // 0=diag,1=up,2=left
dp[0][0] = 0;
const GAP_COST = 0.7;
for (let i = 0; i <= m; i++) {
for (let j = 0; j <= n; j++) {
if (i > 0 && j > 0) {
const a = pdfFiltered[i - 1].norm;
const b = ttsFiltered[j - 1].norm;
const sim = cmp.compare(a, b);
const subCost = 1 - sim;
const cand = dp[i - 1][j - 1] + subCost;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 0;
}
}
if (i > 0) {
const cand = dp[i - 1][j] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 1;
}
}
if (j > 0) {
const cand = dp[i][j - 1] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 2;
}
}
}
}
let i = m;
let j = n;
while (i > 0 || j > 0) {
const move = bt[i][j];
if (i > 0 && j > 0 && move === 0) {
const pdfIdx = pdfFiltered[i - 1].tokenIndex;
const ttsIdx = ttsFiltered[j - 1].wordIndex;
if (wordToToken[ttsIdx] === -1) {
wordToToken[ttsIdx] = pdfIdx;
}
i -= 1;
j -= 1;
} else if (i > 0 && (move === 1 || j === 0)) {
i -= 1;
} else if (j > 0 && (move === 2 || i === 0)) {
j -= 1;
} else {
break;
}
}
// Propagate nearest known mapping to fill gaps
let lastSeen = -1;
for (let k = 0; k < wordToToken.length; k++) {
if (wordToToken[k] !== -1) {
lastSeen = wordToToken[k];
} else if (lastSeen !== -1) {
wordToToken[k] = lastSeen;
}
}
let nextSeen = -1;
for (let k = wordToToken.length - 1; k >= 0; k--) {
if (wordToToken[k] !== -1) {
nextSeen = wordToToken[k];
} else if (nextSeen !== -1) {
wordToToken[k] = nextSeen;
}
}
}
lastSentenceWordToTokenMap = wordToToken;
}
const mappedIndex =
lastSentenceWordToTokenMap && wordIndex < lastSentenceWordToTokenMap.length
? lastSentenceWordToTokenMap[wordIndex]
: -1;
if (mappedIndex === -1) return;
const chosenTokenIndex = mappedIndex;
const token = lastTokens[chosenTokenIndex];
const span = lastSpanNodes[token.spanIndex];
if (!span) return;
const node = token.textNode;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
try {
const range = document.createRange();
range.setStart(node, token.startOffset);
range.setEnd(node, token.endOffset);
const highlightTarget = getOrCreateHighlightLayer(span);
if (!highlightTarget) return;
const { layer: highlightLayer, pageRect } = highlightTarget;
const rects = Array.from(range.getClientRects());
rects.forEach((rect) => {
const highlight = document.createElement('div');
highlight.className = 'pdf-word-highlight-overlay';
highlight.style.position = 'absolute';
highlight.style.backgroundColor = 'var(--accent)';
highlight.style.opacity = '0.4';
highlight.style.pointerEvents = 'none';
highlight.style.left = `${rect.left - pageRect.left}px`;
highlight.style.top = `${rect.top - pageRect.top}px`;
highlight.style.width = `${rect.width}px`;
highlight.style.height = `${rect.height}px`;
highlight.style.zIndex = '2';
highlightLayer.appendChild(highlight);
});
} catch {
// Ignore range errors
}
}
// Debounce for PDF viewer
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}