I wanted my invoice folder to say 2026-03-14_Acme-Hosting_INV-1042_49.00USD.pdf instead of download (3).pdf. Every tool I found for this wanted me to upload the documents. Invoices contain client names, addresses and bank details, so I built a Mac app that reads them on the device instead. It is called StampName, and this post explains how the reading works, what I tried that I did not ship, and where it still fails.
The shape of the problem
A rename needs four values from a document: the date, the vendor, an invoice number, and the amount. The documents arrive in two forms.
- PDFs with a text layer, where the characters are already there.
- Scans and photos, where there is only an image.
Everything after getting text is the same pipeline. The interesting work is turning a page of text into four trustworthy values.
Step 1: text from the PDF layer
For PDFs with a text layer I use PDFKit. Each page gives me its string, and a text selection gives me the words with their bounding boxes. The boxes matter later: the app keeps, for every value it chooses, the page and box it came from, so the user can see where a number was read.
One PDFKit detail cost me a day. Reading attributed text from a selection can raise an Objective-C exception when a font is missing. Swift cannot catch that, and it crashes the app. The fix is a small guard written in Objective-C that serializes the call and catches the exception, and every read goes through it. If you read attributed strings from PDFSelection at scale, expect to need the same.
Step 2: scans with Apple Vision
When a page has no usable text layer, I render it to an image and run Apple's Vision framework on the device. On macOS 26 the request is RecognizeDocumentsRequest, which returns text with positions and a confidence per line. I turn that into the same word-and-box structure the PDF path produces, so nothing downstream knows which path it came from.
Two settings made a visible difference on receipts. Language detection is automatic by default, so a French invoice does not need configuration. And the minimum text height: Vision's default silently drops small print, which on a till slip is half the content. Lowering the minimum text height to a small fraction of the image height keeps it.
Nothing is sent anywhere. The recognition runs through the operating system, and the app has no network code on its document path.
Step 3: candidates, not answers
The extraction stage does not decide anything. It finds every plausible candidate:
- Dates, in many formats, with the label next to them (Invoice date, Due date, Issued).
- Amounts, with currency and the label (Total, Amount due, Subtotal, Tax).
- Invoice numbers, from label patterns and typical shapes.
- Vendor names, from a bundled catalog of about 500 US and UK brands (matched on names, domains, email addresses and the page header), with a layout fallback for the top of the page when no brand matches.
Words that are also ordinary words are a trap. A company called Shell or Next appears in sentences that have nothing to do with the vendor, so such names count only when they occupy a whole line. Each one of these has a test.
Each candidate keeps its provenance: page, box, and the label context that scored it.
Step 4: a rule cascade picks one per field
A cascade of rules chooses among the candidates. It prefers the amount labelled as the total over a subtotal, the issue date over the due date, and a vendor that agrees with both the header and the email domain. It is ordinary code with ordinary tests. Given the same file and the same settings it returns the same name, which I treat as a requirement: no reading of the current locale or time zone anywhere in parsing, UTC calendars, decimal arithmetic for money, and stable sorting.
The hardest field is the date. 03/04/2026 is March 4 in the US and April 3 elsewhere. The rules first look for evidence on the page: another date on the page that can only be read one way, dates that agree with each other, a dollar or pound sign. If the page does not settle it, the stored preference (seeded from the Mac's region, changeable in settings) decides, and the row is always flagged.
Why there is no language model
I did try one. I built a small on-device CoreML model that chose between candidates, and an evaluation harness to compare configurations. The rules alone scored as good or better on every evaluation set I had, so the model stays out of the shipping app. The model code is still in the repository for experiments. A deterministic result is also easier to explain to a user who asks why a name came out the way it did: the app can point to the box.
That conclusion has a limit. My evaluation data is synthetic, because I will not use real invoices as test fixtures. It is generated in code in several families: generic documents, layouts that imitate the structure of large billers, US and UK English locales, and point-of-sale receipts. They contain the traps I know about. They do not contain the ones I do not know about, which is why the next section matters.
Step 5: confidence and amber rows
Every value carries a confidence. A row's confidence is limited by its weakest value. Rows where a value is a fallback or disagrees with competing candidates are amber, and amber rows are never applied automatically. Review mode shows the page with each value outlined in its own colour, and clicking a value lists the other candidates found so the user can swap in one click.
The app never invents a value. If it cannot find an invoice number, the token is dropped from the name without leaving a stray separator, or the row is flagged. The point is that a wrong confident name is worse than a visible gap.
Step 6: renaming without losing files
Renaming is the part where a bug costs someone a file, so it is deliberately boring.
- Before any rename, the app writes a record to a local SQLite journal: batch id, time, original path, new path, the file's inode and a SHA-256 hash.
- Names that collide in a batch are disambiguated with the document ID or a numeric suffix. Nothing is overwritten.
- Undo verifies that the file at the new path is still the same file (inode and hash) before moving it back. If the user edited or replaced it, undo refuses rather than guessing.
- Undo is free and works after a restart.
What still fails
- Invoices whose total is an image, or laid out as a table the line-by-line reader splits apart.
- Unusual vendors that are not in the catalog and have no clear header. They fall back to layout heuristics and show up amber.
- Dates in formats the page itself does not disambiguate.
- Heavily skewed photos. There is an orientation check, but a phone photo of a crumpled receipt is still the weakest case.
These are listed in the project's evaluation documents with the number of misses per layout, which is how I decide what to work on next.
The product, briefly
StampName is a paid macOS app (macOS 26 or later, Apple silicon): Pro Lifetime $39.99 or Yearly $19.99, 14-day refund. It ships with built-in sample invoices from fictional vendors so you can see Review mode and undo work before you pay. There is no account, no analytics and no AI service.
If you try it, the most useful thing you can send me is the shape of an invoice it gets wrong. Please describe it or redact it; do not send the real document. https://stampname.app, support@stampname.app
Top comments (0)