refactor(pdf): improve text normalization with font ascent and vertical overlap logic

Enhance text normalization by incorporating font ascent and descent data to more accurately position glyphs, especially for decorative initials. Update merge logic to better detect line membership using vertical overlap, ensuring drop caps and overlapping glyphs are merged correctly. Extend tests to cover these layout scenarios.
This commit is contained in:
Richard R 2026-06-02 22:31:36 -06:00
parent 529f42528a
commit 690628b318
6 changed files with 81 additions and 5 deletions

View file

@ -53,7 +53,11 @@ function joinText(items: PdfTextItem[]): string {
const prevEndX = prev.x + prev.width;
const gap = item.x - prevEndX;
const lineJump = item.y - prev.y;
const lineBreak = lineJump > Math.max(2, Math.min(prev.height, item.height) * 0.6);
const prevBottom = prev.y + prev.height;
const itemBottom = item.y + item.height;
const verticalOverlap = Math.max(0, Math.min(prevBottom, itemBottom) - Math.max(prev.y, item.y));
const sharesLineBand = verticalOverlap >= Math.max(1, Math.min(prev.height, item.height) * 0.5);
const lineBreak = !sharesLineBand && lineJump > Math.max(2, Math.min(prev.height, item.height) * 0.6);
const avgCharWidth = item.width / Math.max(1, item.text.length);
const needsSpace = lineBreak || gap > Math.max(avgCharWidth * 0.3, 2);
out += needsSpace ? ` ${item.text}` : item.text;

View file

@ -6,6 +6,11 @@ interface ViewportLike {
transform: readonly number[];
}
interface TextStyleLike {
ascent?: number;
descent?: number;
}
function applyViewportTransform(
x: number,
y: number,
@ -23,7 +28,25 @@ function applyViewportTransform(
};
}
export function normalizeTextItemsForLayout(items: TextItem[], viewport: ViewportLike): PdfTextItem[] {
function resolveTopOffset(height: number, style: TextStyleLike | undefined): number {
const ascent = Number(style?.ascent);
if (Number.isFinite(ascent) && ascent > 0) {
return height * ascent;
}
const descent = Number(style?.descent);
if (Number.isFinite(descent) && descent < 0) {
return height * (1 + descent);
}
return height;
}
export function normalizeTextItemsForLayout(
items: TextItem[],
viewport: ViewportLike,
styles: Record<string, TextStyleLike> = {},
): PdfTextItem[] {
return items
.filter((item) => {
if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false;
@ -46,11 +69,13 @@ export function normalizeTextItemsForLayout(items: TextItem[], viewport: Viewpor
);
const width = Math.max(0, Number(item.width ?? 0));
const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1)));
const topOffset = resolveTopOffset(height, styles[item.fontName ?? '']);
// pdf.js text transforms are in PDF user-space and may include non-zero
// page origins via the viewport transform. Map the text baseline into
// viewport coordinates first, then convert baseline to top-left.
// viewport coordinates first, then adjust upward using font ascent,
// which matches how pdf.js positions glyph runs in its text layer.
const x = Math.max(0, origin.x);
const y = Math.max(0, origin.y - height);
const y = Math.max(0, origin.y - topOffset);
return {
text: item.str,
x,

View file

@ -73,6 +73,7 @@ export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument>
const textItems = normalizeTextItemsForLayout(
textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item),
viewport,
textContent.styles,
);
if (textItems.length > 0) sawText = true;

View file

@ -291,6 +291,10 @@ export function usePdfDocument(): PdfDocumentState {
pdfDocumentRef.current = pdfDocument;
}, [pdfDocument]);
useEffect(() => {
pageTextCacheRef.current.clear();
}, [parsedDocument, documentSettings.pdf?.skipBlockKinds]);
useEffect(() => {
setCurrDocPage(currDocPageNumber);
}, [currDocPageNumber]);
@ -538,7 +542,10 @@ export function usePdfDocument(): PdfDocumentState {
if (!currDocId) return;
try {
const forced = await forceReparsePdfDocument(currDocId);
loadSeqRef.current += 1;
pageTextCacheRef.current.clear();
setParsedDocument(null);
setCurrDocText(undefined);
setParseStatus(forced.status);
setParseProgress(null);
setActiveParseOpId(forced.opId ?? null);

View file

@ -34,4 +34,19 @@ describe('mergeTextWithRegions', () => {
expect(merged).toHaveLength(1);
expect(merged[0].text).toBe('inside');
});
test('keeps decorative drop caps attached to the same line when boxes overlap vertically', () => {
const regions = [
{ bbox: [0, 0, 200, 120] as [number, number, number, number], label: 'text' as const },
];
const textItems = [
{ text: 'I', x: 0, y: 10, width: 12, height: 60 },
{ text: 'ts funny,', x: 12, y: 40, width: 50, height: 12 },
];
const merged = mergeTextWithRegions(regions, textItems);
expect(merged).toHaveLength(1);
expect(merged[0].text).toBe('Its funny,');
});
});

View file

@ -35,6 +35,8 @@ describe('normalizeTextItemsForLayout', () => {
const normalized = normalizeTextItemsForLayout([horizontal, rotated], {
height: 800,
transform: [1, 0, 0, -1, 0, 800],
}, {
test: { ascent: 0.8, descent: -0.2 },
});
expect(normalized).toHaveLength(1);
expect(normalized[0]?.text).toBe('Powered by large language models');
@ -47,6 +49,8 @@ describe('normalizeTextItemsForLayout', () => {
const normalized = normalizeTextItemsForLayout([vertical, skewed], {
height: 800,
transform: [1, 0, 0, -1, 0, 800],
}, {
test: { ascent: 0.8, descent: -0.2 },
});
expect(normalized).toEqual([]);
});
@ -61,10 +65,30 @@ describe('normalizeTextItemsForLayout', () => {
const normalized = normalizeTextItemsForLayout([croppedPageLine], {
height: 666.0074,
transform: [1, 0, 0, -1, -53.4352, 720.565],
}, {
test: { ascent: 0.716, descent: -0.269 },
});
expect(normalized).toHaveLength(1);
expect(normalized[0]?.x).toBeCloseTo(74.0648, 4);
expect(normalized[0]?.y).toBeCloseTo(64.9522, 4);
expect(normalized[0]?.y).toBeCloseTo(68.1356, 2);
});
test('uses font ascent to place decorative initials closer to the visible glyph top', () => {
const dropCap = makeTextItem(
'I',
[60, 0, 0, 60, 111.1326, 434.46],
9.18,
);
const normalized = normalizeTextItemsForLayout([dropCap], {
height: 666.0074,
transform: [1, 0, 0, -1, -53.4352, 720.565],
}, {
test: { ascent: 0.638, descent: -0.134 },
});
expect(normalized).toHaveLength(1);
expect(normalized[0]?.y).toBeCloseTo(247.825, 3);
});
});