## Summary - add a `PdfJsonConversionService` that serializes PDF text, fonts, and metadata to JSON and rebuilds a PDF from the same structure - expose REST endpoints for `/pdf/json` and `/json/pdf` conversions using the existing convert API infrastructure - define JSON model classes capturing document metadata, font information, and positioned text elements ## Testing - `./gradlew spotlessApply` *(fails: plugin org.springframework.boot:3.5.4 unavailable in build environment)* - `./gradlew build` *(fails: plugin org.springframework.boot:3.5.4 unavailable in build environment)* ------ https://chatgpt.com/codex/tasks/task_b_68f8e98d94ac8328a0e499e541528b6f --------- Co-authored-by: EthanHealy01 <ethan.healy.21@gmail.com>
1400 lines
45 KiB
TypeScript
1400 lines
45 KiB
TypeScript
import {
|
||
BoundingBox,
|
||
PdfJsonDocument,
|
||
PdfJsonPage,
|
||
PdfJsonTextElement,
|
||
PdfJsonImageElement,
|
||
TextGroup,
|
||
DEFAULT_PAGE_HEIGHT,
|
||
DEFAULT_PAGE_WIDTH,
|
||
} from '@app/tools/pdfTextEditor/pdfTextEditorTypes';
|
||
|
||
const LINE_TOLERANCE = 2;
|
||
const GAP_FACTOR = 0.6;
|
||
const SPACE_MIN_GAP = 1.5;
|
||
const MIN_CHAR_WIDTH_FACTOR = 0.35;
|
||
const MAX_CHAR_WIDTH_FACTOR = 1.25;
|
||
const EXTRA_GAP_RATIO = 0.8;
|
||
|
||
type FontMetrics = {
|
||
unitsPerEm: number;
|
||
ascent: number;
|
||
descent: number;
|
||
};
|
||
|
||
type FontMetricsMap = Map<string, FontMetrics>;
|
||
|
||
const sanitizeParagraphText = (text: string | undefined | null): string => {
|
||
if (!text) {
|
||
return '';
|
||
}
|
||
return text.replace(/\r?\n/g, '');
|
||
};
|
||
|
||
const splitParagraphIntoLines = (text: string | undefined | null): string[] => {
|
||
if (text === null || text === undefined) {
|
||
return [''];
|
||
}
|
||
return text.replace(/\r/g, '').split('\n');
|
||
};
|
||
|
||
const extractElementBaseline = (element: PdfJsonTextElement): number | null => {
|
||
if (!element) {
|
||
return null;
|
||
}
|
||
if (element.textMatrix && element.textMatrix.length >= 6) {
|
||
const baseline = element.textMatrix[5];
|
||
return typeof baseline === 'number' ? baseline : null;
|
||
}
|
||
if (typeof element.y === 'number') {
|
||
return element.y;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const shiftElementsBy = (elements: PdfJsonTextElement[], delta: number): PdfJsonTextElement[] => {
|
||
if (delta === 0) {
|
||
return elements.map(cloneTextElement);
|
||
}
|
||
return elements.map((element) => {
|
||
const clone = cloneTextElement(element);
|
||
if (clone.textMatrix && clone.textMatrix.length >= 6) {
|
||
const matrix = [...clone.textMatrix];
|
||
matrix[5] = (matrix[5] ?? 0) + delta;
|
||
clone.textMatrix = matrix;
|
||
}
|
||
if (typeof clone.y === 'number') {
|
||
clone.y += delta;
|
||
} else if (clone.y === null || clone.y === undefined) {
|
||
clone.y = delta;
|
||
}
|
||
return clone;
|
||
});
|
||
};
|
||
|
||
const countGraphemes = (text: string): number => {
|
||
if (!text) {
|
||
return 0;
|
||
}
|
||
return Array.from(text).length;
|
||
};
|
||
|
||
const metricsFor = (metrics: FontMetricsMap | undefined, fontId?: string | null): FontMetrics | undefined => {
|
||
if (!metrics || !fontId) {
|
||
return undefined;
|
||
}
|
||
return metrics.get(fontId) ?? undefined;
|
||
};
|
||
|
||
const buildFontMetrics = (document: PdfJsonDocument | null | undefined): FontMetricsMap => {
|
||
const metrics: FontMetricsMap = new Map();
|
||
document?.fonts?.forEach((font) => {
|
||
if (!font) {
|
||
return;
|
||
}
|
||
const unitsPerEm = font.unitsPerEm && font.unitsPerEm > 0 ? font.unitsPerEm : 1000;
|
||
const ascent = font.ascent ?? unitsPerEm * 0.8;
|
||
const descent = font.descent ?? -(unitsPerEm * 0.2);
|
||
const metric: FontMetrics = { unitsPerEm, ascent, descent };
|
||
if (font.id) {
|
||
metrics.set(font.id, metric);
|
||
}
|
||
if (font.uid) {
|
||
metrics.set(font.uid, metric);
|
||
}
|
||
});
|
||
return metrics;
|
||
};
|
||
|
||
export const valueOr = (value: number | null | undefined, fallback = 0): number => {
|
||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||
return fallback;
|
||
}
|
||
return value;
|
||
};
|
||
|
||
export const cloneTextElement = (element: PdfJsonTextElement): PdfJsonTextElement => ({
|
||
...element,
|
||
textMatrix: element.textMatrix ? [...element.textMatrix] : element.textMatrix ?? undefined,
|
||
});
|
||
|
||
const clearGlyphHints = (element: PdfJsonTextElement): void => {
|
||
if (!element) {
|
||
return;
|
||
}
|
||
element.charCodes = undefined;
|
||
};
|
||
|
||
export const cloneImageElement = (element: PdfJsonImageElement): PdfJsonImageElement => ({
|
||
...element,
|
||
transform: element.transform ? [...element.transform] : element.transform ?? undefined,
|
||
});
|
||
|
||
const getBaseline = (element: PdfJsonTextElement): number => {
|
||
if (element.textMatrix && element.textMatrix.length === 6) {
|
||
return valueOr(element.textMatrix[5]);
|
||
}
|
||
return valueOr(element.y);
|
||
};
|
||
|
||
const getX = (element: PdfJsonTextElement): number => {
|
||
if (element.textMatrix && element.textMatrix.length === 6) {
|
||
return valueOr(element.textMatrix[4]);
|
||
}
|
||
return valueOr(element.x);
|
||
};
|
||
|
||
const getWidth = (element: PdfJsonTextElement, metrics?: FontMetricsMap): number => {
|
||
const width = valueOr(element.width, 0);
|
||
if (width > 0) {
|
||
return width;
|
||
}
|
||
|
||
const text = element.text ?? '';
|
||
const glyphCount = Math.max(1, countGraphemes(text));
|
||
const spacingFallback = Math.max(
|
||
valueOr(element.spaceWidth, 0),
|
||
valueOr(element.wordSpacing, 0),
|
||
valueOr(element.characterSpacing, 0),
|
||
);
|
||
|
||
if (spacingFallback > 0 && text.trim().length === 0) {
|
||
return spacingFallback;
|
||
}
|
||
|
||
const fontSize = getFontSize(element);
|
||
const fontMetrics = metricsFor(metrics, element.fontId);
|
||
if (fontMetrics) {
|
||
const unitsPerEm = fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000;
|
||
const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8;
|
||
const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2));
|
||
const combinedUnits = Math.max(unitsPerEm * 0.8, ascentUnits + descentUnits);
|
||
const averageAdvanceUnits = Math.max(unitsPerEm * 0.5, combinedUnits / Math.max(1, glyphCount));
|
||
const fallbackWidth = (averageAdvanceUnits / unitsPerEm) * glyphCount * fontSize;
|
||
if (fallbackWidth > 0) {
|
||
return fallbackWidth;
|
||
}
|
||
}
|
||
|
||
return fontSize * glyphCount * 0.5;
|
||
};
|
||
|
||
const getFontSize = (element: PdfJsonTextElement): number => valueOr(element.fontMatrixSize ?? element.fontSize, 12);
|
||
|
||
const getHeight = (element: PdfJsonTextElement, metrics?: FontMetricsMap): number => {
|
||
const height = valueOr(element.height, 0);
|
||
if (height > 0) {
|
||
return height;
|
||
}
|
||
const fontSize = getFontSize(element);
|
||
const fontMetrics = metricsFor(metrics, element.fontId);
|
||
if (fontMetrics) {
|
||
const unitsPerEm = fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000;
|
||
const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8;
|
||
const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2));
|
||
const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits);
|
||
if (totalUnits > 0) {
|
||
return (totalUnits / unitsPerEm) * fontSize;
|
||
}
|
||
}
|
||
return fontSize;
|
||
};
|
||
|
||
const getElementBounds = (
|
||
element: PdfJsonTextElement,
|
||
metrics?: FontMetricsMap,
|
||
): BoundingBox => {
|
||
const left = getX(element);
|
||
const width = getWidth(element, metrics);
|
||
const baseline = getBaseline(element);
|
||
const height = getHeight(element, metrics);
|
||
|
||
let ascentRatio = 0.8;
|
||
let descentRatio = 0.2;
|
||
const fontMetrics = metricsFor(metrics, element.fontId);
|
||
if (fontMetrics) {
|
||
const unitsPerEm = fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000;
|
||
const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8;
|
||
const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2));
|
||
const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits);
|
||
if (totalUnits > 0) {
|
||
ascentRatio = ascentUnits / totalUnits;
|
||
descentRatio = descentUnits / totalUnits;
|
||
}
|
||
}
|
||
|
||
const bottom = baseline + height * ascentRatio;
|
||
const top = baseline - height * descentRatio;
|
||
return {
|
||
left,
|
||
right: left + width,
|
||
top,
|
||
bottom,
|
||
};
|
||
};
|
||
|
||
export const getImageBounds = (element: PdfJsonImageElement): BoundingBox => {
|
||
const left = valueOr(element.left ?? element.x, 0);
|
||
const computedWidth = valueOr(element.width, Math.max(valueOr(element.right, left) - left, 0));
|
||
const right = valueOr(element.right ?? left + computedWidth, left + computedWidth);
|
||
const bottom = valueOr(element.bottom ?? element.y, 0);
|
||
const computedHeight = valueOr(element.height, Math.max(valueOr(element.top, bottom) - bottom, 0));
|
||
const top = valueOr(element.top ?? bottom + computedHeight, bottom + computedHeight);
|
||
return {
|
||
left,
|
||
right,
|
||
bottom,
|
||
top,
|
||
};
|
||
};
|
||
|
||
const getSpacingHint = (element: PdfJsonTextElement): number => {
|
||
const spaceWidth = valueOr(element.spaceWidth, 0);
|
||
if (spaceWidth > 0) {
|
||
return spaceWidth;
|
||
}
|
||
const wordSpacing = valueOr(element.wordSpacing, 0);
|
||
if (wordSpacing > 0) {
|
||
return wordSpacing;
|
||
}
|
||
const characterSpacing = valueOr(element.characterSpacing, 0);
|
||
return Math.max(characterSpacing, 0);
|
||
};
|
||
|
||
const estimateCharWidth = (
|
||
element: PdfJsonTextElement,
|
||
avgFontSize: number,
|
||
metrics?: FontMetricsMap,
|
||
): number => {
|
||
const rawWidth = getWidth(element, metrics);
|
||
const minWidth = avgFontSize * MIN_CHAR_WIDTH_FACTOR;
|
||
const maxWidth = avgFontSize * MAX_CHAR_WIDTH_FACTOR;
|
||
return Math.min(Math.max(rawWidth, minWidth), maxWidth);
|
||
};
|
||
|
||
const mergeBounds = (bounds: BoundingBox[]): BoundingBox => {
|
||
if (bounds.length === 0) {
|
||
return { left: 0, right: 0, top: 0, bottom: 0 };
|
||
}
|
||
return bounds.reduce(
|
||
(acc, current) => ({
|
||
left: Math.min(acc.left, current.left),
|
||
right: Math.max(acc.right, current.right),
|
||
top: Math.min(acc.top, current.top),
|
||
bottom: Math.max(acc.bottom, current.bottom),
|
||
}),
|
||
{ ...bounds[0] }
|
||
);
|
||
};
|
||
|
||
const shouldInsertSpace = (
|
||
prev: PdfJsonTextElement,
|
||
current: PdfJsonTextElement,
|
||
metrics?: FontMetricsMap,
|
||
): boolean => {
|
||
const prevRight = getX(prev) + getWidth(prev, metrics);
|
||
const trailingGap = Math.max(0, getX(current) - prevRight);
|
||
const avgFontSize = (getFontSize(prev) + getFontSize(current)) / 2;
|
||
const baselineAdvance = Math.max(0, getX(current) - getX(prev));
|
||
const charWidthEstimate = estimateCharWidth(prev, avgFontSize, metrics);
|
||
const inferredGap = Math.max(0, baselineAdvance - charWidthEstimate);
|
||
const spacingHint = Math.max(
|
||
SPACE_MIN_GAP,
|
||
getSpacingHint(prev),
|
||
getSpacingHint(current),
|
||
avgFontSize * GAP_FACTOR,
|
||
);
|
||
|
||
if (trailingGap > spacingHint) {
|
||
return true;
|
||
}
|
||
|
||
if (inferredGap > spacingHint * EXTRA_GAP_RATIO) {
|
||
return true;
|
||
}
|
||
|
||
const prevText = (prev.text ?? '').trimEnd();
|
||
if (prevText.endsWith('-')) {
|
||
return false;
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
const buildGroupText = (elements: PdfJsonTextElement[], metrics?: FontMetricsMap): string => {
|
||
let result = '';
|
||
elements.forEach((element, index) => {
|
||
const value = element.text ?? '';
|
||
if (index === 0) {
|
||
result += value;
|
||
return;
|
||
}
|
||
|
||
const previous = elements[index - 1];
|
||
const needsSpace = shouldInsertSpace(previous, element, metrics);
|
||
const startsWithWhitespace = /^\s/u.test(value);
|
||
|
||
if (needsSpace && !startsWithWhitespace) {
|
||
result += ' ';
|
||
}
|
||
result += value;
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const rgbToCss = (components: number[]): string => {
|
||
if (components.length >= 3) {
|
||
const r = Math.round(Math.max(0, Math.min(1, components[0])) * 255);
|
||
const g = Math.round(Math.max(0, Math.min(1, components[1])) * 255);
|
||
const b = Math.round(Math.max(0, Math.min(1, components[2])) * 255);
|
||
return `rgb(${r}, ${g}, ${b})`;
|
||
}
|
||
return 'rgb(0, 0, 0)';
|
||
};
|
||
|
||
const cmykToCss = (components: number[]): string => {
|
||
if (components.length >= 4) {
|
||
const c = Math.max(0, Math.min(1, components[0]));
|
||
const m = Math.max(0, Math.min(1, components[1]));
|
||
const y = Math.max(0, Math.min(1, components[2]));
|
||
const k = Math.max(0, Math.min(1, components[3]));
|
||
const r = Math.round(255 * (1 - c) * (1 - k));
|
||
const g = Math.round(255 * (1 - m) * (1 - k));
|
||
const b = Math.round(255 * (1 - y) * (1 - k));
|
||
return `rgb(${r}, ${g}, ${b})`;
|
||
}
|
||
return 'rgb(0, 0, 0)';
|
||
};
|
||
|
||
const grayToCss = (components: number[]): string => {
|
||
if (components.length >= 1) {
|
||
const gray = Math.round(Math.max(0, Math.min(1, components[0])) * 255);
|
||
return `rgb(${gray}, ${gray}, ${gray})`;
|
||
}
|
||
return 'rgb(0, 0, 0)';
|
||
};
|
||
|
||
const extractColor = (element: PdfJsonTextElement): string | null => {
|
||
const fillColor = element.fillColor;
|
||
if (!fillColor || !fillColor.components || fillColor.components.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const colorSpace = (fillColor.colorSpace ?? '').toLowerCase();
|
||
|
||
if (colorSpace.includes('rgb') || colorSpace.includes('srgb')) {
|
||
return rgbToCss(fillColor.components);
|
||
}
|
||
if (colorSpace.includes('cmyk')) {
|
||
return cmykToCss(fillColor.components);
|
||
}
|
||
if (colorSpace.includes('gray') || colorSpace.includes('grey')) {
|
||
return grayToCss(fillColor.components);
|
||
}
|
||
|
||
// Default to RGB interpretation
|
||
if (fillColor.components.length >= 3) {
|
||
return rgbToCss(fillColor.components);
|
||
}
|
||
if (fillColor.components.length === 1) {
|
||
return grayToCss(fillColor.components);
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const RAD_TO_DEG = 180 / Math.PI;
|
||
|
||
const normalizeAngle = (angle: number): number => {
|
||
let normalized = angle % 360;
|
||
if (normalized > 180) {
|
||
normalized -= 360;
|
||
} else if (normalized <= -180) {
|
||
normalized += 360;
|
||
}
|
||
return normalized;
|
||
};
|
||
|
||
const extractElementRotation = (element: PdfJsonTextElement): number | null => {
|
||
const matrix = element.textMatrix;
|
||
if (!matrix || matrix.length !== 6) {
|
||
return null;
|
||
}
|
||
const a = matrix[0];
|
||
const b = matrix[1];
|
||
if (Math.abs(a) < 1e-6 && Math.abs(b) < 1e-6) {
|
||
return null;
|
||
}
|
||
const angle = Math.atan2(b, a) * RAD_TO_DEG;
|
||
if (Math.abs(angle) < 0.5) {
|
||
return null;
|
||
}
|
||
return normalizeAngle(angle);
|
||
};
|
||
|
||
const computeGroupRotation = (elements: PdfJsonTextElement[]): number | null => {
|
||
const angles = elements
|
||
.map(extractElementRotation)
|
||
.filter((angle): angle is number => angle !== null);
|
||
if (angles.length === 0) {
|
||
return null;
|
||
}
|
||
const vector = angles.reduce(
|
||
(acc, angle) => {
|
||
const radians = (angle * Math.PI) / 180;
|
||
acc.x += Math.cos(radians);
|
||
acc.y += Math.sin(radians);
|
||
return acc;
|
||
},
|
||
{ x: 0, y: 0 },
|
||
);
|
||
if (Math.abs(vector.x) < 1e-6 && Math.abs(vector.y) < 1e-6) {
|
||
return null;
|
||
}
|
||
const average = Math.atan2(vector.y, vector.x) * RAD_TO_DEG;
|
||
const normalized = normalizeAngle(average);
|
||
return Math.abs(normalized) < 0.5 ? null : normalized;
|
||
};
|
||
|
||
const getAnchorPoint = (element: PdfJsonTextElement): { x: number; y: number } => {
|
||
if (element.textMatrix && element.textMatrix.length === 6) {
|
||
return {
|
||
x: valueOr(element.textMatrix[4]),
|
||
y: valueOr(element.textMatrix[5]),
|
||
};
|
||
}
|
||
return {
|
||
x: valueOr(element.x),
|
||
y: valueOr(element.y),
|
||
};
|
||
};
|
||
|
||
const computeBaselineLength = (
|
||
elements: PdfJsonTextElement[],
|
||
metrics?: FontMetricsMap,
|
||
): number => elements.reduce((acc, current) => acc + getWidth(current, metrics), 0);
|
||
|
||
const computeAverageBaseline = (elements: PdfJsonTextElement[]): number | null => {
|
||
if (elements.length === 0) {
|
||
return null;
|
||
}
|
||
let sum = 0;
|
||
elements.forEach((element) => {
|
||
sum += getBaseline(element);
|
||
});
|
||
return sum / elements.length;
|
||
};
|
||
|
||
const createGroup = (
|
||
pageIndex: number,
|
||
idSuffix: number,
|
||
elements: PdfJsonTextElement[],
|
||
metrics?: FontMetricsMap,
|
||
): TextGroup => {
|
||
const clones = elements.map(cloneTextElement);
|
||
const originalClones = clones.map(cloneTextElement);
|
||
const bounds = mergeBounds(elements.map((element) => getElementBounds(element, metrics)));
|
||
const firstElement = elements[0];
|
||
const rotation = computeGroupRotation(elements);
|
||
const anchor = rotation !== null ? getAnchorPoint(firstElement) : null;
|
||
const baselineLength = computeBaselineLength(elements, metrics);
|
||
const baseline = computeAverageBaseline(elements);
|
||
|
||
return {
|
||
id: `${pageIndex}-${idSuffix}`,
|
||
pageIndex,
|
||
fontId: firstElement?.fontId,
|
||
fontSize: firstElement?.fontSize,
|
||
fontMatrixSize: firstElement?.fontMatrixSize,
|
||
color: firstElement ? extractColor(firstElement) : null,
|
||
fontWeight: null, // Will be determined from font descriptor
|
||
rotation,
|
||
anchor,
|
||
baselineLength,
|
||
baseline,
|
||
elements: clones,
|
||
originalElements: originalClones,
|
||
text: buildGroupText(elements, metrics),
|
||
originalText: buildGroupText(elements, metrics),
|
||
bounds,
|
||
};
|
||
};
|
||
|
||
const cloneLineTemplate = (line: TextGroup): TextGroup => ({
|
||
...line,
|
||
childLineGroups: null,
|
||
lineElementCounts: null,
|
||
lineSpacing: null,
|
||
elements: line.elements.map(cloneTextElement),
|
||
originalElements: line.originalElements.map(cloneTextElement),
|
||
});
|
||
|
||
const groupLinesIntoParagraphs = (
|
||
lineGroups: TextGroup[],
|
||
pageWidth: number,
|
||
metrics?: FontMetricsMap,
|
||
): TextGroup[] => {
|
||
if (lineGroups.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const paragraphs: TextGroup[][] = [];
|
||
let currentParagraph: TextGroup[] = [lineGroups[0]];
|
||
const bulletFlags = new Map<string, boolean>();
|
||
bulletFlags.set(lineGroups[0].id, false);
|
||
|
||
for (let i = 1; i < lineGroups.length; i++) {
|
||
const prevLine = lineGroups[i - 1];
|
||
const currentLine = lineGroups[i];
|
||
|
||
// Calculate line spacing
|
||
const prevBaseline = prevLine.baseline ?? 0;
|
||
const currentBaseline = currentLine.baseline ?? 0;
|
||
const lineSpacing = Math.abs(prevBaseline - currentBaseline);
|
||
|
||
// Calculate average font size
|
||
const prevFontSize = prevLine.fontSize ?? 12;
|
||
const currentFontSize = currentLine.fontSize ?? 12;
|
||
const avgFontSize = (prevFontSize + currentFontSize) / 2;
|
||
|
||
// Check horizontal alignment (left edge)
|
||
const prevLeft = prevLine.bounds.left;
|
||
const currentLeft = currentLine.bounds.left;
|
||
const leftAlignmentTolerance = avgFontSize * 0.3;
|
||
const isLeftAligned = Math.abs(prevLeft - currentLeft) <= leftAlignmentTolerance;
|
||
|
||
// Check if fonts match
|
||
const sameFont = prevLine.fontId === currentLine.fontId;
|
||
|
||
// Check for consistent spacing rather than expected spacing
|
||
// Line spacing in PDFs can range from 1.0x to 3.0x font size
|
||
// We just want to ensure spacing is consistent between consecutive lines
|
||
// and not excessively large (which would indicate a paragraph break)
|
||
const maxReasonableSpacing = avgFontSize * 3.0; // Max ~3x font size for normal line spacing
|
||
const hasReasonableSpacing = lineSpacing <= maxReasonableSpacing;
|
||
|
||
// Check if current line looks like a bullet/list item
|
||
const prevRight = prevLine.bounds.right;
|
||
const currentRight = currentLine.bounds.right;
|
||
const prevWidth = prevRight - prevLeft;
|
||
const currentWidth = currentRight - currentLeft;
|
||
|
||
// Count word count to help identify bullets (typically short)
|
||
const prevWords = (prevLine.text ?? '').split(/\s+/).filter(w => w.length > 0).length;
|
||
const currentWords = (currentLine.text ?? '').split(/\s+/).filter(w => w.length > 0).length;
|
||
const prevText = (prevLine.text ?? '').trim();
|
||
const currentText = (currentLine.text ?? '').trim();
|
||
|
||
// Bullet detection - look for bullet markers or very short lines
|
||
const bulletMarkerRegex = /^[\u2022\u2023\u25E6\u2043\u2219•·◦‣⁃\-*]\s|^\d+[.)]\s|^[a-z][.)]\s/i;
|
||
const prevHasBulletMarker = bulletMarkerRegex.test(prevText);
|
||
const currentHasBulletMarker = bulletMarkerRegex.test(currentText);
|
||
|
||
// True bullets are:
|
||
// 1. Have bullet markers/numbers OR
|
||
// 2. Very short (< 10 words) AND much narrower than average (< 60% of page width)
|
||
const headingKeywords = ['action items', 'next steps', 'notes', 'logistics', 'tasks'];
|
||
const normalizedPageWidth = pageWidth > 0 ? pageWidth : avgFontSize * 70;
|
||
const maxReferenceWidth = normalizedPageWidth > 0 ? normalizedPageWidth : avgFontSize * 70;
|
||
const indentDelta = currentLeft - prevLeft;
|
||
const indentThreshold = Math.max(avgFontSize * 0.6, 8);
|
||
const hasIndent = indentDelta > indentThreshold;
|
||
const currentWidthRatio = maxReferenceWidth > 0 ? currentWidth / maxReferenceWidth : 0;
|
||
const prevWidthRatio = maxReferenceWidth > 0 ? prevWidth / maxReferenceWidth : 0;
|
||
const prevLooksLikeHeading =
|
||
prevText.endsWith(':') ||
|
||
(prevWords <= 4 && prevWidthRatio < 0.4) ||
|
||
headingKeywords.some((keyword) => prevText.toLowerCase().includes(keyword));
|
||
|
||
const wrapCandidate =
|
||
!currentHasBulletMarker &&
|
||
!hasIndent &&
|
||
!prevLooksLikeHeading &&
|
||
currentWords <= 12 &&
|
||
currentWidthRatio < 0.45 &&
|
||
Math.abs(prevLeft - currentLeft) <= leftAlignmentTolerance &&
|
||
currentWidth < prevWidth * 0.85;
|
||
|
||
const currentIsBullet = wrapCandidate
|
||
? false
|
||
: currentHasBulletMarker ||
|
||
(hasIndent && (currentWords <= 14 || currentWidthRatio <= 0.65)) ||
|
||
(prevLooksLikeHeading && (currentWords <= 16 || currentWidthRatio <= 0.8 || prevWidthRatio < 0.35)) ||
|
||
(currentWords <= 8 && currentWidthRatio <= 0.45 && prevWidth - currentWidth > avgFontSize * 4);
|
||
|
||
const prevIsBullet = bulletFlags.get(prevLine.id) ?? prevHasBulletMarker;
|
||
bulletFlags.set(currentLine.id, currentIsBullet);
|
||
|
||
// Detect paragraph→bullet transition
|
||
const likelyBulletStart = !prevIsBullet && currentIsBullet;
|
||
|
||
// Don't merge two consecutive bullets
|
||
const bothAreBullets = prevIsBullet && currentIsBullet;
|
||
|
||
// Merge into paragraph if:
|
||
// 1. Left aligned
|
||
// 2. Same font
|
||
// 3. Reasonable line spacing
|
||
// 4. NOT transitioning to bullets
|
||
// 5. NOT both are bullets
|
||
const shouldMerge =
|
||
isLeftAligned &&
|
||
sameFont &&
|
||
hasReasonableSpacing &&
|
||
!likelyBulletStart &&
|
||
!bothAreBullets &&
|
||
!currentIsBullet;
|
||
|
||
if (i < 10 || likelyBulletStart || bothAreBullets || !shouldMerge) {
|
||
console.log(` Line ${i}:`);
|
||
console.log(` prev: "${prevText.substring(0, 40)}" (${prevWords}w, ${prevWidth.toFixed(0)}pt, marker:${prevHasBulletMarker}, bullet:${prevIsBullet})`);
|
||
console.log(` curr: "${currentText.substring(0, 40)}" (${currentWords}w, ${currentWidth.toFixed(0)}pt, marker:${currentHasBulletMarker}, bullet:${currentIsBullet})`);
|
||
console.log(` checks: leftAlign:${isLeftAligned} (${Math.abs(prevLeft - currentLeft).toFixed(1)}pt), sameFont:${sameFont}, spacing:${hasReasonableSpacing} (${lineSpacing.toFixed(1)}pt/${maxReasonableSpacing.toFixed(1)}pt)`);
|
||
console.log(` decision: merge=${shouldMerge} (bulletStart:${likelyBulletStart}, bothBullets:${bothAreBullets})`);
|
||
}
|
||
|
||
if (shouldMerge) {
|
||
currentParagraph.push(currentLine);
|
||
} else {
|
||
paragraphs.push(currentParagraph);
|
||
currentParagraph = [currentLine];
|
||
}
|
||
}
|
||
|
||
// Don't forget the last paragraph
|
||
if (currentParagraph.length > 0) {
|
||
paragraphs.push(currentParagraph);
|
||
}
|
||
|
||
// Merge line groups into single paragraph groups
|
||
return paragraphs.map((lines, _paragraphIndex) => {
|
||
if (lines.length === 1) {
|
||
return lines[0];
|
||
}
|
||
|
||
// Combine all elements from all lines
|
||
const lineTemplates = lines.map(line => cloneLineTemplate(line));
|
||
const flattenedLineTemplates = lineTemplates.flatMap((line) =>
|
||
line.childLineGroups && line.childLineGroups.length > 0
|
||
? line.childLineGroups
|
||
: [line],
|
||
);
|
||
const allLines = flattenedLineTemplates.length > 0 ? flattenedLineTemplates : lineTemplates;
|
||
const allElements = allLines.flatMap(line => line.originalElements);
|
||
const pageIndex = lines[0].pageIndex;
|
||
const lineElementCounts = allLines.map((line) => line.originalElements.length);
|
||
|
||
// Create merged group with newlines between lines
|
||
const paragraphText = allLines.map(line => line.text).join('\n');
|
||
const mergedBounds = mergeBounds(allLines.map(line => line.bounds));
|
||
const spacingValues: number[] = [];
|
||
for (let i = 1; i < allLines.length; i++) {
|
||
const prevBaseline = allLines[i - 1].baseline ?? allLines[i - 1].bounds.bottom;
|
||
const currentBaseline = allLines[i].baseline ?? allLines[i].bounds.bottom;
|
||
const spacing = Math.abs(prevBaseline - currentBaseline);
|
||
if (spacing > 0) {
|
||
spacingValues.push(spacing);
|
||
}
|
||
}
|
||
const averageSpacing =
|
||
spacingValues.length > 0
|
||
? spacingValues.reduce((sum, value) => sum + value, 0) / spacingValues.length
|
||
: null;
|
||
|
||
const firstElement = allElements[0];
|
||
const rotation = computeGroupRotation(allElements);
|
||
const anchor = rotation !== null ? getAnchorPoint(firstElement) : null;
|
||
const baselineLength = computeBaselineLength(allElements, metrics);
|
||
const baseline = computeAverageBaseline(allElements);
|
||
|
||
return {
|
||
id: lines[0].id, // Keep the first line's ID
|
||
pageIndex,
|
||
fontId: firstElement?.fontId,
|
||
fontSize: firstElement?.fontSize,
|
||
fontMatrixSize: firstElement?.fontMatrixSize,
|
||
lineSpacing: averageSpacing,
|
||
lineElementCounts: lines.length > 1 ? lineElementCounts : null,
|
||
color: firstElement ? extractColor(firstElement) : null,
|
||
fontWeight: null,
|
||
rotation,
|
||
anchor,
|
||
baselineLength,
|
||
baseline,
|
||
elements: allElements.map(cloneTextElement),
|
||
originalElements: allElements.map(cloneTextElement),
|
||
text: paragraphText,
|
||
originalText: paragraphText,
|
||
bounds: mergedBounds,
|
||
childLineGroups: allLines,
|
||
};
|
||
});
|
||
};
|
||
|
||
export const groupPageTextElements = (
|
||
page: PdfJsonPage | null | undefined,
|
||
pageIndex: number,
|
||
metrics?: FontMetricsMap,
|
||
groupingMode: 'auto' | 'paragraph' | 'singleLine' = 'auto',
|
||
): TextGroup[] => {
|
||
if (!page?.textElements || page.textElements.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const pageWidth = valueOr(page.width, DEFAULT_PAGE_WIDTH);
|
||
|
||
const elements = page.textElements
|
||
.map(cloneTextElement)
|
||
.filter((element) => element.text !== null && element.text !== undefined);
|
||
|
||
elements.sort((a, b) => getBaseline(b) - getBaseline(a));
|
||
|
||
const lines: { baseline: number; elements: PdfJsonTextElement[] }[] = [];
|
||
|
||
elements.forEach((element) => {
|
||
const baseline = getBaseline(element);
|
||
const fontSize = getFontSize(element);
|
||
const tolerance = Math.max(LINE_TOLERANCE, fontSize * 0.12);
|
||
|
||
const existingLine = lines.find((line) => Math.abs(line.baseline - baseline) <= tolerance);
|
||
|
||
if (existingLine) {
|
||
existingLine.elements.push(element);
|
||
} else {
|
||
lines.push({ baseline, elements: [element] });
|
||
}
|
||
});
|
||
|
||
lines.forEach((line) => {
|
||
line.elements.sort((a, b) => getX(a) - getX(b));
|
||
});
|
||
|
||
let groupCounter = 0;
|
||
const lineGroups: TextGroup[] = [];
|
||
|
||
lines.forEach((line) => {
|
||
let currentBucket: PdfJsonTextElement[] = [];
|
||
|
||
line.elements.forEach((element) => {
|
||
if (currentBucket.length === 0) {
|
||
currentBucket.push(element);
|
||
return;
|
||
}
|
||
|
||
const previous = currentBucket[currentBucket.length - 1];
|
||
const gap = getX(element) - (getX(previous) + getWidth(previous, metrics));
|
||
const avgFontSize = (getFontSize(previous) + getFontSize(element)) / 2;
|
||
const splitThreshold = Math.max(SPACE_MIN_GAP, avgFontSize * GAP_FACTOR);
|
||
|
||
const sameFont = previous.fontId === element.fontId;
|
||
let shouldSplit = gap > splitThreshold * (sameFont ? 1.4 : 1.0);
|
||
|
||
if (shouldSplit) {
|
||
const prevBaseline = getBaseline(previous);
|
||
const currentBaseline = getBaseline(element);
|
||
const baselineDelta = Math.abs(prevBaseline - currentBaseline);
|
||
const prevEndX = getX(previous) + getWidth(previous, metrics);
|
||
const _prevEndY = prevBaseline;
|
||
const diagonalGap = Math.hypot(Math.max(0, getX(element) - prevEndX), baselineDelta);
|
||
const diagonalThreshold = Math.max(avgFontSize * 0.8, splitThreshold);
|
||
if (diagonalGap <= diagonalThreshold) {
|
||
shouldSplit = false;
|
||
}
|
||
}
|
||
|
||
const previousRotation = extractElementRotation(previous);
|
||
const currentRotation = extractElementRotation(element);
|
||
if (
|
||
shouldSplit &&
|
||
previousRotation !== null &&
|
||
currentRotation !== null &&
|
||
Math.abs(normalizeAngle(previousRotation - currentRotation)) < 1
|
||
) {
|
||
shouldSplit = false;
|
||
}
|
||
|
||
if (shouldSplit) {
|
||
lineGroups.push(createGroup(pageIndex, groupCounter, currentBucket, metrics));
|
||
groupCounter += 1;
|
||
currentBucket = [element];
|
||
} else {
|
||
currentBucket.push(element);
|
||
}
|
||
});
|
||
|
||
if (currentBucket.length > 0) {
|
||
lineGroups.push(createGroup(pageIndex, groupCounter, currentBucket, metrics));
|
||
groupCounter += 1;
|
||
}
|
||
});
|
||
|
||
// Apply paragraph grouping based on mode
|
||
if (groupingMode === 'singleLine') {
|
||
// Single line mode: skip paragraph grouping
|
||
return lineGroups;
|
||
}
|
||
|
||
if (groupingMode === 'paragraph') {
|
||
// Paragraph mode: always apply grouping
|
||
return groupLinesIntoParagraphs(lineGroups, pageWidth, metrics);
|
||
}
|
||
|
||
// Auto mode: use heuristic to determine if we should group
|
||
// Analyze the page content to decide
|
||
let multiLineGroups = 0;
|
||
let totalWords = 0;
|
||
let longTextGroups = 0;
|
||
let totalGroups = 0;
|
||
const wordCounts: number[] = [];
|
||
let fullWidthLines = 0;
|
||
|
||
// Define "full width" as extending to at least 70% of page width
|
||
const fullWidthThreshold = pageWidth * 0.7;
|
||
|
||
lineGroups.forEach((group) => {
|
||
const text = (group.text || '').trim();
|
||
if (text.length === 0) return;
|
||
|
||
totalGroups++;
|
||
const lines = text.split('\n');
|
||
const lineCount = lines.length;
|
||
const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length;
|
||
|
||
totalWords += wordCount;
|
||
wordCounts.push(wordCount);
|
||
|
||
if (lineCount > 1) {
|
||
multiLineGroups++;
|
||
}
|
||
|
||
if (wordCount >= 10 || text.length >= 50) {
|
||
longTextGroups++;
|
||
}
|
||
|
||
// Check if this line extends close to the right margin (paragraph-like)
|
||
const rightEdge = group.bounds.right;
|
||
if (rightEdge >= fullWidthThreshold) {
|
||
fullWidthLines++;
|
||
}
|
||
});
|
||
|
||
if (totalGroups === 0) {
|
||
return lineGroups;
|
||
}
|
||
|
||
const avgWordsPerGroup = totalWords / totalGroups;
|
||
const longTextRatio = longTextGroups / totalGroups;
|
||
const fullWidthRatio = fullWidthLines / totalGroups;
|
||
|
||
// Calculate variance in line lengths (paragraphs have varying lengths, lists are uniform)
|
||
const variance = wordCounts.reduce((sum, count) => {
|
||
const diff = count - avgWordsPerGroup;
|
||
return sum + diff * diff;
|
||
}, 0) / totalGroups;
|
||
const stdDev = Math.sqrt(variance);
|
||
const coefficientOfVariation = avgWordsPerGroup > 0 ? stdDev / avgWordsPerGroup : 0;
|
||
|
||
// Check each criterion
|
||
const criterion1 = avgWordsPerGroup > 5;
|
||
const criterion2 = longTextRatio > 0.4;
|
||
const criterion3 = coefficientOfVariation > 0.5 || fullWidthRatio > 0.6; // High variance OR many full-width lines = paragraph text
|
||
|
||
const isParagraphPage = criterion1 && criterion2 && criterion3;
|
||
|
||
// Log detection stats
|
||
console.log(`📄 Page ${pageIndex} Grouping Analysis (mode: ${groupingMode}):`);
|
||
console.log(` Stats:`);
|
||
console.log(` • Page width: ${pageWidth.toFixed(1)}pt (full-width threshold: ${fullWidthThreshold.toFixed(1)}pt)`);
|
||
console.log(` • Multi-line groups: ${multiLineGroups}`);
|
||
console.log(` • Total groups: ${totalGroups}`);
|
||
console.log(` • Total words: ${totalWords}`);
|
||
console.log(` • Long text groups (≥10 words or ≥50 chars): ${longTextGroups}`);
|
||
console.log(` • Full-width lines (≥70% page width): ${fullWidthLines}`);
|
||
console.log(` • Avg words per group: ${avgWordsPerGroup.toFixed(2)}`);
|
||
console.log(` • Long text ratio: ${(longTextRatio * 100).toFixed(1)}%`);
|
||
console.log(` • Full-width ratio: ${(fullWidthRatio * 100).toFixed(1)}%`);
|
||
console.log(` • Std deviation: ${stdDev.toFixed(2)}`);
|
||
console.log(` • Coefficient of variation: ${coefficientOfVariation.toFixed(2)}`);
|
||
console.log(` Criteria:`);
|
||
console.log(` 1. Avg Words Per Group: ${criterion1 ? '✅ PASS' : '❌ FAIL'}`);
|
||
console.log(` (${avgWordsPerGroup.toFixed(2)} > 5)`);
|
||
console.log(` 2. Long Text Ratio: ${criterion2 ? '✅ PASS' : '❌ FAIL'}`);
|
||
console.log(` (${(longTextRatio * 100).toFixed(1)}% > 40%)`);
|
||
console.log(` 3. Line Width Pattern: ${criterion3 ? '✅ PASS' : '❌ FAIL'}`);
|
||
console.log(` (CV ${coefficientOfVariation.toFixed(2)} > 0.5 OR ${(fullWidthRatio * 100).toFixed(1)}% > 60%)`);
|
||
console.log(` ${coefficientOfVariation > 0.5 ? '✓ High variance (varying line lengths)' : '✗ Low variance'} ${fullWidthRatio > 0.6 ? '✓ Many full-width lines (paragraph-like)' : '✗ Few full-width lines (list-like)'}`);
|
||
console.log(` Decision: ${isParagraphPage ? '📝 PARAGRAPH MODE' : '📋 LINE MODE'}`);
|
||
if (isParagraphPage) {
|
||
console.log(` Reason: All three criteria passed (AND logic)`);
|
||
} else {
|
||
const failedReasons = [];
|
||
if (!criterion1) failedReasons.push('low average words per group');
|
||
if (!criterion2) failedReasons.push('low ratio of long text groups');
|
||
if (!criterion3) failedReasons.push('low variance and few full-width lines (list-like structure)');
|
||
console.log(` Reason: ${failedReasons.join(', ')}`);
|
||
}
|
||
console.log('');
|
||
|
||
// Only apply paragraph grouping if it looks like a paragraph-heavy page
|
||
if (isParagraphPage) {
|
||
console.log(`🔀 Applying paragraph grouping to page ${pageIndex}`);
|
||
return groupLinesIntoParagraphs(lineGroups, pageWidth, metrics);
|
||
}
|
||
|
||
// For sparse pages, keep lines separate
|
||
console.log(`📋 Keeping lines separate for page ${pageIndex}`);
|
||
return lineGroups;
|
||
};
|
||
|
||
export const groupDocumentText = (
|
||
document: PdfJsonDocument | null | undefined,
|
||
groupingMode: 'auto' | 'paragraph' | 'singleLine' = 'auto',
|
||
): TextGroup[][] => {
|
||
const pages = document?.pages ?? [];
|
||
const metrics = buildFontMetrics(document);
|
||
return pages.map((page, index) => groupPageTextElements(page, index, metrics, groupingMode));
|
||
};
|
||
|
||
export const extractPageImages = (
|
||
page: PdfJsonPage | null | undefined,
|
||
pageIndex: number,
|
||
): PdfJsonImageElement[] => {
|
||
const images = page?.imageElements ?? [];
|
||
return images.map((image, imageIndex) => {
|
||
const clone = cloneImageElement(image);
|
||
if (!clone.id || clone.id.trim().length === 0) {
|
||
clone.id = `page-${pageIndex}-image-${imageIndex}`;
|
||
}
|
||
return clone;
|
||
});
|
||
};
|
||
|
||
export const extractDocumentImages = (
|
||
document: PdfJsonDocument | null | undefined,
|
||
): PdfJsonImageElement[][] => {
|
||
const pages = document?.pages ?? [];
|
||
return pages.map((page, index) => extractPageImages(page, index));
|
||
};
|
||
|
||
export const deepCloneDocument = (document: PdfJsonDocument): PdfJsonDocument => {
|
||
if (typeof structuredClone === 'function') {
|
||
return structuredClone(document);
|
||
}
|
||
return JSON.parse(JSON.stringify(document));
|
||
};
|
||
|
||
export const pageDimensions = (page: PdfJsonPage | null | undefined): { width: number; height: number } => {
|
||
const width = valueOr(page?.width, DEFAULT_PAGE_WIDTH);
|
||
const height = valueOr(page?.height, DEFAULT_PAGE_HEIGHT);
|
||
|
||
console.log(`📏 [pageDimensions] Calculating page size:`, {
|
||
hasPage: !!page,
|
||
rawWidth: page?.width,
|
||
rawHeight: page?.height,
|
||
mediaBox: page?.mediaBox,
|
||
cropBox: page?.cropBox,
|
||
rotation: page?.rotation,
|
||
calculatedWidth: width,
|
||
calculatedHeight: height,
|
||
DEFAULT_PAGE_WIDTH,
|
||
DEFAULT_PAGE_HEIGHT,
|
||
commonFormats: {
|
||
'US Letter': '612 × 792 pt',
|
||
'A4': '595 × 842 pt',
|
||
'Legal': '612 × 1008 pt',
|
||
},
|
||
});
|
||
|
||
return { width, height };
|
||
};
|
||
|
||
export const createMergedElement = (group: TextGroup): PdfJsonTextElement => {
|
||
const reference = group.originalElements[0];
|
||
const merged = cloneTextElement(reference);
|
||
merged.text = sanitizeParagraphText(group.text);
|
||
clearGlyphHints(merged);
|
||
if (reference.textMatrix && reference.textMatrix.length === 6) {
|
||
merged.textMatrix = [...reference.textMatrix];
|
||
}
|
||
return merged;
|
||
};
|
||
|
||
const distributeTextAcrossElements = (text: string | undefined, elements: PdfJsonTextElement[]): boolean => {
|
||
if (elements.length === 0) {
|
||
return true;
|
||
}
|
||
|
||
const normalizedText = sanitizeParagraphText(text);
|
||
const targetChars = Array.from(normalizedText);
|
||
if (targetChars.length === 0) {
|
||
elements.forEach((element) => {
|
||
element.text = '';
|
||
clearGlyphHints(element);
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const capacities = elements.map((element) => {
|
||
const originalText = element.text ?? '';
|
||
const graphemeCount = Array.from(originalText).length;
|
||
return graphemeCount > 0 ? graphemeCount : 1;
|
||
});
|
||
|
||
let cursor = 0;
|
||
elements.forEach((element, index) => {
|
||
const remaining = targetChars.length - cursor;
|
||
let sliceLength = 0;
|
||
if (remaining > 0) {
|
||
if (index === elements.length - 1) {
|
||
sliceLength = remaining;
|
||
} else {
|
||
const capacity = Math.max(capacities[index], 1);
|
||
const minRemainingForRest = Math.max(elements.length - index - 1, 0);
|
||
sliceLength = Math.min(capacity, Math.max(remaining - minRemainingForRest, 1));
|
||
}
|
||
}
|
||
|
||
element.text = sliceLength > 0 ? targetChars.slice(cursor, cursor + sliceLength).join('') : '';
|
||
clearGlyphHints(element);
|
||
cursor += sliceLength;
|
||
});
|
||
|
||
elements.forEach((element) => {
|
||
if (element.text == null) {
|
||
element.text = '';
|
||
}
|
||
});
|
||
|
||
return true;
|
||
};
|
||
|
||
const sliceElementsByLineCounts = (group: TextGroup): PdfJsonTextElement[][] => {
|
||
const counts = group.lineElementCounts;
|
||
if (!counts || counts.length === 0) {
|
||
if (!group.originalElements.length) {
|
||
return [];
|
||
}
|
||
return [group.originalElements];
|
||
}
|
||
|
||
const result: PdfJsonTextElement[][] = [];
|
||
let cursor = 0;
|
||
counts.forEach((count) => {
|
||
if (count <= 0) {
|
||
return;
|
||
}
|
||
const slice = group.originalElements.slice(cursor, cursor + count);
|
||
if (slice.length > 0) {
|
||
result.push(slice);
|
||
}
|
||
cursor += count;
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const rebuildParagraphLineElements = (group: TextGroup): PdfJsonTextElement[] | null => {
|
||
if (!group.text || !group.text.includes('\n')) {
|
||
return null;
|
||
}
|
||
|
||
const lineTexts = splitParagraphIntoLines(group.text);
|
||
if (lineTexts.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const lineElementGroups = sliceElementsByLineCounts(group);
|
||
if (!lineElementGroups.length) {
|
||
return null;
|
||
}
|
||
|
||
const lineBaselines = lineElementGroups.map((elements) => {
|
||
for (const element of elements) {
|
||
const baseline = extractElementBaseline(element);
|
||
if (baseline !== null) {
|
||
return baseline;
|
||
}
|
||
}
|
||
return group.baseline ?? null;
|
||
});
|
||
|
||
const spacingFromBaselines = (() => {
|
||
for (let i = 1; i < lineBaselines.length; i += 1) {
|
||
const prev = lineBaselines[i - 1];
|
||
const current = lineBaselines[i];
|
||
if (prev !== null && current !== null) {
|
||
const diff = Math.abs(prev - current);
|
||
if (diff > 0) {
|
||
return diff;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
})();
|
||
|
||
const spacing =
|
||
(group.lineSpacing && group.lineSpacing > 0
|
||
? group.lineSpacing
|
||
: spacingFromBaselines) ??
|
||
Math.max(group.fontMatrixSize ?? group.fontSize ?? 12, 6) * 1.2;
|
||
|
||
let direction = -1;
|
||
for (let i = 1; i < lineBaselines.length; i += 1) {
|
||
const prev = lineBaselines[i - 1];
|
||
const current = lineBaselines[i];
|
||
if (prev !== null && current !== null && Math.abs(prev - current) > 0.05) {
|
||
direction = current < prev ? -1 : 1;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const templateCount = lineElementGroups.length;
|
||
const lastTemplateIndex = Math.max(templateCount - 1, 0);
|
||
const rebuilt: PdfJsonTextElement[] = [];
|
||
|
||
for (let index = 0; index < lineTexts.length; index += 1) {
|
||
const templateIndex = Math.min(index, lastTemplateIndex);
|
||
const templateElements = lineElementGroups[templateIndex];
|
||
if (!templateElements || templateElements.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const shiftSteps = index - templateIndex;
|
||
const delta = shiftSteps * spacing * direction;
|
||
const clones = shiftElementsBy(templateElements, delta);
|
||
const normalizedLine = sanitizeParagraphText(lineTexts[index]);
|
||
const distributed = distributeTextAcrossElements(normalizedLine, clones);
|
||
|
||
if (!distributed) {
|
||
const primary = clones[0];
|
||
primary.text = normalizedLine;
|
||
clearGlyphHints(primary);
|
||
for (let i = 1; i < clones.length; i += 1) {
|
||
clones[i].text = '';
|
||
clearGlyphHints(clones[i]);
|
||
}
|
||
}
|
||
|
||
rebuilt.push(...clones);
|
||
}
|
||
|
||
return rebuilt;
|
||
};
|
||
|
||
export const buildUpdatedDocument = (
|
||
source: PdfJsonDocument,
|
||
groupsByPage: TextGroup[][],
|
||
imagesByPage: PdfJsonImageElement[][],
|
||
): PdfJsonDocument => {
|
||
const updated = deepCloneDocument(source);
|
||
const pages = updated.pages ?? [];
|
||
|
||
updated.pages = pages.map((page, pageIndex) => {
|
||
const groups = groupsByPage[pageIndex] ?? [];
|
||
const images = imagesByPage[pageIndex] ?? [];
|
||
if (!groups.length) {
|
||
return {
|
||
...page,
|
||
imageElements: images.map(cloneImageElement),
|
||
};
|
||
}
|
||
|
||
const updatedElements: PdfJsonTextElement[] = groups.flatMap((group) => {
|
||
if (group.text === group.originalText) {
|
||
return group.originalElements.map(cloneTextElement);
|
||
}
|
||
return [createMergedElement(group)];
|
||
});
|
||
|
||
return {
|
||
...page,
|
||
textElements: updatedElements,
|
||
imageElements: images.map(cloneImageElement),
|
||
contentStreams: page.contentStreams ?? [],
|
||
};
|
||
});
|
||
|
||
return updated;
|
||
};
|
||
|
||
export const restoreGlyphElements = (
|
||
source: PdfJsonDocument,
|
||
groupsByPage: TextGroup[][],
|
||
imagesByPage: PdfJsonImageElement[][],
|
||
originalImagesByPage: PdfJsonImageElement[][],
|
||
forceMergedGroups: boolean = false,
|
||
): PdfJsonDocument => {
|
||
const updated = deepCloneDocument(source);
|
||
const pages = updated.pages ?? [];
|
||
|
||
updated.pages = pages.map((page, pageIndex) => {
|
||
const groups = groupsByPage[pageIndex] ?? [];
|
||
const images = imagesByPage[pageIndex] ?? [];
|
||
const _baselineImages = originalImagesByPage[pageIndex] ?? [];
|
||
|
||
if (!groups.length) {
|
||
return {
|
||
...page,
|
||
imageElements: images.map(cloneImageElement),
|
||
};
|
||
}
|
||
|
||
const rebuiltElements: PdfJsonTextElement[] = [];
|
||
|
||
groups.forEach((group) => {
|
||
if (group.text !== group.originalText) {
|
||
// Always try to rebuild paragraph lines if text has newlines
|
||
const paragraphElements = rebuildParagraphLineElements(group);
|
||
if (paragraphElements && paragraphElements.length > 0) {
|
||
rebuiltElements.push(...paragraphElements);
|
||
return;
|
||
}
|
||
// If no newlines or rebuilding failed, check if we should force merge
|
||
if (forceMergedGroups) {
|
||
rebuiltElements.push(createMergedElement(group));
|
||
return;
|
||
}
|
||
const originalGlyphCount = group.originalElements.reduce(
|
||
(sum, element) => sum + countGraphemes(element.text ?? ''),
|
||
0,
|
||
);
|
||
const normalizedText = sanitizeParagraphText(group.text);
|
||
const targetGlyphCount = countGraphemes(normalizedText);
|
||
|
||
if (targetGlyphCount !== originalGlyphCount) {
|
||
rebuiltElements.push(createMergedElement(group));
|
||
return;
|
||
}
|
||
|
||
const originals = group.originalElements.map(cloneTextElement);
|
||
const distributed = distributeTextAcrossElements(normalizedText, originals);
|
||
if (distributed) {
|
||
rebuiltElements.push(...originals);
|
||
} else {
|
||
rebuiltElements.push(createMergedElement(group));
|
||
}
|
||
return;
|
||
}
|
||
|
||
rebuiltElements.push(...group.originalElements.map(cloneTextElement));
|
||
});
|
||
|
||
return {
|
||
...page,
|
||
textElements: rebuiltElements,
|
||
imageElements: images.map(cloneImageElement),
|
||
contentStreams: page.contentStreams ?? [],
|
||
};
|
||
});
|
||
|
||
return updated;
|
||
};
|
||
|
||
const approxEqual = (a: number | null | undefined, b: number | null | undefined, tolerance = 0.25): boolean => {
|
||
const first = typeof a === 'number' && Number.isFinite(a) ? a : 0;
|
||
const second = typeof b === 'number' && Number.isFinite(b) ? b : 0;
|
||
return Math.abs(first - second) <= tolerance;
|
||
};
|
||
|
||
const arrayApproxEqual = (
|
||
first: number[] | null | undefined,
|
||
second: number[] | null | undefined,
|
||
tolerance = 0.25,
|
||
): boolean => {
|
||
if (!first && !second) {
|
||
return true;
|
||
}
|
||
if (!first || !second) {
|
||
return false;
|
||
}
|
||
if (first.length !== second.length) {
|
||
return false;
|
||
}
|
||
for (let index = 0; index < first.length; index += 1) {
|
||
if (!approxEqual(first[index], second[index], tolerance)) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const areImageElementsEqual = (
|
||
current: PdfJsonImageElement,
|
||
original: PdfJsonImageElement,
|
||
): boolean => {
|
||
if (current === original) {
|
||
return true;
|
||
}
|
||
if (!current || !original) {
|
||
return false;
|
||
}
|
||
|
||
const sameData = (current.imageData ?? null) === (original.imageData ?? null);
|
||
const sameFormat = (current.imageFormat ?? null) === (original.imageFormat ?? null);
|
||
|
||
return (
|
||
sameData &&
|
||
sameFormat &&
|
||
approxEqual(current.x, original.x) &&
|
||
approxEqual(current.y, original.y) &&
|
||
approxEqual(current.width, original.width) &&
|
||
approxEqual(current.height, original.height) &&
|
||
approxEqual(current.left, original.left) &&
|
||
approxEqual(current.right, original.right) &&
|
||
approxEqual(current.top, original.top) &&
|
||
approxEqual(current.bottom, original.bottom) &&
|
||
(current.zOrder ?? null) === (original.zOrder ?? null) &&
|
||
arrayApproxEqual(current.transform, original.transform)
|
||
);
|
||
};
|
||
|
||
export const areImageListsDifferent = (
|
||
current: PdfJsonImageElement[],
|
||
original: PdfJsonImageElement[],
|
||
): boolean => {
|
||
if (current.length !== original.length) {
|
||
return true;
|
||
}
|
||
for (let index = 0; index < current.length; index += 1) {
|
||
if (!areImageElementsEqual(current[index], original[index])) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
export const getDirtyPages = (
|
||
groupsByPage: TextGroup[][],
|
||
imagesByPage: PdfJsonImageElement[][],
|
||
originalGroupsByPage: TextGroup[][],
|
||
originalImagesByPage: PdfJsonImageElement[][],
|
||
): boolean[] => {
|
||
return groupsByPage.map((groups, index) => {
|
||
// Check if any text was modified
|
||
const textDirty = groups.some((group) => group.text !== group.originalText);
|
||
|
||
// Check if any groups were deleted by comparing with original groups
|
||
const originalGroups = originalGroupsByPage[index] ?? [];
|
||
const groupCountChanged = groups.length !== originalGroups.length;
|
||
|
||
const imageDirty = areImageListsDifferent(
|
||
imagesByPage[index] ?? [],
|
||
originalImagesByPage[index] ?? [],
|
||
);
|
||
|
||
const isDirty = textDirty || groupCountChanged || imageDirty;
|
||
|
||
if (groupCountChanged || textDirty) {
|
||
console.log(`📄 Page ${index} dirty check:`, {
|
||
textDirty,
|
||
groupCountChanged,
|
||
originalGroupsLength: originalGroups.length,
|
||
currentGroupsLength: groups.length,
|
||
imageDirty,
|
||
isDirty,
|
||
});
|
||
}
|
||
|
||
return isDirty;
|
||
});
|
||
};
|