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.
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:
"""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
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
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:
-
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?
-
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?
-
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?
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
- pypdf: extract text from a PDF (pypdf project)
- pypdf: Configuration (processing limits) (pypdf project)
- pypdf: security (pypdf project)
- pdfplumber README (table extraction and filtering) (pdfplumber project)
- ReportLab open-source PDF toolkit (ReportLab)
- pdf-lib documentation (pdf-lib project)
- Playwright for Python: Page.pdf() (Microsoft (Playwright))
- resource: Resource usage information (Python 3.14) (Python Software Foundation)
- subprocess: Subprocess management (Python 3.14) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress