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

+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;
}