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); }); });