feat: enhance PDF comparison with new change types and zoom functionality

- Added support for 'moved' and 'style-changed' change types in PDF comparison.
- Implemented category filters for changes, allowing users to filter by text, images, headers, annotations, formatting, and background.
- Introduced zoom functionality with buttons for zooming in, out, and resetting to default.
- Updated UI to reflect new change types and categories, including visual indicators for moved and style-changed items.
- Enhanced summary display to include counts for moved and style-changed changes.
- Refactored rendering logic to accommodate zoom levels and improve performance.
- Added tests for new change detection features and category assignments.
This commit is contained in:
alam00000 committed 2026-03-10 13:51:23 +05:30
1 parent 89d7cd8e3d
commit 5232102ac0
31 files changed
+2503 -183

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ export const COMPARE_COLORS = {
added: { r: 34, g: 197, b: 94 },
removed: { r: 239, g: 68, b: 68 },
modified: { r: 245, g: 158, b: 11 },
moved: { r: 168, g: 85, b: 247 },
'style-changed': { r: 59, g: 130, b: 246 },
} as const;
export const HIGHLIGHT_OPACITY = 0.28;
+213
View File
@@ -0,0 +1,213 @@
import type {
CompareAnnotation,
CompareContentCategory,
CompareImageRef,
ComparePageModel,
CompareRectangle,
CompareTextChange,
} from '../types.ts';
const HEADER_FOOTER_ZONE = 0.12;
export function classifyChangeCategory(
change: CompareTextChange,
pageHeight: number
): CompareContentCategory {
if (change.type === 'style-changed') return 'formatting';
const rects =
change.beforeRects.length > 0 ? change.beforeRects : change.afterRects;
if (rects.length > 0 && isHeaderFooterZone(rects, pageHeight)) {
return 'header-footer';
}
return 'text';
}
function isHeaderFooterZone(
rects: CompareRectangle[],
pageHeight: number
): boolean {
const headerThreshold = pageHeight * HEADER_FOOTER_ZONE;
const footerThreshold = pageHeight * (1 - HEADER_FOOTER_ZONE);
return rects.every(
(r) => r.y < headerThreshold || r.y + r.height > footerThreshold
);
}
export function diffAnnotations(
before: CompareAnnotation[],
after: CompareAnnotation[],
baseId: number
): CompareTextChange[] {
const changes: CompareTextChange[] = [];
const beforeMap = new Map(before.map((a) => [annotationKey(a), a]));
const afterMap = new Map(after.map((a) => [annotationKey(a), a]));
let idx = baseId;
for (const [key, ann] of beforeMap) {
if (!afterMap.has(key)) {
changes.push({
id: `annotation-removed-${idx++}`,
type: 'removed',
category: 'annotation',
description:
`Removed ${ann.subtype} annotation: "${ann.contents || ann.title || ''}"`.trim(),
beforeText: ann.contents || ann.title || '',
afterText: '',
beforeRects: [ann.rect],
afterRects: [],
});
}
}
for (const [key, ann] of afterMap) {
if (!beforeMap.has(key)) {
changes.push({
id: `annotation-added-${idx++}`,
type: 'added',
category: 'annotation',
description:
`Added ${ann.subtype} annotation: "${ann.contents || ann.title || ''}"`.trim(),
beforeText: '',
afterText: ann.contents || ann.title || '',
beforeRects: [],
afterRects: [ann.rect],
});
}
}
return changes;
}
function annotationKey(ann: CompareAnnotation): string {
return `${ann.subtype}|${ann.contents}|${Math.round(ann.rect.x)},${Math.round(ann.rect.y)}`;
}
export function diffImages(
before: CompareImageRef[],
after: CompareImageRef[],
baseId: number
): CompareTextChange[] {
const changes: CompareTextChange[] = [];
const matched = new Set<string>();
let idx = baseId;
for (const bImg of before) {
const match = after.find(
(aImg) => !matched.has(aImg.id) && imagesOverlap(bImg.rect, aImg.rect)
);
if (match) {
matched.add(match.id);
if (bImg.width !== match.width || bImg.height !== match.height) {
changes.push({
id: `image-modified-${idx++}`,
type: 'modified',
category: 'image',
description: `Image resized from ${bImg.width}×${bImg.height} to ${match.width}×${match.height}`,
beforeText: `${bImg.width}×${bImg.height}`,
afterText: `${match.width}×${match.height}`,
beforeRects: [bImg.rect],
afterRects: [match.rect],
});
}
} else {
changes.push({
id: `image-removed-${idx++}`,
type: 'removed',
category: 'image',
description: `Removed image (${bImg.width}×${bImg.height})`,
beforeText: '',
afterText: '',
beforeRects: [bImg.rect],
afterRects: [],
});
}
}
for (const aImg of after) {
if (!matched.has(aImg.id)) {
changes.push({
id: `image-added-${idx++}`,
type: 'added',
category: 'image',
description: `Added image (${aImg.width}×${aImg.height})`,
beforeText: '',
afterText: '',
beforeRects: [],
afterRects: [aImg.rect],
});
}
}
return changes;
}
function imagesOverlap(a: CompareRectangle, b: CompareRectangle): boolean {
const overlapX = Math.max(
0,
Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)
);
const overlapY = Math.max(
0,
Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)
);
const overlapArea = overlapX * overlapY;
const aArea = a.width * a.height;
const bArea = b.width * b.height;
const smallerArea = Math.min(aArea, bArea);
return smallerArea > 0 && overlapArea / smallerArea > 0.3;
}
export function detectBackgroundChanges(
leftModel: ComparePageModel,
rightModel: ComparePageModel,
visualMismatchRatio: number,
textChangeRects: CompareRectangle[],
baseId: number
): CompareTextChange[] {
if (visualMismatchRatio < 0.01) return [];
const textCoverage = textChangeRects.reduce(
(sum, r) => sum + r.width * r.height,
0
);
const pageArea = leftModel.width * leftModel.height;
const textRatio = pageArea > 0 ? textCoverage / pageArea : 0;
if (visualMismatchRatio > textRatio + 0.05) {
return [
{
id: `background-changed-${baseId}`,
type: 'modified',
category: 'background',
description: 'Page background or layout changed',
beforeText: '',
afterText: '',
beforeRects: [
{ x: 0, y: 0, width: leftModel.width, height: leftModel.height },
],
afterRects: [
{ x: 0, y: 0, width: rightModel.width, height: rightModel.height },
],
},
];
}
return [];
}
export function buildCategorySummary(changes: CompareTextChange[]) {
const summary = {
text: 0,
image: 0,
'header-footer': 0,
annotation: 0,
formatting: 0,
background: 0,
};
for (const c of changes) {
summary[c.category] += 1;
}
return summary;
}
+93 -17
View File
@@ -1,10 +1,49 @@
import type { ComparePageModel, ComparePageResult } from '../types.ts';
import type {
ComparePageModel,
ComparePageResult,
CompareCategorySummary,
} from '../types.ts';
import { diffTextRuns } from './diff-text-runs.ts';
import { diffTextRunsAsync } from '../worker-api.ts';
import {
classifyChangeCategory,
diffAnnotations,
diffImages,
buildCategorySummary,
} from './compare-content.ts';
const EMPTY_CATEGORY_SUMMARY: CompareCategorySummary = {
text: 0,
image: 0,
'header-footer': 0,
annotation: 0,
formatting: 0,
background: 0,
};
export function comparePageModels(
leftPage: ComparePageModel | null,
rightPage: ComparePageModel | null
): ComparePageResult {
return comparePageModelsCore(leftPage, rightPage, false) as ComparePageResult;
}
export function comparePageModelsAsync(
leftPage: ComparePageModel | null,
rightPage: ComparePageModel | null
): Promise<ComparePageResult> {
return comparePageModelsCore(
leftPage,
rightPage,
true
) as Promise<ComparePageResult>;
}
function comparePageModelsCore(
leftPage: ComparePageModel | null,
rightPage: ComparePageModel | null,
useWorker: boolean
): ComparePageResult | Promise<ComparePageResult> {
if (leftPage && !rightPage) {
return {
status: 'left-only',
@@ -14,6 +53,7 @@ export function comparePageModels(
{
id: 'page-removed',
type: 'page-removed',
category: 'text',
description: `Page ${leftPage.pageNumber} exists only in the first PDF.`,
beforeText: leftPage.plainText.slice(0, 200),
afterText: '',
@@ -21,7 +61,8 @@ export function comparePageModels(
afterRects: [],
},
],
summary: { added: 0, removed: 1, modified: 0 },
summary: { added: 0, removed: 1, modified: 0, moved: 0, styleChanged: 0 },
categorySummary: { ...EMPTY_CATEGORY_SUMMARY, text: 1 },
visualDiff: null,
usedOcr: leftPage.source === 'ocr',
};
@@ -36,6 +77,7 @@ export function comparePageModels(
{
id: 'page-added',
type: 'page-added',
category: 'text',
description: `Page ${rightPage.pageNumber} exists only in the second PDF.`,
beforeText: '',
afterText: rightPage.plainText.slice(0, 200),
@@ -43,7 +85,8 @@ export function comparePageModels(
afterRects: [],
},
],
summary: { added: 1, removed: 0, modified: 0 },
summary: { added: 1, removed: 0, modified: 0, moved: 0, styleChanged: 0 },
categorySummary: { ...EMPTY_CATEGORY_SUMMARY, text: 1 },
visualDiff: null,
usedOcr: rightPage.source === 'ocr',
};
@@ -55,24 +98,57 @@ export function comparePageModels(
leftPageNumber: null,
rightPageNumber: null,
changes: [],
summary: { added: 0, removed: 0, modified: 0 },
summary: { added: 0, removed: 0, modified: 0, moved: 0, styleChanged: 0 },
categorySummary: { ...EMPTY_CATEGORY_SUMMARY },
visualDiff: null,
usedOcr: false,
};
}
const { changes, summary } = diffTextRuns(
leftPage.textItems,
rightPage.textItems
);
function buildResult(diff: {
changes: ComparePageResult['changes'];
summary: ComparePageResult['summary'];
}): ComparePageResult {
const allChanges = [...diff.changes];
const pageHeight = Math.max(leftPage!.height, rightPage!.height);
return {
status: changes.length > 0 ? 'changed' : 'match',
leftPageNumber: leftPage.pageNumber,
rightPageNumber: rightPage.pageNumber,
changes,
summary,
visualDiff: null,
usedOcr: leftPage.source === 'ocr' || rightPage.source === 'ocr',
};
for (const c of allChanges) {
if (c.category === 'text') {
c.category = classifyChangeCategory(c, pageHeight);
}
}
const annotChanges = diffAnnotations(
leftPage!.annotations ?? [],
rightPage!.annotations ?? [],
allChanges.length
);
allChanges.push(...annotChanges);
const imageChanges = diffImages(
leftPage!.images ?? [],
rightPage!.images ?? [],
allChanges.length
);
allChanges.push(...imageChanges);
return {
status: allChanges.length > 0 ? 'changed' : 'match',
leftPageNumber: leftPage!.pageNumber,
rightPageNumber: rightPage!.pageNumber,
changes: allChanges,
summary: diff.summary,
categorySummary: buildCategorySummary(allChanges),
visualDiff: null,
usedOcr: leftPage!.source === 'ocr' || rightPage!.source === 'ocr',
};
}
if (useWorker) {
return diffTextRunsAsync(leftPage.textItems, rightPage.textItems).then(
buildResult
);
}
return buildResult(diffTextRuns(leftPage.textItems, rightPage.textItems));
}
+77
View File
@@ -0,0 +1,77 @@
import { diffTextRuns } from './diff-text-runs.ts';
import { pairPages } from './pair-pages.ts';
import type {
CompareTextItem,
ComparePageSignature,
ComparePagePair,
ComparePageResult,
CompareChangeSummary,
CompareTextChange,
} from '../types.ts';
interface DiffMessage {
type: 'diff';
id: number;
beforeItems: CompareTextItem[];
afterItems: CompareTextItem[];
}
interface PairMessage {
type: 'pair';
id: number;
leftPages: ComparePageSignature[];
rightPages: ComparePageSignature[];
}
type WorkerMessage = DiffMessage | PairMessage;
interface DiffResult {
type: 'diff';
id: number;
changes: CompareTextChange[];
summary: CompareChangeSummary;
}
interface PairResult {
type: 'pair';
id: number;
pairs: ComparePagePair[];
}
interface ErrorResult {
type: 'error';
id: number;
message: string;
}
type WorkerResult = DiffResult | PairResult | ErrorResult;
self.onmessage = function (e: MessageEvent<WorkerMessage>) {
const msg = e.data;
try {
if (msg.type === 'diff') {
const { changes, summary } = diffTextRuns(
msg.beforeItems,
msg.afterItems
);
const result: DiffResult = {
type: 'diff',
id: msg.id,
changes,
summary,
};
self.postMessage(result);
} else if (msg.type === 'pair') {
const pairs = pairPages(msg.leftPages, msg.rightPages);
const result: PairResult = { type: 'pair', id: msg.id, pairs };
self.postMessage(result);
}
} catch (err) {
const result: ErrorResult = {
type: 'error',
id: msg.id,
message: err instanceof Error ? err.message : String(err),
};
self.postMessage(result);
}
};
+241 -4
View File
@@ -8,13 +8,19 @@ import type {
CompareTextItem,
CompareWordToken,
} from '../types.ts';
import { calculateBoundingRect } from './text-normalization.ts';
import {
calculateBoundingRect,
containsCJK,
segmentCJKText,
} from './text-normalization.ts';
import { COMPARE_GEOMETRY } from '../config.ts';
interface WordToken {
word: string;
compareWord: string;
rect: CompareRectangle;
fontName?: string;
fontSize?: number;
}
function getCharMap(line: CompareTextItem): CharPosition[] {
@@ -30,11 +36,15 @@ function getCharMap(line: CompareTextItem): CharPosition[] {
function splitLineIntoWords(line: CompareTextItem): WordToken[] {
if (line.wordTokens && line.wordTokens.length > 0) {
return line.wordTokens.map((token: CompareWordToken) => ({
const baseTokens = line.wordTokens.map((token: CompareWordToken) => ({
word: token.word,
compareWord: token.compareWord,
rect: token.rect,
fontName: token.fontName,
fontSize: token.fontSize,
}));
if (!containsCJK(line.normalizedText)) return baseTokens;
return baseTokens.flatMap(splitCJKToken);
}
const words = line.normalizedText.split(/\s+/).filter(Boolean);
@@ -43,7 +53,7 @@ function splitLineIntoWords(line: CompareTextItem): WordToken[] {
const charMap = getCharMap(line);
let offset = 0;
return words.map((word) => {
const baseTokens = words.map((word) => {
const startIndex = line.normalizedText.indexOf(word, offset);
const endIndex = startIndex + word.length - 1;
offset = startIndex + word.length;
@@ -75,6 +85,31 @@ function splitLineIntoWords(line: CompareTextItem): WordToken[] {
rect: { x, y: line.rect.y, width: w, height: line.rect.height },
};
});
if (!containsCJK(line.normalizedText)) return baseTokens;
return baseTokens.flatMap(splitCJKToken);
}
function splitCJKToken(token: WordToken): WordToken[] {
if (!containsCJK(token.word)) return [token];
const segments = segmentCJKText(token.word);
if (segments.length <= 1) return [token];
const totalLen = token.word.length;
const charWidth = token.rect.width / Math.max(totalLen, 1);
let charOffset = 0;
return segments.map((seg) => {
const x = token.rect.x + charOffset * charWidth;
const width = seg.length * charWidth;
charOffset += seg.length;
return {
word: seg,
compareWord: seg.toLowerCase(),
rect: { x, y: token.rect.y, width, height: token.rect.height },
};
});
}
function groupAdjacentRects(rects: CompareRectangle[]): CompareRectangle[] {
@@ -138,6 +173,7 @@ function createWordChange(
changes.push({
id,
type,
category: 'text',
description: `Replaced "${beforeText}" with "${afterText}"`,
beforeText,
afterText,
@@ -148,6 +184,7 @@ function createWordChange(
changes.push({
id,
type,
category: 'text',
description: `Removed "${beforeText}"`,
beforeText,
afterText: '',
@@ -158,6 +195,7 @@ function createWordChange(
changes.push({
id,
type,
category: 'text',
description: `Added "${afterText}"`,
beforeText: '',
afterText,
@@ -173,9 +211,11 @@ function toSummary(changes: CompareTextChange[]): CompareChangeSummary {
if (change.type === 'added') summary.added += 1;
if (change.type === 'removed') summary.removed += 1;
if (change.type === 'modified') summary.modified += 1;
if (change.type === 'moved') summary.moved += 1;
if (change.type === 'style-changed') summary.styleChanged += 1;
return summary;
},
{ added: 0, removed: 0, modified: 0 }
{ added: 0, removed: 0, modified: 0, moved: 0, styleChanged: 0 }
);
}
@@ -233,5 +273,202 @@ export function diffTextRuns(
afterIndex += count;
}
detectStyleChanges(changes, beforeWords, afterWords, rawChanges);
detectMovedText(changes);
return { changes, summary: toSummary(changes) };
}
function normalizeFontName(name: string): string {
return name.replace(/^g_d\d+_/, 'g_d_');
}
function hasStyleDifference(before: WordToken, after: WordToken): boolean {
if (
before.fontName &&
after.fontName &&
normalizeFontName(before.fontName) !== normalizeFontName(after.fontName)
)
return true;
if (
before.fontSize &&
after.fontSize &&
Math.abs(before.fontSize - after.fontSize) > 0.5
)
return true;
return false;
}
function detectStyleChanges(
changes: CompareTextChange[],
beforeWords: WordToken[],
afterWords: WordToken[],
rawChanges: ReturnType<typeof diffArrays<string>>
) {
interface StyleFragment {
bFont: string;
aFont: string;
bSize: number | undefined;
aSize: number | undefined;
text: string;
beforeRects: CompareRectangle[];
afterRects: CompareRectangle[];
}
const fragments: StyleFragment[] = [];
let beforeIdx = 0;
let afterIdx = 0;
for (const change of rawChanges) {
const count = change.value.length;
if (change.removed) {
beforeIdx += count;
continue;
}
if (change.added) {
afterIdx += count;
continue;
}
let styleRunStart = -1;
for (let k = 0; k < count; k++) {
const bw = beforeWords[beforeIdx + k];
const aw = afterWords[afterIdx + k];
const isDiff = hasStyleDifference(bw, aw);
if (isDiff && styleRunStart < 0) {
styleRunStart = k;
}
if ((!isDiff || k === count - 1) && styleRunStart >= 0) {
const end = isDiff ? k + 1 : k;
const bTokens = beforeWords.slice(
beforeIdx + styleRunStart,
beforeIdx + end
);
const aTokens = afterWords.slice(
afterIdx + styleRunStart,
afterIdx + end
);
fragments.push({
bFont: bTokens[0].fontName ?? 'unknown',
aFont: aTokens[0].fontName ?? 'unknown',
bSize: bTokens[0].fontSize,
aSize: aTokens[0].fontSize,
text: bTokens.map((w) => w.word).join(' '),
beforeRects: groupAdjacentRects(bTokens.map((w) => w.rect)),
afterRects: groupAdjacentRects(aTokens.map((w) => w.rect)),
});
styleRunStart = -1;
}
}
beforeIdx += count;
afterIdx += count;
}
const groups = new Map<string, StyleFragment[]>();
for (const frag of fragments) {
const key = `${frag.bFont}→${frag.aFont}|${frag.bSize ?? ''}→${frag.aSize ?? ''}`;
const arr = groups.get(key);
if (arr) arr.push(frag);
else groups.set(key, [frag]);
}
for (const groupFrags of groups.values()) {
const bFont = groupFrags[0].bFont;
const aFont = groupFrags[0].aFont;
const bSize = groupFrags[0].bSize;
const aSize = groupFrags[0].aSize;
const allText = groupFrags.map((f) => f.text).join(' … ');
const allBeforeRects = groupFrags.flatMap((f) => f.beforeRects);
const allAfterRects = groupFrags.flatMap((f) => f.afterRects);
let desc = `Style changed (${groupFrags.length} regions)`;
const details: string[] = [];
if (bFont !== aFont) details.push(`Font: ${bFont} → ${aFont}`);
if (bSize && aSize && Math.abs(bSize - aSize) > 0.5)
details.push(`Font size: ${bSize} → ${aSize}`);
if (details.length) desc += '\n' + details.map((d) => `• ${d}`).join('\n');
changes.push({
id: `style-changed-${changes.length}`,
type: 'style-changed',
category: 'formatting',
description: desc,
beforeText: allText,
afterText: allText,
beforeRects: allBeforeRects,
afterRects: allAfterRects,
});
}
}
const MOVE_MIN_WORDS = 3;
const MOVE_SIMILARITY_THRESHOLD = 0.8;
function normalizeForMove(text: string): string {
return text.toLowerCase().replace(/\s+/g, ' ').trim();
}
function moveSimilarity(a: string, b: string): number {
if (a === b) return 1;
if (!a || !b) return 0;
const aWords = a.split(' ');
const bWords = b.split(' ');
const bSet = new Set(bWords);
let matches = 0;
for (const w of aWords) {
if (bSet.has(w)) matches++;
}
return matches / Math.max(aWords.length, bWords.length);
}
function detectMovedText(changes: CompareTextChange[]) {
const removed = changes.filter((c) => c.type === 'removed');
const added = changes.filter((c) => c.type === 'added');
if (removed.length === 0 || added.length === 0) return;
const matchedRemoved = new Set<string>();
const matchedAdded = new Set<string>();
for (const rem of removed) {
const remNorm = normalizeForMove(rem.beforeText);
const remWordCount = remNorm.split(' ').length;
if (remWordCount < MOVE_MIN_WORDS) continue;
let bestMatch: CompareTextChange | null = null;
let bestScore = MOVE_SIMILARITY_THRESHOLD;
for (const add of added) {
if (matchedAdded.has(add.id)) continue;
const addNorm = normalizeForMove(add.afterText);
const score = moveSimilarity(remNorm, addNorm);
if (score > bestScore) {
bestScore = score;
bestMatch = add;
}
}
if (bestMatch) {
matchedRemoved.add(rem.id);
matchedAdded.add(bestMatch.id);
changes.push({
id: `moved-${changes.length}`,
type: 'moved',
category: 'text',
description: `Moved "${rem.beforeText.slice(0, 80)}"`,
beforeText: rem.beforeText,
afterText: bestMatch.afterText,
beforeRects: rem.beforeRects,
afterRects: bestMatch.afterRects,
});
}
}
for (let i = changes.length - 1; i >= 0; i--) {
if (matchedRemoved.has(changes[i].id) || matchedAdded.has(changes[i].id)) {
changes.splice(i, 1);
}
}
}
+168 -6
View File
@@ -1,6 +1,8 @@
import * as pdfjsLib from 'pdfjs-dist';
import type {
CompareAnnotation,
CompareImageRef,
ComparePageModel,
CompareTextItem,
CharPosition,
@@ -9,6 +11,8 @@ import type {
import {
joinCompareTextItems,
normalizeCompareText,
containsCJK,
segmentCJKText,
} from './text-normalization.ts';
type PageTextItem = {
@@ -69,11 +73,14 @@ function measureTextWidth(fontSpec: string, text: string): number {
return width;
}
type FontNameMap = Map<string, string>;
function buildItemWordTokens(
viewport: pdfjsLib.PageViewport,
item: PageTextItem,
fallbackRect: CompareTextItem['rect'],
styles: TextStyles
styles: TextStyles,
fontNameMap: FontNameMap
): CompareWordToken[] {
const rawText = item.str || '';
if (!rawText.trim()) {
@@ -216,19 +223,47 @@ function buildItemWordTokens(
(previousToken
? shouldJoinTokenWithPrevious(previousToken.word, normalizedWord)
: false),
fontName: fontNameMap.get(item.fontName) ?? item.fontName ?? undefined,
fontSize: fontScale > 0 ? Math.round(fontScale * 100) / 100 : undefined,
});
previousEnd = endIndex;
}
return tokens;
if (!containsCJK(rawText)) return tokens;
return tokens.flatMap(splitCJKWordToken);
}
function splitCJKWordToken(token: CompareWordToken): CompareWordToken[] {
if (!containsCJK(token.word)) return [token];
const segments = segmentCJKText(token.word);
if (segments.length <= 1) return [token];
const totalLen = token.word.length;
const charWidth = token.rect.width / Math.max(totalLen, 1);
let charOffset = 0;
return segments.map((seg, i) => {
const x = token.rect.x + charOffset * charWidth;
const width = seg.length * charWidth;
charOffset += seg.length;
return {
word: seg,
compareWord: seg.toLowerCase(),
rect: { x, y: token.rect.y, width, height: token.rect.height },
joinsWithPrevious: i > 0 ? true : token.joinsWithPrevious,
fontName: token.fontName,
fontSize: token.fontSize,
};
});
}
function toRect(
viewport: pdfjsLib.PageViewport,
item: PageTextItem,
index: number,
styles: TextStyles
styles: TextStyles,
fontNameMap: FontNameMap
) {
const normalizedText = normalizeCompareText(item.str);
@@ -256,7 +291,7 @@ function toRect(
text: item.str,
normalizedText,
rect,
wordTokens: buildItemWordTokens(viewport, item, rect, styles),
wordTokens: buildItemWordTokens(viewport, item, rect, styles, fontNameMap),
} satisfies CompareTextItem;
}
@@ -387,6 +422,8 @@ function mergeWordTokenRects(
width: maxX - minX,
height: maxY - minY,
},
fontName: left.fontName,
fontSize: left.fontSize,
};
}
@@ -431,6 +468,8 @@ function buildMergedWordTokens(lineItems: CompareTextItem[]) {
word: token.word,
compareWord: token.compareWord,
rect: token.rect,
fontName: token.fontName,
fontSize: token.fontSize,
});
}
});
@@ -496,16 +535,131 @@ export function mergeIntoLines(
});
}
function extractAnnotations(
rawAnnotations: Array<Record<string, unknown>>,
viewport: pdfjsLib.PageViewport
): CompareAnnotation[] {
return rawAnnotations
.filter((ann) => {
const subtype = ann.subtype as string | undefined;
return subtype && subtype !== 'Link' && subtype !== 'Widget';
})
.map((ann, index) => {
const rawRect = ann.rect as number[] | undefined;
let rect = { x: 0, y: 0, width: 0, height: 0 };
if (rawRect && rawRect.length === 4) {
const [p1, p2] = [
viewport.convertToViewportPoint(rawRect[0], rawRect[1]),
viewport.convertToViewportPoint(rawRect[2], rawRect[3]),
];
const x = Math.min(p1[0], p2[0]);
const y = Math.min(p1[1], p2[1]);
rect = {
x,
y,
width: Math.max(Math.abs(p2[0] - p1[0]), 1),
height: Math.max(Math.abs(p2[1] - p1[1]), 1),
};
}
const color = ann.color as number[] | undefined;
return {
id: `ann-${index}`,
subtype: (ann.subtype as string) || 'Unknown',
rect,
contents: ((ann.contents as string) || '').trim(),
title: ((ann.title as string) || '').trim(),
color: color ? `rgb(${color.join(',')})` : '',
};
});
}
function extractImages(
opList: { fnArray: number[]; argsArray: unknown[][] },
viewport: pdfjsLib.PageViewport
): CompareImageRef[] {
const OPS_PAINT_IMAGE = 85;
const OPS_PAINT_INLINE_IMAGE = 84;
const images: CompareImageRef[] = [];
for (let i = 0; i < opList.fnArray.length; i++) {
const op = opList.fnArray[i];
if (op !== OPS_PAINT_IMAGE && op !== OPS_PAINT_INLINE_IMAGE) continue;
const args = opList.argsArray[i];
if (!args) continue;
let imgWidth = 0;
let imgHeight = 0;
if (op === OPS_PAINT_INLINE_IMAGE && args[0]) {
const imgData = args[0] as Record<string, unknown>;
imgWidth = (imgData.width as number) || 0;
imgHeight = (imgData.height as number) || 0;
} else if (op === OPS_PAINT_IMAGE) {
imgWidth = (args[1] as number) || 0;
imgHeight = (args[2] as number) || 0;
}
if (imgWidth < 2 || imgHeight < 2) continue;
const [vpX, vpY] = viewport.convertToViewportPoint(0, 0);
const [vpX2, vpY2] = viewport.convertToViewportPoint(imgWidth, imgHeight);
const x = Math.min(vpX, vpX2);
const y = Math.min(vpY, vpY2);
images.push({
id: `img-${images.length}`,
rect: {
x,
y,
width: Math.abs(vpX2 - vpX) || imgWidth,
height: Math.abs(vpY2 - vpY) || imgHeight,
},
width: imgWidth,
height: imgHeight,
});
}
return images;
}
export async function extractPageModel(
page: pdfjsLib.PDFPageProxy,
viewport: pdfjsLib.PageViewport
): Promise<ComparePageModel> {
const textContent = await page.getTextContent();
const [textContent, rawAnnotations, opList] = await Promise.all([
page.getTextContent(),
page
.getAnnotations({ intent: 'any' })
.catch(() => [] as Array<Record<string, unknown>>),
page
.getOperatorList()
.catch(() => ({ fnArray: [] as number[], argsArray: [] as unknown[][] })),
]);
const styles = textContent.styles ?? {};
const fontNameMap: FontNameMap = new Map();
const seenFonts = new Set<string>();
for (const item of textContent.items) {
if ('fontName' in item && typeof item.fontName === 'string') {
seenFonts.add(item.fontName);
}
}
for (const internalName of seenFonts) {
try {
if (page.commonObjs.has(internalName)) {
const fontObj = page.commonObjs.get(internalName);
if (fontObj?.name && typeof fontObj.name === 'string') {
fontNameMap.set(internalName, fontObj.name);
}
}
} catch {}
}
const rawItems = sortCompareTextItems(
textContent.items
.filter((item): item is PageTextItem => 'str' in item)
.map((item, index) => toRect(viewport, item, index, styles))
.map((item, index) => toRect(viewport, item, index, styles, fontNameMap))
.filter((item) => item.normalizedText.length > 0)
);
const textItems = mergeIntoLines(rawItems);
@@ -518,5 +672,13 @@ export async function extractPageModel(
plainText: joinCompareTextItems(textItems),
hasText: textItems.length > 0,
source: 'pdfjs',
annotations: extractAnnotations(
rawAnnotations as Array<Record<string, unknown>>,
viewport
),
images: extractImages(
opList as { fnArray: number[]; argsArray: unknown[][] },
viewport
),
};
}
@@ -71,6 +71,32 @@ export function tokenizeTextAsSet(text: string): Set<string> {
return new Set(tokenizeText(text));
}
const CJK_REGEX =
/[\u2E80-\u9FFF\uF900-\uFAFF\uFE30-\uFE4F\u{20000}-\u{2FA1F}]/u;
export function containsCJK(text: string): boolean {
return CJK_REGEX.test(text);
}
let cachedSegmenter: Intl.Segmenter | null = null;
function getWordSegmenter(): Intl.Segmenter | null {
if (cachedSegmenter) return cachedSegmenter;
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
cachedSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
return cachedSegmenter;
}
return null;
}
export function segmentCJKText(text: string): string[] {
const segmenter = getWordSegmenter();
if (!segmenter) return [text];
return [...segmenter.segment(text)]
.filter((seg) => seg.isWordLike)
.map((seg) => seg.segment);
}
export function calculateBoundingRect(
rects: CompareRectangle[]
): CompareRectangle {
+14 -2
View File
@@ -6,7 +6,7 @@ import type {
ComparePdfExportMode,
} from '../types.ts';
import { extractPageModel } from '../engine/extract-page-model.ts';
import { comparePageModels } from '../engine/compare-page-models.ts';
import { comparePageModelsAsync } from '../engine/compare-page-models.ts';
import {
COMPARE_COLORS,
HIGHLIGHT_OPACITY,
@@ -42,6 +42,18 @@ const HIGHLIGHT_COLORS: Record<
b: COMPARE_COLORS.modified.b / 255,
opacity: HIGHLIGHT_OPACITY,
},
moved: {
r: COMPARE_COLORS.moved.r / 255,
g: COMPARE_COLORS.moved.g / 255,
b: COMPARE_COLORS.moved.b / 255,
opacity: HIGHLIGHT_OPACITY,
},
'style-changed': {
r: COMPARE_COLORS['style-changed'].r / 255,
g: COMPARE_COLORS['style-changed'].g / 255,
b: COMPARE_COLORS['style-changed'].b / 255,
opacity: HIGHLIGHT_OPACITY,
},
};
const EXTRACT_SCALE = COMPARE_RENDER.EXPORT_EXTRACT_SCALE;
@@ -124,7 +136,7 @@ export async function exportComparePdf(
)
: null;
const comparison = comparePageModels(leftModel, rightModel);
const comparison = await comparePageModelsAsync(leftModel, rightModel);
const changes = comparison.changes;
if (mode === 'split') {
+69 -1
View File
@@ -22,16 +22,24 @@ export interface DiffFocusRegion {
height: number;
}
export interface OcrCacheEntry {
model: ComparePageModel;
width: number;
height: number;
}
export interface CompareCaches {
pageModelCache: LRUCache<string, ComparePageModel>;
comparisonCache: LRUCache<string, ComparePageResult>;
comparisonResultsCache: LRUCache<number, ComparePageResult>;
ocrModelCache: LRUCache<string, OcrCacheEntry>;
}
export interface CompareRenderContext {
useOcr: boolean;
ocrLanguage: string;
viewMode: CompareViewMode;
zoomLevel: number;
showLoader: (message: string, percent?: number) => void;
}
@@ -52,6 +60,8 @@ export interface CompareWordToken {
compareWord: string;
rect: CompareRectangle;
joinsWithPrevious?: boolean;
fontName?: string;
fontSize?: number;
}
export interface CompareTextItem {
@@ -72,6 +82,24 @@ export interface ComparePageModel {
plainText: string;
hasText: boolean;
source: 'pdfjs' | 'ocr';
annotations?: CompareAnnotation[];
images?: CompareImageRef[];
}
export interface CompareAnnotation {
id: string;
subtype: string;
rect: CompareRectangle;
contents: string;
title: string;
color: string;
}
export interface CompareImageRef {
id: string;
rect: CompareRectangle;
width: number;
height: number;
}
export interface ComparePageSignature {
@@ -98,12 +126,23 @@ export type CompareChangeType =
| 'added'
| 'removed'
| 'modified'
| 'moved'
| 'style-changed'
| 'page-added'
| 'page-removed';
export type CompareContentCategory =
| 'text'
| 'image'
| 'header-footer'
| 'annotation'
| 'formatting'
| 'background';
export interface CompareTextChange {
id: string;
type: CompareChangeType;
category: CompareContentCategory;
description: string;
beforeText: string;
afterText: string;
@@ -115,6 +154,17 @@ export interface CompareChangeSummary {
added: number;
removed: number;
modified: number;
moved: number;
styleChanged: number;
}
export interface CompareCategorySummary {
text: number;
image: number;
'header-footer': number;
annotation: number;
formatting: number;
background: number;
}
export interface ComparePageResult {
@@ -123,12 +173,28 @@ export interface ComparePageResult {
rightPageNumber: number | null;
changes: CompareTextChange[];
summary: CompareChangeSummary;
categorySummary: CompareCategorySummary;
visualDiff: CompareVisualDiff | null;
confidence?: number;
usedOcr?: boolean;
}
export type CompareFilterType = 'added' | 'removed' | 'modified' | 'all';
export type CompareFilterType =
| 'added'
| 'removed'
| 'modified'
| 'moved'
| 'style-changed'
| 'all';
export interface CompareCategoryFilterState {
text: boolean;
image: boolean;
'header-footer': boolean;
annotation: boolean;
formatting: boolean;
background: boolean;
}
export interface CompareState {
pdfDoc1: pdfjsLib.PDFDocumentProxy | null;
@@ -140,7 +206,9 @@ export interface CompareState {
activeChangeIndex: number;
pagePairs: ComparePagePair[];
activeFilter: CompareFilterType;
categoryFilter: CompareCategoryFilterState;
changeSearchQuery: string;
useOcr: boolean;
ocrLanguage: string;
zoomLevel: number;
}
+90
View File
@@ -0,0 +1,90 @@
import type {
CompareTextItem,
ComparePageSignature,
ComparePagePair,
CompareChangeSummary,
CompareTextChange,
} from './types.ts';
import { diffTextRuns } from './engine/diff-text-runs.ts';
import { pairPages } from './engine/pair-pages.ts';
let worker: Worker | null = null;
let messageId = 0;
const pending = new Map<
number,
{ resolve: (value: unknown) => void; reject: (reason: unknown) => void }
>();
function getWorker(): Worker | null {
if (worker) return worker;
try {
worker = new Worker(
new URL('./engine/compare.worker.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = function (e) {
const { id, type, ...rest } = e.data;
const p = pending.get(id);
if (!p) return;
pending.delete(id);
if (type === 'error') {
p.reject(new Error((rest as { message: string }).message));
} else {
p.resolve(rest);
}
};
worker.onerror = function () {
worker?.terminate();
worker = null;
for (const [, p] of pending) {
p.reject(new Error('Worker crashed'));
}
pending.clear();
};
return worker;
} catch {
return null;
}
}
function postToWorker(msg: Record<string, unknown>): Promise<unknown> {
const w = getWorker();
if (!w) return Promise.reject(new Error('No worker'));
const id = ++messageId;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
w.postMessage({ ...msg, id });
});
}
export async function diffTextRunsAsync(
beforeItems: CompareTextItem[],
afterItems: CompareTextItem[]
): Promise<{ changes: CompareTextChange[]; summary: CompareChangeSummary }> {
try {
const result = (await postToWorker({
type: 'diff',
beforeItems,
afterItems,
})) as { changes: CompareTextChange[]; summary: CompareChangeSummary };
return result;
} catch {
return diffTextRuns(beforeItems, afterItems);
}
}
export async function pairPagesAsync(
leftPages: ComparePageSignature[],
rightPages: ComparePageSignature[]
): Promise<ComparePagePair[]> {
try {
const result = (await postToWorker({
type: 'pair',
leftPages,
rightPages,
})) as { pairs: ComparePagePair[] };
return result.pairs;
} catch {
return pairPages(leftPages, rightPages);
}
}