Skip to content

Commit 4385876

Browse files
committed
fix(pdf): preserve positioned word boundaries without discarding tables
1 parent 4cc9fa1 commit 4385876

2 files changed

Lines changed: 425 additions & 11 deletions

File tree

‎packages/markitdown/src/markitdown/converters/_pdf_converter.py‎

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import sys
22
import io
33
import re
4+
import logging
45
from typing import BinaryIO, Any
56

67
from .._base_converter import DocumentConverter, DocumentConverterResult
@@ -9,6 +10,12 @@
910

1011
# Pattern for MasterFormat-style partial numbering (e.g., ".1", ".2", ".10")
1112
PARTIAL_NUMBERING_PATTERN = re.compile(r"^\.\d+$")
13+
LONG_ASCII_WORD_PATTERN = re.compile(r"[A-Za-z]{30,}")
14+
15+
# pdfplumber's default x_tolerance can merge adjacent positioned words in PDFs
16+
# that omit literal space glyphs. A smaller tolerance preserves those spaces
17+
# while still allowing table/form detection to operate on word-level positions.
18+
PDFPLUMBER_TEXT_X_TOLERANCE = 1
1219

1320

1421
def _merge_partial_numbering_lines(text: str) -> str:
@@ -57,6 +64,69 @@ def _merge_partial_numbering_lines(text: str) -> str:
5764
return "\n".join(result_lines)
5865

5966

67+
def _has_space_starved_text(text: str) -> bool:
68+
"""
69+
Return True when extracted text appears to have lost most word boundaries.
70+
71+
Some PDFs position words separately instead of storing literal space glyphs.
72+
pdfminer can then produce long alphabetic runs such as
73+
"NaturalLanguageProcessing" throughout the document. This intentionally
74+
uses a conservative threshold so ordinary identifiers/URLs do not trigger
75+
a fallback choice by themselves.
76+
"""
77+
ascii_letters = sum(1 for char in text if char.isascii() and char.isalpha())
78+
if ascii_letters < 500:
79+
return False
80+
81+
whitespace_ratio = sum(1 for char in text if char.isspace()) / ascii_letters
82+
long_word_count = len(LONG_ASCII_WORD_PATTERN.findall(text))
83+
84+
return whitespace_ratio < 0.03 and long_word_count >= 5
85+
86+
87+
def _has_space_starved_words(words: list[dict]) -> bool:
88+
alpha_word_count = sum(
89+
1
90+
for word in words
91+
if any(char.isascii() and char.isalpha() for char in word.get("text", ""))
92+
)
93+
long_word_count = sum(
94+
1 for word in words if LONG_ASCII_WORD_PATTERN.search(word.get("text", ""))
95+
)
96+
97+
if long_word_count < 5 or long_word_count / alpha_word_count < 0.025:
98+
return False
99+
100+
# Long cells and separate text columns are not evidence of missing spaces.
101+
# Preserve layouts with repeated wide gutters between aligned runs.
102+
rows: dict[float, list[dict]] = {}
103+
for word in words:
104+
if not all(key in word for key in ("top", "x0", "x1")):
105+
continue
106+
rows.setdefault(round(word["top"] / 5) * 5, []).append(word)
107+
gutters: dict[float, int] = {}
108+
for row in rows.values():
109+
row.sort(key=lambda word: word["x0"])
110+
row_gutters = {
111+
round((left["x1"] + right["x0"]) / 10) * 10
112+
for left, right in zip(row, row[1:])
113+
if right["x0"] - left["x1"] >= 20
114+
}
115+
for gutter in row_gutters:
116+
gutters[gutter] = gutters.get(gutter, 0) + 1
117+
return not any(
118+
count >= 3 and count >= len(rows) * 0.5 for count in gutters.values()
119+
)
120+
121+
122+
def _select_plain_text_extraction(pdfminer_text: str, pdfplumber_text: str) -> str:
123+
"""Choose the better plain-text PDF extraction result."""
124+
if pdfplumber_text.strip() and _has_space_starved_text(pdfminer_text):
125+
return pdfplumber_text
126+
127+
return pdfminer_text
128+
129+
60130
# Load dependencies
61131
_dependency_exc_info = None
62132
try:
@@ -117,7 +187,9 @@ def fmt_row(row: list[str]) -> str:
117187
return "\n".join(md)
118188

