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:
parent
529f42528a
commit
690628b318
6 changed files with 81 additions and 5 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue