Signing a PDF Without Destroying It
Every free PDF signer rebuilds your contract into a new file. I wrote a PDF parser and an incremental-update writer so the original bytes survive, and then spent a day on an off-by-one that a helpful library had been hiding from me.
I built a PDF signer for floi last week. The interesting part is not the drawing pad. It is that the file you get back is the file you put in, with a few kilobytes stapled to the end.
That sounds like a small distinction. It is the whole product.
What every other signer does
I ran the tools ranking for "sign PDF" on the same 1,966 byte agreement. Here is what came back from iLovePDF:
| Bytes | Original bytes present | |
|---|---|---|
| In | 1,966 | — |
| Out | 45,410 | none |
Twenty three times the size, and not one byte of the input survives. It is not a bug and iLovePDF is not doing anything dishonest. It is what happens when you open a PDF with a library, add a thing, and serialise the result. The library rewrites every object in its own style, re-encodes the streams, rebuilds the cross reference table, and hands you a new document that happens to look the same.
For a meme that is fine. For a contract you are sending back to a counterparty, you have just returned a different file from the one you were sent. Any hash they took no longer matches. Any signature already on it is broken. Any structure their system expected may be gone.
The format has allowed the alternative since 1993 and almost nobody uses it.
Incremental updates
A PDF is a set of numbered objects, plus a table at the end saying which byte offset each object lives at, plus a trailer pointing at the table. The last line is startxref, an offset, and %%EOF.
The key property: a reader finds the table by reading from the end of the file backwards. So you can append. Write new objects after the existing %%EOF, write a new table that lists only the objects you added, and point its trailer at the previous table with /Prev. The reader walks the chain, and objects in the newest section shadow older ones with the same number.
[ original file, byte for byte ] <- untouched, ends at its own %%EOF
5 0 obj ... the signature image ...
6 0 obj ... the q bracket ...
7 0 obj ... the Q bracket and the draw instructions ...
3 0 obj ... a replacement page dict pointing at both ...
xref
5 1
0000001966 00000 n <- object 5 starts exactly where the original ended
... a subsection per run of object numbers ...
trailer
<< /Size 9 /Root 1 0 R /Prev 1742 >>
startxref
11560
%%EOF
Object 3 was the page. It still is, twice: the old one is still sitting in the original bytes, and the new table simply points somewhere else for that number. Nothing was deleted. This is how Acrobat saves a comment on a 200 MB document instantly.
That property is the feature. Measured across ten real PDFs from 1,966 bytes to 2.1 MB, versions 1.4 to 1.7:
| Original bytes intact as the file's prefix | 10 of 10 |
| Text still selectable | 10 of 10 |
| Bytes added | 4,256 to 4,668 |
A spread of 412 bytes across files that differ in size by a factor of a thousand. Signing does not scale with the document, because the document is not touched.
So I had to write a PDF parser
There is no library that does this in a browser at a weight I was willing to ship. pdf.js renders beautifully and cannot write. The writers all rebuild.
The reader turned out to be about 700 lines, and most of that is the parts of the spec you cannot skip:
Two kinds of cross reference. Older files have a plain text xref table. PDF 1.5 introduced cross reference streams, which are compressed binary. You have to read both, and you have to write back whichever kind the file already used, because a reader that only understands one will otherwise choke.
Object streams. In a 1.5+ file, most objects are not sitting in the file individually. They are packed inside a /Type /ObjStm, deflated. To find a page dictionary you inflate the container and parse a little offset header first.
Predictors. Cross reference streams almost always use /Predictor 12, PNG's "up" filter. If you inflate and read the bytes directly, you get plausible looking garbage: every row is a delta against the row above it. I lost an hour to offsets that were nearly right.
The one thing I did not do is decrypt. Encrypted files are refused with a sentence explaining why, rather than attacked:
readPdf(bytes) // -> { ok: false, reason: 'encrypted' }
Every failure reason maps to a sentence the interface actually shows. notpdf, noxref, broken, encrypted, nopages. A tool that silently does nothing is worse than one that says what went wrong.
The bug that a helpful library hid
This is the one worth the price of admission.
The new cross reference table contains byte offsets. They have to be exact. My first writer built the output by pushing chunks into an array and adding up their lengths by hand:
// The original, and it is wrong.
offset += open.length + spec.data.length + 19;
That 19 is my count of \nendstream\nendobj\n. Count it yourself. It is 18.
So every stream object I wrote pushed the recorded offset one byte further from the truth. With four stream objects, startxref pointed four bytes past the xref keyword.
Everything worked. The signed PDFs opened in Preview, in Chrome, in Acrobat. The text was selectable. The signature was in the right place. I shipped it to my own test harness and ran ten files through it and every one came back green.
The reason is that pdf.js, when the index at startxref does not parse, quietly falls back to scanning the entire file for N 0 obj patterns and rebuilding the table itself. It is a genuinely good piece of defensive engineering and it meant my corruption was invisible to the only reader I was testing with.
It surfaced the moment I asked my own parser to reopen the tool's output, which is what happens when you add a second signature to an already signed document. My reader is not defensive. It trusted the offset, landed four bytes into the middle of a keyword, and gave up.
The fix is not interesting. The lesson is:
/**
* Every byte pushed is also counted, in one place.
* Lengths are measured now, never counted.
*/
const push = (bytes) => {
parts.push(bytes);
offset += bytes.length;
};
Two things I took from it. First, never hand-count the length of a literal when the runtime will tell you. Second, and more useful: a forgiving consumer is not a test. If the only thing reading your output is a library built to survive broken files, you have not verified anything. The round trip through your own strict parser is the test.
That is the check I now run on every save: seek to whatever startxref claims, and assert the bytes there actually begin xref. On the file above it says 11560, and 11560 is where xref is.
Placing the signature: three corners beat trigonometry
The user drags a box around on a rendered page. I need a PDF transformation matrix.
The naive version is miserable. PDF's origin is bottom left, the canvas is top left, pages carry a /Rotate that can be 90, 180 or 270, and /CropBox can be offset from /MediaBox so the visible page does not start at zero. Handling those as cases is four code paths and a bug in each.
pdf.js already solved it. Its viewport knows the full mapping and exposes convertToPdfPoint. So instead of deriving the matrix, I convert three corners of the rectangle and read the matrix off them:
export function placementMatrix(viewport, rect) {
const bl = viewport.convertToPdfPoint(rect.x, rect.y + rect.h);
const br = viewport.convertToPdfPoint(rect.x + rect.w, rect.y + rect.h);
const tl = viewport.convertToPdfPoint(rect.x, rect.y);
return [br[0] - bl[0], br[1] - bl[1], tl[0] - bl[0], tl[1] - bl[1], bl[0], bl[1]];
}
Bottom-left is the translation. Bottom-left to bottom-right is the x basis vector. Bottom-left to top-left is the y basis vector. That is the definition of an affine matrix, and rotation and crop offsets fall out of it for free because they were already baked into the viewport. No special cases, six lines.
Wrapping someone else's content stream
To draw on a page you append to its content stream. The problem is that you are appending to a stream you did not write, which may leave the graphics state unbalanced: an extra q with no matching Q, a clip path still active, a transform still applied. Some real PDFs do this. Your signature then inherits it and lands somewhere absurd, or gets clipped away entirely.
A page's /Contents can be an array of streams, which are concatenated. So I bracket the original:
// Object A, before the page's own content:
'q\n'
// Object B, after it:
'Q\n' +
'q\n' + matrix + ' cm\n/FloiSig5 Do\nQ\n'
/Contents becomes [ A, ...original, B ]. The original streams are still referenced, byte for byte, in the middle. Whatever state they leave behind is popped by my Q, and then the signature draws inside its own q/Q pair from a known clean state.
The other trap in the same area is /Resources. It is inheritable, so a page dictionary may not have one at all and you have to walk up the /Parent chain to find it. Miss that and you attach an /XObject to a page whose fonts have just vanished, because you replaced an inherited resource dictionary with one containing only your image.
Making a mouse-drawn name look like a name
This is not PDF work but it is the difference between a signature and a scribble.
Draw with a mouse at constant stroke width and you get a wire bent into the shape of your name. Real pens deposit less ink when they move fast. So the width follows a smoothed pointer velocity:
const speed = dist / Math.max(1, p.t - pad.last.t);
const target = Math.max(0.9, 3.6 - speed * 2.6) * pad.dpr;
// Smoothed, because raw pointer velocity is noisy enough to make the line
// look chewed rather than tapered.
const w = pad.width + (target - pad.width) * 0.35;
Each segment is drawn as a quadrilateral from the previous half-width to the current one, not as a stroked line, so the taper is continuous. The 0.35 smoothing matters more than the formula. Raw dist/dt off a mouse is jittery enough that unsmoothed widths make the stroke look gnawed.
Paper removal is not a threshold
For uploaded photos of a signature on paper, the obvious approach is: pixel darker than X becomes ink, otherwise transparent. Do that and you get a staircase, because a pen stroke's edge is anti-aliased over two or three pixels and you have just quantised it to one bit.
Darkness should become opacity:
const lum = (data[o] * 0.299 + data[o + 1] * 0.587 + data[o + 2] * 0.114) / 255;
let a = (cut - lum) / soft + 0.5;
a = a <= 0 ? 0 : a >= 1 ? 1 : a;
out.data[o] = r; // flat ink colour
out.data[o + 1] = g;
out.data[o + 2] = b;
out.data[o + 3] = Math.round(a * 255 * (data[o + 3] / 255));
The soft = 0.18 band is what keeps the edge. Two side effects worth noting. The colour becomes whatever ink you picked rather than the greys of the photograph, so the RGB plane is constant and compresses to nearly nothing. And you can now change ink colour after the fact, because colour and shape live in different planes.
Two bugs that had nothing to do with PDFs
The signature pad locked the tab. The first pad redrew every past segment on every pointermove. That is O(n²): a hundred point stroke does about five thousand canvas operations, and a real signature is several hundred points. Fixed by drawing each new segment incrementally and doing a full repaint only on undo, clear, or an ink change.
And my favourite, because it is a JavaScript bug rather than a domain bug. Dragging a placed signature threw it to the bottom of the page on the first pixel of movement. Resizing slammed instantly to whichever size limit it was heading for. One line:
const start = { x: e.clientX, y: e.clientY, ...mark };
mark also has x and y. They are the mark's position as a fraction of the page, around 0.35. The spread comes last, so it silently overwrites the pointer's clientX. Every delta was then computed as clientX - 0.35: a pixel minus a fraction, which is most of the page wide.
The fix is to stop pretending two different x values can share an object:
const from = { px: e.clientX, py: e.clientY };
const start = { x: mark.x, y: mark.y, w: mark.w, h: mark.h };
A drag of 60px left and 120px up now moves the mark by exactly −18.0% and −27.8% of the page, which is what 60 and 120 pixels are. Object spread is a lovely feature and it will overwrite your keys without a word.
What it costs
The three ways of making a signature do not cost the same, and the order surprised me. Same name, same document, measured through the shipped tool:
| Made with | Ink pixels | Added to the PDF |
|---|---|---|
| Draw | 258 x 114 | 4.3 KB |
| Upload a photo | 900 x 166 | 9.6 KB |
| Type | 522 x 99 | 17 KB |
I had assumed the photograph would be worst, because photographs carry noise and noise does not compress. Wrong. Almost all of a signature's weight is the alpha mask carrying the shape of the strokes, and a mask compresses well when most of it is empty or flat. A drawn name leaves most of its box untouched. A handwriting typeface lays fine, evenly shaded strokes right across the whole box, which is the worst case there is.
Measuring that also paid for itself. The typed signature was rendering at 180px, which works out to 312 DPI against a 180pt placement, and cost 30 KB. Dropping to 120px is about 200 DPI, costs 17 KB, and is visually identical. I would not have found that by reasoning about it.
One more nice property: four signatures on two pages still add 17 KB, not 68 KB. The image is embedded once and drawn four times with different matrices.
The honest limit
It makes a picture of your name. That is an electronic signature, not a digital one, and the tool says so on screen while you use it.
A real digital signature needs a private key that belongs to you and a certificate from someone who vouches for you. No web page can hand you either. A signer that generated a key for you would be signing as itself.
There is a one command test, because a signature dictionary is forbidden by the spec from living in a compressed object stream, so the phrase is always sitting in the plain bytes:
strings contract.pdf | grep ByteRange
Output means a real cryptographic signature. Silence means a picture, however handwritten it looks.
Out of curiosity I ran it across every PDF in my downloads folder: 37 files, including bank statements, invoices and payment processor reports. Zero carried one. Neither did iLovePDF's output. The entire everyday PDF economy runs on pictures of names and trusting the channel the file arrived through.
Which is fine. Just worth knowing that the difference between the free tools is not cryptography. It is whether you get your document back, or a copy of it.
The tool is at floi.dev/sign-pdf. It runs entirely in the tab, so nothing is uploaded, which is also why the parser had to be written rather than borrowed.