mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-09 17:14:16 +01:00
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:
1 parent
89d7cd8e3d
commit
5232102ac0
31 files changed
+2503
-183
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user