Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/pdf/src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function groupIntoLines<T extends LineItem>(items: T[]): T[][] {
return lines;
}

function isUpright(item: LineItem): boolean {
export function isUpright(item: { dx?: number; dy?: number }): boolean {
const dx = item.dx ?? 1;
const dy = item.dy ?? 0;
return Math.abs(dy) <= Math.abs(dx) * 0.35;
Expand Down
53 changes: 49 additions & 4 deletions packages/pdf/src/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { decryptBytes, type EncryptParams, type FileCrypt, openEncrypt } from '.
import { xObjectToImage } from './images.js';
import {
groupIntoLines,
isUpright,
type LayoutBox,
lineBox,
orderBoxes,
Expand Down Expand Up @@ -142,7 +143,7 @@ async function convertUniqueImages(

function finishPdf(extracted: ExtractedPdf, imageBlocks: MarkdownBlock[]): string {
const markdown = itemsToMarkdown(
mergeScriptItems(dedupeOverlappingItems(extracted.items)),
mergeNumericFragments(mergeScriptItems(dedupeOverlappingItems(extracted.items))),
extracted.strokeLines,
extracted.pageRects,
imageBlocks,
Expand Down Expand Up @@ -2868,6 +2869,14 @@ function markDecorations(items: TextItem[], lines: StrokeLine[], httpRects: Rect
for (const rule of rules) {
const overlap = Math.min(item.x + item.width, rule.x2) - Math.max(item.x, rule.x1);
if (overlap < item.width * 0.6) continue;
const ruleW = rule.x2 - rule.x1;
const leftHang = Math.max(0, item.x - rule.x1);
const rightHang = Math.max(0, rule.x2 - (item.x + item.width));
const fs = Math.max(item.fontSize, 8);
const maxHang = Math.max(leftHang, rightHang);
if (ruleW > Math.max(item.width * 6, fs * 12) && maxHang > Math.max(item.width * 4, fs * 8)) {
continue;
}
const below = Math.max(item.fontSize * 0.72, 3);
if (!skipHttp && rule.y >= item.y - below && rule.y <= item.y + 1) {
item.isUnderline = true;
Expand Down Expand Up @@ -2961,6 +2970,41 @@ function canAttachScript(parent: TextItem, item: TextItem): boolean {
return true;
}

function isNumericFragment(text: string): boolean {
const t = text.trim();
return t.length > 0 && t.length <= 8 && /^[\d.,\s]+%?$/.test(t) && /[\d%]/.test(t);
}

function canMergeNumeric(prev: TextItem, item: TextItem): boolean {
if (prev.page !== item.page) return false;
if (!isUpright(prev) || !isUpright(item)) return false;
if (!isNumericFragment(prev.text) || !isNumericFragment(item.text)) return false;
const fs = Math.max(prev.fontSize, item.fontSize, 8);
if (Math.abs(prev.y - item.y) > fs * 0.6) return false;
const prevW = Math.max(prev.width, fs * 0.35);
const gap = item.x - (prev.x + prevW);
if (gap > fs * 0.55 || gap < -fs * 0.35) return false;
return true;
}

function mergeNumericFragments(items: TextItem[]): TextItem[] {
if (items.length < 2) return items;
const sorted = items.slice().sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x);
const out: TextItem[] = [];
for (const item of sorted) {
const prev = out[out.length - 1];
if (prev && canMergeNumeric(prev, item)) {
const next = item.text.trim();
prev.text = `${prev.text.trimEnd()}${next}`;
prev.width = Math.max(prev.width, item.x + item.width - prev.x);
prev.height = Math.max(prev.height, item.height);
continue;
}
out.push(item);
}
return out;
}

function mergeScriptItems(items: TextItem[]): TextItem[] {
if (items.length < 2) return items;
const byPage = new Map<number, TextItem[]>();
Expand Down Expand Up @@ -3006,13 +3050,14 @@ function shouldJoinItems(prev: TextItem, curr: TextItem): boolean {
if (currFirst !== undefined && ".,;!?)]}'".includes(currFirst)) return true;
const prevLast = [...prev.text.trimEnd()].at(-1);
if (prevLast === ':' && currFirst !== undefined && /[\p{L}\p{N}]/u.test(currFirst)) return false;
if (prev.width > 0) {
const fs = Math.max(prev.fontSize, curr.fontSize, 8);
if (isUpright(prev) && isUpright(curr) && prev.width > 0) {
const gap = curr.x - (prev.x + prev.width);
const fs = prev.fontSize;
if (gap > fs * 3 || gap < -fs) return false;
return gap < fs * 0.12;
}
return false;
const dist = Math.hypot(curr.x - prev.x, curr.y - prev.y);
return dist < fs * 1.8;
}

function needsSpace(prev: TextItem, curr: TextItem, result: string): boolean {
Expand Down
125 changes: 124 additions & 1 deletion packages/pdf/src/tables.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
/** Ruled-grid and borderless table detection from lines, thin `re` borders, and aligned text. */

import { isUpright } from './layout.js';

export interface TableTextItem {
text: string;
x: number;
y: number;
width: number;
page: number;
fontSize?: number;
dx?: number;
dy?: number;
}

export interface TableLine {
Expand Down Expand Up @@ -229,6 +233,7 @@ function buildGridTable(
if (claimed.has(idx)) continue;
const item = items[idx]!;
if (item.page !== page || item.text.trim().length === 0) continue;
if (!isUpright(item)) continue;
const cx = item.x + Math.max(item.width, 0) / 2;
const cy = item.y;
if (cx < xLeft - 4 || cx > xRight + 4 || cy < yBottom - 4 || cy > yTop + 4) continue;
Expand Down Expand Up @@ -314,6 +319,7 @@ function acceptGrid(grid: string[][]): boolean {
if (avg > 220) return false;
const long = lengths.filter((n) => n > 160).length;
if (long > 0 && long / lengths.length > 0.2 && longCols > 2) return false;
if (looksLikeDateFragments(grid)) return false;
return true;
}

Expand Down Expand Up @@ -376,6 +382,7 @@ function collectAlignedRows(items: TableTextItem[], page: number, claimed: Set<n
if (claimed.has(idx)) continue;
const item = items[idx]!;
if (item.page !== page || item.text.trim().length === 0) continue;
if (!isUpright(item)) continue;
pts.push({ idx, item });
}
pts.sort((a, b) => b.item.y - a.item.y || a.item.x - b.item.x);
Expand Down Expand Up @@ -671,10 +678,21 @@ function acceptBorderless(
if (words > MAX_TABLE_WORDS) return false;
const avgChars = cells.reduce((n, c) => n + [...c].length, 0) / Math.max(cells.length, 1);
const nCols = grid[0]!.length;
if (nCols === 2 && avgChars > PROSE_AVG_CELL_CHARS) {
if (nCols >= 2 && avgChars > PROSE_AVG_CELL_CHARS) {
const avgWords = words / Math.max(cells.length, 1);
if (avgWords > 4) return false;
}
if (
looksLikeDateFragments(grid) ||
looksLikeChartLegend(grid) ||
looksLikeChartValues(grid, run) ||
looksLikeNumericScatter(grid) ||
looksLikeLabeledTicks(grid) ||
looksLikeHeaderlessJunk(grid)
) {
return false;
}
if (mixedChartAndProse(grid)) return false;
if (pageBox && nCols <= 2) {
const x1 = Math.min(...run.flatMap((r) => r.segs.map((s) => s.x)));
const x2 = Math.max(...run.flatMap((r) => r.segs.map((s) => s.x2)));
Expand All @@ -687,6 +705,111 @@ function acceptBorderless(
return true;
}

function looksLikeDateFragments(grid: string[][]): boolean {
const cells = grid.flat().filter((c) => c.length > 0);
if (cells.length < 8) return false;
if (grid[0]!.length < 6) return false;
if (cells.some((c) => [...c].length > 5)) return false;
if (!cells.some((c) => c.includes('/'))) return false;
const frag = cells.filter((c) => /^(?:\d{1,4}\/?|\/?\d{1,4})$/.test(c.trim())).length;
return frag / cells.length >= 0.6;
}
Comment thread
cursor[bot] marked this conversation as resolved.

function looksLikeChartLegend(grid: string[][]): boolean {
if (grid.length < 2 || grid.length > 3) return false;
if (grid[0]!.length < 3) return false;
const first = grid[0]!.filter((c) => c.length > 0);
const last = grid[grid.length - 1]!.filter((c) => c.length > 0);
if (first.length < 3 || last.length === 0) return false;
const categories = first.every((c) => wordCount(c) <= 2 && /[\p{L}]/u.test(c));
const years = last.every((c) => /^(?:19|20)\d{2}$/.test(c.trim()));
return categories && years;
}

function looksLikeChartValues(grid: string[][], run: RowCand[]): boolean {
const cells = grid.flat().filter((c) => c.length > 0);
if (cells.length < 4) return false;
const numeric = cells.filter((c) => /^-?[\d.,]+%?$/.test(c.trim())).length;
if (numeric / cells.length < 0.8) return false;
const avgLen = cells.reduce((n, c) => n + [...c].length, 0) / cells.length;
if (avgLen > 8) return false;
const nCols = grid[0]!.length;
const fill = cells.length / (grid.length * nCols);
return fill < 0.55 || rowPitchIrregular(run);
}

function mixedChartAndProse(grid: string[][]): boolean {
const cells = grid.flat().filter((c) => c.length > 0);
const long = cells.filter((c) => wordCount(c) >= 6).length;
const chartish = cells.filter((c) => /%/.test(c) || /^-?[\d.,]+%?$/.test(c.trim())).length;
return long >= 2 && chartish >= 3;
}

function isNumericToken(text: string): boolean {
const t = text.trim();
return /^-?[\d.,]+%?$/.test(t) || /^(?:19|20)\d{2}$/.test(t);
}

function looksLikeNumericScatter(grid: string[][]): boolean {
const header = grid[0]!.filter((c) => c.length > 0);
const headerLabels = header.filter((c) => /[\p{L}]/u.test(c) && !isNumericToken(c)).length;
if (header.length >= 2 && headerLabels >= Math.ceil(header.length * 0.6)) return false;
const col0 = grid.map((row) => row[0] ?? '').filter((c) => c.length > 0);
const col0Labels = col0.filter((c) => /[\p{L}]/u.test(c) && !isNumericToken(c)).length;
if (col0.length >= 2 && col0Labels / col0.length >= 0.6) return false;
const cells = grid.flat().filter((c) => c.length > 0);
const tokens = cells.flatMap((c) => c.split(/\s+/).filter((w) => w.length > 0));
if (tokens.length < 6) return false;
const numeric = tokens.filter((t) => isNumericToken(t)).length;
if (numeric / tokens.length < 0.45) return false;
const letters = tokens.filter((t) => /[\p{L}]{3,}/u.test(t) && !isNumericToken(t)).length;
return letters <= tokens.length * 0.4;
}

function looksLikeLabeledTicks(grid: string[][]): boolean {
const filled = grid.map((row) => row.filter((c) => c.length > 0)).filter((r) => r.length >= 2);
const isPercentRow = (r: string[]): boolean =>
r.filter((c) => /%/.test(c) || /^-?[\d.,]+$/.test(c.trim())).length >=
Math.ceil(r.length * 0.7) && r.every((c) => wordCount(c) <= 2);
const isLabelRow = (r: string[]): boolean =>
r.length >= 3 && r.every((c) => wordCount(c) <= 2 && /[\p{L}]/u.test(c) && !/\d/.test(c));
const percentAt = filled.findIndex(isPercentRow);
const labelAt = filled.findIndex(isLabelRow);
return percentAt >= 0 && labelAt >= 0 && percentAt < labelAt;
}
Comment thread
cursor[bot] marked this conversation as resolved.

function looksLikeHeaderlessJunk(grid: string[][]): boolean {
if (grid.length !== 2 || grid[0]!.length < 3) return false;
const r0 = grid[0]!.filter((c) => c.length > 0);
const r1 = grid[1]!.filter((c) => c.length > 0);
if (r0.length < 2 || r1.length === 0) return false;
const allLabels = (row: string[]): boolean =>
row.every((c) => !/\d/.test(c) && wordCount(c) <= 6);
const hasDigit = (row: string[]): boolean => row.some((c) => /\d/.test(c));
if (allLabels(r0) && hasDigit(r1)) return false;
if (!hasDigit(r0) || !hasDigit(r1)) return false;
const labels = r0.filter((c) => /[\p{L}]/u.test(c) && !isNumericToken(c)).length;
const years = r0.filter((c) => isNumericToken(c)).length;
if (labels >= 1 && years >= 2) return false;
if (labels >= Math.ceil(r0.length * 0.6)) return false;
return true;
}
Comment thread
cursor[bot] marked this conversation as resolved.

function rowPitchIrregular(run: RowCand[]): boolean {
if (run.length < 3) return false;
const gaps: number[] = [];
for (let i = 1; i < run.length; i += 1) {
const g = run[i - 1]!.y - run[i]!.y;
if (g > 0.5) gaps.push(g);
}
if (gaps.length < 2) return false;
const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
let sumSq = 0;
for (const g of gaps) sumSq += (g - mean) ** 2;
const std = Math.sqrt(sumSq / gaps.length);
return std > mean * 0.45 && std > 6;
}

function nearestCol(colXs: number[], x: number): number {
let best = 0;
let bestD = Number.POSITIVE_INFINITY;
Expand Down
Loading