Extract Text and Tables From PDFs in R

Pull text, positions, metadata, and tables out of PDF files with pdftools — plus the honest 2026 path for messy or scanned documents

Programming

A practical, current recipe for getting text — and tables — out of PDF files into R. Read whole pages with pdftools::pdf_text(), get word-level positions with pdf_data(), read metadata with pdf_info(), and clean the result into a data frame. For tables: reconstruct clean digital tables from positions, reach for tabulapdf on lattice/stream tables, and use OCR (tesseract) or an LLM/vision pass for scanned or messy PDFs. Built on the maintained pdftools core — not the archived tabulizer.

Author
Published

July 19, 2026

Modified

July 19, 2026

TipWhat you’ll learn
  • Read text out of a PDF with pdftools — whole pages with pdf_text(), word-level position data with pdf_data(), document metadata with pdf_info().
  • Clean the raw text — strip page breaks, collapse whitespace, and split into lines with base R and stringr.
  • Get tables out three honest ways: reconstruct clean digital tables from pdf_data() positions, use tabulapdf for lattice/stream tables, and reach for OCR or an LLM/vision pass on scanned or messy PDFs.
  • Which approach fits which PDF — a short decision guide so you don’t waste an afternoon regex-ing a scan.
  • Why the maintained pdftools core is the right base in 2026 — and why you should not start with the archived tabulizer.

You were handed a bank statement, a lab report, a government table, or a journal PDF — and the numbers you need are trapped inside it. In R the first, maintained tool for the job is pdftools (from rOpenSci): it reads the text, the word positions, and the metadata straight out of a digital PDF, with no Java and no external service. This guide covers the full practical spine — read the text, clean it, get a data frame out, then handle the table cases — and it is honest about the 2026 reality: clean digital PDFs give up their text easily, but messy or scanned ones need OCR or an LLM pass, and no amount of regex changes that.

A schematic of a PDF page on the left flowing through pdftools into a tidy data frame on the right, in Datanovia brand azure.

From a PDF page to a tidy data frame in R — pdftools reads the text and the word positions, and you shape them into columns.

NoteThe code below is illustrative — install pdftools and it runs

pdftools, tabulapdf, and tesseract rely on system libraries (poppler, Java, Tesseract) that aren’t installed on this page’s lightweight renderer, so the R blocks here are shown as illustrative listings rather than executed live. Install the package for the section you need (below) and every snippet runs unchanged in your own R session. Where we show extracted output, it’s a realistic captured example, clearly labelled — the shapes and column names match what you’ll see.

Install pdftools

pdftools wraps the poppler C++ PDF library, so on Linux you install one system package first; on macOS and Windows the CRAN binary bundles what it needs. Per the pdftools installation notes:

# Linux (Debian/Ubuntu) needs the poppler dev headers first:
#   sudo apt-get install libpoppler-cpp-dev
# Fedora: sudo dnf install poppler-cpp-devel
# macOS/Windows: the CRAN binary just works.

install.packages("pdftools")

Then load it. pdftools — the rOpenSci package for reading and rendering PDF documents — is all you need for the text sections:

library(pdftools)

Read the text: pdf_text()

The workhorse is pdf_text(): give it a file path (or URL) and it returns a character vector with one element per page. Each element is the full plain text of that page, newlines and all.

# One string per page
pages <- pdf_text("statement.pdf")

length(pages)      # number of pages
#> [1] 3

# Peek at the first page
cat(pages[[1]])
#> Datanovia Bank — Monthly Statement
#> Account 0042 · Period 2026-06
#>
#> Date        Description               Amount
#> 2026-06-03  Opening balance          1,200.00
#> 2026-06-07  Grocery                    -54.20
#> 2026-06-19  Salary                   2,400.00

pdf_text() preserves the page’s visual layout with runs of spaces, which is often helpful — columns stay roughly aligned, so you can split on whitespace later. For a whole document as a single string, collapse the vector:

full_text <- paste(pages, collapse = "\n")

That is the 80% case: for a normal, digitally-generated PDF, pdf_text() alone gets the words out.

Clean the raw text

Extracted PDF text arrives with page breaks, ragged whitespace, and blank lines. Cleaning it is ordinary string work — split the page into lines, then trim and squish with base R or stringr:

library(stringr)

lines <- pages[[1]] |>
  str_split("\n") |>       # one page-string -> a vector of lines
  unlist() |>
  str_squish()             # trim ends + collapse internal runs of spaces

