Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Scraping & Browser Automation (Python and JavaScript)  Module 9 – Office automation: Excel, PDF and email

PDFs: extract text and tables, merge, generate

Extract text with pypdf and tables with pdfplumber, check them against the source, then merge, split, stamp and generate report PDFs in Python and Node.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, quickjs 0.32.0, pypdf 6.19.0, pdfplumber 0.11.10, reportlab 5.0.1 and pdf-lib 1.17.1
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: A PDF page holds text at positions, not a table · pdfplumber rebuilds the rows, and the source data checks them. With a pass: 3 in-depth parts and 3 interview answers, about 12 minutes.

What you will learn

  • Extract a PDF's text with pypdf and its tables with pdfplumber, and check every value against the source
  • Explain why a scanned page gives no text and why a table has to be rebuilt from positions
  • Read tables that run across pages or draw no lines, and check them with the PDF's own totals
  • Merge, split and stamp PDFs with pypdf, and generate a report PDF in Python or in Node
  • Open PDFs from the web with limits on size, type, time and decompression

Before you start

What you’ll learn · Before you start

What you will learn

  • Extract a PDF's text with pypdf and its tables with pdfplumber, and check every value against the source
  • Explain why a scanned page gives no text and why a table has to be rebuilt from positions
  • Read tables that run across pages or draw no lines, and check them with the PDF's own totals
  • Merge, split and stamp PDFs with pypdf, and generate a report PDF in Python or in Node
  • Open PDFs from the web with limits on size, type, time and decompression

Before you start

On this page

The practice site’s weekly market bulletin is a one-page PDF with a table of 40 prices. pypdf reads its text as 290 short lines, one value each, and nothing in them says which value belongs to which column; pdfplumber, which reads where every word sits on the page, gives back the 41 rows of the table, header included, cell by cell.

Here you extract the bulletin both ways and check every value against the data it was printed from, and see why a scanned page gives no text at all. After that come tables that run across pages, merging and stamping files, generating report PDFs, and opening PDFs from the web safely.

A PDF page holds text at positions, not a table

A PDF describes how a page looks: draw these characters, in this font, at this point. The pypdf documentation puts it plainly: the text within a PDF is absolutely positioned, the file has no semantic layer, and tables are typically just positioned text (pypdf). So a program gets the characters back, and the table has to be rebuilt from where they are. The program reads the bulletin three ways, then a page made from a picture:

The bulletin's text, laid out, as words with positions, and a scanned page Python · pdf/pdf_text.py
"""What a PDF page gives a program: pypdf 6.19.0's text, the same page laid out, words with their positions from
pdfplumber 0.11.10, and a scanned page, which is a picture with no text in it."""
import pdfplumber
import pypdf
from PIL import Image, ImageDraw

page = pypdf.PdfReader("bulletin.pdf").pages[0]
lines = page.extract_text().splitlines()
print(f"pypdf, extract_text(): {len(lines)} lines; lines 11 to 17, the first row of the table:")
print(" ", lines[10:17])
laid_out = page.extract_text(extraction_mode="layout").splitlines()
row = next(line.strip() for line in laid_out if "Green chilli" in line)
print('pypdf, extraction_mode="layout", the Green chilli row:')
print(" ", row)
print(f"  split on spaces: {len(row.split())} pieces for 7 columns")

with pdfplumber.open("bulletin.pdf") as pdf:
    words = pdf.pages[0].extract_words()
first = next(w for w in words if w["text"] == "Green")
print("pdfplumber, the words of that row and where each starts (x0, in points from the left edge):")
print(" ", ", ".join(f"{w['text']} {w['x0']:.0f}" for w in words if abs(w["top"] - first["top"]) < 1))

# A scanned page is a picture of text: draw one, save it as a PDF and ask both libraries for its text.
picture = Image.new("L", (900, 120), 255)
ImageDraw.Draw(picture).text((30, 50), "Onion  Red  Nimbu Nagar  1,270  1,535  1,440  112.9", fill=0)
picture.save("scanned.pdf")
with pdfplumber.open("scanned.pdf") as pdf:
    scanned = pdf.pages[0]
    print(f"scanned.pdf: pypdf text {pypdf.PdfReader('scanned.pdf').pages[0].extract_text()!r}, "
          f"pdfplumber characters {len(scanned.chars)}, images {len(scanned.images)}")

Output

