Feature/v2/compare tool (#4751)

# Description of Changes

- Addition of the compare tool
- 
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
EthanHealy01
2025-11-12 14:54:01 +00:00
committed by GitHub
co-authored by James Brunton
parent f22f697edc
commit a5e2b54274
49 changed files with 6651 additions and 81 deletions
@@ -0,0 +1,566 @@
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import { appendWord as sharedAppendWord } from '@app/utils/textDiff';
import { PARAGRAPH_SENTINEL } from '@app/types/compare';
import type { StirlingFile } from '@app/types/fileContext';
import type { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
import type {
CompareChange,
CompareDiffToken,
CompareResultData,
TokenBoundingBox,
CompareParagraph,
} from '@app/types/compare';
export interface TokenMetadata {
page: number;
paragraph: number;
bbox: TokenBoundingBox | null;
}
export interface ExtractedContent {
tokens: string[];
metadata: TokenMetadata[];
pageSizes: { width: number; height: number }[];
paragraphs: CompareParagraph[];
}
const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null;
const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null;
const textMeasurementCache: Map<string, number> | null = measurementContext ? new Map() : null;
let lastMeasurementFont = '';
const DEFAULT_CHAR_WIDTH = 1;
const DEFAULT_SPACE_WIDTH = 0.33;
export const measureTextWidth = (fontSpec: string, text: string): number => {
if (!measurementContext) {
if (!text) return 0;
if (text === ' ') return DEFAULT_SPACE_WIDTH;
return text.length * DEFAULT_CHAR_WIDTH;
}
if (lastMeasurementFont !== fontSpec) {
measurementContext.font = fontSpec;
lastMeasurementFont = fontSpec;
}
const key = `${fontSpec}|${text}`;
const cached = textMeasurementCache?.get(key);
if (cached !== undefined) {
return cached;
}
const width = measurementContext.measureText(text).width || 0;
textMeasurementCache?.set(key, width);
return width;
};
export const appendWord = (existing: string, word: string) => {
if (!existing) {
return sharedAppendWord('', word);
}
return sharedAppendWord(existing, word);
};
export const aggregateTotals = (tokens: CompareDiffToken[]) => {
return tokens.reduce(
(totals, token) => {
if (token.text === '\uE000PARA') { // PARAGRAPH_SENTINEL safeguard if serialized
return totals;
}
switch (token.type) {
case 'added':
totals.added += 1;
break;
case 'removed':
totals.removed += 1;
break;
default:
totals.unchanged += 1;
}
return totals;
},
{ added: 0, removed: 0, unchanged: 0 }
);
};
export const buildChanges = (
tokens: CompareDiffToken[],
baseMetadata: TokenMetadata[],
comparisonMetadata: TokenMetadata[]
): CompareChange[] => {
const changes: CompareChange[] = [];
let baseIndex = 0;
let comparisonIndex = 0;
let current: CompareChange | null = null;
let currentBaseParagraph: number | null = null;
let currentComparisonParagraph: number | null = null;
const ensureCurrent = (): CompareChange => {
if (!current) {
current = {
id: `change-${changes.length}`,
base: null,
comparison: null,
};
}
return current;
};
const flush = () => {
if (current) {
if (current.base) {
current.base.text = current.base.text.trim();
}
if (current.comparison) {
current.comparison.text = current.comparison.text.trim();
}
if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) {
changes.push(current);
}
}
current = null;
currentBaseParagraph = null;
currentComparisonParagraph = null;
};
for (const token of tokens) {
if (token.type === 'removed') {
const meta = baseMetadata[baseIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.base) {
active.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentBaseParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentBaseParagraph !== null &&
paragraph !== currentBaseParagraph &&
active.base.text.trim().length > 0
) {
flush();
const next = ensureCurrent();
next.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.base.text = appendWord(active.base.text, token.text);
}
if (meta && active.base.page === null) {
active.base.page = meta.page;
}
if (meta && active.base.paragraph === null) {
active.base.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentBaseParagraph = paragraph;
}
}
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
continue;
}
if (token.type === 'added') {
const meta = comparisonMetadata[comparisonIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.comparison) {
active.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentComparisonParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentComparisonParagraph !== null &&
paragraph !== currentComparisonParagraph &&
active.comparison.text.trim().length > 0
) {
flush();
const next = ensureCurrent();
next.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.comparison.text = appendWord(active.comparison.text, token.text);
}
if (meta && active.comparison.page === null) {
active.comparison.page = meta.page;
}
if (meta && active.comparison.paragraph === null) {
active.comparison.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentComparisonParagraph = paragraph;
}
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
continue;
}
// unchanged token
flush();
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
}
flush();
return changes;
};
export const createSummaryFile = (result: CompareResultData): File => {
const exportPayload = {
generatedAt: new Date(result.totals.processedAt).toISOString(),
base: {
name: result.base.fileName,
totalWords: result.base.wordCount,
},
comparison: {
name: result.comparison.fileName,
totalWords: result.comparison.wordCount,
},
totals: {
added: result.totals.added,
removed: result.totals.removed,
unchanged: result.totals.unchanged,
durationMs: result.totals.durationMs,
},
changes: result.changes.map((change) => ({
base: change.base,
comparison: change.comparison,
})),
warnings: result.warnings,
};
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`;
return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' });
};
export const clamp = (value: number): number => Math.min(1, Math.max(0, value));
export const getWorkerErrorCode = (value: unknown): 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' | undefined => {
if (typeof value === 'object' && value !== null && 'code' in value) {
const potentialCode = (value as { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' }).code;
return potentialCode;
}
return undefined;
};
// Produce a filtered view of tokens/metadata that excludes paragraph sentinel markers,
// returning a mapping to original indices for potential future use.
export const filterTokensForDiff = (
tokens: string[],
metadata: TokenMetadata[],
): { tokens: string[]; metadata: TokenMetadata[]; filteredToOriginal: number[] } => {
const outTokens: string[] = [];
const outMeta: TokenMetadata[] = [];
const map: number[] = [];
for (let i = 0; i < tokens.length; i += 1) {
const t = tokens[i];
const isPara = t === PARAGRAPH_SENTINEL || t.startsWith('\uE000') || t.includes('PARA');
if (!isPara) {
outTokens.push(t);
if (metadata[i]) outMeta.push(metadata[i]);
map.push(i);
}
}
return { tokens: outTokens, metadata: outMeta, filteredToOriginal: map };
};
export const extractContentFromPdf = async (file: StirlingFile): Promise<ExtractedContent> => {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
try {
const tokens: string[] = [];
const metadata: TokenMetadata[] = [];
const pageSizes: { width: number; height: number }[] = [];
const paragraphs: CompareParagraph[] = [];
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
const page: PDFPageProxy = await pdfDoc.getPage(pageIndex);
const viewport = page.getViewport({ scale: 1 });
const content: TextContent = await page.getTextContent({
disableCombineTextItems: true,
} as Parameters<PDFPageProxy['getTextContent']>[0]);
const styles: Record<string, { fontFamily?: string; ascent?: number; descent?: number }> = content.styles ?? {};
let paragraphIndex = 1;
let paragraphBuffer = '';
let prevItem: TextItem | null = null;
pageSizes.push({ width: viewport.width, height: viewport.height });
const normalizeToken = (s: string) =>
s
.normalize('NFKC')
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '')
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/[–—]/g, '-')
.replace(/\u00A0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const isParagraphBreak = (curr: TextItem, prev: TextItem | null) => {
const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL;
if (hasHardBreak) return true;
if (!prev) return false;
const prevY = prev.transform[5];
const currY = curr.transform[5];
const dy = Math.abs(currY - prevY);
const currX = curr.transform[4];
const prevX = prev.transform[4];
const approxLine = Math.max(10, Math.abs((curr as any).height ?? 0) * 0.9);
const looksLikeParagraph = dy > approxLine * 1.8;
const likelySoftWrap = currX < prevX && dy < approxLine * 0.6;
return looksLikeParagraph && !likelySoftWrap;
};
const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => {
if (width <= 0 || height <= 0) {
return null;
}
const MIN_WIDTH = 0.004;
const MIN_HORIZONTAL_PAD = 0.0012;
const HORIZONTAL_PAD_RATIO = 0.12;
const MIN_VERTICAL_PAD = 0.0008;
const VERTICAL_PAD_RATIO = 0.18;
const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD);
const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD);
let expandedLeft = left - horizontalPad;
let expandedRight = left + width + horizontalPad;
let expandedTop = top - verticalPad;
let expandedBottom = top + height + verticalPad;
if (expandedRight - expandedLeft < MIN_WIDTH) {
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
expandedLeft -= deficit / 2;
expandedRight += deficit / 2;
}
expandedLeft = clamp(expandedLeft);
expandedRight = clamp(expandedRight);
expandedTop = clamp(expandedTop);
expandedBottom = clamp(expandedBottom);
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
return null;
}
return {
left: expandedLeft,
top: expandedTop,
width: expandedRight - expandedLeft,
height: expandedBottom - expandedTop,
};
};
for (const item of content.items as TextItem[]) {
if (!item?.str) {
prevItem = null;
continue;
}
const rawText = item.str;
const totalLen = Math.max(rawText.length, 1);
const textStyle = item.fontName ? styles[item.fontName] : undefined;
const fontFamily = textStyle?.fontFamily ?? 'sans-serif';
const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0);
const fontSpec = `${fontScale}px ${fontFamily}`;
const weights: number[] = new Array(totalLen);
let runningText = '';
let previousAdvance = 0;
for (let i = 0; i < totalLen; i += 1) {
runningText += rawText[i];
const advance = measureTextWidth(fontSpec, runningText);
let width = advance - previousAdvance;
if (!Number.isFinite(width) || width <= 0) {
width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
weights[i] = width;
previousAdvance = advance;
}
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
for (let i = 0; i < totalLen; i += 1) {
weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
}
const prefix: number[] = new Array(totalLen + 1);
prefix[0] = 0;
for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i];
const totalWeight = prefix[totalLen] || 1;
const rawX = item.transform[4];
const rawY = item.transform[5];
const transformed = [
viewport.convertToViewportPoint(rawX, rawY),
viewport.convertToViewportPoint(rawX + item.width, rawY),
viewport.convertToViewportPoint(rawX, rawY + item.height),
viewport.convertToViewportPoint(rawX + item.width, rawY + item.height),
];
const xs = transformed.map(([px]) => px);
const ys = transformed.map(([, py]) => py);
const left = Math.min(...xs);
const right = Math.max(...xs);
const top = Math.min(...ys);
const bottom = Math.max(...ys);
if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) {
prevItem = item;
continue;
}
const [baselineStart, baselineEnd, verticalEnd] = transformed;
const baselineVector: [number, number] = [
baselineEnd[0] - baselineStart[0],
baselineEnd[1] - baselineStart[1],
];
const verticalVector: [number, number] = [
verticalEnd[0] - baselineStart[0],
verticalEnd[1] - baselineStart[1],
];
const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]);
const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]);
const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6;
const font = item.fontName ? styles[item.fontName] : undefined;
const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9;
const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2;
const verticalScale = Math.min(1, Math.max(0.75, ascent + descent));
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(rawText)) !== null) {
const wordRaw = match[0];
const normalizedWord = normalizeToken(wordRaw);
if (!normalizedWord) {
continue;
}
const startIndex = match.index;
const endIndex = startIndex + wordRaw.length;
const relStart = prefix[startIndex] / totalWeight;
const relEnd = prefix[endIndex] / totalWeight;
let wordLeftAbs: number;
let wordRightAbs: number;
let wordTopAbs: number;
let wordBottomAbs: number;
if (hasOrientationVectors) {
const segStart: [number, number] = [
baselineStart[0] + baselineVector[0] * relStart,
baselineStart[1] + baselineVector[1] * relStart,
];
const segEnd: [number, number] = [
baselineStart[0] + baselineVector[0] * relEnd,
baselineStart[1] + baselineVector[1] * relEnd,
];
const cornerPoints: Array<[number, number]> = [
segStart,
[segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]],
[segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]],
segEnd,
];
const cornerXs = cornerPoints.map(([px]) => px);
const cornerYs = cornerPoints.map(([, py]) => py);
wordLeftAbs = Math.min(...cornerXs);
wordRightAbs = Math.max(...cornerXs);
wordTopAbs = Math.min(...cornerYs);
wordBottomAbs = Math.max(...cornerYs);
} else {
const segLeftAbs = left + (right - left) * relStart;
const segRightAbs = left + (right - left) * relEnd;
wordLeftAbs = Math.min(segLeftAbs, segRightAbs);
wordRightAbs = Math.max(segLeftAbs, segRightAbs);
wordTopAbs = top;
wordBottomAbs = bottom;
}
const wordLeft = clamp(wordLeftAbs / viewport.width);
const wordRight = clamp(wordRightAbs / viewport.width);
const wordTop = clamp(wordTopAbs / viewport.height);
const wordBottom = clamp(wordBottomAbs / viewport.height);
const wordWidth = Math.max(0, wordRight - wordLeft);
let wordHeight = Math.max(0, wordBottom - wordTop);
if (wordHeight > 0 && verticalScale < 1) {
const midY = (wordTop + wordBottom) / 2;
const shrunkHeight = Math.max(0, wordHeight * verticalScale);
const half = shrunkHeight / 2;
const newTop = clamp(midY - half);
const newBottom = clamp(midY + half);
wordHeight = Math.max(0, newBottom - newTop);
const bbox = adjustBoundingBox(wordLeft, newTop, wordWidth, wordHeight);
tokens.push(normalizedWord);
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox });
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
continue;
}
const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight);
tokens.push(normalizedWord);
metadata.push({
page: pageIndex,
paragraph: paragraphIndex,
bbox,
});
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
}
if (isParagraphBreak(item as TextItem, prevItem)) {
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
paragraphBuffer = '';
}
tokens.push('\uE000PARA');
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
paragraphIndex += 1;
}
prevItem = item as TextItem;
}
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
paragraphBuffer = '';
tokens.push('\uE000PARA');
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
}
}
return { tokens, metadata, pageSizes, paragraphs };
} finally {
pdfWorkerManager.destroyDocument(pdfDoc);
}
};
@@ -0,0 +1,559 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ADDITION_HIGHLIGHT,
CompareDiffToken,
CompareFilteredTokenInfo,
CompareResultData,
CompareWorkerRequest,
CompareWorkerResponse,
CompareWorkerWarnings,
REMOVAL_HIGHLIGHT,
} from '@app/types/compare';
import { CompareParameters } from '@app/hooks/tools/compare/useCompareParameters';
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
import type { StirlingFile } from '@app/types/fileContext';
import { useFileContext } from '@app/contexts/file/fileHooks';
import {
aggregateTotals,
buildChanges,
createSummaryFile,
extractContentFromPdf,
getWorkerErrorCode,
filterTokensForDiff,
} from '@app/hooks/tools/compare/operationUtils';
import { alert, dismissToast } from '@app/components/toast';
import type { ToastLocation } from '@app/components/toast/types';
import CompareWorkerCtor from '@app/workers/compareWorker?worker';
const LONG_RUNNING_PAGE_THRESHOLD = 2000;
export interface CompareOperationHook extends ToolOperationHook<CompareParameters> {
result: CompareResultData | null;
warnings: string[];
}
// extractContentFromPdf moved to utils
export const useCompareOperation = (): CompareOperationHook => {
const { t } = useTranslation();
const { selectors } = useFileContext();
const workerRef = useRef<Worker | null>(null);
const previousUrl = useRef<string | null>(null);
const activeRunIdRef = useRef(0);
const cancelledRef = useRef(false);
type OperationStatus = 'idle' | 'extracting' | 'processing' | 'complete' | 'cancelled' | 'error';
const [isLoading, setIsLoading] = useState(false);
const [statusState, setStatusState] = useState<OperationStatus>('idle');
const [statusDetailMs, setStatusDetailMs] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [result, setResult] = useState<CompareResultData | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const longRunningToastIdRef = useRef<string | null>(null);
const dissimilarityToastIdRef = useRef<string | null>(null);
const dissimilarityToastShownRef = useRef<boolean>(false);
const ensureWorker = useCallback(() => {
if (!workerRef.current) {
workerRef.current = new CompareWorkerCtor();
}
return workerRef.current;
}, []);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
setResult(null);
setWarnings([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
setStatusState('idle');
setStatusDetailMs(null);
setErrorMessage(null);
}, [cleanupDownloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const runCompareWorker = useCallback(
async (baseTokens: string[], comparisonTokens: string[], warningMessages: CompareWorkerWarnings, onChunk?: (chunk: CompareDiffToken[]) => void) => {
const worker = ensureWorker();
return await new Promise<{
tokens: CompareDiffToken[];
stats: { baseWordCount: number; comparisonWordCount: number; durationMs: number };
warnings: string[];
}>((resolve, reject) => {
const collectedWarnings: string[] = [];
const collectedTokens: CompareDiffToken[] = [];
const handleMessage = (event: MessageEvent<CompareWorkerResponse>) => {
if (cancelledRef.current) {
cleanup();
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
return;
}
const message = event.data;
if (!message) {
return;
}
switch (message.type) {
case 'chunk': {
if (message.tokens.length > 0) {
collectedTokens.push(...message.tokens);
onChunk?.(message.tokens);
}
break;
}
case 'success':
cleanup();
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
resolve({
tokens: collectedTokens,
stats: message.stats,
warnings: collectedWarnings,
});
break;
case 'warning':
collectedWarnings.push(message.message);
break;
case 'error': {
cleanup();
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
const error: Error & { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' } = new Error(message.message);
error.code = message.code;
reject(error);
break;
}
default:
break;
}
};
const handleError = (event: ErrorEvent) => {
cleanup();
if (cancelledRef.current) {
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
} else {
reject(event.error ?? new Error(event.message));
}
};
const cleanup = () => {
worker.removeEventListener('message', handleMessage as EventListener);
worker.removeEventListener('error', handleError as EventListener);
};
worker.addEventListener('message', handleMessage as EventListener);
worker.addEventListener('error', handleError as EventListener);
const request: CompareWorkerRequest = {
type: 'compare',
payload: {
baseTokens,
comparisonTokens,
warnings: warningMessages,
// Static worker settings to support large documents
settings: {
batchSize: 5000,
complexThreshold: 120000,
maxWordThreshold: 200000,
},
},
};
worker.postMessage(request);
});
},
[ensureWorker]
);
const executeOperation = useCallback(
async (params: CompareParameters, selectedFiles: StirlingFile[]) => {
// start new run
const runId = ++activeRunIdRef.current;
cancelledRef.current = false;
if (!params.baseFileId || !params.comparisonFileId) {
setErrorMessage(t('compare.error.selectRequired', 'Select the original and edited document.'));
return;
}
const baseFile = selectedFiles.find((file) => file.fileId === params.baseFileId)
?? selectors.getFile(params.baseFileId);
const comparisonFile = selectedFiles.find((file) => file.fileId === params.comparisonFileId)
?? selectors.getFile(params.comparisonFileId);
if (!baseFile || !comparisonFile) {
setErrorMessage(t('compare.error.filesMissing', 'Unable to locate the selected files. Please re-select them.'));
return;
}
setIsLoading(true);
setStatusState('extracting');
setStatusDetailMs(null);
setErrorMessage(null);
setWarnings([]);
setResult(null);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
const warningMessages: CompareWorkerWarnings = {
// No accuracy warning any more
tooLargeMessage: t(
'compare.large.file.message',
'These documents are very large; comparison may take several minutes. Please keep this tab open.'
),
emptyTextMessage: t(
'compare.no.text.message',
'One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison.'
),
tooDissimilarMessage: t(
'compare.too.dissimilar.message',
'These documents appear highly dissimilar. Comparison was stopped to save time.'
),
};
const operationStart = performance.now();
try {
const [baseContent, comparisonContent] = await Promise.all([
extractContentFromPdf(baseFile),
extractContentFromPdf(comparisonFile),
]);
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
if (baseContent.tokens.length === 0 || comparisonContent.tokens.length === 0) {
throw Object.assign(new Error(warningMessages.emptyTextMessage), { code: 'EMPTY_TEXT' });
}
setStatusState('processing');
// Filter out paragraph sentinels before diffing to avoid large false-positive runs
const baseFiltered = filterTokensForDiff(baseContent.tokens, baseContent.metadata);
const comparisonFiltered = filterTokensForDiff(comparisonContent.tokens, comparisonContent.metadata);
const combinedPageCount =
(baseContent.pageSizes?.length ?? 0) + (comparisonContent.pageSizes?.length ?? 0);
if (
combinedPageCount >= LONG_RUNNING_PAGE_THRESHOLD &&
!longRunningToastIdRef.current
) {
const toastId = alert({
alertType: 'neutral',
title: t('compare.longJob.title', 'Large comparison in progress'),
body: t(
'compare.longJob.body',
'These PDFs together exceed 2,000 pages. Processing can take several minutes.'
),
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
expandable: false,
});
longRunningToastIdRef.current = toastId || null;
}
// Heuristic: surface an early warning toast when we observe a very high ratio of differences
const EARLY_TOAST_MIN_TOKENS = 15000; // wait for some signal before warning
const EARLY_TOAST_DIFF_RATIO = 0.8; // 80% added/removed vs unchanged
let observedAddedRemoved = 0;
let observedUnchanged = 0;
const handleEarlyDissimilarity = () => {
if (dissimilarityToastShownRef.current || dissimilarityToastIdRef.current) return;
const toastId = alert({
alertType: 'warning',
title: t('compare.earlyDissimilarity.title', 'These PDFs look highly different'),
body: t(
'compare.earlyDissimilarity.body',
"We're seeing very few similarities so far. You can stop the comparison if these aren't related documents."
),
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
expandable: false,
buttonText: t('compare.earlyDissimilarity.stopButton', 'Stop comparison'),
buttonCallback: () => {
try { cancelOperation(); } catch {
console.error('Failed to cancel operation');
}
try { window.dispatchEvent(new CustomEvent('compare:clear-selected')); } catch {
console.error('Failed to dispatch clear selected event');
}
if (dissimilarityToastIdRef.current) {
dismissToast(dissimilarityToastIdRef.current);
dissimilarityToastIdRef.current = null;
}
},
});
dissimilarityToastIdRef.current = toastId || null;
dissimilarityToastShownRef.current = true;
};
const { tokens, stats, warnings: workerWarnings } = await runCompareWorker(
baseFiltered.tokens,
comparisonFiltered.tokens,
warningMessages,
(chunk) => {
// Incremental ratio tracking for early warning
for (const tok of chunk) {
if (tok.type === 'unchanged') observedUnchanged += 1;
else observedAddedRemoved += 1;
}
const seen = observedAddedRemoved + observedUnchanged;
if (
!dissimilarityToastShownRef.current &&
seen >= EARLY_TOAST_MIN_TOKENS &&
observedAddedRemoved / Math.max(1, seen) >= EARLY_TOAST_DIFF_RATIO
) {
handleEarlyDissimilarity();
}
}
);
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
const baseHasHighlight = new Array<boolean>(baseFiltered.tokens.length).fill(false);
const comparisonHasHighlight = new Array<boolean>(comparisonFiltered.tokens.length).fill(false);
let baseTokenPointer = 0;
let comparisonTokenPointer = 0;
for (const diffToken of tokens) {
if (diffToken.type === 'removed') {
if (baseTokenPointer < baseHasHighlight.length) {
baseHasHighlight[baseTokenPointer] = true;
}
baseTokenPointer += 1;
} else if (diffToken.type === 'added') {
if (comparisonTokenPointer < comparisonHasHighlight.length) {
comparisonHasHighlight[comparisonTokenPointer] = true;
}
comparisonTokenPointer += 1;
} else {
if (baseTokenPointer < baseHasHighlight.length) {
baseTokenPointer += 1;
}
if (comparisonTokenPointer < comparisonHasHighlight.length) {
comparisonTokenPointer += 1;
}
}
}
const buildFilteredTokenData = (
tokensList: typeof baseFiltered.tokens,
metadataList: typeof baseFiltered.metadata,
highlightFlags: boolean[]
): CompareFilteredTokenInfo[] =>
tokensList.map((token, index) => {
const meta = metadataList[index];
return {
token,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
bbox: meta?.bbox ?? null,
hasHighlight: highlightFlags[index] ?? false,
metaIndex: index,
};
});
const totals = aggregateTotals(tokens);
const processedAt = Date.now();
const baseMetadata = baseFiltered.metadata;
const comparisonMetadata = comparisonFiltered.metadata;
const changes = buildChanges(tokens, baseMetadata, comparisonMetadata);
const comparisonResult: CompareResultData = {
base: {
fileId: baseFile.fileId,
fileName: baseFile.name,
highlightColor: REMOVAL_HIGHLIGHT,
wordCount: stats.baseWordCount,
pageSizes: baseContent.pageSizes,
},
comparison: {
fileId: comparisonFile.fileId,
fileName: comparisonFile.name,
highlightColor: ADDITION_HIGHLIGHT,
wordCount: stats.comparisonWordCount,
pageSizes: comparisonContent.pageSizes,
},
totals: {
...totals,
durationMs: stats.durationMs,
processedAt,
},
tokens,
tokenMetadata: {
base: baseMetadata,
comparison: comparisonMetadata,
},
filteredTokenData: {
base: buildFilteredTokenData(baseFiltered.tokens, baseFiltered.metadata, baseHasHighlight),
comparison: buildFilteredTokenData(
comparisonFiltered.tokens,
comparisonFiltered.metadata,
comparisonHasHighlight
),
},
sourceTokens: {
base: baseContent.tokens,
comparison: comparisonContent.tokens,
},
changes,
warnings: workerWarnings,
baseParagraphs: baseContent.paragraphs,
comparisonParagraphs: comparisonContent.paragraphs,
};
setResult(comparisonResult);
setWarnings(workerWarnings);
const summaryFile = createSummaryFile(comparisonResult);
setFiles([summaryFile]);
cleanupDownloadUrl();
const blobUrl = URL.createObjectURL(summaryFile);
previousUrl.current = blobUrl;
setDownloadUrl(blobUrl);
setDownloadFilename(summaryFile.name);
setStatusState('complete');
} catch (error: unknown) {
console.error('[compare] operation failed', error);
const errorCode = getWorkerErrorCode(error);
if (errorCode === 'EMPTY_TEXT') {
setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
} else {
const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.');
if (error instanceof Error && error.message) {
setErrorMessage(error.message);
} else if (typeof error === 'string' && error.trim().length > 0) {
setErrorMessage(error);
} else {
setErrorMessage(fallbackMessage);
}
}
} finally {
const duration = performance.now() - operationStart;
setStatusDetailMs(Math.round(duration));
setIsLoading(false);
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
if (dissimilarityToastIdRef.current) {
dismissToast(dissimilarityToastIdRef.current);
dissimilarityToastIdRef.current = null;
}
dissimilarityToastShownRef.current = false;
}
},
[cleanupDownloadUrl, runCompareWorker, selectors, t]
);
const cancelOperation = useCallback(() => {
if (!isLoading) return;
cancelledRef.current = true;
setIsLoading(false);
setStatusState('cancelled');
if (workerRef.current) {
try {
workerRef.current.terminate();
// eslint-disable-next-line no-empty
} catch {}
workerRef.current = null;
}
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
}, [isLoading]);
const undoOperation = useCallback(async () => {
resetResults();
}, [resetResults]);
useEffect(() => {
return () => {
cleanupDownloadUrl();
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
};
}, [cleanupDownloadUrl]);
const status = useMemo(() => {
const label =
statusState === 'idle' ? ''
: statusState === 'extracting' ? t('compare.status.extracting', 'Extracting text...')
: statusState === 'processing' ? t('compare.status.processing', 'Analyzing differences...')
: statusState === 'complete' ? t('compare.status.complete', 'Comparison ready')
: statusState === 'cancelled' ? t('operationCancelled', 'Operation cancelled')
: '';
if (label && statusDetailMs != null) return `${label} (${statusDetailMs} ms)`;
return label;
}, [statusState, statusDetailMs, t]);
return useMemo<CompareOperationHook>(
() => ({
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
result,
warnings,
}),
[
cancelOperation,
clearError,
downloadFilename,
downloadUrl,
errorMessage,
executeOperation,
files,
isLoading,
resetResults,
result,
status,
undoOperation,
warnings,
]
);
};
@@ -0,0 +1,23 @@
import { BaseParametersHook, useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
import type { FileId } from '@app/types/file';
export interface CompareParameters {
baseFileId: FileId | null;
comparisonFileId: FileId | null;
}
export const defaultParameters: CompareParameters = {
baseFileId: null,
comparisonFileId: null,
};
export type CompareParametersHook = BaseParametersHook<CompareParameters>;
export const useCompareParameters = (): CompareParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'compare',
validateFn: (params) =>
Boolean(params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId),
});
};
+4 -2
View File
@@ -1,14 +1,16 @@
import { useCallback } from 'react';
import { useFileActions } from '@app/contexts/FileContext';
import type { StirlingFile } from '@app/types/fileContext';
export const useFileHandler = () => {
const { actions } = useFileActions();
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}) => {
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}): Promise<StirlingFile[]> => {
// Merge default options with passed options - passed options take precedence
const mergedOptions = { selectFiles: true, ...options };
// Let FileContext handle deduplication with quickKey logic
await actions.addFiles(files, mergedOptions);
const result = await actions.addFiles(files, mergedOptions);
return result;
}, [actions.addFiles]);
return {
+9
View File
@@ -0,0 +1,9 @@
import { useMediaQuery } from '@mantine/hooks';
/**
* Custom hook to detect mobile viewport
* Uses a consistent breakpoint across the application
*/
export const useIsMobile = (): boolean => {
return useMediaQuery('(max-width: 1024px)') ?? false;
};
@@ -0,0 +1,279 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import type { PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
import { PagePreview } from '@app/types/compare';
const DISPLAY_SCALE = 1;
const BATCH_SIZE = 10; // Render 10 pages at a time
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
interface ProgressivePagePreviewsOptions {
file: File | null;
enabled: boolean;
cacheKey: number | null;
visiblePageRange?: { start: number; end: number }; // 0-based page indices
}
interface ProgressivePagePreviewsState {
pages: PagePreview[];
loading: boolean;
totalPages: number;
loadedPages: Set<number>; // 0-based page indices that have been loaded
loadingPages: Set<number>; // 0-based page indices currently being loaded
}
export const useProgressivePagePreviews = ({
file,
enabled,
cacheKey,
visiblePageRange,
}: ProgressivePagePreviewsOptions) => {
const [state, setState] = useState<ProgressivePagePreviewsState>({
pages: [],
loading: false,
totalPages: 0,
loadedPages: new Set(),
loadingPages: new Set(),
});
const pdfRef = useRef<PDFDocumentProxy | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const renderPageBatch = useCallback(async (
pdf: PDFDocumentProxy,
pageNumbers: number[],
signal: AbortSignal
): Promise<PagePreview[]> => {
const previews: PagePreview[] = [];
const dpr = getDevicePixelRatio();
const renderScale = Math.max(2, Math.min(3, dpr * 2));
for (const pageNumber of pageNumbers) {
if (signal.aborted) break;
try {
const page = await pdf.getPage(pageNumber);
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
const renderViewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = Math.round(renderViewport.width);
canvas.height = Math.round(renderViewport.height);
if (!context) {
page.cleanup();
continue;
}
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
previews.push({
pageNumber,
width: Math.round(displayViewport.width),
height: Math.round(displayViewport.height),
rotation: (page.rotate || 0) % 360,
url: canvas.toDataURL(),
});
page.cleanup();
canvas.width = 0;
canvas.height = 0;
} catch (error) {
console.error(`[progressive-pages] failed to render page ${pageNumber}:`, error);
}
}
return previews;
}, []);
const loadPageRange = useCallback(async (
startPage: number,
endPage: number,
signal: AbortSignal
) => {
// Use the live PDF ref for bounds instead of possibly stale state
const totalPages = pdfRef.current?.numPages ?? state.totalPages;
if (startPage < 0 || endPage >= totalPages || startPage > endPage) {
return;
}
// Check which pages need to be loaded
const pagesToLoad: number[] = [];
for (let i = startPage; i <= endPage; i++) {
if (!state.loadedPages.has(i) && !state.loadingPages.has(i)) {
pagesToLoad.push(i + 1); // Convert to 1-based page numbers
}
}
if (pagesToLoad.length === 0) return;
// Mark pages as loading
setState(prev => ({
...prev,
loadingPages: new Set([...prev.loadingPages, ...pagesToLoad.map(p => p - 1)]),
}));
try {
const pdfDoc = pdfRef.current;
if (!pdfDoc) return;
const previews = await renderPageBatch(pdfDoc, pagesToLoad, signal);
if (!signal.aborted) {
setState(prev => {
const newPages = [...prev.pages];
const newLoadedPages = new Set(prev.loadedPages);
const newLoadingPages = new Set(prev.loadingPages);
// Add new previews and mark as loaded
for (const preview of previews) {
const pageIndex = preview.pageNumber - 1; // Convert to 0-based
newLoadedPages.add(pageIndex);
newLoadingPages.delete(pageIndex);
// Insert preview in correct position
const insertIndex = newPages.findIndex(p => p.pageNumber > preview.pageNumber);
if (insertIndex === -1) {
newPages.push(preview);
} else {
newPages.splice(insertIndex, 0, preview);
}
}
return {
...prev,
pages: newPages,
loadedPages: newLoadedPages,
loadingPages: newLoadingPages,
};
});
}
} catch (error) {
if (!signal.aborted) {
console.error('[progressive-pages] failed to load page batch:', error);
}
} finally {
if (!signal.aborted) {
setState(prev => {
const newLoadingPages = new Set(prev.loadingPages);
pagesToLoad.forEach(p => newLoadingPages.delete(p - 1));
return { ...prev, loadingPages: newLoadingPages };
});
}
}
}, [state.loadedPages, state.loadingPages, state.totalPages, renderPageBatch]);
// Initialize PDF and load first batch
useEffect(() => {
let cancelled = false;
if (!file || !enabled) {
setState({
pages: [],
loading: false,
totalPages: 0,
loadedPages: new Set(),
loadingPages: new Set(),
});
return () => {
cancelled = true;
};
}
const initialize = async () => {
try {
setState(prev => ({ ...prev, loading: true }));
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
if (cancelled) {
pdfWorkerManager.destroyDocument(pdf);
return;
}
pdfRef.current = pdf;
const totalPages = pdf.numPages;
setState(prev => ({
...prev,
totalPages,
loading: false,
}));
// Load first batch of pages using a real abort controller
const initAbort = new AbortController();
const firstBatchEnd = Math.min(BATCH_SIZE - 1, totalPages - 1);
await loadPageRange(0, firstBatchEnd, initAbort.signal);
} catch (error) {
console.error('[progressive-pages] failed to initialize PDF:', error);
if (!cancelled) {
setState(prev => ({
...prev,
loading: false,
totalPages: 0,
}));
}
}
};
initialize();
return () => {
cancelled = true;
if (pdfRef.current) {
pdfWorkerManager.destroyDocument(pdfRef.current);
pdfRef.current = null;
}
};
}, [file, enabled, cacheKey, loadPageRange]);
// Load pages based on visible range
useEffect(() => {
if (!visiblePageRange || state.totalPages === 0) return;
const { start, end } = visiblePageRange;
const startPage = Math.max(0, start - 5); // Add buffer before
const endPage = Math.min(state.totalPages - 1, end + 5); // Add buffer after
// Cancel previous loading
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
loadPageRange(startPage, endPage, abortController.signal);
return () => {
abortController.abort();
};
}, [visiblePageRange, state.totalPages, loadPageRange]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (pdfRef.current) {
pdfWorkerManager.destroyDocument(pdfRef.current);
}
};
}, []);
return {
pages: state.pages,
loading: state.loading,
totalPages: state.totalPages,
loadedPages: state.loadedPages,
loadingPages: state.loadingPages,
};
};
export type UseProgressivePagePreviewsReturn = ReturnType<typeof useProgressivePagePreviews>;
@@ -1,9 +1,9 @@
import { useMediaQuery } from '@mantine/hooks';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useIsMobile } from '@app/hooks/useIsMobile';
export function useShouldShowWelcomeModal(): boolean {
const { preferences } = usePreferences();
const isMobile = useMediaQuery("(max-width: 1024px)");
const isMobile = useIsMobile();
return !preferences.hasCompletedOnboarding
&& preferences.toolPanelModePromptSeen