<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[OneFileKit]]></title><description><![CDATA[Practical notes on working with PDFs, images and files, from the builder of OneFileKit (onefilekit.com).]]></description><link>https://onefilekit.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:06:22 GMT</lastBuildDate><atom:link href="https://onefilekit.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Splitting PDFs in Python with pypdf: Ranges, Single Pages and Bookmarks]]></title><description><![CDATA[Practical pypdf recipes for splitting a PDF into single pages, custom page ranges, fixed-size chunks and one file per bookmark, with runnable Python code.]]></description><link>https://onefilekit.hashnode.dev/splitting-pdfs-in-python-with-pypdf-ranges-single-pages-and-bookmarks</link><guid isPermaLink="true">https://onefilekit.hashnode.dev/splitting-pdfs-in-python-with-pypdf-ranges-single-pages-and-bookmarks</guid><category><![CDATA[Python]]></category><category><![CDATA[pdf]]></category><category><![CDATA[automation]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[OneFileKit]]></dc:creator><pubDate>Thu, 05 Nov 2026 06:18:31 GMT</pubDate><content:encoded><![CDATA[<p>Splitting PDFs is one of those jobs that sounds trivial until you get the actual request. It's rarely "split every page". It's "pages 1 to 4 go to legal, 5 to 12 go to finance, and the appendix is its own file", or "cut this 600-page export into one file per chapter". I build file tools, so I've written versions of this script more times than I'd like to admit. These are the pypdf recipes I keep coming back to.</p>
<h2>Setup</h2>
<p><a href="https://pypi.org/project/pypdf/">pypdf</a> is pure Python with no system dependencies, so it installs cleanly anywhere:</p>
<pre><code class="language-bash">pip install pypdf
</code></pre>
<p>Every recipe below uses the same two classes. <code>PdfReader</code> opens the source and <code>PdfWriter</code> builds each output file. Pages are copied structurally, not re-rendered, so text stays selectable and image quality doesn't change.</p>
<p>One thing to keep in mind is that pypdf indexes pages from zero, while people count from one. I keep everything 1-based at the edges (arguments, filenames) and convert in one place, because mixing the two is where off-by-one bugs come from.</p>
<h2>Recipe 1: one file per page</h2>
<pre><code class="language-python">from pathlib import Path
from pypdf import PdfReader, PdfWriter

def split_every_page(src: str, out_dir: str = "pages") -&gt; None:
    reader = PdfReader(src)
    out = Path(out_dir)
    out.mkdir(exist_ok=True)
    width = len(str(len(reader.pages)))
    for i, page in enumerate(reader.pages, start=1):
        writer = PdfWriter()
        writer.add_page(page)
        with open(out / f"page-{i:0{width}d}.pdf", "wb") as f:
            writer.write(f)

split_every_page("input.pdf")
</code></pre>
<p>The <code>width</code> trick zero-pads filenames (<code>page-007.pdf</code> rather than <code>page-7.pdf</code>) so they sort correctly in a file manager and in <code>ls</code>. It's a small detail, but whoever receives a folder of 300 pages will notice it.</p>
<h2>Recipe 2: custom ranges</h2>
<p>This is the request I get most often. I let people write ranges the way they'd type them in a print dialog, such as <code>1-4,5-12,13-end</code>, and turn each comma-separated piece into its own file.</p>
<pre><code class="language-python">from pypdf import PdfReader, PdfWriter

def parse_ranges(spec: str, total: int) -&gt; list[tuple[int, int]]:
    ranges = []
    for part in spec.split(","):
        part = part.strip()
        if "-" in part:
            start, end = part.split("-", 1)
            start = int(start)
            end = total if end.strip().lower() == "end" else int(end)
        else:
            start = end = int(part)
        if not (1 &lt;= start &lt;= end &lt;= total):
            raise ValueError(f"Bad range {part!r} for a {total}-page file")
        ranges.append((start, end))
    return ranges

def split_ranges(src: str, spec: str) -&gt; None:
    reader = PdfReader(src)
    for start, end in parse_ranges(spec, len(reader.pages)):
        writer = PdfWriter()
        for n in range(start - 1, end):  # convert to 0-based here only
            writer.add_page(reader.pages[n])
        with open(f"pages-{start}-{end}.pdf", "wb") as f:
            writer.write(f)

split_ranges("input.pdf", "1-4,5-12,13-end")
</code></pre>
<p>The validation matters more than it looks. Without it, a typo like <code>12-5</code> quietly produces an empty file, and someone emails a blank PDF to their accountant.</p>
<h2>Recipe 3: fixed-size chunks</h2>
<p>Upload portals often cap file size or page count. Splitting into chunks of N pages is a quick way under the limit:</p>
<pre><code class="language-python">from pypdf import PdfReader, PdfWriter

def split_chunks(src: str, size: int = 10) -&gt; None:
    reader = PdfReader(src)
    total = len(reader.pages)
    for start in range(0, total, size):
        writer = PdfWriter()
        for n in range(start, min(start + size, total)):
            writer.add_page(reader.pages[n])
        with open(f"part-{start // size + 1}.pdf", "wb") as f:
            writer.write(f)

