Skip to content

Troubleshooting

Find the symptom you’re seeing, run the fastest check, apply the fix. Each entry links to the concepts page that explains why it happens.

find('text:contains(...)') returns None but I can see the text

Section titled “find('text:contains(...)') returns None but I can see the text”

Most likely cause: the phrase spans an element boundary. A PDF stores glyphs, not words — natural-pdf groups them into word elements, and a word splits wherever the font changes (bold label vs. regular value) or a horizontal gap exceeds the tolerance. :contains() tests each element separately, so a phrase that crosses a boundary matches nothing, even though extract_text() — which stitches elements together — shows it plainly. This is the most-reported issue in the library (#4, #5).

Fastest check: search for a shorter fragment, and inspect the actual elements.

from natural_pdf import PDF
pdf = PDF("pdfs/01-practice.pdf")
page = pdf.pages[0]
print("Site: Durham" in page.extract_text()) # the phrase is "there"...
print(page.find('text:contains("Site: Durham")')) # ...but spans two elements
True
None
page.find_all('text:contains("Durham")').inspect()

Collection Inspection (2 elements)

Word Elements

text x0 top x1 bottom font_family font_variant size styles source confidence color
Durham’s Meatpacking 74 84 182 94 Helvetica 10 native 1.00 #000000
to the world as Durham’s Pure Leaf Lard! 50 240 232 250 Helvetica 10 native 1.00 #000000

The inspect() table shows where the element boundaries really are — Site: (bold) and Durham’s Meatpacking (regular) are separate elements.

Fix: anchor on the shortest distinctive piece that lives inside one element, then navigate to the rest:

page.find('text:contains("Site")').right().extract_text()
'Durham’s Meatpacking Chicago, Ill.'

If the text looks right but has confusable characters (curly quotes, l/1, O/0), use the fuzzy :ocr() pseudo-class — it matches everything :contains() matches plus garbled variants:

page.find("""text:ocr("Durham's Meatpacking")""").text # curly apostrophe in the PDF
'Durham’s Meatpacking'

If one specific document keeps splitting words apart (letter-spaced text, missing space characters), re-load with different grouping tolerances — this changes how elements are built, not how they’re searched:

pdf = PDF("report.pdf", text_tolerance={"x_tolerance": 5})

Why words group the way they do, and every knob text_tolerance= accepts: Text and Elements.

Most likely cause: the page is a scan. There is no text layer — just one big image — so there is nothing to extract.

Fastest check: count the characters the page actually has.

scan = PDF("pdfs/needs-ocr.pdf")
scan_page = scan.pages[0]
print(len(scan_page.chars))
print(repr(scan_page.extract_text()))
0
''

describe() says the same thing — an element census with no words in it:

scan_page.describe()

Page 1 Summary

Page Info:

  • page number: 1
  • dimensions: 1275 x 1651 pts

Count: 1

Fix: run OCR, then extract. The default engine is RapidOCR, included in pip install "natural-pdf[all]":

scan_page.apply_ocr() # adds text elements to the page
text = scan_page.extract_text() # now has content

If len(page.chars) is not zero but extract_text() is still empty, check your exclusions: page.show(exclusions='red') and page.extract_text(apply_exclusions=False) will tell you whether an exclusion zone is swallowing the page (see “My exclusion didn’t work” below).

Most likely cause: the evidence extract_table() needs isn’t there — or there’s extra evidence confusing it. Table extraction is a ladder: bordered tables work out of the box, everything else needs one rung more effort. See Tables: the Recovery Ladder for the full ladder; the short version:

Fastest check: look at the page. Does the table have ruling lines?

page.show()

extract_table() returns junk or nothing

len(page.find_all('line'))
21

Fix, by what you saw:

  • Lines exist but the output has junk rows/columns — a heading underline or section separator got swept into the grid. Build the grid explicitly with Guides.from_lines(), remove the bad guide (guides.horizontal.remove_at(0)), then guides.extract_table(). Every edit is visible via guides.show().
  • No lines at all — the evidence is alignment, not lines. Build verticals from the header words (guides.vertical.from_headers(...)) and rows from content, or scope the extraction to a region anchored on the table’s heading so page furniture can’t leak in (worked example in Text and tables).
  • Rows silently missing — check page.show(exclusions='red'). An exclusion overlapping the table thins it with no error.
  • Several tables on the pageextract_table() returns one table. Use page.extract_tables() (all of them) or scope to a region per table.

Most likely cause: resolution too low for the engine, or the wrong engine for this document. Different engines fail differently on the same scan.

Fastest check: run the engines you have side by side and look:

cmp = page.compare_ocr(engines=["rapidocr", "doctr"])
cmp.summary() # per-engine word counts, confidence
cmp.show() # side-by-side render
cmp.diff() # where they disagree

Fix:

  • Raise the render resolutionpage.apply_ocr(resolution=300). Small text at low DPI is the classic garble source.
  • Switch engines — after comparing, keep the winner: cmp.apply("doctr"). Engine trade-offs and install commands: Engines and Models.
  • Search through the garble instead of re-OCRing — the :ocr() selector treats confusable characters (l/1/I, O/0) as near-matches: page.find('text:ocr("Invoice Total")').
  • Measure itpage.to_llm() includes a dictionary-based garble rate in its text-layer diagnostics when the quality extra is installed (pip install "natural-pdf[quality]"), so you can flag bad text layers across a batch instead of eyeballing.

My exclusion didn’t work (or excluded too much)

Section titled “My exclusion didn’t work (or excluded too much)”

Most likely cause: binding time. A selector string or element collection is resolved when you call add_exclusion() — the matched elements are stored then. A callable is stored as-is and re-evaluated at every read. If you registered a selector-string exclusion and then ran OCR (or anything that adds elements), the new elements were never matched — use a callable instead. The other classic: registering a page-shaped fix at the wrong level — page.add_exclusion(...) affects one page; pdf.add_exclusion(lambda page: ...) runs per page across the document.

Fastest check: render the zones — exclusion bugs are geometry bugs.

expdf = PDF("pdfs/01-practice.pdf")
expage = expdf.pages[0]
expage.add_exclusion(expage.region(top=0, left=0, height=80), label="letterhead")
expage.show(exclusions='red')

My exclusion didn't work (or excluded too much)

And read without the filter to see what’s hidden:

print(expage.extract_text()[:40])
print(expage.extract_text(apply_exclusions=False)[:40])
Site: Durham’s Meatpacking Chicago, Ill.
Jungle Health and Safety Inspection Serv

Fix:

  • Exclusion should track content that changes → pass a callable: pdf.add_exclusion(lambda page: page.find('line[width>=2]').below()).
  • Exclusion eating your data (table rows vanishing, selectors “mysteriously” empty) → shrink the zone, or use method="element" to drop only matched elements instead of a whole rectangle.
  • Remember it’s a read-time filter, not a deletion — every read accepts apply_exclusions=False. Full model: Exclusions.

Most likely cause: you meant the other parameter. In directional methods, the travel direction takes a number and the cross direction takes a mode string ('full' or 'element'). For .below(), the travel parameter is height=; width= only selects how wide the band is (full page vs. the anchor element’s width).

Fastest check: read the error — it names both parameters:

label = page.find('text:contains("Summary")')
try:
label.below(width=200)
except TypeError as e:
print(e)
below() got width=200: width selects the cross-direction mode and must be 'full' or 'element'. Use height=<number> to control how far the region extends below; for a custom cross-size, use below(width='element').expand(...) or page.region(...).

Fix: one of three, depending on what you meant:

label.below(height=200) # extend 200 pts downward
label.below(width='element').expand(right=50) # custom cross-size
page.region(x0, top, x1, bottom) # you already know the coordinates

Before 0.7, below(width=200) was silently treated like width='element' — a plausible-looking region with the wrong bounds. The loud TypeError is deliberate. Why the two defaults differ (.right() stays in the row, .below() spans the page): The Spatial Model.

Most likely cause: the feature you called needs an optional dependency that isn’t in your environment — or is installed but broken.

Fastest check:

Terminal window
npdf doctor

It prints every dependency group with OK/MISS status, installed versions, and the exact pip install line for anything missing.

Fix, by error message:

  • Optional dependency '...' is not installed. Install with: ... — run the install command in the message. The feature-by-feature install matrix maps each method to its extra; the full dependency table is in Installation Extras.
  • Optional dependency '...' is installed but failed to import: ... — this is not a missing package; reinstalling the same thing won’t help. The message includes the underlying import error (usually a conflicting transitive dependency, e.g. a numpy version clash). Fix the environment issue it names.
  • ModuleNotFoundError: natural_pdf — wrong environment: compare pip show natural-pdf against sys.executable.

Most likely cause: PDFs opened in a loop and never closed. Each open PDF holds its backing file and rendered/parsed state.

Fix: close every PDF when you’re done with it — PDF is a context manager, so the canonical loop is:

for path in pdf_paths:
with PDF(path) as pdf:
for page in pdf.pages:
... # extract what you need, keep only plain data

Keep results (strings, DataFrames, dicts) — not Page or Element objects — across iterations, since holding elements keeps their PDF alive.

Note close() is partial: lightweight state that was already materialized may still be readable afterward, but anything needing the live PDF backing (loading unseen pages, OCR, rendering) is unavailable once closed.

Most likely cause: a multi-column page. extract_text() reads in visual top-to-bottom order across the full page width, which interleaves the columns:

mc = PDF("pdfs/multicolumn.pdf")
mc_page = mc.pages[0]
print(mc_page.extract_text()[:120])
This is some text 25 8115 XXX21 6783
This is some text 26 8448 XXX22 7116
27 8781 XXX23 7449
This is some text 28 9114 X

Those lines mix all three columns.

Fix: describe the columns as regions and stack them into a Flow — reads left column top to bottom, then the next:

from natural_pdf.flows import Flow
col_w = mc_page.width / 3
cols = [mc_page.region(left=i * col_w, right=(i + 1) * col_w) for i in range(3)]
flow = Flow(segments=cols, arrangement="vertical")
print(flow.extract_text()[:120])
This is some text
This is some text
This is some text
This is some text
This is some text
This is some text
This is some

Flows support find() / find_all() and spatial navigation just like pages, so anchors and until= boundaries can cross column (and page) breaks. Worked examples: Page structure and Multi-column reflow.