A file name does not name a file
truecopy 2.0.15 gives every document it opens the SHA-256 of its bytes. Why a reading kept for years needs it, and why it is taken before pdf.js touches the file.
A reading outlives the file it came from. A row read off a statement, a figure a model cited from an annual report, an anchor that says page 4 prints 1 234: each is worth something later only if somebody can go back to the page and look. Until 2.0.15, the only thing a truecopy document said about its source was the name it had been given.
A name is a label on the outside
statement-2026-09.pdf is what the issuer called the file, or what a download folder called it, or download (1).pdf when it was fetched twice. Both directions go wrong. An issuer corrects a document and publishes it again at the same address under the same name, so one name ends up meaning two files with different figures. And one file reaches you under two names, so a check that compares names sees two documents where there is one.
A reading that points at a name points at whichever file carries that name today. Nothing in the reading can tell that the file changed underneath it.
2.0.15 names the bytes
openDocument now sets document.digest: the SHA-256 of the exact bytes it was given, in lowercase hex. The two PDFs below were built for this post and differ by one printed figure, 1234 in the first and 1243 in the second. Played against truecopy 2.0.15:
import { openDocument } from 'truecopy';
const a = await openDocument(new File([september], 'statement-2026-09.pdf'));
const b = await openDocument(new File([september], 'download (1).pdf'));
const c = await openDocument(new File([corrected], 'statement-2026-09.pdf'));
a.digest === b.digest; // true: the same bytes under two names
a.digest === c.digest; // false: the same name, one figure apart
Keep the digest beside the reading, and keep the file under its digest, and the reading points at one set of bytes for as long as the archive lasts. Web Crypto computes it, in a browser as in Node, so the library gained no dependency for it.
A document built from a string with documentFromText carries no digest at all. There is no file behind a paste, and a hash of the text would claim one.
Why it is taken before the engine sees the file
This is the part that a test had to pin down, because getting it wrong looks exactly like getting it right.
pdf.js takes the buffer it is handed. Measured with the build truecopy loads by default, in Node: a 580-byte PDF is read into an ArrayBuffer, pdf.js opens it, and the same buffer then reads 0 bytes. A digest taken at that point is the SHA-256 of nothing:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
It is 64 hex characters, like any other digest. It would be the same for every PDF ever opened, so every reading would claim the same source, and no check would object, because there is nothing malformed about it. A field that always holds a plausible value is the hardest kind of wrong to notice.
So openDocument hashes the bytes first and hands them to the engine after. The test that holds it opens one PDF under two names and compares both digests with a hash of the bytes taken outside the library. A digest of the empty buffer would fail it on the first line.
What it does not do
It does not say a file is genuine. It says these are the bytes the reading was made on, not that the issuer wrote them.
It moves no reading. Rows, cells and findings are what they were in 2.0.14, so an application that does not read digest sees no change, and there is nothing for a consumer to re-measure.
The reference is on the open page, the release notes are on the versions page, and the change itself is pull request #101.