mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
co-authored by
Anthony Stirling
EthanHealy01
parent
089e448cf4
commit
66a75b1f28
Generated
+22
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,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 };
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user