From 6c0e9c7232dc2d5ef241e41376578b6d9b7bff5e Mon Sep 17 00:00:00 2001 From: alam00000 Date: Mon, 16 Mar 2026 21:47:35 +0530 Subject: [PATCH] feat: enhance PDF comparison with overlay options and filters --- .../compare/reporting/export-compare-pdf.ts | 126 ++++++- src/js/compare/types.ts | 11 +- src/js/logic/compare-pdfs-page.ts | 342 ++++++++++++++---- src/js/types/compare-pdfs-type.ts | 1 + src/pages/compare-pdfs.html | 59 ++- 5 files changed, 454 insertions(+), 85 deletions(-) diff --git a/src/js/compare/reporting/export-compare-pdf.ts b/src/js/compare/reporting/export-compare-pdf.ts index 3e56afc..25d7413 100644 --- a/src/js/compare/reporting/export-compare-pdf.ts +++ b/src/js/compare/reporting/export-compare-pdf.ts @@ -1,18 +1,20 @@ import { PDFDocument, rgb } from 'pdf-lib'; import * as pdfjsLib from 'pdfjs-dist'; import type { + CompareCaches, ComparePagePair, CompareTextChange, ComparePdfExportMode, } from '../types.ts'; -import { extractPageModel } from '../engine/extract-page-model.ts'; -import { comparePageModelsAsync } from '../engine/compare-page-models.ts'; import { + COMPARE_CACHE_MAX_SIZE, COMPARE_COLORS, HIGHLIGHT_OPACITY, COMPARE_RENDER, } from '../config.ts'; import { downloadFile } from '../../utils/helpers.ts'; +import { computeComparisonForPair } from '../../logic/compare-render.ts'; +import { LRUCache } from '../lru-cache.ts'; const HIGHLIGHT_COLORS: Record< string, @@ -56,7 +58,7 @@ const HIGHLIGHT_COLORS: Record< }, }; -const EXTRACT_SCALE = COMPARE_RENDER.EXPORT_EXTRACT_SCALE; +const EXTRACT_SCALE = COMPARE_RENDER.OFFLINE_SCALE; function drawHighlights( page: ReturnType, @@ -86,7 +88,14 @@ export async function exportComparePdf( pdfDoc1: pdfjsLib.PDFDocumentProxy | null, pdfDoc2: pdfjsLib.PDFDocumentProxy | null, pairs: ComparePagePair[], - onProgress?: (message: string, percent: number) => void + onProgress?: (message: string, percent: number) => void, + options?: { + overlayOpacity?: number; + includeChange?: (change: CompareTextChange) => boolean; + useOcr?: boolean; + ocrLanguage?: string; + showOverlayDocument?: boolean; + } ) { if (!pdfDoc1 && !pdfDoc2) { throw new Error('At least one PDF document is required for export.'); @@ -107,6 +116,25 @@ export async function exportComparePdf( bytes2 ? PDFDocument.load(bytes2, { ignoreEncryption: true }) : null, ]); + const includeChange = options?.includeChange ?? (() => true); + const overlayOpacity = options?.overlayOpacity ?? 0.5; + const showOverlayDocument = options?.showOverlayDocument ?? true; + const exportCaches: CompareCaches = { + pageModelCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), + comparisonCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), + comparisonResultsCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), + ocrModelCache: new LRUCache(COMPARE_CACHE_MAX_SIZE), + }; + const renderContext = { + useOcr: options?.useOcr ?? true, + ocrLanguage: options?.ocrLanguage ?? 'eng', + viewMode: mode === 'overlay' ? 'overlay' : 'side-by-side', + zoomLevel: 1, + showLoader: (message: string, percent?: number) => { + onProgress?.(message, percent ?? 0); + }, + } as const; + for (let i = 0; i < pairs.length; i++) { const pair = pairs[i]; onProgress?.( @@ -123,21 +151,83 @@ export async function exportComparePdf( ? await pdfDoc2.getPage(pair.rightPageNumber) : null; - const leftModel = leftPdjsPage - ? await extractPageModel( - leftPdjsPage, - leftPdjsPage.getViewport({ scale: EXTRACT_SCALE }) - ) - : null; - const rightModel = rightPdjsPage - ? await extractPageModel( - rightPdjsPage, - rightPdjsPage.getViewport({ scale: EXTRACT_SCALE }) - ) - : null; + const comparison = await computeComparisonForPair( + pdfDoc1, + pdfDoc2, + pair, + exportCaches, + renderContext + ); + const changes = comparison.changes.filter(includeChange); - const comparison = await comparePageModelsAsync(leftModel, rightModel); - const changes = comparison.changes; + if (mode === 'overlay') { + const leftViewport = leftPdjsPage?.getViewport({ scale: 1.0 }) ?? null; + const rightViewport = rightPdjsPage?.getViewport({ scale: 1.0 }) ?? null; + const pageWidth = leftViewport?.width ?? rightViewport?.width; + const pageHeight = leftViewport?.height ?? rightViewport?.height; + + if (!pageWidth || !pageHeight) { + continue; + } + + const outPage = outDoc.addPage([pageWidth, pageHeight]); + + if (pair.leftPageNumber && libDoc1) { + const [copied] = await outDoc.copyPages(libDoc1, [ + pair.leftPageNumber - 1, + ]); + const embedded = await outDoc.embedPage(copied); + outPage.drawPage(embedded, { + x: 0, + y: 0, + width: pageWidth, + height: pageHeight, + }); + } + + if (pair.rightPageNumber && libDoc2) { + const shouldDrawRightPage = !pair.leftPageNumber || showOverlayDocument; + if (shouldDrawRightPage) { + const [copied] = await outDoc.copyPages(libDoc2, [ + pair.rightPageNumber - 1, + ]); + const embedded = await outDoc.embedPage(copied); + outPage.drawPage(embedded, { + x: 0, + y: 0, + width: pageWidth, + height: pageHeight, + opacity: + pair.leftPageNumber && pair.rightPageNumber && showOverlayDocument + ? overlayOpacity + : 1, + }); + } + } + + if (changes.length) { + for (const change of changes) { + const color = HIGHLIGHT_COLORS[change.type]; + if (!color) continue; + for (const rect of [...change.beforeRects, ...change.afterRects]) { + outPage.drawRectangle({ + x: rect.x / EXTRACT_SCALE, + y: + pageHeight - + rect.y / EXTRACT_SCALE - + rect.height / EXTRACT_SCALE, + width: rect.width / EXTRACT_SCALE, + height: rect.height / EXTRACT_SCALE, + color: rgb(color.r, color.g, color.b), + opacity: color.opacity, + }); + } + } + } + + await new Promise((r) => setTimeout(r, 0)); + continue; + } if (mode === 'split') { const refPage = leftPdjsPage || rightPdjsPage; diff --git a/src/js/compare/types.ts b/src/js/compare/types.ts index d5d7a09..e9dff97 100644 --- a/src/js/compare/types.ts +++ b/src/js/compare/types.ts @@ -3,7 +3,14 @@ import type { LRUCache } from './lru-cache.ts'; export type CompareViewMode = 'overlay' | 'side-by-side'; -export type ComparePdfExportMode = 'split' | 'alternating' | 'left' | 'right'; +export type ComparePdfExportMode = + | 'split' + | 'alternating' + | 'left' + | 'right' + | 'overlay'; + +export type CompareOverlayChangeScope = 'all' | 'content-only'; export interface RenderedPage { model: ComparePageModel | null; @@ -201,6 +208,8 @@ export interface CompareState { pdfDoc2: pdfjsLib.PDFDocumentProxy | null; currentPage: number; viewMode: CompareViewMode; + overlayChangeScope: CompareOverlayChangeScope; + overlayDocumentVisible: boolean; isSyncScroll: boolean; currentComparison: ComparePageResult | null; activeChangeIndex: number; diff --git a/src/js/logic/compare-pdfs-page.ts b/src/js/logic/compare-pdfs-page.ts index 2e267aa..80c1ad3 100644 --- a/src/js/logic/compare-pdfs-page.ts +++ b/src/js/logic/compare-pdfs-page.ts @@ -35,6 +35,8 @@ const pageState: CompareState = { pdfDoc2: null, currentPage: 1, viewMode: 'side-by-side', + overlayChangeScope: 'all', + overlayDocumentVisible: true, isSyncScroll: true, currentComparison: null, activeChangeIndex: 0, @@ -81,41 +83,76 @@ function getRenderContext(): CompareRenderContext { }; } +function getEffectiveCategoryFilter(): CompareCategoryFilterState { + if ( + pageState.viewMode !== 'overlay' || + pageState.overlayChangeScope !== 'content-only' + ) { + return pageState.categoryFilter; + } + + return { + ...pageState.categoryFilter, + formatting: false, + }; +} + +function matchesActiveFilter(change: CompareTextChange) { + if (pageState.activeFilter === 'all') { + return true; + } + + if (pageState.activeFilter === 'removed') { + return change.type === 'removed' || change.type === 'page-removed'; + } + + if (pageState.activeFilter === 'added') { + return change.type === 'added' || change.type === 'page-added'; + } + + return change.type === pageState.activeFilter; +} + +function matchesSearch(change: CompareTextChange, searchQuery: string) { + if (!searchQuery) { + return true; + } + + const searchableText = [ + change.description, + change.beforeText, + change.afterText, + ] + .join(' ') + .toLowerCase(); + + return searchableText.includes(searchQuery); +} + +function shouldIncludeChange( + change: CompareTextChange, + options: { includeSearch: boolean } +) { + if (!matchesActiveFilter(change)) { + return false; + } + + const effectiveCategoryFilter = getEffectiveCategoryFilter(); + if (!effectiveCategoryFilter[change.category]) { + return false; + } + + return options.includeSearch + ? matchesSearch(change, pageState.changeSearchQuery.trim().toLowerCase()) + : true; +} + function getVisibleChanges(result: ComparePageResult | null) { if (!result) return []; - const filteredByType = - pageState.activeFilter === 'all' - ? result.changes - : result.changes.filter((change) => { - if (pageState.activeFilter === 'removed') { - return change.type === 'removed' || change.type === 'page-removed'; - } - if (pageState.activeFilter === 'added') { - return change.type === 'added' || change.type === 'page-added'; - } - return change.type === pageState.activeFilter; - }); - - const filteredByCategory = filteredByType.filter( - (change) => pageState.categoryFilter[change.category] + return result.changes.filter((change) => + shouldIncludeChange(change, { includeSearch: true }) ); - - const searchQuery = pageState.changeSearchQuery.trim().toLowerCase(); - if (!searchQuery) { - return filteredByCategory; - } - - return filteredByCategory.filter((change) => { - const searchableText = [ - change.description, - change.beforeText, - change.afterText, - ] - .join(' ') - .toLowerCase(); - return searchableText.includes(searchQuery); - }); } function updateFilterButtons() { @@ -131,9 +168,88 @@ function updateFilterButtons() { const button = getElement(id); if (!button) return; button.classList.toggle('active', pageState.activeFilter === filter); + const isDisabled = + id === 'filter-style-changed' && + pageState.viewMode === 'overlay' && + pageState.overlayChangeScope === 'content-only'; + button.disabled = isDisabled; + button.classList.toggle('opacity-50', isDisabled); + button.classList.toggle('cursor-not-allowed', isDisabled); }); } +function updateOverlayScopeButtons() { + const allButton = getElement('overlay-scope-all'); + const contentOnlyButton = getElement( + 'overlay-scope-content-only' + ); + + const applyState = (button: HTMLButtonElement | null, active: boolean) => { + if (!button) return; + button.classList.toggle('bg-indigo-600', active); + button.classList.toggle('bg-gray-700', !active); + }; + + applyState(allButton, pageState.overlayChangeScope === 'all'); + applyState( + contentOnlyButton, + pageState.overlayChangeScope === 'content-only' + ); +} + +function updateExportMenuForViewMode() { + const overlayItems = document.querySelectorAll('.export-menu-item-overlay'); + const sideItems = document.querySelectorAll('.export-menu-item-side'); + const isOverlay = pageState.viewMode === 'overlay'; + + overlayItems.forEach((item) => { + item.classList.toggle('hidden', !isOverlay); + }); + sideItems.forEach((item) => { + item.classList.toggle('hidden', isOverlay); + }); +} + +function updateOverlayPreviewState() { + const canvas2 = getElement('canvas-compare-2'); + const panel2 = getElement('panel-2'); + const opacitySlider = getElement('opacity-slider'); + const activePair = getActivePair(); + const hasLeftPage = Boolean(activePair?.leftPageNumber); + const hasRightPage = Boolean(activePair?.rightPageNumber); + + if (!canvas2 || !panel2) { + return; + } + + if (pageState.viewMode !== 'overlay') { + canvas2.style.opacity = '1'; + panel2.style.opacity = '1'; + return; + } + + panel2.style.opacity = '1'; + + if (!hasRightPage) { + canvas2.style.opacity = '0'; + return; + } + + if (!hasLeftPage) { + canvas2.style.opacity = '1'; + return; + } + + if (pageState.overlayChangeScope === 'content-only') { + canvas2.style.opacity = '0'; + return; + } + + canvas2.style.opacity = pageState.overlayDocumentVisible + ? opacitySlider?.value || '0.5' + : '0'; +} + function updateSummary() { const comparison = pageState.currentComparison; const addedCount = getElement('summary-added-count'); @@ -182,14 +298,15 @@ function updateCategoryPills(comparison: ComparePageResult | null) { ]; const summary = comparison?.categorySummary; + const effectiveCategoryFilter = getEffectiveCategoryFilter(); for (const key of categoryKeys) { const countEl = getElement(`category-count-${key}`); const pill = getElement(`category-${key}`); if (countEl) countEl.textContent = summary ? summary[key].toString() : '0'; if (pill) { - pill.classList.toggle('active', pageState.categoryFilter[key]); - pill.classList.toggle('disabled', !pageState.categoryFilter[key]); + pill.classList.toggle('active', effectiveCategoryFilter[key]); + pill.classList.toggle('disabled', !effectiveCategoryFilter[key]); } } } @@ -349,10 +466,30 @@ function renderChangeList() { } function renderComparisonUI() { + updateOverlayScopeButtons(); + updateExportMenuForViewMode(); updateFilterButtons(); renderHighlights(); renderChangeList(); updateSummary(); + updateOverlayPreviewState(); + syncComparePaneHeights(); +} + +function syncComparePaneHeights() { + const wrapper = getElement('compare-viewer-wrapper'); + const sidebar = document.querySelector('.compare-sidebar'); + + if (!wrapper || !sidebar) { + return; + } + + if (window.innerWidth <= 1023) { + wrapper.style.height = ''; + return; + } + + wrapper.style.height = `${sidebar.offsetHeight}px`; } async function buildPagePairs() { @@ -528,8 +665,7 @@ function setViewMode(mode: 'overlay' | 'side-by-side') { btnSide.classList.add('bg-gray-700'); } if (canvas2 && opacitySlider) { - const panel2 = getElement('panel-2'); - if (panel2) panel2.style.opacity = opacitySlider.value; + canvas2.style.transition = 'opacity 150ms ease-in-out'; } pageState.isSyncScroll = true; } else { @@ -551,6 +687,11 @@ function setViewMode(mode: 'overlay' | 'side-by-side') { if (panel2) panel2.style.opacity = '1'; } + updateOverlayScopeButtons(); + updateExportMenuForViewMode(); + updateOverlayPreviewState(); + syncComparePaneHeights(); + const p1 = getElement('panel-1'); const p2 = getElement('panel-2'); if (mode === 'overlay' && p1 && p2) { @@ -715,28 +856,17 @@ document.addEventListener('DOMContentLoaded', function () { 'opacity-slider' ) as HTMLInputElement; - // Track flicker state - let flickerVisible = true; - if (flickerBtn) { flickerBtn.addEventListener('click', function () { - flickerVisible = !flickerVisible; - const p2 = getElement('panel-2'); - if (p2) { - p2.style.transition = 'opacity 150ms ease-in-out'; - p2.style.opacity = flickerVisible ? opacitySlider?.value || '0.5' : '0'; - } + pageState.overlayDocumentVisible = !pageState.overlayDocumentVisible; + updateOverlayPreviewState(); }); } if (opacitySlider) { opacitySlider.addEventListener('input', function () { - flickerVisible = true; - const p2 = getElement('panel-2'); - if (p2) { - p2.style.transition = ''; - p2.style.opacity = opacitySlider.value; - } + pageState.overlayDocumentVisible = true; + updateOverlayPreviewState(); }); } @@ -752,7 +882,12 @@ document.addEventListener('DOMContentLoaded', function () { ); const exportDropdownMenu = getElement('export-dropdown-menu'); const ocrToggle = getElement('ocr-toggle'); + const overlayOcrToggle = getElement('overlay-ocr-toggle'); const searchInput = getElement('compare-search-input'); + const overlayAllBtn = getElement('overlay-scope-all'); + const overlayContentOnlyBtn = getElement( + 'overlay-scope-content-only' + ); const filterButtons: Array<{ id: string; filter: CompareFilterType }> = [ { id: 'filter-modified', filter: 'modified' }, @@ -874,6 +1009,13 @@ document.addEventListener('DOMContentLoaded', function () { const button = getElement(id); if (!button) return; button.addEventListener('click', function () { + if ( + filter === 'style-changed' && + pageState.viewMode === 'overlay' && + pageState.overlayChangeScope === 'content-only' + ) { + return; + } if (pageState.activeFilter === filter) { pageState.activeFilter = 'all'; } else { @@ -897,6 +1039,13 @@ document.addEventListener('DOMContentLoaded', function () { const pill = getElement(`category-${key}`); if (pill) { pill.addEventListener('click', function () { + if ( + key === 'formatting' && + pageState.viewMode === 'overlay' && + pageState.overlayChangeScope === 'content-only' + ) { + return; + } pageState.categoryFilter[key] = !pageState.categoryFilter[key]; pageState.activeChangeIndex = 0; renderComparisonUI(); @@ -904,21 +1053,59 @@ document.addEventListener('DOMContentLoaded', function () { } } + if (overlayAllBtn) { + overlayAllBtn.addEventListener('click', function () { + pageState.overlayChangeScope = 'all'; + pageState.activeChangeIndex = 0; + pageState.overlayDocumentVisible = true; + renderComparisonUI(); + }); + } + + if (overlayContentOnlyBtn) { + overlayContentOnlyBtn.addEventListener('click', function () { + pageState.overlayChangeScope = 'content-only'; + if (pageState.activeFilter === 'style-changed') { + pageState.activeFilter = 'all'; + } + pageState.activeChangeIndex = 0; + pageState.overlayDocumentVisible = false; + renderComparisonUI(); + }); + } + + async function handleOcrToggleChange(nextValue: boolean) { + try { + pageState.useOcr = nextValue; + if (ocrToggle) { + ocrToggle.checked = nextValue; + } + if (overlayOcrToggle) { + overlayOcrToggle.checked = nextValue; + } + 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(); + } + } + if (ocrToggle) { ocrToggle.checked = pageState.useOcr; ocrToggle.addEventListener('change', async function () { - 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(); - } + await handleOcrToggleChange(ocrToggle.checked); + }); + } + + if (overlayOcrToggle) { + overlayOcrToggle.checked = pageState.useOcr; + overlayOcrToggle.addEventListener('change', async function () { + await handleOcrToggleChange(overlayOcrToggle.checked); }); } @@ -933,15 +1120,25 @@ document.addEventListener('DOMContentLoaded', function () { let resizeFrame = 0; window.addEventListener('resize', function () { if (!pageState.pdfDoc1 || !pageState.pdfDoc2) { + syncComparePaneHeights(); return; } window.cancelAnimationFrame(resizeFrame); resizeFrame = window.requestAnimationFrame(function () { + syncComparePaneHeights(); renderBothPages().catch(console.error); }); }); + const sidebar = document.querySelector('.compare-sidebar'); + if (sidebar && typeof ResizeObserver !== 'undefined') { + const resizeObserver = new ResizeObserver(function () { + syncComparePaneHeights(); + }); + resizeObserver.observe(sidebar); + } + if (exportDropdownBtn && exportDropdownMenu) { exportDropdownBtn.addEventListener('click', function (e) { e.stopPropagation(); @@ -971,6 +1168,24 @@ document.addEventListener('DOMContentLoaded', function () { pageState.pagePairs, function (message, percent) { showLoader(message, percent); + }, + { + useOcr: pageState.useOcr, + ocrLanguage: pageState.ocrLanguage, + showOverlayDocument: + pageState.viewMode === 'overlay' + ? pageState.overlayChangeScope === 'all' && + pageState.overlayDocumentVisible + : undefined, + overlayOpacity: + pageState.viewMode === 'overlay' + ? Number.parseFloat(opacitySlider?.value || '0.5') + : undefined, + includeChange: + pageState.viewMode === 'overlay' + ? (change) => + shouldIncludeChange(change, { includeSearch: false }) + : undefined, } ); } catch (e) { @@ -985,5 +1200,8 @@ document.addEventListener('DOMContentLoaded', function () { createIcons({ icons }); updateFilterButtons(); + updateOverlayScopeButtons(); + updateExportMenuForViewMode(); + syncComparePaneHeights(); setViewMode(pageState.viewMode); }); diff --git a/src/js/types/compare-pdfs-type.ts b/src/js/types/compare-pdfs-type.ts index 279e7a9..27fdc7a 100644 --- a/src/js/types/compare-pdfs-type.ts +++ b/src/js/types/compare-pdfs-type.ts @@ -1,6 +1,7 @@ export type { CompareState, ComparePdfExportMode, + CompareOverlayChangeScope, RenderedPage, ComparisonPageLoad, DiffFocusRegion, diff --git a/src/pages/compare-pdfs.html b/src/pages/compare-pdfs.html index 03346aa..31b31d9 100644 --- a/src/pages/compare-pdfs.html +++ b/src/pages/compare-pdfs.html @@ -72,14 +72,19 @@