How to Build a PDF Merger in Python with Streamlit and pypdf
This guide explains a complete PDF merger web app, one section at a time. Users upload PDFs, reorder them, choose pages from each file, and download a single merged document.
What the app does
The app is a single Python file built with Streamlit for the interface and pypdf for reading and writing PDFs. Everything runs in memory, so no files are saved to disk.
- Upload any number of PDF files at once.
- Move files up or down to set their order in the final document.
- Pick pages for each file, such as
1-3or5,1,3. - Validate every input before anything is merged.
- Download the result under a safe filename.
The script flows from top to bottom: upload → read → order → select pages → merge → download. Streamlit re-runs the whole script after every interaction, which explains several design choices below.
Install and run
You need Python 3.9 or newer. Install the two dependencies and start the app:
pip install streamlit pypdf
streamlit run pdf_merge.py
Streamlit opens the app in your browser, usually at http://localhost:8501.
Imports and page setup
import io
import re
import streamlit as st
from pypdf import PdfReader, PdfWriter
st.set_page_config(page_title="PDF Merger", page_icon="📄", layout="wide")
st.title("📄 PDF Merger")
ioprovidesBytesIO, which lets pypdf read uploaded bytes as if they were a file.reis used later to clean the output filename.PdfReaderreads pages, andPdfWriterbuilds the merged file.st.set_page_configmust be the first Streamlit call. It sets the browser tab title, icon, and wide layout.
The script then shows a short description and a caption with page selection examples, so users know the syntax before they type.
Parsing page selections
The function parse_page_selection turns text like 2-4,7 into a list of zero-based page indexes. Zero-based indexing matches how pypdf stores pages, while users still type page numbers starting at 1.
Handling "all" and basic checks
def parse_page_selection(selection: str, total_pages: int) -> list[int]:
selection = (selection or "all").strip().lower()
if selection in {"all", "*"}:
return list(range(total_pages))
if not selection:
raise ValueError("Enter `all` or one or more page numbers/ranges.")
pages: list[int] = []
seen: set[int] = set()
Empty input defaults to all. The pages list keeps the requested order, and the seen set prevents duplicates.
Splitting the input into parts
for part in selection.split(","):
part = part.strip()
if not part:
raise ValueError("There is an empty item in the page selection.")
if "-" in part:
bits = part.split("-")
if len(bits) != 2 or not all(bit.strip().isdigit() for bit in bits):
raise ValueError(f"Invalid page range: `{part}`.")
start, end = (int(bit.strip()) for bit in bits)
if start < 1 or end < 1:
raise ValueError("Page numbers must be 1 or greater.")
if start > end:
raise ValueError(f"Range `{part}` must be in ascending order.")
requested = range(start, end + 1)
else:
if not part.isdigit():
raise ValueError(f"Invalid page number: `{part}`.")
requested = [int(part)]
Each comma-separated part is either a range (2-4) or a single page (7). Anything else, such as letters or 1-2-3, raises a clear error.
Range check and duplicates
for page in requested:
if page < 1 or page > total_pages:
raise ValueError(
f"Page {page} is out of range. This PDF has {total_pages} page(s)."
)
if page not in seen:
pages.append(page - 1)
seen.add(page)
if not pages:
raise ValueError("No pages were selected.")
return pages
Every page is checked against the PDF length. Valid pages are converted to zero-based indexes and added once.
| Input | Meaning | Result (zero-based) |
|---|---|---|
all | Every page | All pages in order |
1-3 | Pages 1 to 3 | [0, 1, 2] |
1,3,5 | Individual pages | [0, 2, 4] |
5,1,3 | Custom order | [4, 0, 2] |
2-4,7 | Range plus one page | [1, 2, 3, 6] |
Cleaning the output filename
def safe_filename(name: str) -> str:
name = (name or "").strip()
if not name:
name = "merged.pdf"
if not name.lower().endswith(".pdf"):
name += ".pdf"
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name)
cleaned = cleaned.strip("._")
if not cleaned:
cleaned = "merged.pdf"
if not cleaned.lower().endswith(".pdf"):
cleaned += ".pdf"
return cleaned
This function guarantees a safe download name:
- It falls back to
merged.pdfwhen the field is empty. - It adds the
.pdfextension if missing. - It replaces unusual characters with underscores, so
my report (v2)becomesmy_report_v2_.pdf. - It trims leading and trailing dots and underscores, then checks the extension once more.
Uploading and reading PDFs
Session state and the uploader
if "pdf_order" not in st.session_state:
st.session_state.pdf_order = []
uploads = st.file_uploader(
"Choose PDF files",
type=["pdf"],
accept_multiple_files=True,
)
if not uploads:
st.info("Upload one or more PDFs to get started.")
st.stop()
Because Streamlit re-runs the script on every click, the file order must live in st.session_state. Otherwise it would reset each time. st.stop() ends the run early until at least one file is uploaded.
Reading each file safely
current_items = []
for i, upload in enumerate(uploads):
key = f"{upload.name}::{upload.size}::{i}"
raw = upload.getvalue()
try:
reader = PdfReader(io.BytesIO(raw), strict=False)
if reader.is_encrypted:
try:
result = reader.decrypt("")
if not result:
st.error(f"`{upload.name}` is password-protected. ...")
continue
except Exception:
st.error(f"`{upload.name}` is password-protected. ...")
continue
page_count = len(reader.pages)
if page_count == 0:
st.warning(f"`{upload.name}` contains no pages and will be skipped.")
continue
current_items.append({
"key": key, "name": upload.name, "size": upload.size,
"bytes": raw, "pages": page_count,
})
except Exception as exc:
st.error(f"Could not read `{upload.name}`: {exc}")
- The
keycombines name, size, and position, so two files with the same name stay distinct. strict=Falselets pypdf open slightly damaged PDFs instead of failing.- Encrypted files are tested with an empty password. If that fails, the user sees a clear message and the file is skipped.
- Empty PDFs are skipped with a warning, and unreadable files show the error without crashing the app.
Valid files are stored as dictionaries holding the raw bytes and page count for later steps.
Reordering files
item_by_key = {item["key"]: item for item in current_items}
current_keys = [item["key"] for item in current_items]
old_order = [key for key in st.session_state.pdf_order if key in item_by_key]
new_keys = [key for key in current_keys if key not in old_order]
st.session_state.pdf_order = old_order + new_keys
This block syncs saved order with the current uploads. Files the user removed disappear, files that remain keep their position, and new uploads are added at the end.
for position, key in enumerate(st.session_state.pdf_order):
item = item_by_key[key]
left, middle, up_col, down_col = st.columns([5, 2, 1, 1])
left.markdown(f"**{position + 1}. {item['name']}**")
middle.caption(f"{item['pages']} page(s) · {item['size'] / 1024:.1f} KB")
if up_col.button("↑", key=f"up_{key}", disabled=(position == 0)):
order = st.session_state.pdf_order.copy()
order[position - 1], order[position] = order[position], order[position - 1]
st.session_state.pdf_order = order
st.rerun()
Each file gets a row with its name, page count, size, and two buttons. A button swaps the file with its neighbor, saves the new order, and calls st.rerun() so the list refreshes immediately. The up button is disabled for the first file, and the down button works the same way, disabled for the last file.
Choosing pages per file
page_selections: dict[str, str] = {}
for position, key in enumerate(st.session_state.pdf_order):
item = item_by_key[key]
with st.container(border=True):
st.markdown(f"**{position + 1}. {item['name']}**")
st.caption(f"Total pages: {item['pages']}")
page_selections[key] = st.text_input(
"Pages to include",
value="all",
key=f"pages_{key}",
placeholder="all, 1-3, 1,3,5, or 5,1,3",
)
Every file gets a bordered card with a text box that defaults to all. The text is stored by file key, so it stays attached to the right PDF even after reordering. Nothing is validated here. That happens when the user clicks Merge.
Merging and downloading
The output filename and Merge button
output_name_input = st.text_input("Output filename", value="merged.pdf")
output_name = safe_filename(output_name_input)
if st.button("Merge PDFs", type="primary", use_container_width=True):
Validate first
validated_selections = {}
errors = []
for key in st.session_state.pdf_order:
item = item_by_key[key]
try:
validated_selections[key] = parse_page_selection(
page_selections.get(key, "all"), item["pages"]
)
except ValueError as exc:
errors.append(f"{item['name']}: {exc}")
if errors:
st.error("Please correct these page-selection errors:\n\n" + "\n\n".join(errors))
All selections are checked before any output is created. If several files have mistakes, the user sees every error at once instead of fixing them one by one.
Build the merged PDF
else:
writer = PdfWriter()
total_added = 0
try:
for key in st.session_state.pdf_order:
item = item_by_key[key]
reader = PdfReader(io.BytesIO(item["bytes"]), strict=False)
if reader.is_encrypted:
result = reader.decrypt("")
if not result:
raise ValueError(f"{item['name']} is encrypted ...")
for page_index in validated_selections[key]:
writer.add_page(reader.pages[page_index])
total_added += 1
output = io.BytesIO()
writer.write(output)
output_bytes = output.getvalue()
The code loops through files in the chosen order and adds the selected pages one by one. The result is written to an in-memory buffer rather than a file on disk.
Show the result
st.success(
f"Successfully merged {total_added} page(s) from "
f"{len(st.session_state.pdf_order)} PDF file(s)."
)
st.download_button(
label=f"Download {output_name}",
data=output_bytes,
file_name=output_name,
mime="application/pdf",
use_container_width=True,
)
except Exception as exc:
st.error(f"Could not create the merged PDF: {exc}")
On success, the user sees a summary and a download button. Any unexpected failure is caught and shown as a readable error.
Ideas for improvement
- Password support: add a password field so encrypted PDFs can be unlocked.
- Page previews: render thumbnails so users can pick pages visually.
- Reverse and rotate options: pypdf can rotate pages with
page.rotate(90). - Bookmarks: add an outline entry for each source file with
writer.add_outline_item(). - Size limits: warn when total upload size is large, since all data stays in memory.
Conclusion
This app shows how a small amount of Python can deliver a practical tool. Streamlit handles the interface, session state keeps the order stable, and pypdf does the merging. Validating first, handling errors clearly, and sanitizing filenames keep it reliable. Copy the sections above into one file, run it, and adapt it to your own workflow.

Comments
Post a Comment