119189

120-
def _extract_form_content_from_words(page: Any) -> str | None:
190+
def _extract_form_content_from_words(
191+
page: Any, words: list[dict] | None = None, *, space_starved: bool = False
192+
) -> str | None:
121193
"""
122194
Extract form-style content from a PDF page by analyzing word positions.
123195
This handles borderless forms/tables where words are aligned in columns.
@@ -129,10 +201,14 @@ def _extract_form_content_from_words(page: Any) -> str | None:
129201
Returns None if the page doesn't appear to be a form-style document,
130202
indicating that pdfminer should be used instead for better text spacing.
131203
"""
132-
words = page.extract_words(keep_blank_chars=True, x_tolerance=3, y_tolerance=3)
204+
if words is None:
205+
words = page.extract_words(keep_blank_chars=True, x_tolerance=3, y_tolerance=3)
133206
if not words:
134207
return None
135208

209+
if space_starved or _has_space_starved_words(words):
210+
return None
211+
136212
# Group words by their Y position (rows)
137213
y_tolerance = 5
138214
rows_by_y: dict[float, list[dict]] = {}
@@ -551,29 +627,57 @@ def convert(
551627

552628
with pdfplumber.open(pdf_bytes) as pdf:
553629
for page_idx, page in enumerate(pdf.pages):
554-
page_content = _extract_form_content_from_words(page)
630+
words = page.extract_words(
631+
keep_blank_chars=True, x_tolerance=3, y_tolerance=3
632+
)
633+
page_is_space_starved = _has_space_starved_words(words)
634+
if page_is_space_starved:
635+
words = page.extract_words(
636+
keep_blank_chars=True,
637+
x_tolerance=PDFPLUMBER_TEXT_X_TOLERANCE,
638+
y_tolerance=3,
639+
)
640+
# Tight words no longer contain the collapsed runs, so retain
641+
# the original page classification when checking for forms.
642+
page_content = _extract_form_content_from_words(
643+
page, words=words, space_starved=page_is_space_starved
644+
)
555645

556646
if page_content is not None:
557647
form_page_count += 1
558648
if page_content.strip():
559649
markdown_chunks.append(page_content)
560650
else:
561651
plain_page_indices.append(page_idx)
562-
text = page.extract_text()
652+
if page_is_space_starved:
653+
text = page.extract_text(
654+
x_tolerance=PDFPLUMBER_TEXT_X_TOLERANCE
655+
)
656+
else:
657+
text = page.extract_text()
563658
if text and text.strip():
564659
markdown_chunks.append(text.strip())
565660

566661
page.close() # Free cached page data immediately
567662

568663
# If no pages had form-style content, use pdfminer for
569-
# the whole document (better text spacing for prose).
664+
# the whole document when it has good spacing. Some PDFs
665+
# encode words by position without literal spaces; in that
666+
# case pdfplumber with a tight x_tolerance preserves word
667+
# boundaries better.
668+
pdfplumber_markdown = "\n\n".join(markdown_chunks).strip()
570669
if form_page_count == 0:
571670
pdf_bytes.seek(0)
572-
markdown = pdfminer.high_level.extract_text(pdf_bytes)
671+
pdfminer_markdown = pdfminer.high_level.extract_text(pdf_bytes)
672+
markdown = _select_plain_text_extraction(
673+
pdfminer_markdown, pdfplumber_markdown
674+
)
573675
else:
574-
markdown = "\n\n".join(markdown_chunks).strip()
676+
markdown = pdfplumber_markdown
575677

576-
except Exception:
678+
except Exception as exc:
679+
# Preserve fallback behavior, but expose unexpected parser failures.
680+
logging.getLogger(__name__).warning("PDF table extraction failed: %s", exc)
577681
# Fallback if pdfplumber fails
578682
pdf_bytes.seek(0)
579683
markdown = pdfminer.high_level.extract_text(pdf_bytes)

0 commit comments

Comments
 (0)