pypdf, extract_text(): 290 lines; lines 11 to 17, the first row of the table:
  ['Onion', 'Red', 'Nimbu Nagar', '1,270', '1,535', '1,440', '112.9']
pypdf, extraction_mode="layout", the Green chilli row:
  Green chilli     Local            Tulsi Peth           1,150        1,350        1,260          163.3
  split on spaces: 9 pieces for 7 columns
pdfplumber, the words of that row and where each starts (x0, in points from the left edge):
  Green 118, chilli 144, Local 178, Tulsi 237, Peth 257, 1,150 312, 1,350 357, 1,260 402, 163.3 456
scanned.pdf: pypdf text '', pdfplumber characters 0, images 1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pdf_text.py

extract_text() gave 290 lines, one value each, in the order they were drawn: the row is there only as seven lines in a row. The layout mode lines the values up again, but splitting that line on spaces gives 9 pieces for 7 columns, because Green chilli and Tulsi Peth hold spaces themselves. pdfplumber’s words carry their positions, and positions decide the columns: Green and chilli start at 118 and 144 points, both left of Local at 178, where the Variety column begins.

The scanned page gave nothing at all: no characters, one image. Its text layer: The characters a PDF page draws as text, each with a font and a position, which programs can read back. A scanned page is a picture and has none, so text extraction returns nothing until OCR adds one. is missing, because the page is a picture of text, and pypdf’s documentation says it will never extract text from images. Such pages need OCR, which is outside this track; for a single file, MySmartCoPilot’s OCR tool makes a scan searchable:

OCR PDF (Make Scanned PDFs Searchable) Make one scanned PDF searchable with OCR in your browser, then check its text before you use it.

Checkpoint

Check: Text at positions, and scanned pages

  1. Question 1 of 2 pypdf's layout mode gives the line "Green chilli Local Tulsi Peth 1,150 1,350 1,260 163.3". Splitting it on spaces gave 9 pieces for 7 columns. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Green chilli and Tulsi Peth hold spaces of their own, so a space cannot tell a cell's end from a word's end

    A cell with a space in it looks exactly like two cells once the positions are gone. pdfplumber keeps each word's position, which is what puts Green and chilli in the same column.

  2. Question 2 of 2 A scanned page gave pypdf the text '' and pdfplumber 0 characters and 1 image. What does the page lack?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A text layer; the page is a picture of text

    pypdf's documentation says it will never extract text from images. Such pages need OCR before any extraction, and their result needs checking too.

pdfplumber rebuilds the rows, and the source data checks them

pdfplumber finds a table from the ruling lines: The lines a page draws between the rows and columns of a table. pdfplumber's default table setting builds its cells from them, so a table printed without lines needs other settings. the page draws, in five steps its README describes: find the lines, merge the ones that nearly overlap, find where they cross, take the smallest rectangles between crossings as cells, and group touching cells into tables. Step through them with the bulletin’s own counts:

Interactive · stage by stage How pdfplumber finds the bulletin's table Step through the five steps that turn the lines and words of the page into rows of cells.
How pdfplumber finds the bulletin's table, step 6 of 6. Showing Lines on the page, Lines snapped and joined, Crossings, Cells, Words placed in cells, Rows of cells. In focus: Rows of cells.Lines on the page8 vertical, 42 horizontalLines snapped and joinednear lines become oneCrossings336 intersectionsCells287 rectanglesWords placed in cellsby where each one sitsRows of cells41 rows of 7 strings

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Lines first. pdfplumber starts from the lines the page draws. The bulletin's grid has 8 vertical lines, one at each column edge, and 42 horizontal ones, one above and below each of its 41 rows.
  2. Snap and join. Parallel lines a few points apart are snapped to one position, and pieces of the same line are joined, so a grid drawn cell by cell becomes long clean edges.
  3. Where lines cross. Every place where a vertical edge crosses a horizontal one is an intersection. 8 times 42 gives the 336 the program counted.
  4. The smallest rectangles. The finder takes the smallest rectangles whose corners are intersections. Here that is 287 cells, 41 rows of 7, grouped into one table because they touch.
  5. Words go where they sit. Each word goes into the cell whose box holds it, so Green chilli stays one value in the Commodity column, however many spaces it has.
  6. Rows of strings. extract_table() returns the table as a list of rows, each a list of 7 strings, the header first. Every value is still text, such as 2,03,020, until your code reads it.