split_chunks("input.pdf", size=10)
</code></pre>
<p>If the limit is in megabytes rather than pages, chunking by page count is only an approximation. A single scanned page can be bigger than fifty pages of text. In that case I compress first and split second.</p>
<h2>Recipe 4: one file per top-level bookmark</h2>
<p>This is the most useful recipe and the least well known. Many long PDFs (reports, manuals, books exported from Word) have an outline, the bookmark panel in a PDF viewer. pypdf exposes it as <code>reader.outline</code>, a list where nested lists hold child bookmarks. Top-level entries are usually chapters.</p>
<pre><code class="language-python">import re
from pypdf import PdfReader, PdfWriter

def split_by_bookmarks(src: str) -&gt; None:
    reader = PdfReader(src)
    total = len(reader.pages)
    # Top-level items only; nested lists are sub-bookmarks.
    marks = [
        (item.title, reader.get_destination_page_number(item))
        for item in reader.outline
        if not isinstance(item, list)
    ]
    if not marks:
        raise ValueError("This PDF has no bookmarks")
    marks.sort(key=lambda m: m[1])
    for idx, (title, start) in enumerate(marks):
        end = marks[idx + 1][1] if idx + 1 &lt; len(marks) else total
        if end &lt;= start:
            continue  # two bookmarks pointing at the same page
        writer = PdfWriter()
        for n in range(start, end):
            writer.add_page(reader.pages[n])
        safe = re.sub(r"[^\w\- ]", "", title).strip() or f"section-{idx + 1}"
        with open(f"{idx + 1:02d} {safe}.pdf", "wb") as f:
            writer.write(f)

split_by_bookmarks("manual.pdf")
</code></pre>
<p>A few things I learned the hard way:</p>
<ul>
<li><strong>Bookmarks aren't always in page order.</strong> Sorting by start page avoids negative ranges.</li>
<li><strong>Two bookmarks can point to the same page.</strong> The <code>end &lt;= start</code> check skips the empty section instead of writing a zero-page file.</li>
<li><strong>Titles contain characters filenames can't.</strong> Slashes, colons and question marks show up constantly in chapter names, so they get stripped.</li>
<li><strong>Pages before the first bookmark are dropped.</strong> If the cover and table of contents matter, add a <code>("Front matter", 0)</code> entry when the first bookmark doesn't start at page 0.</li>
</ul>
<h2>Encrypted files</h2>
<p>If a PDF has an open password, pypdf can't read its pages until you decrypt it:</p>
<pre><code class="language-python">reader = PdfReader("locked.pdf")
if reader.is_encrypted:
    reader.decrypt("the-password")
</code></pre>
<p>For AES-encrypted files, pypdf needs a crypto backend: <code>pip install pypdf[crypto]</code>. Files with only an owner password (the kind that restricts printing or editing) usually open with <code>decrypt("")</code>. Whether you should split them is a question for whoever owns the document.</p>
<h2>Keeping output files small</h2>
<p>When you copy pages into a new writer, pypdf brings along the resources each page references, such as fonts and images. If several pages share a large image, each split file gets its own copy, which is correct because every file has to stand alone. But it means the total size of the parts can be well above the original. That's expected, not a bug.</p>
<p>If an individual part is still too big, run <code>writer.compress_identical_objects()</code> before writing (available in recent pypdf versions), or pass the output through a dedicated compression step.</p>
<h2>Making it a command-line tool</h2>
<p>Once I had these functions, I wrapped them in a small CLI so nobody on the team had to edit Python to change a range. <code>argparse</code> from the standard library is enough:</p>
<pre><code class="language-python">import argparse

parser = argparse.ArgumentParser(description="Split a PDF")
parser.add_argument("src")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--ranges", help="e.g. 1-4,5-12,13-end")
group.add_argument("--chunk", type=int, help="pages per file")
group.add_argument("--bookmarks", action="store_true")
args = parser.parse_args()

if args.ranges:
    split_ranges(args.src, args.ranges)
elif args.chunk:
    split_chunks(args.src, args.chunk)
else:
    split_by_bookmarks(args.src)
</code></pre>
<p>Put it in the same file as the functions above, and <code>python split.py report.pdf --ranges 1-4,13-end</code> does what you'd expect. The mutually exclusive group means argparse rejects ambiguous calls with a clear error, so the script doesn't have to guess which mode you meant. In my experience that matters more than any extra feature: the people who use a tool like this run it once a month and don't remember the details, so a clear refusal beats a silent wrong guess every time.</p>
<h2>When a script is overkill</h2>
<p>All of this is worth it when splitting is part of a pipeline: monthly statements, generated reports, batch jobs on a server. For a one-off ("I just need pages 3 to 7 of this contract"), writing Python is slower than just doing it. I wrote a longer walkthrough on <a href="https://onefilekit.com/blog/split-pdf-python">splitting PDFs with Python</a> on the OneFileKit blog, which also covers the no-code options for people who'd rather not open a terminal.</p>
<h2>Wrapping up</h2>
<ul>
<li><code>PdfReader</code> plus a fresh <code>PdfWriter</code> per output file covers almost every split.</li>
<li>Keep page numbers 1-based for humans and convert to 0-based in exactly one place.</li>
<li>Validate ranges, because silent empty files are worse than errors.</li>
<li><code>reader.outline</code> with <code>get_destination_page_number()</code> gets you per-chapter splits for free.</li>
</ul>
<p>If you've got a weirder splitting requirement than "one file per bookmark", tell me in the comments. I collect them.</p>
]]></content:encoded></item></channel></rss>