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", "Original")}
+
+
+
+ {t("compare.pixel.diff", "Differences")}
+
+
+
+ {t("compare.pixel.comparison", "Edited")}
+
+
+ )}
+
+ {viewMode === "diff-only" && (
+
+
+
+ )}
+
+ {viewMode === "overlay" && (
+
+

+

+
+ )}
+
+ );
+};
+
+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",
+ },
],
}),
],