11import sys
22import io
33import re
4+ import logging
45from typing import BinaryIO , Any
56
67from .._base_converter import DocumentConverter , DocumentConverterResult
910
1011# Pattern for MasterFormat-style partial numbering (e.g., ".1", ".2", ".10")
1112PARTIAL_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
1421def _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
62132try :
@@ -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