From 690628b3180a0a51e584635814a3f3ab0224d2a4 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 2 Jun 2026 22:31:36 -0600 Subject: [PATCH] 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. --- compute/core/src/pdf/merge.ts | 6 +++- compute/core/src/pdf/normalize-text.ts | 31 +++++++++++++++++-- compute/core/src/pdf/parse.ts | 1 + src/app/(app)/pdf/[id]/usePdfDocument.ts | 7 +++++ ...pdf-merge-text-with-regions.vitest.spec.ts | 15 +++++++++ ...-parse-normalize-text-items.vitest.spec.ts | 26 +++++++++++++++- 6 files changed, 81 insertions(+), 5 deletions(-) diff --git a/compute/core/src/pdf/merge.ts b/compute/core/src/pdf/merge.ts index aeff9ab..e1f71b2 100644 --- a/compute/core/src/pdf/merge.ts +++ b/compute/core/src/pdf/merge.ts @@ -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; diff --git a/compute/core/src/pdf/normalize-text.ts b/compute/core/src/pdf/normalize-text.ts index 7b1a374..1e91101 100644 --- a/compute/core/src/pdf/normalize-text.ts +++ b/compute/core/src/pdf/normalize-text.ts @@ -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 = {}, +): 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, diff --git a/compute/core/src/pdf/parse.ts b/compute/core/src/pdf/parse.ts index 01f9519..2b3a6f7 100644 --- a/compute/core/src/pdf/parse.ts +++ b/compute/core/src/pdf/parse.ts @@ -73,6 +73,7 @@ export async function parsePdf(input: ParsePdfInput): Promise 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; diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 1720f63..b252618 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -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); diff --git a/tests/unit/pdf-merge-text-with-regions.vitest.spec.ts b/tests/unit/pdf-merge-text-with-regions.vitest.spec.ts index 135e9c3..449824e 100644 --- a/tests/unit/pdf-merge-text-with-regions.vitest.spec.ts +++ b/tests/unit/pdf-merge-text-with-regions.vitest.spec.ts @@ -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: 't’s funny,', x: 12, y: 40, width: 50, height: 12 }, + ]; + + const merged = mergeTextWithRegions(regions, textItems); + expect(merged).toHaveLength(1); + expect(merged[0].text).toBe('It’s funny,'); + }); }); diff --git a/tests/unit/pdf-parse-normalize-text-items.vitest.spec.ts b/tests/unit/pdf-parse-normalize-text-items.vitest.spec.ts index 8685819..b61bd26 100644 --- a/tests/unit/pdf-parse-normalize-text-items.vitest.spec.ts +++ b/tests/unit/pdf-parse-normalize-text-items.vitest.spec.ts @@ -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); }); });