Add pixel comparison mode to Compare tool (#6109)

Co-authored-by: Anthony Stirling <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
plind
2026-04-20 19:31:07 +01:00
committed by GitHub
co-authored by Anthony Stirling EthanHealy01
parent 089e448cf4
commit 66a75b1f28
14 changed files with 1302 additions and 7 deletions
+22
View File
@@ -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",
+1
View File
@@ -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",
@@ -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."
@@ -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 (
<Stack gap="xs" className="compare-pixel-page">
<Group justify="space-between" align="center">
<Text size="sm" fw={600}>
{t("compare.pixel.pageLabel", "Page")} {page.pageNumber}
</Text>
<Group gap="xs">
{page.missingBase && (
<Badge size="xs" color="red" variant="light">
{t("compare.pixel.missingInBase", "Missing in original")}
</Badge>
)}
{page.missingComparison && (
<Badge size="xs" color="red" variant="light">
{t("compare.pixel.missingInComparison", "Missing in edited")}
</Badge>
)}
{page.sizeMismatch && (
<Badge size="xs" color="yellow" variant="light">
{t("compare.pixel.sizeMismatch", "Size mismatch")}
</Badge>
)}
<Badge
size="xs"
color={page.diffPixels > 0 ? "red" : "green"}
variant="light"
>
{formatPercent(page.diffRatio)}{" "}
{t("compare.pixel.changed", "changed")}
</Badge>
</Group>
</Group>
{viewMode === "side-by-side" && (
<div className="compare-pixel-triptych">
<figure>
<img
src={page.baseImageUrl}
alt={t("compare.pixel.base", "Original")}
loading="lazy"
decoding="async"
style={{ aspectRatio, width: "100%" }}
/>
<figcaption>{t("compare.pixel.base", "Original")}</figcaption>
</figure>
<figure>
<img
src={page.diffImageUrl}
alt={t("compare.pixel.diff", "Differences")}
loading="lazy"
decoding="async"
style={{ aspectRatio, width: "100%" }}
/>
<figcaption>{t("compare.pixel.diff", "Differences")}</figcaption>
</figure>
<figure>
<img
src={page.comparisonImageUrl}
alt={t("compare.pixel.comparison", "Edited")}
loading="lazy"
decoding="async"
style={{ aspectRatio, width: "100%" }}
/>
<figcaption>{t("compare.pixel.comparison", "Edited")}</figcaption>
</figure>
</div>
)}
{viewMode === "diff-only" && (
<figure>
<img
src={page.diffImageUrl}
alt={t("compare.pixel.diff", "Differences")}
loading="lazy"
decoding="async"
style={{ aspectRatio, width: "100%" }}
/>
</figure>
)}
{viewMode === "overlay" && (
<div className="compare-pixel-overlay" style={{ aspectRatio }}>
<img
src={page.baseImageUrl}
alt={t("compare.pixel.base", "Original")}
loading="lazy"
decoding="async"
className="compare-pixel-overlay-img"
/>
<img
src={page.diffImageUrl}
alt={t("compare.pixel.diff", "Differences")}
loading="lazy"
decoding="async"
className="compare-pixel-overlay-img compare-pixel-overlay-top"
/>
</div>
)}
</Stack>
);
};
const ComparePixelWorkbenchView = ({
result,
}: ComparePixelWorkbenchViewProps) => {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<PixelViewMode>("side-by-side");
const totalPercent = useMemo(
() => formatPercent(result.totals.diffRatio),
[result.totals.diffRatio],
);
return (
<Stack className="compare-workbench compare-pixel-workbench" gap="md">
<Group justify="space-between" align="center" wrap="wrap">
<Group gap="md">
<Text fw={600}>
{t("compare.pixel.summaryTitle", "Pixel comparison")}
</Text>
<Badge
color={result.totals.diffPixels > 0 ? "red" : "green"}
variant="light"
>
{totalPercent} {t("compare.pixel.overall", "overall")}
</Badge>
<Badge color="blue" variant="light">
{result.totals.pagesWithChanges}/{result.pages.length}{" "}
{t("compare.pixel.pagesChanged", "pages changed")}
</Badge>
<Badge color="gray" variant="light">
{result.settings.dpi} DPI
</Badge>
</Group>
<SegmentedControl
size="xs"
value={viewMode}
onChange={(value) => 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") },
]}
/>
</Group>
{result.warnings.length > 0 && (
<Stack gap={4}>
{result.warnings.map((w, i) => (
<Text key={i} size="xs" c="yellow.7">
{w}
</Text>
))}
</Stack>
)}
<Stack gap="lg" className="compare-pixel-list">
{result.pages.map((page) => (
<PixelPageCard
key={page.pageNumber}
page={page}
viewMode={viewMode}
t={t}
/>
))}
</Stack>
</Stack>
);
};
export default ComparePixelWorkbenchView;
@@ -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 <ComparePixelWorkbenchView result={rawResult} />;
}
return <CompareTextWorkbenchView data={data} />;
};
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;
@@ -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;
}
}
@@ -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<CompareParameters> {
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<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState("");
const [result, setResult] = useState<CompareResultData | null>(null);
const [result, setResult] = useState<CompareAnyResult | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
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<string | null>(null);
const dissimilarityToastIdRef = useRef<string | null>(null);
const dissimilarityToastShownRef = useRef<boolean>(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,
@@ -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<CompareParameters>;
@@ -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<CompareResultPixelData> => {
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<CompareResultPixelData>((resolve, reject) => {
const handleMessage = (event: MessageEvent<PixelCompareWorkerResponse>) => {
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);
});
};
+30
View File
@@ -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 */}
<Box
style={{
gridColumn: hasBothSelected ? "1 / span 2" : "1",
marginTop: "0.25rem",
}}
>
<SegmentedControl
fullWidth
size="xs"
value={params.mode}
onChange={(value) =>
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"),
},
]}
/>
</Box>
{/* Header row: Original PDF + Clear selected aligned to swap column */}
<Box
style={{
+101 -1
View File
@@ -65,7 +65,48 @@ export interface CompareChange {
comparison: CompareChangeSide | null;
}
export type CompareMode = "text" | "pixel";
export interface ComparePixelPageResult {
pageNumber: number;
width: number;
height: number;
baseImageUrl: string;
comparisonImageUrl: string;
diffImageUrl: string;
diffPixels: number;
totalPixels: number;
diffRatio: number;
sizeMismatch: boolean;
// One side of the comparison had no corresponding page (the other PDF was shorter).
// The missing side is rendered as a blank white canvas and the entire opposite page
// is marked as diff.
missingBase?: boolean;
missingComparison?: boolean;
}
export interface CompareResultPixelData {
mode: "pixel";
base: { fileId: string; fileName: string };
comparison: { fileId: string; fileName: string };
pages: ComparePixelPageResult[];
totals: {
diffPixels: number;
totalPixels: number;
diffRatio: number;
pagesWithChanges: number;
durationMs: number;
processedAt: number;
};
warnings: string[];
settings: {
dpi: number;
threshold: number;
};
}
export interface CompareResultData {
mode: "text";
base: CompareDocumentInfo;
comparison: CompareDocumentInfo;
totals: {
@@ -146,6 +187,63 @@ export type CompareWorkerResponse =
code?: "EMPTY_TEXT" | "TOO_LARGE" | "TOO_DISSIMILAR";
};
export interface PixelCompareWorkerWarnings {
pageCountMismatch: string; // supports {{base}}, {{comparison}}, {{shared}}
noPages: string;
}
export interface PixelCompareWorkerErrors {
canvasContextUnavailable: string;
}
export type PixelRgb = [number, number, number];
export interface PixelCompareWorkerRequest {
type: "pixel-compare";
payload: {
// File objects (not ArrayBuffers): structured-cloning a File is O(1) since
// the underlying blob storage is reference-counted, so we avoid reading
// the whole PDF into memory on the main thread before posting.
baseFile: File;
comparisonFile: File;
dpi: number;
threshold: number;
concurrency?: number;
warnings: PixelCompareWorkerWarnings;
errors: PixelCompareWorkerErrors;
// Colour for pixels removed from the base (content in base, missing in comparison).
diffColor: PixelRgb;
// Colour for pixels added in the comparison (content in comparison, missing in base).
// When null/undefined, pixelmatch falls back to diffColor for all diffs.
diffColorAlt?: PixelRgb;
};
}
export interface PixelCompareWorkerPagePayload {
pageNumber: number;
width: number;
height: number;
baseBlob: Blob;
comparisonBlob: Blob;
diffBlob: Blob;
diffPixels: number;
totalPixels: number;
diffRatio: number;
sizeMismatch: boolean;
missingBase?: boolean;
missingComparison?: boolean;
}
export type PixelCompareWorkerResponse =
| { type: "progress"; pageNumber: number; totalPages: number }
| { type: "page"; page: PixelCompareWorkerPagePayload }
| {
type: "success";
totals: Omit<CompareResultPixelData["totals"], "processedAt">;
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;
+1 -1
View File
@@ -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";
@@ -0,0 +1,517 @@
/// <reference lib="webworker" />
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, string | number>,
): 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<Blob> => {
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<Blob> => {
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 <T>(
items: number[],
concurrency: number,
worker: (pageNumber: number) => Promise<T>,
onResult: (pageNumber: number, value: T) => void,
): Promise<void> => {
let cursor = 0;
const runners: Promise<void>[] = [];
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<PixelCompareWorkerRequest>) => {
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<typeof getDocument>[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 };
+13
View File
@@ -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",
},
],
}),
],