DEV Community

shaojie gong
shaojie gong

Posted on

Splitting PDFs in the browser: a forgiving page-range parser, synced thumbnails and a ZIP that sorts correctly

I'm the developer of MakeQuickWork, a small set of free browser tools. This post walks through how its Split PDF tool works, with the parts that took longer than I expected.

"Split PDF" sounds like a one-liner: open the document, copy some pages, save. The copying really is the easy part. What took most of the time was everything around it: letting people describe pages the way they naturally write them, keeping a text field and clickable thumbnails in agreement, and producing output files that behave well once they land in someone's Downloads folder.

The tool runs entirely in the browser. The PDF is read into memory, processed in the tab, and saved with a normal download. Nothing is uploaded, which you can check in the DevTools Network tab while you use it: https://makequickwork.com/pdf/split-pdf/

Three modes, one engine

Users ask for three different things when they say "split":

  • Extract pages: "give me pages 2–3 as a new PDF". Click thumbnails or type 1-3, 5. Output is one PDF.
  • Every page separately: one PDF per page, delivered as a ZIP.
  • Custom ranges: 1-3, 4-6, 7- gives one PDF per range, also in a ZIP.

Under the hood all three reduce to a list of [from, to] ranges. "Every page" is just [1,1], [2,2], ..., and extract mode is a single output built from the expanded page list. So the core is a range parser and a "copy these pages into a new document" function.

There's also no dedicated "delete pages" button. Removing pages is select all, deselect the ones you don't want, extract. I went back and forth on adding one, but it would be the same code path with an inverted selection, and one fewer mode to explain felt better.

A range parser that explains itself

People type page ranges in many ways: 1-3,5, 1 - 3; 5, 8- for "to the end", -3 for "from the start". Some will paste an en dash from a Word document. And when the input is wrong, a red border tells you nothing, so every failure gets a sentence telling you how to fix it.

Here is the parser, lightly trimmed:

export type Range = [number, number];
export class RangeError extends Error {}

/** Parses "1-3, 5, 8-" into ranges. `-3` means 1–3, `8-` means 8 to the end. */
export function parseRanges(text: string, pageCount: number): Range[] {
  // "2 - 6" means 2-6, so join the dash to its numbers before splitting on spaces.
  const parts = text
    .replace(/\s*([-–])\s*/g, "$1")
    .split(/[,;\s]+/)
    .map((p) => p.trim())
    .filter(Boolean);
  if (!parts.length) throw new RangeError("Enter the pages you want, for example 1-3, 5.");
  return parts.map((part) => {
    const m = /^(\d*)\s*[-–]\s*(\d*)$/.exec(part) ?? /^(\d+)$/.exec(part);
    if (!m) throw new RangeError(`"${part}" is not a page or range. Use numbers like 4 or 2-6.`);
    const from = m[1] ? Number(m[1]) : 1;
    const to = m.length > 2 ? (m[2] ? Number(m[2]) : pageCount) : from;
    if (from < 1 || to < 1) throw new RangeError("Page numbers start at 1.");
    if (from > pageCount || to > pageCount)
      throw new RangeError(`This PDF has ${pageCount} page${pageCount === 1 ? "" : "s"}.`);
    if (from > to) throw new RangeError(`"${part}" goes backwards. Write it as ${to}-${from}.`);
    return [from, to];
  });
}
Enter fullscreen mode Exit fullscreen mode

A few decisions worth calling out:

  • Separators are generous. Commas, semicolons and whitespace all separate parts, and both - and – work as the range dash. Because whitespace is also a separator, an early version split 2 - 6 into three tokens. Now the input is normalized first with .replace(/\s*([-–])\s*/g, "$1"), which pulls the dash onto its numbers, so 2 - 6 works exactly like 2-6.
  • The error names the bad token. "6-3" goes backwards. Write it as 3-6. is more useful than "invalid range", and it costs one template string.
  • The page count is part of the error. If you type 12 on a 10-page file, you get This PDF has 10 pages. That answers the next question before you ask it.
  • Open-ended ranges resolve immediately. 7- becomes [7, pageCount] at parse time, so the rest of the code never deals with "infinity".

Keeping thumbnails and the text field in sync

In extract mode there are two inputs for the same state: you can click page thumbnails or type in the "Pages to extract" field. Both have to agree at all times.

The source of truth is a Set<number> of selected pages. The two directions work differently:

  • Thumbnail click → text. Toggle the page in the set, then rewrite the text field from the set with a small formatter that collapses runs: {1,2,3,5} becomes 1-3, 5.
  • Text → thumbnails. Parse on every input event and update the set, but do not rewrite the text field. If you reformat while someone is typing 1-, the cursor jumps and their half-finished range disappears. So typing only flows one way until the next click.

If the text doesn't parse, the selection clears, the error sentence appears under the field, and the button is disabled. The button label follows the set too ("Extract 2 pages"), which turned out to be the clearest confirmation that the parser understood you.

Copying pages without touching them

For the actual work I use a maintained fork of pdf-lib. Copying pages from one document into a new one keeps their content streams, fonts and images as they are, so nothing is re-rendered or re-compressed. Text stays selectable and vector graphics stay vector. Each page also keeps its own size.

PDF.js (Mozilla) is used separately, only to draw the thumbnails. Both libraries are loaded lazily after you pick a file, so the page itself stays light.

The main known limit: password-protected PDFs aren't supported yet. One more behavior worth knowing: in extract mode the pages are written in page order, so typing 5, 2 gives you page 2 then page 5.

A ZIP that sorts correctly

The two multi-file modes return a ZIP. Two small details made a visible difference.

Zero-padded names. If a 120-page file is split per page and named page-1.pdf ... page-120.pdf, most file managers sort page-10 before page-2. Padding to the width of the page count fixes that:

const width = String(pageCount).length;      // 120 → 3
const pad = (n: number) => String(n).padStart(width, "0");
// report-page-001.pdf, report-page-002.pdf, ..., report-pages-004-006.pdf
Enter fullscreen mode Exit fullscreen mode

If someone types the same range twice, the second file gets a -2 suffix instead of silently overwriting the first.

No compression. The ZIP is built with fflate's zipSync(files, { level: 0 }). PDFs are already compressed internally, so deflating them again burns CPU for a few percent at most. Store-only zipping is close to a memory copy, which matters on phones.

How I check that nothing is uploaded

"Runs in your browser" is easy to claim, so there is an automated test that loads each tool, processes a file, and fails if the page makes any POST or any request to a third-party host other than Google Analytics during processing (the site uses GA4 for anonymous visit counts, and the test also fails if an analytics event contains the file name). The site is static (Astro on Cloudflare Pages), so there is no upload endpoint to call in the first place. You do need a connection to open the page and load the tool code.

Wrapping up

The PDF part was maybe a fifth of the work. The rest was input handling, state sync and file naming, which is the part users actually touch. If you have opinions on range syntax (should 5- on a 5-page file be allowed? it is today) or ideas for edge cases I missed, I'd like to hear them in the comments.

Try it here: https://makequickwork.com/pdf/split-pdf/

Top comments (0)