Every value comes back as text, so the program reads the numbers, 2,03,020 as 203020 whatever the grouping, and then compares each record with the data the bulletin was printed from:

The bulletin's table, checked row by row against its source

pdf/bulletin_tables.py

"""The bulletin's table read with pdfplumber 0.11.10, then checked value by value against the data it was printed
from (bulletin_data.py)."""
import pdfplumber

from bulletin_data import HEADER, ROWS


def number(text):
    """2,150 and 2,03,020 are read the same way: the commas only group the digits."""
    return int(text.replace(",", ""))


with pdfplumber.open("bulletin.pdf") as pdf:
    page = pdf.pages[0]
    finder = page.debug_tablefinder()
    kinds = [edge["orientation"] for edge in finder.edges]
    print(f"lines on the page: {kinds.count('v')} vertical, {kinds.count('h')} horizontal; "
          f"crossings {len(finder.intersections)}; cells {len(finder.cells)}")
    table = page.extract_table()

print(f"extract_table(): {len(table)} rows of {len(table[0])} cells")
print("row 1:", table[0])
print("row 14:", table[13])
records = [(c, v, m, number(lo), number(hi), number(mo), float(t)) for c, v, m, lo, hi, mo, t in table[1:]]
same = sum(got == want for got, want in zip(records, ROWS))
print(f"header as printed: {table[0] == HEADER}; rows equal to the source: {same} of {len(ROWS)}")
print("row 14 as a record:", records[12])

Output

lines on the page: 8 vertical, 42 horizontal; crossings 336; cells 287
extract_table(): 41 rows of 7 cells
row 1: ['Commodity', 'Variety', 'Market', 'Min', 'Max', 'Modal', 'Arrivals (t)']
row 14: ['Cardamom', 'Bold', 'Nimbu Nagar', '1,89,110', '2,15,360', '2,03,020', '23.5']
header as printed: True; rows equal to the source: 40 of 40
row 14 as a record: ('Cardamom', 'Bold', 'Nimbu Nagar', 189110, 215360, 203020, 23.5)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bulletin_tables.py

pdf/bulletin_data.py

"""The practice site's market bulletins, as the data they were printed from. Every market and price is invented.

ROWS is week 18's one-page bulletin (bulletin.pdf): 40 rows of prices in rupees per quintal and arrivals in tonnes.
BOARD is the three-page board of the same week (board.pdf): 120 rows, printed without ruling lines.
"""
import random

ITEMS = [("Onion", "Red"), ("Potato", "Jyoti"), ("Tomato", "Hybrid"), ("Green chilli", "Local"), ("Brinjal", "Round"),
         ("Cabbage", "Golden Acre"), ("Cauliflower", "Local"), ("Wheat", "Lokwan"), ("Bengal gram", "Desi"),
         ("Soybean", "Yellow"), ("Cotton", "Long staple"), ("Turmeric", "Finger"), ("Cardamom", "Bold"),
         ("Garlic", "Ooty"), ("Ginger", "Fresh"), ("Maize", "Yellow"), ("Groundnut", "Bold"), ("Mustard", "Black"),
         ("Coriander", "Badami"), ("Cumin", "Bold")]
MARKETS = ["Nimbu Nagar", "Kesar Ganj", "Amla Pur", "Tulsi Peth", "Neem Chowk", "Kamal Bagh"]
BASE = {"Cardamom": 210000, "Cumin": 24000, "Turmeric": 13500, "Cotton": 7400, "Garlic": 9800, "Ginger": 6200,
        "Mustard": 5900, "Coriander": 7600, "Groundnut": 6400, "Bengal gram": 5600, "Soybean": 4700, "Wheat": 2600,
        "Maize": 2200}  # rupees a quintal; vegetables get 900 to 3,000
HEADER = ["Commodity", "Variety", "Market", "Min", "Max", "Modal", "Arrivals (t)"]