lines <- lines[lines != ""]  # drop blank lines
head(lines)
#> [1] "Datanovia Bank — Monthly Statement"
#> [2] "Account 0042 · Period 2026-06"
#> [3] "Date Description Amount"
#> [4] "2026-06-03 Opening balance 1,200.00"
#> [5] "2026-06-07 Grocery -54.20"

From clean lines you can pull exactly what you need. To grab every currency amount, match a number pattern; to keep only the transaction rows, filter on a leading date:

# All amounts on the page (regex for a number with optional thousands + decimals)
str_extract_all(lines, "-?[0-9,]+\\.[0-9]{2}") |> unlist()
#> [1] "1,200.00" "-54.20"   "2,400.00"

# Only the rows that start with an ISO date
txn <- lines[str_detect(lines, "^\\d{4}-\\d{2}-\\d{2}")]

This is the everyday pattern: pdf_text() to get the words, stringr to shape them, base R to filter. For a free-form document (a contract, a paper) that is usually all you need — you’re after passages, not a grid.

Word-level positions: pdf_data()

When layout matters — you’re trying to rebuild a table — whole-page text throws away the geometry. pdf_data() keeps it: it returns one data frame per page, with a row for every word and its bounding box. Introduced in pdftools 2.0, each row carries these columns:

Column Meaning
x, y Position of the word’s top-left corner on the page
width, height Size of the word’s bounding box
space Is the word followed by a space (vs. a line break)?
text The word itself
# A list with one data frame per page
tokens <- pdf_data("statement.pdf")

head(tokens[[1]])
#>   width height   x   y space   text
#> 1    64     11  72 60  TRUE   Datanovia
#> 2    30     11 140 60  TRUE   Bank
#> 3    12     11  72 96  TRUE   Date
#> 4    78     11 150 96  TRUE   Description
#> 5    46     11 360 96 FALSE  Amount
#> 6    58     11  72 120 TRUE  2026-06-03

x and y are the key to structure: words on the same row share a y, and columns line up on x. That is exactly what you need to reconstruct a table — next.

Read the metadata: pdf_info()

Before you process a file, you often want its facts — page count, whether it’s encrypted, when it was created. pdf_info() returns them as a list:

info <- pdf_info("statement.pdf")

info$pages       #> [1] 3
info$encrypted   #> [1] FALSE
info$created     #> [1] "2026-06-30 09:14:00 UTC"

Use info$pages to size a loop, info$encrypted to decide whether you need a password (pdf_text(..., upw = "…")), and the timestamps for provenance. (pdf_toc() and pdf_fonts() round out the family when you need the outline or font list.)

Tables, case 1: reconstruct a clean digital table from positions

For a clean, digitally-generated table, you don’t need a table library at all — the positions from pdf_data() are enough. The recipe: group words into rows by y, order each row’s words by x, and assign columns by where the x values cluster.

Because rows sit at (almost) the same y, round y to snap words onto the same line, then split:

page <- pdf_data("statement.pdf")[[1]]

# 1. Group words into rows: round y so a line's words share a key
page$row <- round(page$y / 5) * 5          # tolerance for tiny y jitter

# 2. Split into rows, then order each row left-to-right by x
rows <- split(page, page$row)
rows <- lapply(rows, function(r) r[order(r$x), ])

# 3. Turn each row into its cells. For a fixed-column table, cut on x breaks
#    you read off the header positions (here: Date ~72, Description ~150, Amount ~360)
breaks <- c(-Inf, 130, 340, Inf)
to_cells <- function(r) {
  col <- cut(r$x, breaks, labels = c("date", "description", "amount"))
  tapply(r$text, col, paste, collapse = " ")
}

cells <- lapply(rows, to_cells)
tbl   <- do.call(rbind, cells)

The idea is deliberately transparent: y finds the rows, x finds the columns. You read the column break positions off the header once (or detect gaps in the sorted x values), and the same breaks apply to every row. This is robust for statements, invoices, and reports that were generated by software — the geometry is exact. It gets fiddly when columns wrap or merge, which is where a dedicated table extractor earns its keep.

Tables, case 2: tabulapdf for lattice and stream tables

When positional logic gets painful — ruled “lattice” tables with borders, or dense “stream” tables — reach for tabulapdf, the rOpenSci R bindings to the Tabula Java table-extraction engine. Its extract_tables() returns a list of data frames, one per detected table:

# install.packages("tabulapdf")   # needs Java (rJava) — see the note below
library(tabulapdf)

tables <- extract_tables("report.pdf", pages = 2)
tables[[1]]     # the first table on page 2, as a data frame

