From 66a75b1f2876b2dcd692a25e86abc6e6894bd9a0 Mon Sep 17 00:00:00 2001 From: plind <59729252+plind-junior@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:31:07 -0700 Subject: [PATCH] Add pixel comparison mode to Compare tool (#6109) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> --- frontend/package-lock.json | 22 + frontend/package.json | 1 + .../public/locales/en-GB/translation.toml | 27 + .../compare/ComparePixelWorkbenchView.tsx | 211 +++++++ .../tools/compare/CompareWorkbenchView.tsx | 17 +- .../components/tools/compare/compareView.css | 55 ++ .../tools/compare/useCompareOperation.ts | 104 +++- .../tools/compare/useCompareParameters.ts | 10 + .../src/core/services/pixelCompareService.ts | 198 +++++++ frontend/src/core/tools/Compare.tsx | 30 + frontend/src/core/types/compare.ts | 102 +++- frontend/src/core/utils/textDiff.ts | 2 +- .../src/core/workers/pixelCompareWorker.ts | 517 ++++++++++++++++++ frontend/vite.config.ts | 13 + 14 files changed, 1302 insertions(+), 7 deletions(-) create mode 100644 frontend/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx create mode 100644 frontend/src/core/services/pixelCompareService.ts create mode 100644 frontend/src/core/workers/pixelCompareWorker.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6328aa1c..0bb7efc56 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -68,6 +68,7 @@ "license-report": "^6.8.0", "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", + "pixelmatch": "^7.1.0", "posthog-js": "^1.268.0", "qrcode.react": "^4.2.0", "react": "^19.1.1", @@ -10880,6 +10881,18 @@ "node": ">=0.10.0" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -10934,6 +10947,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 511164b53..a26b3315f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -64,6 +64,7 @@ "license-report": "^6.8.0", "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", + "pixelmatch": "^7.1.0", "posthog-js": "^1.268.0", "qrcode.react": "^4.2.0", "react": "^19.1.1", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index cf746a705..a6497e02b 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -2810,6 +2810,33 @@ confirmTitle = "Re-run comparison?" unlinkedBody = "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." unlinkedTitle = "Independent scroll & pan enabled" +[compare.mode] +pixel = "Pixel Comparison" +text = "Text Comparison" + +[compare.pixel] +base = "Original" +changed = "changed" +comparison = "Edited" +diff = "Differences" +diffOnly = "Diff only" +missingInBase = "Missing in original" +missingInComparison = "Missing in edited" +overall = "overall" +overlay = "Overlay" +pageLabel = "Page" +pagesChanged = "pages changed" +sideBySide = "Side-by-side" +sizeMismatch = "Size mismatch" +summaryTitle = "Pixel comparison" + +[compare.pixel.warnings] +noPages = "One or both documents have no pages." +pageCountMismatch = "Page count mismatch: original has {{base}} page(s), edited has {{comparison}}. Extra pages are shown one-sided and marked as fully removed/added." + +[compare.pixel.errors] +canvasContextUnavailable = "Unable to acquire 2D canvas context for pixel comparison." + [compare.too.dissimilar] message = "These documents appear highly dissimilar. Comparison was stopped to save time." diff --git a/frontend/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx b/frontend/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx new file mode 100644 index 000000000..d0b16336a --- /dev/null +++ b/frontend/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx @@ -0,0 +1,211 @@ +import { useMemo, useState } from "react"; +import { Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; + +import type { + CompareResultPixelData, + ComparePixelPageResult, +} from "@app/types/compare"; +import "@app/components/tools/compare/compareView.css"; + +interface ComparePixelWorkbenchViewProps { + result: CompareResultPixelData; +} + +type PixelViewMode = "side-by-side" | "diff-only" | "overlay"; + +const formatPercent = (ratio: number): string => { + if (!Number.isFinite(ratio) || ratio <= 0) return "0%"; + if (ratio < 0.0001) return "<0.01%"; + return `${(ratio * 100).toFixed(2)}%`; +}; + +const PixelPageCard = ({ + page, + viewMode, + t, +}: { + page: ComparePixelPageResult; + viewMode: PixelViewMode; + t: TFunction; +}) => { + const aspectRatio = `${page.width} / ${page.height}`; + + return ( + + + + {t("compare.pixel.pageLabel", "Page")} {page.pageNumber} + + + {page.missingBase && ( + + {t("compare.pixel.missingInBase", "Missing in original")} + + )} + {page.missingComparison && ( + + {t("compare.pixel.missingInComparison", "Missing in edited")} + + )} + {page.sizeMismatch && ( + + {t("compare.pixel.sizeMismatch", "Size mismatch")} + + )} + 0 ? "red" : "green"} + variant="light" + > + {formatPercent(page.diffRatio)}{" "} + {t("compare.pixel.changed", "changed")} + + + + + {viewMode === "side-by-side" && ( +
+
+ {t("compare.pixel.base", +
{t("compare.pixel.base", "Original")}
+
+
+ {t("compare.pixel.diff", +
{t("compare.pixel.diff", "Differences")}
+
+
+ {t("compare.pixel.comparison", +
{t("compare.pixel.comparison", "Edited")}
+
+
+ )} + + {viewMode === "diff-only" && ( +
+ {t("compare.pixel.diff", +
+ )} + + {viewMode === "overlay" && ( +
+ {t("compare.pixel.base", + {t("compare.pixel.diff", +
+ )} +
+ ); +}; + +const ComparePixelWorkbenchView = ({ + result, +}: ComparePixelWorkbenchViewProps) => { + const { t } = useTranslation(); + const [viewMode, setViewMode] = useState("side-by-side"); + + const totalPercent = useMemo( + () => formatPercent(result.totals.diffRatio), + [result.totals.diffRatio], + ); + + return ( + + + + + {t("compare.pixel.summaryTitle", "Pixel comparison")} + + 0 ? "red" : "green"} + variant="light" + > + {totalPercent} {t("compare.pixel.overall", "overall")} + + + {result.totals.pagesWithChanges}/{result.pages.length}{" "} + {t("compare.pixel.pagesChanged", "pages changed")} + + + {result.settings.dpi} DPI + + + setViewMode(value as PixelViewMode)} + data={[ + { + value: "side-by-side", + label: t("compare.pixel.sideBySide", "Side-by-side"), + }, + { + value: "diff-only", + label: t("compare.pixel.diffOnly", "Diff only"), + }, + { value: "overlay", label: t("compare.pixel.overlay", "Overlay") }, + ]} + /> + + + {result.warnings.length > 0 && ( + + {result.warnings.map((w, i) => ( + + {w} + + ))} + + )} + + + {result.pages.map((page) => ( + + ))} + + + ); +}; + +export default ComparePixelWorkbenchView; diff --git a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx index 348d9b2d4..5d2842cd6 100644 --- a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx @@ -12,6 +12,7 @@ import { computeMaxSharedPages, } from "@app/components/tools/compare/compare"; import { CompareResultData, CompareWorkbenchData } from "@app/types/compare"; +import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView"; import { useFileContext } from "@app/contexts/file/fileHooks"; import { useRightRailButtons } from "@app/hooks/useRightRailButtons"; import CompareDocumentPane from "@app/components/tools/compare/CompareDocumentPane"; @@ -36,11 +37,25 @@ interface CompareWorkbenchViewProps { // helpers moved to compare.ts const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { + const rawResult = data?.result ?? null; + if (rawResult && rawResult.mode === "pixel") { + return ; + } + return ; +}; + +interface CompareTextWorkbenchViewProps { + data: CompareWorkbenchData | null; +} + +const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { const { t } = useTranslation(); const prefersStacked = useIsMobile(); const { selectors } = useFileContext(); - const result: CompareResultData | null = data?.result ?? null; + const rawResult = data?.result ?? null; + const result: CompareResultData | null = + rawResult && rawResult.mode === "text" ? rawResult : null; const baseFileId = data?.baseFileId ?? null; const comparisonFileId = data?.comparisonFileId ?? null; const isOperationLoading = data?.isLoading ?? false; diff --git a/frontend/src/core/components/tools/compare/compareView.css b/frontend/src/core/components/tools/compare/compareView.css index 64b46b436..c4c4278f0 100644 --- a/frontend/src/core/components/tools/compare/compareView.css +++ b/frontend/src/core/components/tools/compare/compareView.css @@ -521,3 +521,58 @@ margin-right: 0 !important; } } + +/* Pixel compare mode */ +.compare-pixel-workbench { + padding: 16px 24px 32px; + overflow-y: auto; +} +.compare-pixel-list { + padding-bottom: 32px; +} +.compare-pixel-page { + border: 1px solid var(--mantine-color-default-border, #dee2e6); + border-radius: 8px; + padding: 12px; + background: var(--mantine-color-body, #fff); +} +.compare-pixel-triptych { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; +} +.compare-pixel-triptych figure { + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.compare-pixel-triptych figure img { + display: block; + border: 1px solid var(--mantine-color-default-border, #dee2e6); + background: #fff; +} +.compare-pixel-triptych figcaption { + font-size: 11px; + text-align: center; + color: var(--mantine-color-dimmed, #868e96); +} +.compare-pixel-overlay { + position: relative; + width: 100%; +} +.compare-pixel-overlay-img { + display: block; + width: 100%; + border: 1px solid var(--mantine-color-default-border, #dee2e6); +} +.compare-pixel-overlay-top { + position: absolute; + inset: 0; + mix-blend-mode: normal; +} +@media (max-width: 768px) { + .compare-pixel-triptych { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/core/hooks/tools/compare/useCompareOperation.ts b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts index 2f0ae5efb..cee12c851 100644 --- a/frontend/src/core/hooks/tools/compare/useCompareOperation.ts +++ b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ADDITION_HIGHLIGHT, + CompareAnyResult, CompareDiffToken, CompareFilteredTokenInfo, CompareResultData, @@ -10,6 +11,10 @@ import { CompareWorkerWarnings, REMOVAL_HIGHLIGHT, } from "@app/types/compare"; +import { + runPixelCompare, + revokePixelResult, +} from "@app/services/pixelCompareService"; import { CompareParameters } from "@app/hooks/tools/compare/useCompareParameters"; import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation"; import type { StirlingFile } from "@app/types/fileContext"; @@ -28,8 +33,9 @@ import CompareWorkerCtor from "@app/workers/compareWorker?worker"; const LONG_RUNNING_PAGE_THRESHOLD = 2000; export interface CompareOperationHook extends ToolOperationHook { - result: CompareResultData | null; + result: CompareAnyResult | null; warnings: string[]; + pixelProgress: { current: number; total: number } | null; } // extractContentFromPdf moved to utils @@ -56,8 +62,23 @@ export const useCompareOperation = (): CompareOperationHook => { const [files, setFiles] = useState([]); const [downloadUrl, setDownloadUrl] = useState(null); const [downloadFilename, setDownloadFilename] = useState(""); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const [warnings, setWarnings] = useState([]); + const [pixelProgress, setPixelProgress] = useState<{ + current: number; + total: number; + } | null>(null); + const pixelSignalRef = useRef<{ cancelled: boolean }>({ cancelled: false }); + const pixelResultRef = useRef< + import("@app/types/compare").CompareResultPixelData | null + >(null); + + const revokePixelUrls = useCallback(() => { + if (pixelResultRef.current) { + revokePixelResult(pixelResultRef.current); + pixelResultRef.current = null; + } + }, []); const longRunningToastIdRef = useRef(null); const dissimilarityToastIdRef = useRef(null); const dissimilarityToastShownRef = useRef(false); @@ -77,16 +98,18 @@ export const useCompareOperation = (): CompareOperationHook => { }, []); const resetResults = useCallback(() => { + revokePixelUrls(); setResult(null); setWarnings([]); setFiles([]); + setPixelProgress(null); cleanupDownloadUrl(); setDownloadUrl(null); setDownloadFilename(""); setStatusState("idle"); setStatusDetailMs(null); setErrorMessage(null); - }, [cleanupDownloadUrl]); + }, [cleanupDownloadUrl, revokePixelUrls]); const clearError = useCallback(() => { setErrorMessage(null); @@ -248,12 +271,79 @@ export const useCompareOperation = (): CompareOperationHook => { setStatusDetailMs(null); setErrorMessage(null); setWarnings([]); + revokePixelUrls(); setResult(null); setFiles([]); + setPixelProgress(null); cleanupDownloadUrl(); setDownloadUrl(null); setDownloadFilename(""); + if (params.mode === "pixel") { + const operationStartPx = performance.now(); + pixelSignalRef.current = { cancelled: false }; + try { + setStatusState("processing"); + const pixelResult = await runPixelCompare({ + baseFile, + comparisonFile, + baseFileId: baseFile.fileId, + comparisonFileId: comparisonFile.fileId, + dpi: params.pixelDpi, + threshold: params.pixelThreshold, + onProgress: (current, total) => { + if (activeRunIdRef.current === runId) + setPixelProgress({ current, total }); + }, + signal: pixelSignalRef.current, + warnings: { + pageCountMismatch: t( + "compare.pixel.warnings.pageCountMismatch", + "Page count mismatch: original has {{base}} page(s), edited has {{comparison}}. Extra pages are shown one-sided and marked as fully removed/added.", + ), + noPages: t( + "compare.pixel.warnings.noPages", + "One or both documents have no pages.", + ), + }, + errors: { + canvasContextUnavailable: t( + "compare.pixel.errors.canvasContextUnavailable", + "Unable to acquire 2D canvas context for pixel comparison.", + ), + }, + }); + if (cancelledRef.current || activeRunIdRef.current !== runId) { + revokePixelResult(pixelResult); + return; + } + pixelResultRef.current = pixelResult; + setResult(pixelResult); + setWarnings(pixelResult.warnings); + setStatusState("complete"); + } catch (error: unknown) { + if (error instanceof Error && error.message === "CANCELLED") { + setStatusState("cancelled"); + } else { + console.error("[compare] pixel operation failed", error); + const fallback = t( + "compare.error.generic", + "Unable to compare these files.", + ); + setErrorMessage( + error instanceof Error && error.message + ? error.message + : fallback, + ); + } + } finally { + setStatusDetailMs(Math.round(performance.now() - operationStartPx)); + setIsLoading(false); + setPixelProgress(null); + } + return; + } + const warningMessages: CompareWorkerWarnings = { // No accuracy warning any more tooLargeMessage: t( @@ -456,6 +546,7 @@ export const useCompareOperation = (): CompareOperationHook => { const changes = buildChanges(tokens, baseMetadata, comparisonMetadata); const comparisonResult: CompareResultData = { + mode: "text", base: { fileId: baseFile.fileId, fileName: baseFile.name, @@ -557,8 +648,10 @@ export const useCompareOperation = (): CompareOperationHook => { const cancelOperation = useCallback(() => { if (!isLoading) return; cancelledRef.current = true; + pixelSignalRef.current.cancelled = true; setIsLoading(false); setStatusState("cancelled"); + setPixelProgress(null); if (workerRef.current) { try { workerRef.current.terminate(); @@ -579,6 +672,7 @@ export const useCompareOperation = (): CompareOperationHook => { useEffect(() => { return () => { cleanupDownloadUrl(); + revokePixelUrls(); if (workerRef.current) { workerRef.current.terminate(); workerRef.current = null; @@ -588,7 +682,7 @@ export const useCompareOperation = (): CompareOperationHook => { longRunningToastIdRef.current = null; } }; - }, [cleanupDownloadUrl]); + }, [cleanupDownloadUrl, revokePixelUrls]); const status = useMemo(() => { const label = @@ -626,6 +720,7 @@ export const useCompareOperation = (): CompareOperationHook => { undoOperation, result, warnings, + pixelProgress, }), [ cancelOperation, @@ -636,6 +731,7 @@ export const useCompareOperation = (): CompareOperationHook => { executeOperation, files, isLoading, + pixelProgress, resetResults, result, status, diff --git a/frontend/src/core/hooks/tools/compare/useCompareParameters.ts b/frontend/src/core/hooks/tools/compare/useCompareParameters.ts index 0614d0e1c..693f537fa 100644 --- a/frontend/src/core/hooks/tools/compare/useCompareParameters.ts +++ b/frontend/src/core/hooks/tools/compare/useCompareParameters.ts @@ -2,16 +2,26 @@ import { BaseParametersHook, useBaseParameters, } from "@app/hooks/tools/shared/useBaseParameters"; +import type { CompareMode } from "@app/types/compare"; import type { FileId } from "@app/types/file"; +export const DEFAULT_PIXEL_DPI = 150; +export const DEFAULT_PIXEL_THRESHOLD = 0.1; + export interface CompareParameters { baseFileId: FileId | null; comparisonFileId: FileId | null; + mode: CompareMode; + pixelDpi: number; + pixelThreshold: number; } export const defaultParameters: CompareParameters = { baseFileId: null, comparisonFileId: null, + mode: "text", + pixelDpi: DEFAULT_PIXEL_DPI, + pixelThreshold: DEFAULT_PIXEL_THRESHOLD, }; export type CompareParametersHook = BaseParametersHook; diff --git a/frontend/src/core/services/pixelCompareService.ts b/frontend/src/core/services/pixelCompareService.ts new file mode 100644 index 000000000..0e6158292 --- /dev/null +++ b/frontend/src/core/services/pixelCompareService.ts @@ -0,0 +1,198 @@ +import PixelCompareWorkerCtor from "@app/workers/pixelCompareWorker?worker"; +import { + ADDITION_HIGHLIGHT, + REMOVAL_HIGHLIGHT, + type ComparePixelPageResult, + type CompareResultPixelData, + type PixelCompareWorkerErrors, + type PixelCompareWorkerRequest, + type PixelCompareWorkerResponse, + type PixelCompareWorkerWarnings, + type PixelRgb, +} from "@app/types/compare"; + +const hexToRgb = (hex: string): PixelRgb => { + const normalised = hex.replace("#", ""); + const value = parseInt( + normalised.length === 3 + ? normalised + .split("") + .map((c) => c + c) + .join("") + : normalised, + 16, + ); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +}; + +// Keep pixel mode visually consistent with text mode: +// - removed (content in base, not in comparison) → REMOVAL_HIGHLIGHT (red) +// - added (content in comparison, not in base) → ADDITION_HIGHLIGHT (green) +const DEFAULT_DIFF_COLOR: PixelRgb = hexToRgb(REMOVAL_HIGHLIGHT); +const DEFAULT_DIFF_COLOR_ALT: PixelRgb = hexToRgb(ADDITION_HIGHLIGHT); + +export const revokePixelResult = ( + result: CompareResultPixelData | null | undefined, +): void => { + if (!result) return; + for (const page of result.pages) { + URL.revokeObjectURL(page.baseImageUrl); + URL.revokeObjectURL(page.comparisonImageUrl); + URL.revokeObjectURL(page.diffImageUrl); + } +}; + +export interface RunPixelCompareArgs { + baseFile: File; + comparisonFile: File; + baseFileId: string; + comparisonFileId: string; + dpi: number; + threshold: number; + onProgress?: (pageNumber: number, totalPages: number) => void; + onPageReady?: (page: ComparePixelPageResult) => void; + signal?: { cancelled: boolean }; + warnings: PixelCompareWorkerWarnings; + errors: PixelCompareWorkerErrors; + // Max in-flight pages processed in parallel inside the worker. + concurrency?: number; + // Override diff highlight colours. Defaults to REMOVAL_HIGHLIGHT / ADDITION_HIGHLIGHT + // so pixel mode matches text mode (removed = red, added = green). + diffColor?: PixelRgb; + diffColorAlt?: PixelRgb; +} + +export const runPixelCompare = async ({ + baseFile, + comparisonFile, + baseFileId, + comparisonFileId, + dpi, + threshold, + onProgress, + onPageReady, + signal, + warnings, + errors, + concurrency, + diffColor = DEFAULT_DIFF_COLOR, + diffColorAlt = DEFAULT_DIFF_COLOR_ALT, +}: RunPixelCompareArgs): Promise => { + if (signal?.cancelled) throw new Error("CANCELLED"); + + const worker = new PixelCompareWorkerCtor(); + const emittedUrls: string[] = []; + const pages: ComparePixelPageResult[] = []; + + const cleanupOnFailure = () => { + for (const url of emittedUrls) URL.revokeObjectURL(url); + }; + + return await new Promise((resolve, reject) => { + const handleMessage = (event: MessageEvent) => { + const message = event.data; + if (!message) return; + if (signal?.cancelled) { + terminateWorker(); + cleanupOnFailure(); + reject(new Error("CANCELLED")); + return; + } + + switch (message.type) { + case "progress": { + onProgress?.(message.pageNumber, message.totalPages); + break; + } + case "page": { + const payload = message.page; + const baseUrl = URL.createObjectURL(payload.baseBlob); + const comparisonUrl = URL.createObjectURL(payload.comparisonBlob); + const diffUrl = URL.createObjectURL(payload.diffBlob); + emittedUrls.push(baseUrl, comparisonUrl, diffUrl); + const pageResult: ComparePixelPageResult = { + pageNumber: payload.pageNumber, + width: payload.width, + height: payload.height, + baseImageUrl: baseUrl, + comparisonImageUrl: comparisonUrl, + diffImageUrl: diffUrl, + diffPixels: payload.diffPixels, + totalPixels: payload.totalPixels, + diffRatio: payload.diffRatio, + sizeMismatch: payload.sizeMismatch, + ...(payload.missingBase ? { missingBase: true } : {}), + ...(payload.missingComparison ? { missingComparison: true } : {}), + }; + pages.push(pageResult); + onPageReady?.(pageResult); + break; + } + case "success": { + pages.sort((a, b) => a.pageNumber - b.pageNumber); + terminateWorker(); + resolve({ + mode: "pixel", + base: { fileId: baseFileId, fileName: baseFile.name }, + comparison: { + fileId: comparisonFileId, + fileName: comparisonFile.name, + }, + pages, + totals: { + ...message.totals, + processedAt: Date.now(), + }, + warnings: message.warnings, + settings: { dpi, threshold }, + }); + break; + } + case "error": { + terminateWorker(); + cleanupOnFailure(); + reject(new Error(message.message)); + break; + } + } + }; + + const handleError = (event: ErrorEvent) => { + terminateWorker(); + cleanupOnFailure(); + reject( + event.error ?? new Error(event.message || "Pixel compare worker error"), + ); + }; + + const terminateWorker = () => { + worker.removeEventListener("message", handleMessage as EventListener); + worker.removeEventListener("error", handleError as EventListener); + try { + worker.terminate(); + } catch { + /* swallow */ + } + }; + + worker.addEventListener("message", handleMessage as EventListener); + worker.addEventListener("error", handleError as EventListener); + + const request: PixelCompareWorkerRequest = { + type: "pixel-compare", + payload: { + baseFile, + comparisonFile, + dpi, + threshold, + concurrency, + warnings, + errors, + diffColor, + diffColorAlt, + }, + }; + + worker.postMessage(request); + }); +}; diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx index c88ee801a..7cc36b86b 100644 --- a/frontend/src/core/tools/Compare.tsx +++ b/frontend/src/core/tools/Compare.tsx @@ -9,6 +9,7 @@ import { Button, Modal, ActionIcon, + SegmentedControl, } from "@mantine/core"; import SwapVertRoundedIcon from "@mui/icons-material/SwapVertRounded"; import AddIcon from "@mui/icons-material/Add"; @@ -638,6 +639,35 @@ const Compare = (props: BaseToolProps) => { width: "100%", }} > + {/* Mode toggle */} + + + base.params.setParameters((prev) => ({ + ...prev, + mode: value as "text" | "pixel", + })) + } + data={[ + { + value: "text", + label: t("compare.mode.text", "Text Comparison"), + }, + { + value: "pixel", + label: t("compare.mode.pixel", "Pixel Comparison"), + }, + ]} + /> + {/* Header row: Original PDF + Clear selected aligned to swap column */} ; + warnings: string[]; + } + | { type: "error"; message: string }; + export interface CompareDocumentPaneProps { pane: "base" | "comparison"; layout: "side-by-side" | "stacked"; @@ -326,8 +424,10 @@ export interface WordHighlightEntry { // Removed legacy upload section types; upload flow now uses the standard active files workbench +export type CompareAnyResult = CompareResultData | CompareResultPixelData; + export interface CompareWorkbenchData { - result: CompareResultData | null; + result: CompareAnyResult | null; baseFileId: FileId | null; comparisonFileId: FileId | null; onSelectBase?: (fileId: FileId | null) => void; diff --git a/frontend/src/core/utils/textDiff.ts b/frontend/src/core/utils/textDiff.ts index 8891fc193..ecbb65e1c 100644 --- a/frontend/src/core/utils/textDiff.ts +++ b/frontend/src/core/utils/textDiff.ts @@ -1,4 +1,4 @@ -// Shared text diff and normalization utilities for compare tool +// Shared text Comparison and normalization utilities for compare tool export const shouldConcatWithoutSpace = (word: string) => { return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s"; diff --git a/frontend/src/core/workers/pixelCompareWorker.ts b/frontend/src/core/workers/pixelCompareWorker.ts new file mode 100644 index 000000000..bb57bb75f --- /dev/null +++ b/frontend/src/core/workers/pixelCompareWorker.ts @@ -0,0 +1,517 @@ +/// + +import { + GlobalWorkerOptions, + getDocument, + type PDFDocumentProxy, + type PDFPageProxy, +} from "pdfjs-dist/legacy/build/pdf.mjs"; +import pixelmatch from "pixelmatch"; + +import type { + PixelCompareWorkerPagePayload, + PixelCompareWorkerRequest, + PixelCompareWorkerResponse, + PixelCompareWorkerWarnings, +} from "@app/types/compare"; + +declare const self: DedicatedWorkerGlobalScope; + +GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/legacy/build/pdf.worker.min.mjs", + import.meta.url, +).toString(); + +// PDF.js' default canvas factory assumes a DOM (`document.createElement('canvas')`). +// Inside a Web Worker there's no DOM, so we provide an OffscreenCanvas-based factory. +interface CanvasAndContext { + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; +} + +interface ErrorStrings { + canvasContextUnavailable: string; +} + +const DEFAULT_ERRORS: ErrorStrings = { + canvasContextUnavailable: "Unable to acquire 2D canvas context.", +}; + +// pdfjs-dist 5.x expects CanvasFactory and FilterFactory to be **class constructors** +// (it does `new CanvasFactory({ ownerDocument, enableHWA })` internally), so we build +// the class on demand with request-scoped error strings captured in its closure. +const createOffscreenCanvasFactory = (errorStrings: ErrorStrings) => + class OffscreenCanvasFactory { + constructor(_opts?: { ownerDocument?: unknown; enableHWA?: boolean }) { + /* ownerDocument/enableHWA ignored — we always use OffscreenCanvas */ + } + + create(width: number, height: number): CanvasAndContext { + const canvas = new OffscreenCanvas( + Math.max(1, width), + Math.max(1, height), + ); + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error(errorStrings.canvasContextUnavailable); + return { canvas, context }; + } + + reset( + canvasAndContext: CanvasAndContext, + width: number, + height: number, + ): void { + canvasAndContext.canvas.width = Math.max(1, width); + canvasAndContext.canvas.height = Math.max(1, height); + } + + destroy(canvasAndContext: CanvasAndContext): void { + canvasAndContext.canvas.width = 0; + canvasAndContext.canvas.height = 0; + (canvasAndContext as { canvas: OffscreenCanvas | null }).canvas = null; + ( + canvasAndContext as { + context: OffscreenCanvasRenderingContext2D | null; + } + ).context = null; + } + }; + +// BaseFilterFactory's defaults already return "none" for every filter, which is what +// we want in a worker (no DOM, no SVG). Re-declare the same no-op class so pdfjs can +// instantiate it without hitting DOMFilterFactory's document.createElementNS calls. +class NoopFilterFactory { + constructor(_opts?: { docId?: string; ownerDocument?: unknown }) { + /* noop */ + } + addFilter() { + return "none"; + } + addHCMFilter() { + return "none"; + } + addAlphaFilter() { + return "none"; + } + addLuminosityFilter() { + return "none"; + } + addHighlightHCMFilter() { + return "none"; + } + destroy(_keepHCM?: boolean) { + /* noop */ + } +} + +const CSS_DPI = 72; + +const post = ( + message: PixelCompareWorkerResponse, + transfer: Transferable[] = [], +) => { + self.postMessage(message, transfer); +}; + +const formatWarning = ( + template: string, + values: Record, +): string => + template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => + String(values[key] ?? ""), + ); + +const renderPageToBitmap = async ( + page: PDFPageProxy, + scale: number, + targetWidth: number, + targetHeight: number, + errorStrings: ErrorStrings, +): Promise<{ imageData: ImageData; bitmap: ImageBitmap }> => { + const viewport = page.getViewport({ scale }); + const renderedW = Math.max(1, Math.round(viewport.width)); + const renderedH = Math.max(1, Math.round(viewport.height)); + + const canvas = new OffscreenCanvas(targetWidth, targetHeight); + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) throw new Error(errorStrings.canvasContextUnavailable); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, targetWidth, targetHeight); + + const offsetX = Math.round((targetWidth - renderedW) / 2); + const offsetY = Math.round((targetHeight - renderedH) / 2); + ctx.save(); + ctx.translate(offsetX, offsetY); + // pdfjs accepts OffscreenCanvas via canvas option + await page.render({ + canvas: canvas as unknown as HTMLCanvasElement, + canvasContext: ctx as unknown as CanvasRenderingContext2D, + viewport, + }).promise; + ctx.restore(); + + const imageData = ctx.getImageData(0, 0, targetWidth, targetHeight); + const bitmap = canvas.transferToImageBitmap(); + return { imageData, bitmap }; +}; + +const ENCODE_OPTS: ImageEncodeOptions = { type: "image/webp", quality: 0.85 }; + +const bitmapToBlob = async ( + bitmap: ImageBitmap, + width: number, + height: number, + errorStrings: ErrorStrings, +): Promise => { + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error(errorStrings.canvasContextUnavailable); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + return await canvas.convertToBlob(ENCODE_OPTS); +}; + +const diffDataToBlob = async ( + diff: ImageData, + width: number, + height: number, + errorStrings: ErrorStrings, +): Promise => { + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error(errorStrings.canvasContextUnavailable); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + ctx.putImageData(diff, 0, 0); + return await canvas.convertToBlob(ENCODE_OPTS); +}; + +interface PageTotals { + diffPixels: number; + totalPixels: number; + hasChanges: boolean; +} + +interface PixelMatchColours { + diffColor: [number, number, number]; + diffColorAlt?: [number, number, number]; +} + +// Produce a fully-white ImageData + matching ImageBitmap for use when one side of the +// comparison has no corresponding page (the other PDF was shorter). +const createBlankRender = async ( + width: number, + height: number, + errorStrings: ErrorStrings, +): Promise<{ imageData: ImageData; bitmap: ImageBitmap }> => { + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) throw new Error(errorStrings.canvasContextUnavailable); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + const imageData = ctx.getImageData(0, 0, width, height); + const bitmap = canvas.transferToImageBitmap(); + return { imageData, bitmap }; +}; + +const processPage = async ( + baseDoc: PDFDocumentProxy, + compDoc: PDFDocumentProxy, + pageNumber: number, + basePages: number, + compPages: number, + scale: number, + threshold: number, + colours: PixelMatchColours, + errorStrings: ErrorStrings, +): Promise<{ payload: PixelCompareWorkerPagePayload; totals: PageTotals }> => { + const missingBase = pageNumber > basePages; + const missingComparison = pageNumber > compPages; + + const basePage = missingBase ? null : await baseDoc.getPage(pageNumber); + const compPage = missingComparison ? null : await compDoc.getPage(pageNumber); + try { + // Determine target dimensions from whichever side(s) exist. When only one side has + // this page, the blank side is rendered at the same canvas size so pixelmatch can + // flag every non-white pixel as a diff. + const baseViewport = basePage ? basePage.getViewport({ scale }) : null; + const compViewport = compPage ? compPage.getViewport({ scale }) : null; + + const refWidth = baseViewport?.width ?? compViewport?.width ?? 1; + const refHeight = baseViewport?.height ?? compViewport?.height ?? 1; + const targetWidth = Math.max( + 1, + Math.round( + Math.max( + baseViewport?.width ?? refWidth, + compViewport?.width ?? refWidth, + ), + ), + ); + const targetHeight = Math.max( + 1, + Math.round( + Math.max( + baseViewport?.height ?? refHeight, + compViewport?.height ?? refHeight, + ), + ), + ); + const sizeMismatch = + baseViewport && compViewport + ? Math.round(baseViewport.width) !== Math.round(compViewport.width) || + Math.round(baseViewport.height) !== Math.round(compViewport.height) + : false; + + const [base, comp] = await Promise.all([ + basePage && baseViewport + ? renderPageToBitmap( + basePage, + scale, + targetWidth, + targetHeight, + errorStrings, + ) + : createBlankRender(targetWidth, targetHeight, errorStrings), + compPage && compViewport + ? renderPageToBitmap( + compPage, + scale, + targetWidth, + targetHeight, + errorStrings, + ) + : createBlankRender(targetWidth, targetHeight, errorStrings), + ]); + + const diffImage = new ImageData(targetWidth, targetHeight); + const diffCount = pixelmatch( + base.imageData.data, + comp.imageData.data, + diffImage.data, + targetWidth, + targetHeight, + { + threshold, + includeAA: true, + alpha: 0.3, + diffColor: colours.diffColor, + ...(colours.diffColorAlt ? { diffColorAlt: colours.diffColorAlt } : {}), + }, + ); + + const [baseBlob, comparisonBlob, diffBlob] = await Promise.all([ + bitmapToBlob(base.bitmap, targetWidth, targetHeight, errorStrings), + bitmapToBlob(comp.bitmap, targetWidth, targetHeight, errorStrings), + diffDataToBlob(diffImage, targetWidth, targetHeight, errorStrings), + ]); + + const totalPixels = targetWidth * targetHeight; + + const payload: PixelCompareWorkerPagePayload = { + pageNumber, + width: targetWidth, + height: targetHeight, + baseBlob, + comparisonBlob, + diffBlob, + diffPixels: diffCount, + totalPixels, + diffRatio: totalPixels > 0 ? diffCount / totalPixels : 0, + sizeMismatch, + ...(missingBase ? { missingBase: true } : {}), + ...(missingComparison ? { missingComparison: true } : {}), + }; + + return { + payload, + totals: { + diffPixels: diffCount, + totalPixels, + hasChanges: diffCount > 0, + }, + }; + } finally { + basePage?.cleanup(); + compPage?.cleanup(); + } +}; + +const runPool = async ( + items: number[], + concurrency: number, + worker: (pageNumber: number) => Promise, + onResult: (pageNumber: number, value: T) => void, +): Promise => { + let cursor = 0; + const runners: Promise[] = []; + const limit = Math.max(1, Math.min(concurrency, items.length)); + for (let i = 0; i < limit; i += 1) { + runners.push( + (async () => { + while (cursor < items.length) { + const idx = cursor; + cursor += 1; + const pageNumber = items[idx]; + const value = await worker(pageNumber); + onResult(pageNumber, value); + } + })(), + ); + } + await Promise.all(runners); +}; + +self.addEventListener( + "message", + async (event: MessageEvent) => { + const message = event.data; + if (!message || message.type !== "pixel-compare") return; + + const { + baseFile, + comparisonFile, + dpi, + threshold, + concurrency, + warnings: warningTemplates, + errors: errorTemplates, + diffColor, + diffColorAlt, + } = message.payload; + const errorStrings: ErrorStrings = { ...DEFAULT_ERRORS, ...errorTemplates }; + const OffscreenCanvasFactory = createOffscreenCanvasFactory(errorStrings); + const colours: PixelMatchColours = { diffColor, diffColorAlt }; + const warnings: string[] = []; + const scale = Math.max(0.5, dpi / CSS_DPI); + const startedAt = performance.now(); + + let baseDoc: PDFDocumentProxy | null = null; + let compDoc: PDFDocumentProxy | null = null; + + try { + // `CanvasFactory`/`FilterFactory` are supported at runtime but not declared on legacy types. + // pdfjs-dist 5.x renamed the option to `CanvasFactory` (capital C); without a FilterFactory + // the default DOMFilterFactory crashes in a worker calling document.createElementNS. + // Resolve CMap/standard-font URLs against the worker's own origin. Vite copies these + // directories from pdfjs-dist at build time via viteStaticCopy (see vite.config.ts). + const assetsBase = new URL("/pdfjs/", self.location.origin).toString(); + const loaderOpts = (data: ArrayBuffer) => + ({ + data, + CanvasFactory: OffscreenCanvasFactory, + FilterFactory: NoopFilterFactory, + cMapUrl: `${assetsBase}cmaps/`, + cMapPacked: true, + standardFontDataUrl: `${assetsBase}standard_fonts/`, + // Force glyph-path rendering. The default path uses the FontFace / document.fonts + // APIs which reference `globalThis.document` — undefined in a DedicatedWorker, so + // text either silently fails to install or renders as placeholder glyphs. + disableFontFace: true, + useSystemFonts: false, + isEvalSupported: false, + }) as unknown as Parameters[0]; + + const [baseBuffer, comparisonBuffer] = await Promise.all([ + baseFile.arrayBuffer(), + comparisonFile.arrayBuffer(), + ]); + + [baseDoc, compDoc] = await Promise.all([ + getDocument(loaderOpts(baseBuffer)).promise, + getDocument(loaderOpts(comparisonBuffer)).promise, + ]); + + const basePages = baseDoc.numPages; + const compPages = compDoc.numPages; + const sharedPages = Math.min(basePages, compPages); + + if (basePages !== compPages) { + warnings.push( + formatWarning(warningTemplates.pageCountMismatch, { + base: basePages, + comparison: compPages, + shared: sharedPages, + }), + ); + } + if (Math.max(basePages, compPages) === 0) { + throw new Error(warningTemplates.noPages); + } + + // Walk every page in the longer document. Pages past the shorter document's length + // are processed one-sided: the missing side is rendered blank and the whole present + // side is treated as a diff. + const totalPages = Math.max(basePages, compPages); + const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1); + const hwThreads = self.navigator?.hardwareConcurrency ?? 4; + const defaultConcurrency = Math.max(2, Math.min(8, hwThreads - 1)); + const poolSize = Math.max( + 1, + Math.min(concurrency ?? defaultConcurrency, totalPages), + ); + + let totalDiffPixels = 0; + let totalPixelsCount = 0; + let pagesWithChanges = 0; + + await runPool( + pageNumbers, + poolSize, + async (pageNumber) => { + post({ type: "progress", pageNumber, totalPages }); + return await processPage( + baseDoc as PDFDocumentProxy, + compDoc as PDFDocumentProxy, + pageNumber, + basePages, + compPages, + scale, + threshold, + colours, + errorStrings, + ); + }, + (_pageNumber, value) => { + totalDiffPixels += value.totals.diffPixels; + totalPixelsCount += value.totals.totalPixels; + if (value.totals.hasChanges) pagesWithChanges += 1; + post({ type: "page", page: value.payload }); + }, + ); + + post({ + type: "success", + totals: { + diffPixels: totalDiffPixels, + totalPixels: totalPixelsCount, + diffRatio: + totalPixelsCount > 0 ? totalDiffPixels / totalPixelsCount : 0, + pagesWithChanges, + durationMs: performance.now() - startedAt, + }, + warnings, + }); + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : String(error ?? "Unknown error"); + post({ type: "error", message }); + } finally { + try { + if (baseDoc) await baseDoc.destroy(); + } catch { + /* swallow */ + } + try { + if (compDoc) await compDoc.destroy(); + } catch { + /* swallow */ + } + } + }, +); + +// Re-export for type consumers so the import is not dropped. +export type { PixelCompareWorkerWarnings }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bc1079708..174f0a23b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -57,6 +57,19 @@ export default defineConfig(({ mode }) => { src: "public/vendor/jscanify/*", dest: "vendor/jscanify", }, + { + // pdfjs-dist CMap data for CJK / non-latin glyph mapping — required + // when rendering PDFs inside workers where the default DOM fetch paths + // aren't available. + src: "node_modules/pdfjs-dist/cmaps/*", + dest: "pdfjs/cmaps", + }, + { + // pdfjs-dist standard font data (Helvetica/Times/etc.) — needed so + // workers can substitute non-embedded base 14 fonts without DOM access. + src: "node_modules/pdfjs-dist/standard_fonts/*", + dest: "pdfjs/standard_fonts", + }, ], }), ],