mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 20:02:47 +01:00
240 lines
6.2 KiB
TypeScript
240 lines
6.2 KiB
TypeScript
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
|
||
.filter(shouldCompareAnnotation)
|
||
.map((annotation) => [annotationKey(annotation), annotation])
|
||
);
|
||
const afterMap = new Map(
|
||
after
|
||
.filter(shouldCompareAnnotation)
|
||
.map((annotation) => [annotationKey(annotation), annotation])
|
||
);
|
||
|
||
let idx = baseId;
|
||
for (const [key, ann] of beforeMap) {
|
||
if (!afterMap.has(key)) {
|
||
changes.push({
|
||
id: `annotation-removed-${idx++}`,
|
||
type: 'removed',
|
||
category: 'annotation',
|
||
description: formatAnnotationDescription('Removed', ann),
|
||
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: formatAnnotationDescription('Added', ann),
|
||
beforeText: '',
|
||
afterText: ann.contents || ann.title || '',
|
||
beforeRects: [],
|
||
afterRects: [ann.rect],
|
||
});
|
||
}
|
||
}
|
||
|
||
return changes;
|
||
}
|
||
|
||
function shouldCompareAnnotation(annotation: CompareAnnotation): boolean {
|
||
if (annotation.subtype !== 'Highlight') {
|
||
return true;
|
||
}
|
||
|
||
return Boolean(annotation.contents || annotation.title);
|
||
}
|
||
|
||
function formatAnnotationDescription(
|
||
action: 'Added' | 'Removed',
|
||
annotation: CompareAnnotation
|
||
): string {
|
||
const label = annotation.contents || annotation.title;
|
||
if (!label) {
|
||
return `${action} ${annotation.subtype} annotation`;
|
||
}
|
||
|
||
return `${action} ${annotation.subtype} annotation: "${label}"`;
|
||
}
|
||
|
||
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;
|
||
}
|