extract_tables() has a method argument — "lattice" for tables drawn with ruling lines, "stream" for tables aligned only by whitespace — and an interactive extract_areas() that lets you draw the table region by hand when detection struggles.

ImportantUse tabulapdf, not the archived tabulizer

Older tutorials point you at tabulizerdon’t start there. It was archived on CRAN in 2021 (its package checks were left unfixed). Its maintained successor is tabulapdf, the same Tabula bindings kept current for modern Java. Both still require Java on your system — that dependency is the reason to prefer the pure-C++ pdftools for text, and to bring in tabulapdf only when you specifically need its table detection.

Tables, case 3: scanned or messy PDFs need OCR or an LLM

Here is the honest part most tutorials skip. If your PDF is a scan — a photo or fax of a page — there is no text layer to extract at all. pdf_text() returns empty strings, and pdf_data() returns nothing, because the “text” is just pixels. No regex will fix that. You have two modern routes.

Optical character recognition (OCR) — reading text out of an image — with tesseract (rOpenSci bindings to Google’s Tesseract engine). pdftools even ships a helper, pdf_ocr_text(), that renders each page to an image and OCRs it in one call:

# install.packages("tesseract")   # needs the Tesseract + Leptonica system libs
library(pdftools)

# Renders each page to an image, then runs Tesseract OCR
scanned_text <- pdf_ocr_text("scanned-report.pdf", language = "eng")

# tesseract can also return word-level boxes (like pdf_data, but for a scan).
# ocr_data() needs an image *file*, so render the page to a PNG first.
library(tesseract)
png   <- pdf_convert("scanned-report.pdf", pages = 1, dpi = 300)
boxes <- ocr_data(png[1])

OCR is imperfect — expect stray characters, and always spot-check numbers before you trust them — but for a legible scan it recovers most of the text, and ocr_data() gives you positions to rebuild tables the same x/y way as above.

An LLM (large language model) / vision extraction pass is the other 2026 option, and it’s often the fastest path for a genuinely messy document — a multi-column form, a table with merged cells and footnotes, a low-quality scan. You send the page (as text or as a rendered image) to a vision-capable model and ask for structured output — say, JSON with named fields — then read that into a data frame. This is exactly the “feed an unstructured document into an analysis pipeline” task of the moment.

The catch, and it’s the whole game: a model will happily invent a plausible number. So treat any LLM extraction as a draft and close the loop — check the pulled figures against the source. Two cheap guards:

  • Reconcile against a total. If the document has a sum, a count, or a subtotal, compute it from the extracted rows and compare. A statement whose transactions don’t add up to its stated balance is a red flag that the extraction dropped or misread a row.
  • Verify against pdf_text() where a text layer exists. For a hybrid PDF (real text plus an image region), pull the text layer with pdftools and confirm the model’s numbers actually appear in it.

That verification step — judging the generated answer against the source rather than trusting it — is the difference between a pipeline you can put in a report and one that quietly corrupts your data. Reach for an LLM to parse the mess, but let the numbers be checked by something deterministic.

Which approach for which PDF

A quick decision guide so you pick the right tool the first time:

Your PDF Reach for Why
Digital, free-form text (contract, paper) pdf_text() + stringr The words are right there; you want passages, not a grid
Digital table, software-generated pdf_data() positions (or tabulapdf) Exact geometry — y gives rows, x gives columns
Ruled / dense table, tricky layout tabulapdf (extract_tables, extract_areas) Purpose-built table detection (lattice/stream)
Scanned image, no text layer tesseract OCR (pdf_ocr_text) There is no text to extract — you must read the pixels
Messy, merged, or mixed-quality LLM / vision pass, then verify Fastest on chaos — but reconcile every number against the source

The order of preference is also the order of trust: a real text layer is exact, OCR is approximate, and an LLM is a draft until you’ve checked it. Start with pdftools; escalate only when the document forces you to.

Common issues

pdf_text() returns empty strings ("") for every page. The PDF is almost certainly a scan — an image with no text layer. Nothing in pdftools can extract text that isn’t there. Confirm with pdf_data() (also empty), then switch to OCR (pdf_ocr_text() / tesseract).

The columns are jumbled when I split pdf_text() on spaces. Whole-page text loses exact geometry, so multi-space alignment is only approximate — wrapped cells and proportional fonts break a naive split. Use pdf_data() and reconstruct columns from x positions instead, or hand the table to tabulapdf.

tabulapdf won’t install or errors on load. It needs Java (via rJava), which is a common install snag, especially on Windows. Check that a JDK is on your system and that rJava loads (library(rJava)) before tabulapdf. If you only need text (not table detection), you can avoid Java entirely by staying with pdftools.