def rows(count, seed):
    rng = random.Random(seed)
    out = []
    for n in range(count):
        commodity, variety = ITEMS[n % len(ITEMS)]
        market = MARKETS[(n // len(ITEMS) + n) % len(MARKETS)]
        base = BASE.get(commodity, 900 + 100 * rng.randrange(0, 22))
        low = base - base // 10 + 10 * rng.randrange(0, 20)
        high = low + base // 8 + 10 * rng.randrange(0, 20)
        modal = low + (high - low) * rng.randrange(40, 80) // 100 // 10 * 10
        arrivals = rng.randrange(5, 4000) / 10  # tonnes, one decimal
        out.append((commodity, variety, market, low, high, modal, arrivals))
    return out


ROWS = rows(40, 20261004)
BOARD = rows(120, 20261018)


def grouped(n):
    """Rupees with Indian digit grouping, as the bulletins print them: 2,150 and 2,10,000."""
    s = str(n)
    if len(s) <= 3:
        return s
    head, tail = s[:-3], s[-3:]
    parts = []
    while len(head) > 2:
        parts.insert(0, head[-2:])
        head = head[:-2]
    return ",".join([head, *parts, tail]) if head else ",".join([*parts, tail])

The counts match the figure: 8 vertical and 42 horizontal lines, 336 crossings, 287 cells, 41 rows of 7, and all 40 rows equal the source. A job rarely has the source to compare with, which is why its checks come from the PDF itself.

Key idea

pdfplumber rebuilds a ruled table from the lines the page draws and hands back every cell as text: read each value yourself, and check the rows before anything is stored.

When a page draws no lines, pdfplumber can guess columns from where the words line up, its text setting, or take column edges that your code gives it. pypdf’s PdfWriter merges, splits and stamps files page by page. Merging copies the pages of several files into one, splitting copies chosen pages into files of their own, and a stamp draws one page, such as a mark that says the data is for practice, over each page of another. A report PDF can be written with ReportLab in Python or pdf-lib in Node. Generating it from the stored records and reading it back proves that it says what the records say. And a PDF from the web is untrusted input, read with limits.

Checkpoint

Check: Rebuilding a table and checking it

  1. Question 1 of 2 A ruled table has 5 vertical lines and 11 horizontal lines, one at every column edge and one above and below every row. How many cells does pdfplumber's table finder make from them?

    Type a number.

    A hint

    The lines bound 4 columns and 10 rows.

    Show the answer: Question 1 of 2

    Answer: 40 cells

    5 vertical lines make 4 columns and 11 horizontal lines make 10 rows, so the smallest rectangles between crossings are 4 times 10, 40 cells. The bulletin's 8 and 42 lines made 7 times 41, its 287 cells.

  2. Question 2 of 2 extract_table() returned the cell '2,03,020' for the cardamom row. What is it, and what does the program do with it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A string; the program removes the commas and reads 203020

    A PDF holds characters, so a table extractor hands back strings. Read numbers yourself, with a rule written for the formats the document prints, and refuse what does not fit.

Tables across pages and without lines, checked value by value Pro: opens with a Pro, Premium or Ultimate pass

The board of the same week runs over three pages, repeats its header and draws no lines at all. This part reads it three ways, counts what each gets wrong, and then checks a reading with nothing but the numbers the board prints.

In this part:

  • Three settings on three unruled pages, measured
  • Checks from the board itself catch a wrong reading
  • Turn a board's pages into checked records
    • Results of the sample tests

2 runnable examples (board_checks.py, board_tables.py, bulletin_data.py) · 1 exercise with sample tests · about 8 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Merging, splitting and stamping, and what each step keeps Pro: opens with a Pro, Premium or Ultimate pass

pypdf’s PdfWriter copies pages from file to file, and each way of copying decides what comes along. This part merges the bulletin and the board with bookmarks, splits the board into pages, stamps every page, and measures what each step kept and what the stamp did to the table.

In this part:

  • Merging with bookmarks and splitting by page, measured
  • A stamp changed 12 cells, and a filter changed them back

2 runnable examples (Python) · about 4 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Generating report PDFs, and reading downloaded ones safely Pro: opens with a Pro, Premium or Ultimate pass

A report PDF can be written from code and then read back, which proves that it says what the records say. PDFs that arrive from the web are a different matter, so this part ends with the limits a job needs before it opens one.

In this part:

  • The weekly report with ReportLab, read back with pdfplumber
  • The same report in Node with pdf-lib, which refuses the rupee sign
  • Limits for PDFs from the web: size, type, time and inflation

4 runnable examples (Python, read_one.py (the child), report_pdf.mjs, report_pdf.py, safe_open.py, week.mjs, week.py) · about 4 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise reading PDFs

Test yourself on text at positions and on rebuilding a table, starting where your answers have been weakest:

Practice that adapts to you

Practise this lesson

7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 7 What did pypdf's plain extract_text() give for the bulletin's table?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: One value per line, in the order the values were drawn

    Text extraction returns characters in drawing order. Whether that order is row by row depends on how the file was made, which is why positions, not order, should decide the columns.

  2. Question 2 of 7 According to pypdf's documentation, what does a PDF file not contain?

    Choose one answer.

    Show the answer: Question 2 of 7

    Answer: A semantic layer saying which text is a header, a paragraph or a table

    A PDF says how a page looks, not what its parts are. Everything a program wants to call a table has to be rebuilt from positions, lines and order.

  3. Question 3 of 7 A job gets 200 PDFs a day. How can it tell, before extracting, which ones are scans that need OCR?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: Pages with no characters and one or more images, as pdfplumber reported for scanned.pdf

    Count characters and images per page. A page with images and no characters goes to OCR or to a person; a page with characters goes to extraction.

  4. Question 4 of 7 A row's words start at these points: Green 118, chilli 144, Local 178, Tulsi 237. The Variety column begins at 178. Which words are in the Commodity cell?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: Green and chilli

    Column membership comes from positions. That is how pdfplumber keeps Green chilli in one cell while splitting on spaces gives two pieces.

  5. Question 5 of 7 What does pdfplumber's table finder start from on a ruled page?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: The lines the page draws

    Lines, merged lines, crossings, cells, then tables of touching cells: five steps from the drawing to the rows.

  6. Question 6 of 7 bulletin_tables.py found 40 of 40 rows equal to the source. Which mistake would that comparison have caught that counting the rows would not?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: A price read as 20302 instead of 203020

    Counts catch lost and extra rows; only values catch wrong values. While building a job, compare with the source cell by cell; after that, compare with totals the document prints.

  7. Question 7 of 7 A new report arrives as a table with no lines between its cells. What will pdfplumber's default table settings return for it, and what are the two other ways to find its columns?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: Probably no table; columns can come from where words line up, or from edges your code gives it

    The default setting is the lines setting. For text without lines, pdfplumber offers the text setting and explicit edges, and the right choice depends on how the page is made.

Interview questions

Warm-up (fresher): Why is getting a table out of a PDF harder than getting its text?

Because a PDF stores how a page looks, not what it means. Its text is a set of characters drawn at positions, and a table is only those characters lined up, sometimes with lines drawn between them. Text extraction returns the characters in drawing order, so the rows and columns are gone and a cell with a space in it cannot be told from two cells. A table extractor such as pdfplumber rebuilds the cells from the lines the page draws, or from where words line up, and every value still comes back as text to be read and checked. A scanned page is harder still: it has no text at all until OCR adds it.

Three harder questions start from this lesson: shifted prices after a supplier changed its template, invoices with no text, and a PDF that took down a worker. The module’s bank gives the first step of each:

  1. Mid to senior

    A nightly job extracts a supplier's price table from its PDF bulletins. After the supplier changed its template, about a third of the stored prices sit in the wrong columns, and nothing failed. How do you find the cause, repair the data and make the job catch this next time?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A job reads the totals of PDF invoices that suppliers email in. For about a fifth of them it finds no text at all, and their totals are stored as empty. What is going on, and how should the job handle those files?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A service extracts text from PDFs that users upload. One upload of under 100 KB made a worker use all its memory and stop, and the queue behind it stalled. How do you make the service safe against such files?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A PDF page holds characters at positions; text extraction loses the table, and a scanned page has no text at all.
  • pdfplumber rebuilds a ruled table from its lines, and every value comes back as text for your code to read.
  • Check every extracted table, against its source while you build the job and against the PDF’s own numbers after.

Terms in this lesson

content stream
The drawing instructions of a PDF page, such as which font to use, where to move and what text to show. Content streams are usually compressed, so a small file can hold a stream that is large once inflated.
ruling lines
The lines a page draws between the rows and columns of a table. pdfplumber's default table setting builds its cells from them, so a table printed without lines needs other settings.
standard PDF fonts
The 14 fonts, such as Helvetica, Times and Courier, that PDF readers provide themselves, so a file can name them without embedding them. They cover a Western character set only.
text layer
The characters a PDF page draws as text, each with a font and a position, which programs can read back. A scanned page is a picture and has none, so text extraction returns nothing until OCR adds one.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.