The third time you build the same tool

DHSeaDev — Chrome Extensions, Windows Tools, & Idle Games

Over the last few weeks I have built four tools that cut one PDF into several. They were written for different people, at different times, and I did not plan them as a set. They turned out to be the same tool four times, which is a useful thing to notice about your own work.

Four splitters, two different problems

Two of these are not really the same job. It is worth separating them before anything else.

The first kind splits by position. A fixed cover page comes off the front and the rest becomes a second file. That is the splitter tab in PDF Tools: drop a file, pick one page or two, get {name}_Cover.pdf and {name}_Remaining.pdf in a Split subfolder, with the original left untouched. Nothing has to be read. The interesting parts are all safety: outputs go to a temp file and get moved into place atomically, encrypted inputs get a sentence instead of a stack trace, and document metadata is copied across explicitly, because the library does not carry it for you. The older merge tool had been silently stripping the author and creation date off every file it touched.

The second kind splits by content. A combined filing arrives as one PDF and has to come apart into the documents inside it, which means something has to read each page and decide what it is looking at. Three tools do that, for three different offices, and the browser one turns the rules themselves into settings: an MV3 side panel where the split-and-rename logic is a preset you can edit, running entirely on your machine with no network permission at all. It is packaged and tested; it is not on the Chrome Web Store.

The only hard part is deciding what a page is

THE SAME FIVE STAGES, EVERY CLIENTextract textpymupdfclassify pageheading plus bodygroup pagescontinuationsname filefile numberwrite splitnever overwritethe only stage that changes per clienteverything else is scaffold
Extraction, grouping, naming and writing are the same every time.

Extraction is a solved problem here, and for a boring reason: these documents are born digital. They come out of a word processor through a PDF printer, so they arrive with a full text layer and none of this needs OCR. PyMuPDF reads the text and writes the page ranges, and that is the entire dependency list. (Import it as pymupdf — the old fitz alias is deprecated.)

Classification is where the work is. The approach that survived three builds is to read only the first thirty-five non-blank lines of a page — enough to capture the court caption, the document title and the opening paragraph — and match a heading anchor against those. A full-page body search exists only as a fallback, for pages with no distinctive title. Heading first, body second, and the order of the rules matters more than the rules do.

Three traps, each of which cost real time

An Order talks about the Motion it grants. If you test for Motion before Order, every Order page that says Motion for Approval in its body gets filed as a Motion. The fix is not a better pattern, it is an ordering: check Order first, with an anchor that matches the heading line exactly rather than as a substring.

An attachment is not a document. Boilerplate stapled to the back of a form belongs to the form, not to a file of its own — and the specific attachment that taught me this contains the phrases serve any order, writ and Requests for appointment in its prose. A substring classifier reads it as an Order and as a Motion. Continuation detection has to run before classification, return nothing, and let the page attach to whatever came before it.

WHY PAGE COUNT IS NOT DOCUMENT COUNTONE COMBINED PDFp1heading: ALIAS REQUESTclassified: Aliasp2heading: REQUEST FOR APPOINTMENTclassified: Motionp3matches a continuation patternclassified: noneTWO DOCUMENTS OUT100234-Alias.pdf1 page100234-Motion.pdf2 pages – the attachment stapled onthe attachment page contains the words serve any order and Requests for appointment,so a substring match calls it an Order or a Motion. Continuation is checked FIRST.
Three pages in, two documents out. The third page is an attachment, not a filing.

The output collided with the input. The best bug of the set. The default output folder was the folder the file came from, so an input named {number}_ALIAS.pdf produced an ALIAS output at exactly its own path. The collision guard did precisely what it was written to do and refused to overwrite, so every single document reported SKIP and the tool did nothing at all. A safety feature working perfectly, rendering the program useless. Outputs now default to a subfolder, and pointing them back at the source folder is refused by name.

A rule I was given that turned out not to be the rule

Partway through the third build I was told that a particular jurisdiction normally has no Order in its packets. Written down literally, that becomes a branch: look at where the filing came from, then decide what documents to expect.

It was a deliberate test, and I am glad it was, because the shape was wrong even where the fact was right. Whether a packet contains an Order is a property of the packet. It is sitting there in the text, and reading it costs nothing. A classifier that branches on jurisdiction is confidently wrong the first time an office does something slightly unusual, and it fails silently, because it never looked. The rule that came out of it: classify from the document, never from what you know about its source.

The build machine is its own adversary

The tool worked. The build script could not find a file sitting in the same folder as itself, and said so. The cause is that these machines run off a network share, and cmd.exe cannot set a UNC path as its working directory — it falls back to C:\Windows without saying anything, so every relative path in the script resolves somewhere else entirely.

The replacement anchors everything to the script directory in PowerShell, stages the build into local app data when the source is a network path, and never activates the virtual environment — it calls the interpreter and pip inside it by full path, which makes the execution policy irrelevant. There is also a manual eight-step version for machines that refuse to run scripts at all, ending with the honest escape hatch: skip the executable, just run the Python file.

The frozen executable itself remains unverified from where I work. I have no Windows to test it on, and a build script you cannot run is a hypothesis. What is proven is the Python entry point, against real packets.

What got written down instead of built a fourth time

By the third one I was re-deriving the same five stages and re-discovering the same two ordering traps from scratch. So the fourth thing I made was not a splitter. It was the scaffold: the pipeline, the heading-line extractor, the file-number priority chain, the safety rules, and the two classifier-ordering mistakes recorded as regression cases so they cannot be made again quietly.

What is left per client is a short list of rules — a label, a heading test, a body test — and everything around it stopped being a decision. Two builds is a coincidence. Three is a shape, and a shape is worth writing down.

Each of these ships with a verification script rather than a promise: page-for-page text hashes against real filings, plus negative fixtures — the attachment on its own, the pages shuffled, a scan with no text layer — that have to be quarantined rather than guessed at. Quarantine is the point. A splitter that guesses is worse than one that stops.

More on the small-tools end of this: the fifteen-second problem · PDF Tools · all projects · the full devlog index.

Leave a Reply

Your email address will not be published. Required fields are marked *