OCR output is full of stray characters. OCR quality depends on image resolution — render at a higher DPI (pdf_ocr_text(..., dpi = 600)), make sure you’re using the right language data, and always sanity-check extracted numbers against a known total.

Frequently asked questions

It depends on the table. For a clean, software-generated table, use pdftools::pdf_data() to get every word’s x/y position, then group words into rows by y and into columns by x — the geometry is exact. For ruled or dense tables, use tabulapdf::extract_tables() (Tabula’s table-detection engine), or extract_areas() to select the region interactively. For a scanned table there is no text to extract — you must OCR it first with tesseract. There’s no single call that handles every PDF; match the tool to the document.

Use pdftools as your base: it’s the maintained rOpenSci package for reading PDF text, positions, and metadata, it’s pure C++ (poppler) with no Java, and it’s actively developed. Do not start with tabulizer — it was archived on CRAN in 2021 and targets a deprecated Java version. If you specifically need automatic table detection beyond what pdf_data() positions give you, use tabulapdf, tabulizer’s maintained successor (same Tabula engine, modern Java).

A scanned PDF is an image, so pdf_text() returns nothing — you need OCR (optical character recognition). The simplest path is pdftools::pdf_ocr_text("scan.pdf", language = "eng"), which renders each page to an image and runs the Tesseract engine over it (install the tesseract package and its system libraries first). Render at a higher dpi for cleaner results, and always verify the extracted numbers — OCR is approximate, not exact.

Call pdftools::pdf_text("file.pdf"). It returns a character vector with one string per page, each holding that page’s plain text. Collapse it with paste(pages, collapse = "\n") for the whole document, then clean it with stringr::str_squish() (trim and collapse whitespace) and str_split("\n") to work line by line.

Yes, and for messy or scanned documents it’s often the fastest path — send the page text or a rendered image to a vision-capable model and ask for structured output (e.g. JSON), then read it into a data frame. The essential caveat: a model can fabricate a convincing number, so never trust the output blind. Reconcile the extracted values against a total or subtotal in the document, and cross-check against the pdftools text layer where one exists. Use the LLM to parse the chaos; let something deterministic verify the numbers.

Test your understanding

You receive three PDFs and need the numbers from each in R:

  1. A bank statement exported from online banking — crisp, selectable text, neat columns.
  2. A 1998 journal article scanned from paper, with a results table halfway down page 4.
  3. A modern PDF report whose table has merged header cells, footnotes, and rotated text.

Which extraction approach fits each — and which one’s output must you verify most carefully?

  1. pdftoolspdf_data() positions (rows by y, columns by x) reconstruct the statement table exactly; it’s a clean, software-generated PDF, so no OCR or Java needed.
  2. OCR — it’s a scan, so there’s no text layer; pdftools::pdf_ocr_text() (Tesseract) reads the pixels. Render at a high DPI and spot-check the results — OCR is approximate.
  3. An LLM / vision pass is the pragmatic choice for the merged/rotated mess — but its output needs the most scrutiny: reconcile every extracted number against a total or the source before you trust it. (You could also try tabulapdf::extract_areas() to draw the region by hand.)

The trust order mirrors the tool order: exact positions → approximate OCR → an LLM draft you must check.

Conclusion

Getting data out of a PDF in R is a matter of matching the tool to the document. For anything with a real text layer, pdftools does the job with no Java and no service: pdf_text() for the words, pdf_data() for word-level x/y positions, pdf_info() for metadata — then stringr and base R to clean and filter. Rebuild clean tables straight from the positions; bring in tabulapdf (the maintained successor to the archived tabulizer) for tricky lattice and stream tables. And be honest about scans and messes: they need OCR (tesseract) or an LLM/vision pass — with the numbers always verified against the source, because a generated answer is a draft, not a fact. Start with pdftools, escalate only when the PDF forces you to, and check what you can’t trust.

Citation

BibTeX citation:
@online{kassambara2026,
  author = {Kassambara, Alboukadel},
  title = {Extract {Text} and {Tables} {From} {PDFs} in {R}},
  date = {2026-07-19},
  url = {https://www.datanovia.com/blog/extract-text-and-tables-from-pdf-in-r},
  langid = {en}
}
For attribution, please cite this work as:
Kassambara, Alboukadel. 2026. “Extract Text and Tables From PDFs in R.” July 19. https://www.datanovia.com/blog/extract-text-and-tables-from-pdf-in-r.