Fix PDF Text Extraction Missing Spaces
Text extracted from a PDF reads Totalrevenuefortheyearended31December — words glued together — while the same page looks perfectly spaced in a viewer. The mirror-image symptom shows up in the same documents: headings come out as A N N U A L R E P O R T, and numbers like 1 2 4 0 . 5 0 are split into single characters. Search, regex matching and any downstream tokeniser break on both.
>>> import pdfplumber
>>> pdfplumber.open("in/report.pdf").pages[2].extract_text()[:60]
'Totalrevenuefortheyearended31December2025increasedby8%'
Root Cause
Most PDF generators do not write space characters between words. They position each word — or each glyph — at explicit coordinates, or use the TJ operator with numeric kerning adjustments to shift the pen right. The visual gap is geometry, not a character. Every extractor therefore infers spaces: when the horizontal distance between the end of one character and the start of the next exceeds a tolerance, it inserts a space. Tight typesetting, justified text, condensed fonts and small font sizes produce inter-word gaps below that tolerance, so words merge. Letter-spaced headings and tabular numbers with generous tracking produce inter-character gaps above it, so letters split. The tolerance is a library default tuned for average documents; your document is not average.
Minimal Diagnostic
Measure the actual gaps on the problem page. pdfplumber exposes every character with its coordinates, so a histogram of gaps between consecutive characters on the same line shows where the natural break between "inside a word" and "between words" sits.
# pip install pdfplumber
from collections import Counter
from pathlib import Path
import pdfplumber
SOURCE = Path("in/report.pdf")
PAGE = 2
def gap_histogram(pdf_path: Path, page_index: int) -> Counter:
try:
pdf = pdfplumber.open(pdf_path)
except Exception as exc:
raise SystemExit(f"cannot open {pdf_path}: {exc}")
with pdf:
chars = sorted(pdf.pages[page_index].chars, key=lambda c: (round(c["top"]), c["x0"]))
gaps = Counter()
for a, b in zip(chars, chars[1:]):
if round(a["top"]) != round(b["top"]): # different line
continue
if a["text"] == " " or b["text"] == " ": # real space characters
continue
gap = b["x0"] - a["x1"]
gaps[round(gap, 1)] += 1
return gaps
if __name__ == "__main__":
hist = gap_histogram(SOURCE, PAGE)
for gap, count in sorted(hist.items()):
if -1 <= gap <= 8:
print(f"{gap:5.1f} pt {'#' * min(count // 5, 60)}")
spaces = sum(1 for c in pdfplumber.open(SOURCE).pages[PAGE].chars if c["text"] == " ")
print(f"space characters on page: {spaces}")
0.0 pt ############################################################
0.2 pt #####################################
1.9 pt ##########
2.1 pt ##################
2.4 pt ######
space characters on page: 0
Zero real space characters and a second cluster of gaps around two points confirms the cause: word boundaries exist only as geometry, and they sit below the default three-point tolerance. The valley between the clusters — here roughly 0.5 to 1.8 points — is where the tolerance belongs.
Fix: Set the Tolerance from the Measured Gaps
In pdfplumber, x_tolerance is the maximum gap still treated as part of the same word. Set it inside the valley.
# pip install pdfplumber
from pathlib import Path
import pdfplumber
SOURCE = Path("in/report.pdf")
def extract_with_tolerance(pdf_path: Path, x_tol: float = 1.0) -> list[str]:
try:
with pdfplumber.open(pdf_path) as pdf:
return [
page.extract_text(
x_tolerance=x_tol, # changed: below the word-gap cluster (was default 3)
y_tolerance=3,
keep_blank_chars=False,
) or ""
for page in pdf.pages
]
except Exception as exc:
raise RuntimeError(f"extraction failed: {exc}") from exc
if __name__ == "__main__":
print(extract_with_tolerance(SOURCE)[2][:80])
# Total revenue for the year ended 31 December 2025 increased by 8%
A fixed tolerance in points breaks when font size varies across the document: a gap of one point separates words in 7-point footnotes but is inter-letter spacing in a 24-point title. Recent pdfplumber versions accept x_tolerance_ratio, which scales the tolerance by each character's size:
# pip install "pdfplumber>=0.11"
import pdfplumber
with pdfplumber.open("in/report.pdf") as pdf:
text = pdf.pages[2].extract_text(x_tolerance_ratio=0.15) # 15% of the font size
Measure first with the histogram, express the valley as a fraction of the dominant font size, and use that ratio.
Choosing the Tolerance Automatically
Batches mix generators, and a tolerance that suits one supplier's invoices merges words in another's. Rather than hard-coding a value, estimate it per document from the gap distribution: collect gaps relative to font size, then place the threshold in the widest empty band between the "inside a word" cluster near zero and the "between words" cluster.
# pip install pdfplumber
from pathlib import Path
import pdfplumber
def estimate_ratio(pdf_path: Path, max_pages: int = 5, default: float = 0.15) -> float:
"""Estimate a word-gap ratio (gap / font size) from the widest valley in the gap distribution."""
ratios = []
try:
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages[:max_pages]:
chars = sorted(page.chars, key=lambda c: (round(c["top"]), c["x0"]))
for a, b in zip(chars, chars[1:]):
if round(a["top"]) != round(b["top"]) or " " in (a["text"], b["text"]):
continue
gap = b["x0"] - a["x1"]
if 0 <= gap < a["size"]: # ignore column gutters
ratios.append(round(gap / a["size"], 2))
except Exception as exc:
raise RuntimeError(f"cannot sample {pdf_path}: {exc}") from exc
if len(ratios) < 200:
return default # too little text to trust
present = sorted(set(ratios))
best_gap, best_mid = 0.0, default
for lo, hi in zip(present, present[1:]):
if lo < 0.02: # still inside the within-word cluster
continue
if hi - lo > best_gap:
best_gap, best_mid = hi - lo, (lo + hi) / 2
return best_mid if best_gap >= 0.04 else default
Log the estimated ratio with every processed file. When extraction quality drops for one supplier, a sudden change in that number — usually after they upgrade their invoicing software — points straight at the cause. Keep the default as a floor for short documents: a two-line delivery note does not have enough character pairs to show a clear valley, and a wrong estimate from a handful of gaps does more harm than the library default.
Variant Fix 1: PyMuPDF Words Run Together
PyMuPDF inserts spaces based on the font's own space width and glyph advances, and does not expose a tolerance parameter. When it merges words, rebuild them from characters in rawdict output with your own threshold:
# pip install pymupdf
from pathlib import Path
import pymupdf
def rebuild_lines(page: pymupdf.Page, ratio: float = 0.12) -> list[str]:
"""Rebuild each line's text, inserting a space where the gap exceeds ratio x font size."""
out = []
raw = page.get_text("rawdict", flags=pymupdf.TEXT_MEDIABOX_CLIP)
for block in raw["blocks"]:
for line in block.get("lines", []):
pieces, prev_x1 = [], None
for span in line["spans"]:
threshold = span["size"] * ratio # changed: size-relative word gap
for ch in span["chars"]:
x0, _, x1, _ = ch["bbox"]
if prev_x1 is not None and x0 - prev_x1 > threshold and ch["c"] != " ":
pieces.append(" ") # changed: insert inferred space
pieces.append(ch["c"])
prev_x1 = x1
out.append(" ".join("".join(pieces).split())) # collapse doubled spaces
return out
if __name__ == "__main__":
with pymupdf.open(Path("in/report.pdf")) as doc:
print("\n".join(rebuild_lines(doc[2])[:5]))
rawdict is slower than plain text extraction — reserve this path for documents your quality check flags. The detection metric is simple: a page whose average token length exceeds about twelve characters is almost certainly gluing words.
Variant Fix 2: Letter-Spaced Text Splits into Characters
The opposite problem needs the opposite adjustment, but lowering the tolerance globally re-breaks body text. Detect runs of single-character tokens and join them afterwards instead:
# pip install pdfplumber
import re
import pdfplumber
SPACED = re.compile(r"\b(?:[A-Za-z0-9] ){3,}[A-Za-z0-9]\b") # four or more single chars
def join_letter_spaced(text: str) -> str:
return SPACED.sub(lambda m: m.group(0).replace(" ", ""), text)
with pdfplumber.open("in/report.pdf") as pdf:
raw = pdf.pages[0].extract_text(x_tolerance=1.0)
print(join_letter_spaced(raw)) # 'S T A T E M E N T' -> 'STATEMENT'
Requiring four or more single characters avoids joining legitimate sequences such as A B testing or initials. For two-word spaced headings (A N N U A L R E P O R T), the double space between the words survives because the pattern only consumes single spaces; check that your extractor preserves the wider gap as two spaces, or split on the wider gap before joining.
Variant Fix 3: pdfminer-Based Pipelines
Tools built directly on pdfminer.six — including many older scripts — control word breaks through LAParams. word_margin is the gap, relative to character width, above which a space is inserted; char_margin groups characters into lines.
# pip install pdfminer.six
from pathlib import Path
from pdfminer.high_level import extract_text
from pdfminer.layout import LAParams
params = LAParams(
word_margin=0.05, # smaller than the 0.1 default: split words on tighter gaps
char_margin=2.0,
line_margin=0.5,
)
try:
text = extract_text(Path("in/report.pdf"), page_numbers=[2], laparams=params)
except Exception as exc:
raise SystemExit(f"pdfminer failed: {exc}")
print(text[:200])
Verification
Check the fix across the whole document with a token-length statistic and a dictionary hit rate, both before and after. Glued words push the average token length up and the dictionary rate down; split letters push the share of one-character tokens up.
# pip install pdfplumber
import re
from pathlib import Path
import pdfplumber
COMMON = {"the", "and", "of", "to", "in", "for", "by", "on", "with", "year", "total", "revenue"}
def spacing_stats(pages: list[str]) -> dict:
tokens = [t for p in pages for t in re.findall(r"[A-Za-z]+", p)]
n = len(tokens) or 1
return {
"avg_token_len": sum(map(len, tokens)) / n,
"single_char_share": sum(len(t) == 1 for t in tokens) / n,
"common_word_share": sum(t.lower() in COMMON for t in tokens) / n,
}
def compare(pdf_path: Path, x_tol: float) -> None:
with pdfplumber.open(pdf_path) as pdf:
before = spacing_stats([p.extract_text() or "" for p in pdf.pages])
after = spacing_stats([p.extract_text(x_tolerance=x_tol) or "" for p in pdf.pages])
for key in before:
print(f"{key:>18}: {before[key]:.3f} -> {after[key]:.3f}")
assert after["avg_token_len"] < 9, "tokens still look glued"
assert after["single_char_share"] < 0.08, "too many single-character tokens"
if __name__ == "__main__":
compare(Path("in/report.pdf"), x_tol=1.0)
avg_token_len: 14.820 -> 5.310
single_char_share: 0.004 -> 0.031
common_word_share: 0.011 -> 0.142
An average English token length between four and six characters is healthy. If the single-character share climbs sharply after lowering the tolerance, you have overshot and are now splitting words; move the tolerance back toward the upper edge of the valley.
FAQ
Why does copy-paste from the viewer have correct spacing when extraction does not? Viewers run their own heuristics, often tuned per font, and some use the document's structure tree when it is tagged. The underlying file has no space characters either way.
Would OCR avoid the problem? OCR infers spaces from pixels and usually gets them right, but it introduces character errors and costs far more time. Tune the tolerance first; OCR only pages that stay broken.
Is this related to (cid:…) output?
No. Missing spaces is a geometry problem with correctly decoded characters. cid codes mean the characters themselves could not be decoded; see fix CID garbled characters in PDF text.
Does the same tolerance affect table extraction? Yes. pdfplumber's table finder builds cell text with the same word logic, so glued cell values have the same fix — see fix PDF text extraction alignment issues.
Related
- Extracting Text and Metadata from PDFs with Python — the full extraction pipeline and quality metrics
- Fix PDF Columns Merged into One DataFrame Column — the tabular version of the same gap problem
- Redact PDF Text by Regex Pattern — letter-spaced form values that defeat pattern matching
- Comparing PDF Table Extraction Libraries — how each library builds words and cells