Files
Stirling-PDF/frontend/src/proprietary/tools/pdfTextEditor/pdfTextEditorUtils.ts
T
b0397da19e PDF Text editor (#4724)
## 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>
2025-11-24 14:15:02 +00:00

1400 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
});
};