refactor: remove HTML report export and implement PDF export options in PDF comparison tool

- Deleted the exportCompareHtmlReport function and its related imports.
- Introduced a dropdown menu for exporting comparison results as PDFs with multiple modes (split, alternating, left, right).
- Updated the comparison logic to utilize caching for page models and comparison results.
- Refactored rendering functions to improve code organization and maintainability.
- Enhanced UI elements for better user experience during PDF export.
This commit is contained in:
alam00000
2026-03-09 23:26:52 +05:30
parent 0fe94795cc
commit d2a1450bc0
15 changed files with 953 additions and 526 deletions

View File

@@ -1,23 +1,28 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { getPDFDocument } from '../utils/helpers.js';
import { showLoader, hideLoader, showAlert } from '../ui.ts';
import { getPDFDocument } from '../utils/helpers.ts';
import { icons, createIcons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import { CompareState } from '@/types';
import type {
CompareFilterType,
ComparePageModel,
ComparePagePair,
ComparePageResult,
CompareTextChange,
} from '../compare/types.ts';
import { extractPageModel } from '../compare/engine/extract-page-model.ts';
import { comparePageModels } from '../compare/engine/compare-page-models.ts';
import { renderVisualDiff } from '../compare/engine/visual-diff.ts';
import { extractDocumentSignatures } from '../compare/engine/page-signatures.ts';
import { pairPages } from '../compare/engine/pair-pages.ts';
import { recognizePageCanvas } from '../compare/engine/ocr-page.ts';
import { exportCompareHtmlReport } from '../compare/reporting/export-html-report.ts';
import { isLowQualityExtractedText } from '../compare/engine/text-normalization.ts';
import type {
ComparePdfExportMode,
CompareCaches,
CompareRenderContext,
} from '../compare/types.ts';
import { exportComparePdf } from '../compare/reporting/export-compare-pdf.ts';
import { LRUCache } from '../compare/lru-cache.ts';
import { COMPARE_CACHE_MAX_SIZE } from '../compare/config.ts';
import {
getElement,
computeComparisonForPair,
getComparisonCacheKey,
} from './compare-render.ts';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -39,343 +44,31 @@ const pageState: CompareState = {
ocrLanguage: 'eng',
};
const pageModelCache = new Map<string, ComparePageModel>();
const comparisonCache = new Map<string, ComparePageResult>();
const comparisonResultsCache = new Map<number, ComparePageResult>();
const caches: CompareCaches = {
pageModelCache: new LRUCache(COMPARE_CACHE_MAX_SIZE),
comparisonCache: new LRUCache(COMPARE_CACHE_MAX_SIZE),
comparisonResultsCache: new LRUCache(COMPARE_CACHE_MAX_SIZE),
};
const documentNames = {
left: 'first.pdf',
right: 'second.pdf',
};
type RenderedPage = {
model: ComparePageModel | null;
exists: boolean;
};
type ComparisonPageLoad = {
model: ComparePageModel | null;
exists: boolean;
};
type DiffFocusRegion = {
x: number;
y: number;
width: number;
height: number;
};
function getElement<T extends HTMLElement>(id: string) {
return document.getElementById(id) as T | null;
}
function clearCanvas(canvas: HTMLCanvasElement) {
const context = canvas.getContext('2d');
canvas.width = 1;
canvas.height = 1;
context?.clearRect(0, 0, 1, 1);
}
function renderMissingPage(
canvas: HTMLCanvasElement,
placeholderId: string,
message: string
) {
clearCanvas(canvas);
const placeholder = getElement<HTMLDivElement>(placeholderId);
if (placeholder) {
placeholder.textContent = message;
placeholder.classList.remove('hidden');
}
}
function hidePlaceholder(placeholderId: string) {
const placeholder = getElement<HTMLDivElement>(placeholderId);
placeholder?.classList.add('hidden');
}
function getRenderScale(page: pdfjsLib.PDFPageProxy, container: HTMLElement) {
const baseViewport = page.getViewport({ scale: 1.0 });
const availableWidth = Math.max(
container.clientWidth - (pageState.viewMode === 'overlay' ? 96 : 56),
320
);
const fitScale = availableWidth / Math.max(baseViewport.width, 1);
const maxScale = pageState.viewMode === 'overlay' ? 2.5 : 2.0;
return Math.min(Math.max(fitScale, 1.0), maxScale);
}
function getPageModelCacheKey(
cacheKeyPrefix: 'left' | 'right',
pageNum: number,
scale: number
) {
return `${cacheKeyPrefix}-${pageNum}-${scale.toFixed(3)}`;
}
function shouldUseOcrForModel(model: ComparePageModel) {
return !model.hasText || isLowQualityExtractedText(model.plainText);
}
function buildDiffFocusRegion(
comparison: ComparePageResult,
leftCanvas: HTMLCanvasElement,
rightCanvas: HTMLCanvasElement
): DiffFocusRegion | undefined {
const leftOffsetX = Math.floor(
(Math.max(leftCanvas.width, rightCanvas.width) - leftCanvas.width) / 2
);
const leftOffsetY = Math.floor(
(Math.max(leftCanvas.height, rightCanvas.height) - leftCanvas.height) / 2
);
const rightOffsetX = Math.floor(
(Math.max(leftCanvas.width, rightCanvas.width) - rightCanvas.width) / 2
);
const rightOffsetY = Math.floor(
(Math.max(leftCanvas.height, rightCanvas.height) - rightCanvas.height) / 2
);
const bounds = {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
};
for (const change of comparison.changes) {
for (const rect of change.beforeRects) {
bounds.minX = Math.min(bounds.minX, rect.x + leftOffsetX);
bounds.minY = Math.min(bounds.minY, rect.y + leftOffsetY);
bounds.maxX = Math.max(bounds.maxX, rect.x + leftOffsetX + rect.width);
bounds.maxY = Math.max(bounds.maxY, rect.y + leftOffsetY + rect.height);
}
for (const rect of change.afterRects) {
bounds.minX = Math.min(bounds.minX, rect.x + rightOffsetX);
bounds.minY = Math.min(bounds.minY, rect.y + rightOffsetY);
bounds.maxX = Math.max(bounds.maxX, rect.x + rightOffsetX + rect.width);
bounds.maxY = Math.max(bounds.maxY, rect.y + rightOffsetY + rect.height);
}
}
if (!Number.isFinite(bounds.minX)) {
return undefined;
}
const fullWidth = Math.max(leftCanvas.width, rightCanvas.width, 1);
const fullHeight = Math.max(leftCanvas.height, rightCanvas.height, 1);
const padding = 40;
const x = Math.max(Math.floor(bounds.minX - padding), 0);
const y = Math.max(Math.floor(bounds.minY - padding), 0);
const maxX = Math.min(Math.ceil(bounds.maxX + padding), fullWidth);
const maxY = Math.min(Math.ceil(bounds.maxY + padding), fullHeight);
return {
x,
y,
width: Math.max(maxX - x, Math.min(320, fullWidth)),
height: Math.max(maxY - y, Math.min(200, fullHeight)),
};
}
async function renderPage(
pdfDoc: pdfjsLib.PDFDocumentProxy,
pageNum: number,
canvas: HTMLCanvasElement,
container: HTMLElement,
placeholderId: string,
cacheKeyPrefix: 'left' | 'right'
): Promise<RenderedPage> {
if (pageNum > pdfDoc.numPages) {
renderMissingPage(
canvas,
placeholderId,
`Page ${pageNum} does not exist in this PDF.`
);
return { model: null, exists: false };
}
const page = await pdfDoc.getPage(pageNum);
const targetScale = getRenderScale(page, container);
const scaledViewport = page.getViewport({ scale: targetScale });
const dpr = window.devicePixelRatio || 1;
const hiResViewport = page.getViewport({ scale: targetScale * dpr });
hidePlaceholder(placeholderId);
canvas.width = hiResViewport.width;
canvas.height = hiResViewport.height;
canvas.style.width = `${scaledViewport.width}px`;
canvas.style.height = `${scaledViewport.height}px`;
const cacheKey = getPageModelCacheKey(cacheKeyPrefix, pageNum, targetScale);
const cachedModel = pageModelCache.get(cacheKey);
const modelPromise = cachedModel
? Promise.resolve(cachedModel)
: extractPageModel(page, scaledViewport);
const renderTask = page.render({
canvasContext: canvas.getContext('2d')!,
viewport: hiResViewport,
canvas,
}).promise;
const [model] = await Promise.all([modelPromise, renderTask]);
let finalModel = model;
if (!cachedModel && pageState.useOcr && shouldUseOcrForModel(model)) {
showLoader(`Running OCR on page ${pageNum}...`);
const ocrModel = await recognizePageCanvas(
canvas,
pageState.ocrLanguage,
function (status, progress) {
showLoader(`OCR: ${status}`, progress * 100);
}
);
finalModel = {
...ocrModel,
pageNumber: pageNum,
};
}
pageModelCache.set(cacheKey, finalModel);
return { model: finalModel, exists: true };
}
async function loadComparisonPage(
pdfDoc: pdfjsLib.PDFDocumentProxy | null,
pageNum: number | null,
side: 'left' | 'right',
renderTarget?: {
canvas: HTMLCanvasElement;
container: HTMLElement;
placeholderId: string;
}
): Promise<ComparisonPageLoad> {
if (!pdfDoc || !pageNum) {
if (renderTarget) {
renderMissingPage(
renderTarget.canvas,
renderTarget.placeholderId,
'No paired page for this side.'
);
}
return { model: null, exists: false };
}
if (renderTarget) {
return renderPage(
pdfDoc,
pageNum,
renderTarget.canvas,
renderTarget.container,
renderTarget.placeholderId,
side
);
}
const renderScale = 1.2;
const cacheKey = getPageModelCacheKey(side, pageNum, renderScale);
const cachedModel = pageModelCache.get(cacheKey);
if (cachedModel) {
return { model: cachedModel, exists: true };
}
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create offscreen comparison canvas.');
}
const extractedModel = await extractPageModel(page, viewport);
await page.render({
canvasContext: context,
viewport,
canvas,
}).promise;
let finalModel = extractedModel;
if (pageState.useOcr && shouldUseOcrForModel(extractedModel)) {
const ocrModel = await recognizePageCanvas(canvas, pageState.ocrLanguage);
finalModel = {
...ocrModel,
pageNumber: pageNum,
};
}
pageModelCache.set(cacheKey, finalModel);
return { model: finalModel, exists: true };
}
async function computeComparisonForPair(
pair: ComparePagePair,
options?: {
renderTargets?: {
left: {
canvas: HTMLCanvasElement;
container: HTMLElement;
placeholderId: string;
};
right: {
canvas: HTMLCanvasElement;
container: HTMLElement;
placeholderId: string;
};
diffCanvas?: HTMLCanvasElement;
};
}
) {
const renderTargets = options?.renderTargets;
const leftPage = await loadComparisonPage(
pageState.pdfDoc1,
pair.leftPageNumber,
'left',
renderTargets?.left
);
const rightPage = await loadComparisonPage(
pageState.pdfDoc2,
pair.rightPageNumber,
'right',
renderTargets?.right
);
const comparison = comparePageModels(leftPage.model, rightPage.model);
comparison.confidence = pair.confidence;
if (
renderTargets?.diffCanvas &&
comparison.status !== 'left-only' &&
comparison.status !== 'right-only'
) {
const focusRegion = buildDiffFocusRegion(
comparison,
renderTargets.left.canvas,
renderTargets.right.canvas
);
comparison.visualDiff = renderVisualDiff(
renderTargets.left.canvas,
renderTargets.right.canvas,
renderTargets.diffCanvas,
focusRegion
);
} else if (renderTargets?.diffCanvas) {
clearCanvas(renderTargets.diffCanvas);
}
return comparison;
}
let renderGeneration = 0;
function getActivePair() {
return pageState.pagePairs[pageState.currentPage - 1] || null;
}
function getRenderContext(): CompareRenderContext {
return {
useOcr: pageState.useOcr,
ocrLanguage: pageState.ocrLanguage,
viewMode: pageState.viewMode,
showLoader,
};
}
function getVisibleChanges(result: ComparePageResult | null) {
if (!result) return [];
@@ -508,14 +201,16 @@ function renderChangeList() {
const emptyState = getElement<HTMLDivElement>('change-list-empty');
const prevChangeBtn = getElement<HTMLButtonElement>('prev-change-btn');
const nextChangeBtn = getElement<HTMLButtonElement>('next-change-btn');
const exportReportBtn = getElement<HTMLButtonElement>('export-report-btn');
const exportDropdownBtn = getElement<HTMLButtonElement>(
'export-dropdown-btn'
);
if (
!list ||
!emptyState ||
!prevChangeBtn ||
!nextChangeBtn ||
!exportReportBtn
!exportDropdownBtn
)
return;
@@ -531,7 +226,7 @@ function renderChangeList() {
list.classList.add('hidden');
prevChangeBtn.disabled = true;
nextChangeBtn.disabled = true;
exportReportBtn.disabled = pageState.pagePairs.length === 0;
exportDropdownBtn.disabled = pageState.pagePairs.length === 0;
return;
}
@@ -560,7 +255,7 @@ function renderChangeList() {
prevChangeBtn.disabled = false;
nextChangeBtn.disabled = false;
exportReportBtn.disabled = pageState.pagePairs.length === 0;
exportDropdownBtn.disabled = pageState.pagePairs.length === 0;
}
function renderComparisonUI() {
@@ -600,34 +295,31 @@ async function buildPagePairs() {
async function buildReportResults() {
const results: ComparePageResult[] = [];
const ctx = getRenderContext();
for (const pair of pageState.pagePairs) {
const cached = comparisonResultsCache.get(pair.pairIndex);
const cached = caches.comparisonResultsCache.get(pair.pairIndex);
if (cached) {
results.push(cached);
continue;
}
const leftSignatureKey = pair.leftPageNumber
? `left-${pair.leftPageNumber}`
: '';
const rightSignatureKey = pair.rightPageNumber
? `right-${pair.rightPageNumber}`
: '';
const cachedResult = comparisonCache.get(
`${leftSignatureKey || 'none'}:${rightSignatureKey || 'none'}:${pageState.useOcr ? 'ocr' : 'no-ocr'}`
);
const cacheKey = getComparisonCacheKey(pair, pageState.useOcr);
const cachedResult = caches.comparisonCache.get(cacheKey);
if (cachedResult) {
results.push(cachedResult);
continue;
}
const comparison = await computeComparisonForPair(pair);
comparisonCache.set(
`${leftSignatureKey || 'none'}:${rightSignatureKey || 'none'}:${pageState.useOcr ? 'ocr' : 'no-ocr'}`,
comparison
const comparison = await computeComparisonForPair(
pageState.pdfDoc1,
pageState.pdfDoc2,
pair,
caches,
ctx
);
comparisonResultsCache.set(pair.pairIndex, comparison);
caches.comparisonCache.set(cacheKey, comparison);
caches.comparisonResultsCache.set(pair.pairIndex, comparison);
results.push(comparison);
}
@@ -640,6 +332,8 @@ async function renderBothPages() {
const pair = getActivePair();
if (!pair) return;
const gen = ++renderGeneration;
showLoader(
`Loading comparison ${pageState.currentPage} of ${pageState.pagePairs.length}...`
);
@@ -652,27 +346,35 @@ async function renderBothPages() {
) as HTMLCanvasElement;
const panel1 = getElement<HTMLElement>('panel-1') as HTMLElement;
const panel2 = getElement<HTMLElement>('panel-2') as HTMLElement;
const wrapper = getElement<HTMLElement>(
'compare-viewer-wrapper'
) as HTMLElement;
const container1 = panel1;
const container2 = pageState.viewMode === 'overlay' ? panel1 : panel2;
const comparison = await computeComparisonForPair(pair, {
renderTargets: {
left: {
canvas: canvas1,
container: container1,
placeholderId: 'placeholder-1',
const ctx = getRenderContext();
const comparison = await computeComparisonForPair(
pageState.pdfDoc1,
pageState.pdfDoc2,
pair,
caches,
ctx,
{
renderTargets: {
left: {
canvas: canvas1,
container: container1,
placeholderId: 'placeholder-1',
},
right: {
canvas: canvas2,
container: container2,
placeholderId: 'placeholder-2',
},
},
right: {
canvas: canvas2,
container: container2,
placeholderId: 'placeholder-2',
},
},
});
}
);
if (gen !== renderGeneration) return;
pageState.currentComparison = comparison;
pageState.activeChangeIndex = 0;
@@ -815,9 +517,9 @@ async function handleFileInput(
showLoader(`Loading ${file.name}...`);
const arrayBuffer = await file.arrayBuffer();
pageState[docKey] = await getPDFDocument({ data: arrayBuffer }).promise;
pageModelCache.clear();
comparisonCache.clear();
comparisonResultsCache.clear();
caches.pageModelCache.clear();
caches.comparisonCache.clear();
caches.comparisonResultsCache.clear();
pageState.changeSearchQuery = '';
const searchInput = getElement<HTMLInputElement>('compare-search-input');
@@ -880,7 +582,7 @@ document.addEventListener('DOMContentLoaded', function () {
prevBtn.addEventListener('click', function () {
if (pageState.currentPage > 1) {
pageState.currentPage--;
renderBothPages();
renderBothPages().catch(console.error);
}
});
}
@@ -895,7 +597,7 @@ document.addEventListener('DOMContentLoaded', function () {
);
if (pageState.currentPage < totalPairs) {
pageState.currentPage++;
renderBothPages();
renderBothPages().catch(console.error);
}
});
}
@@ -955,7 +657,10 @@ document.addEventListener('DOMContentLoaded', function () {
) as HTMLInputElement;
const prevChangeBtn = getElement<HTMLButtonElement>('prev-change-btn');
const nextChangeBtn = getElement<HTMLButtonElement>('next-change-btn');
const exportReportBtn = getElement<HTMLButtonElement>('export-report-btn');
const exportDropdownBtn = getElement<HTMLButtonElement>(
'export-dropdown-btn'
);
const exportDropdownMenu = getElement<HTMLDivElement>('export-dropdown-menu');
const ocrToggle = getElement<HTMLInputElement>('ocr-toggle');
const searchInput = getElement<HTMLInputElement>('compare-search-input');
@@ -1037,12 +742,17 @@ document.addEventListener('DOMContentLoaded', function () {
if (ocrToggle) {
ocrToggle.checked = pageState.useOcr;
ocrToggle.addEventListener('change', async function () {
pageState.useOcr = ocrToggle.checked;
pageModelCache.clear();
comparisonCache.clear();
comparisonResultsCache.clear();
if (pageState.pdfDoc1 && pageState.pdfDoc2) {
await renderBothPages();
try {
pageState.useOcr = ocrToggle.checked;
caches.pageModelCache.clear();
caches.comparisonCache.clear();
caches.comparisonResultsCache.clear();
if (pageState.pdfDoc1 && pageState.pdfDoc2) {
await renderBothPages();
}
} catch (e) {
console.error('OCR toggle failed:', e);
hideLoader();
}
});
}
@@ -1063,22 +773,48 @@ document.addEventListener('DOMContentLoaded', function () {
window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(function () {
renderBothPages();
renderBothPages().catch(console.error);
});
});
if (exportReportBtn) {
exportReportBtn.addEventListener('click', async function () {
if (pageState.pagePairs.length === 0) return;
showLoader('Building compare report...');
const results = await buildReportResults();
exportCompareHtmlReport(
documentNames.left,
documentNames.right,
pageState.pagePairs,
results
);
hideLoader();
if (exportDropdownBtn && exportDropdownMenu) {
exportDropdownBtn.addEventListener('click', function (e) {
e.stopPropagation();
exportDropdownMenu.classList.toggle('hidden');
});
document.addEventListener('click', function () {
exportDropdownMenu.classList.add('hidden');
});
exportDropdownMenu.addEventListener('click', function (e) {
e.stopPropagation();
});
document.querySelectorAll('.export-menu-item').forEach(function (btn) {
btn.addEventListener('click', async function () {
const mode = (btn as HTMLElement).dataset
.exportMode as ComparePdfExportMode;
if (!mode || pageState.pagePairs.length === 0) return;
exportDropdownMenu.classList.add('hidden');
try {
showLoader('Preparing PDF export...');
await exportComparePdf(
mode,
pageState.pdfDoc1,
pageState.pdfDoc2,
pageState.pagePairs,
function (message, percent) {
showLoader(message, percent);
}
);
} catch (e) {
console.error('PDF export failed:', e);
showAlert('Export Error', 'Could not export comparison PDF.');
} finally {
hideLoader();
}
});
});
}

View File

@@ -0,0 +1,365 @@
import * as pdfjsLib from 'pdfjs-dist';
import type {
ComparePageModel,
ComparePagePair,
ComparePageResult,
RenderedPage,
ComparisonPageLoad,
DiffFocusRegion,
CompareCaches,
CompareRenderContext,
} from '../compare/types.ts';
import { extractPageModel } from '../compare/engine/extract-page-model.ts';
import { comparePageModels } from '../compare/engine/compare-page-models.ts';
import { renderVisualDiff } from '../compare/engine/visual-diff.ts';
import { recognizePageCanvas } from '../compare/engine/ocr-page.ts';
import { isLowQualityExtractedText } from '../compare/engine/text-normalization.ts';
import { COMPARE_RENDER, COMPARE_GEOMETRY } from '../compare/config.ts';
export function getElement<T extends HTMLElement>(id: string) {
return document.getElementById(id) as T | null;
}
export function clearCanvas(canvas: HTMLCanvasElement) {
const context = canvas.getContext('2d');
canvas.width = 1;
canvas.height = 1;
context?.clearRect(0, 0, 1, 1);
}
export function renderMissingPage(
canvas: HTMLCanvasElement,
placeholderId: string,
message: string
) {
clearCanvas(canvas);
const placeholder = getElement<HTMLDivElement>(placeholderId);
if (placeholder) {
placeholder.textContent = message;
placeholder.classList.remove('hidden');
}
}
export function hidePlaceholder(placeholderId: string) {
const placeholder = getElement<HTMLDivElement>(placeholderId);
placeholder?.classList.add('hidden');
}
export function getRenderScale(
page: pdfjsLib.PDFPageProxy,
container: HTMLElement,
viewMode: 'overlay' | 'side-by-side'
) {
const baseViewport = page.getViewport({ scale: 1.0 });
const availableWidth = Math.max(
container.clientWidth - (viewMode === 'overlay' ? 96 : 56),
320
);
const fitScale = availableWidth / Math.max(baseViewport.width, 1);
const maxScale =
viewMode === 'overlay'
? COMPARE_RENDER.MAX_SCALE_OVERLAY
: COMPARE_RENDER.MAX_SCALE_SIDE;
return Math.min(Math.max(fitScale, 1.0), maxScale);
}
export function getPageModelCacheKey(
cacheKeyPrefix: 'left' | 'right',
pageNum: number,
scale: number
) {
return `${cacheKeyPrefix}-${pageNum}-${scale.toFixed(3)}`;
}
function shouldUseOcrForModel(model: ComparePageModel) {
return !model.hasText || isLowQualityExtractedText(model.plainText);
}
export function buildDiffFocusRegion(
comparison: ComparePageResult,
leftCanvas: HTMLCanvasElement,
rightCanvas: HTMLCanvasElement
): DiffFocusRegion | undefined {
const leftOffsetX = Math.floor(
(Math.max(leftCanvas.width, rightCanvas.width) - leftCanvas.width) / 2
);
const leftOffsetY = Math.floor(
(Math.max(leftCanvas.height, rightCanvas.height) - leftCanvas.height) / 2
);
const rightOffsetX = Math.floor(
(Math.max(leftCanvas.width, rightCanvas.width) - rightCanvas.width) / 2
);
const rightOffsetY = Math.floor(
(Math.max(leftCanvas.height, rightCanvas.height) - rightCanvas.height) / 2
);
const bounds = {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
};
for (const change of comparison.changes) {
for (const rect of change.beforeRects) {
bounds.minX = Math.min(bounds.minX, rect.x + leftOffsetX);
bounds.minY = Math.min(bounds.minY, rect.y + leftOffsetY);
bounds.maxX = Math.max(bounds.maxX, rect.x + leftOffsetX + rect.width);
bounds.maxY = Math.max(bounds.maxY, rect.y + leftOffsetY + rect.height);
}
for (const rect of change.afterRects) {
bounds.minX = Math.min(bounds.minX, rect.x + rightOffsetX);
bounds.minY = Math.min(bounds.minY, rect.y + rightOffsetY);
bounds.maxX = Math.max(bounds.maxX, rect.x + rightOffsetX + rect.width);
bounds.maxY = Math.max(bounds.maxY, rect.y + rightOffsetY + rect.height);
}
}
if (!Number.isFinite(bounds.minX)) {
return undefined;
}
const fullWidth = Math.max(leftCanvas.width, rightCanvas.width, 1);
const fullHeight = Math.max(leftCanvas.height, rightCanvas.height, 1);
const padding = COMPARE_GEOMETRY.FOCUS_REGION_PADDING;
const x = Math.max(Math.floor(bounds.minX - padding), 0);
const y = Math.max(Math.floor(bounds.minY - padding), 0);
const maxX = Math.min(Math.ceil(bounds.maxX + padding), fullWidth);
const maxY = Math.min(Math.ceil(bounds.maxY + padding), fullHeight);
return {
x,
y,
width: Math.max(
maxX - x,
Math.min(COMPARE_GEOMETRY.FOCUS_REGION_MIN_WIDTH, fullWidth)
),
height: Math.max(
maxY - y,
Math.min(COMPARE_GEOMETRY.FOCUS_REGION_MIN_HEIGHT, fullHeight)
),
};
}
export async function renderPage(
pdfDoc: pdfjsLib.PDFDocumentProxy,
pageNum: number,
canvas: HTMLCanvasElement,
container: HTMLElement,
placeholderId: string,
cacheKeyPrefix: 'left' | 'right',
caches: CompareCaches,
ctx: CompareRenderContext
): Promise<RenderedPage> {
if (pageNum > pdfDoc.numPages) {
renderMissingPage(
canvas,
placeholderId,
`Page ${pageNum} does not exist in this PDF.`
);
return { model: null, exists: false };
}
const page = await pdfDoc.getPage(pageNum);
const targetScale = getRenderScale(page, container, ctx.viewMode);
const scaledViewport = page.getViewport({ scale: targetScale });
const dpr = window.devicePixelRatio || 1;
const hiResViewport = page.getViewport({ scale: targetScale * dpr });
hidePlaceholder(placeholderId);
canvas.width = hiResViewport.width;
canvas.height = hiResViewport.height;
canvas.style.width = `${scaledViewport.width}px`;
canvas.style.height = `${scaledViewport.height}px`;
const cacheKey = getPageModelCacheKey(cacheKeyPrefix, pageNum, targetScale);
const cachedModel = caches.pageModelCache.get(cacheKey);
const modelPromise = cachedModel
? Promise.resolve(cachedModel)
: extractPageModel(page, scaledViewport);
const renderTask = page.render({
canvasContext: canvas.getContext('2d')!,
viewport: hiResViewport,
canvas,
}).promise;
const [model] = await Promise.all([modelPromise, renderTask]);
let finalModel = model;
if (!cachedModel && ctx.useOcr && shouldUseOcrForModel(model)) {
ctx.showLoader(`Running OCR on page ${pageNum}...`);
const ocrModel = await recognizePageCanvas(
canvas,
ctx.ocrLanguage,
function (status, progress) {
ctx.showLoader(`OCR: ${status}`, progress * 100);
}
);
finalModel = {
...ocrModel,
pageNumber: pageNum,
};
}
caches.pageModelCache.set(cacheKey, finalModel);
return { model: finalModel, exists: true };
}
export async function loadComparisonPage(
pdfDoc: pdfjsLib.PDFDocumentProxy | null,
pageNum: number | null,
side: 'left' | 'right',
renderTarget:
| {
canvas: HTMLCanvasElement;
container: HTMLElement;
placeholderId: string;
}
| undefined,
caches: CompareCaches,
ctx: CompareRenderContext
): Promise<ComparisonPageLoad> {
if (!pdfDoc || !pageNum) {
if (renderTarget) {
renderMissingPage(
renderTarget.canvas,
renderTarget.placeholderId,
'No paired page for this side.'
);
}
return { model: null, exists: false };
}
if (renderTarget) {
return renderPage(
pdfDoc,
pageNum,
renderTarget.canvas,
renderTarget.container,
renderTarget.placeholderId,
side,
caches,
ctx
);
}
const renderScale = COMPARE_RENDER.OFFLINE_SCALE;
const cacheKey = getPageModelCacheKey(side, pageNum, renderScale);
const cachedModel = caches.pageModelCache.get(cacheKey);
if (cachedModel) {
return { model: cachedModel, exists: true };
}
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create offscreen comparison canvas.');
}
const extractedModel = await extractPageModel(page, viewport);
await page.render({
canvasContext: context,
viewport,
canvas,
}).promise;
let finalModel = extractedModel;
if (ctx.useOcr && shouldUseOcrForModel(extractedModel)) {
const ocrModel = await recognizePageCanvas(canvas, ctx.ocrLanguage);
finalModel = {
...ocrModel,
pageNumber: pageNum,
};
}
canvas.width = 0;
canvas.height = 0;
caches.pageModelCache.set(cacheKey, finalModel);
return { model: finalModel, exists: true };
}
export async function computeComparisonForPair(
pdfDoc1: pdfjsLib.PDFDocumentProxy | null,
pdfDoc2: pdfjsLib.PDFDocumentProxy | null,
pair: ComparePagePair,
caches: CompareCaches,
ctx: CompareRenderContext,
options?: {
renderTargets?: {
left: {
canvas: HTMLCanvasElement;
container: HTMLElement;
placeholderId: string;
};
right: {
canvas: HTMLCanvasElement;
container: HTMLElement;
placeholderId: string;
};
diffCanvas?: HTMLCanvasElement;
};
}
) {
const renderTargets = options?.renderTargets;
const leftPage = await loadComparisonPage(
pdfDoc1,
pair.leftPageNumber,
'left',
renderTargets?.left,
caches,
ctx
);
const rightPage = await loadComparisonPage(
pdfDoc2,
pair.rightPageNumber,
'right',
renderTargets?.right,
caches,
ctx
);
const comparison = comparePageModels(leftPage.model, rightPage.model);
comparison.confidence = pair.confidence;
if (
renderTargets?.diffCanvas &&
comparison.status !== 'left-only' &&
comparison.status !== 'right-only'
) {
const focusRegion = buildDiffFocusRegion(
comparison,
renderTargets.left.canvas,
renderTargets.right.canvas
);
comparison.visualDiff = renderVisualDiff(
renderTargets.left.canvas,
renderTargets.right.canvas,
renderTargets.diffCanvas,
focusRegion
);
} else if (renderTargets?.diffCanvas) {
clearCanvas(renderTargets.diffCanvas);
}
return comparison;
}
export function getComparisonCacheKey(pair: ComparePagePair, useOcr: boolean) {
const leftKey = pair.leftPageNumber ? `left-${pair.leftPageNumber}` : 'none';
const rightKey = pair.rightPageNumber
? `right-${pair.rightPageNumber}`
: 'none';
return `${leftKey}:${rightKey}:${useOcr ? 'ocr' : 'no-ocr'}`;
}