I Ran a CNN Inside a PDF
a real handwritten-digit classifier, trained in PyTorch and running its convolutions inside the document you draw on
I put a trained convolutional neural network in a PDF and asked Acrobat Reader to classify a digit drawn on the page.
The file is about 546 KB. It contains 26,698 learned parameters, two convolution layers, and the JavaScript that runs the forward pass. PyTorch helped train the network and produce the document; once the PDF is open, prediction has no Python process, browser, API, or network connection to lean on.
There is a fairly obvious question here: why? Because a PDF looks like the last place a CNN should work, yet it has just enough programming and interaction to make the attempt interesting. The arithmetic turned out to be the straightforward part. Getting a human to draw a digit on the page was where the document started arguing back.
Why a PDF?
First, the page needs an input. MNIST images are 28 × 28 grayscale arrays, so my first interface was a 28 × 28 grid of checkboxes. That gave me 784 addressable values and a terrible drawing tool: dragging across it ticked little boxes one at a time. I tried giving the checkboxes square appearances. Acrobat still drew checkmarks in the live form. Technically correct pixels; emotionally, a spreadsheet with a pen problem.
Acrobat already has a freehand Ink annotation tool. An ink stroke is a sequence of page coordinates, which document JavaScript can read. I used the annotation as the drawing surface and converted its line segments into the 784 numbers the model expects. The PDF does not need to pretend that a grid of form widgets is a canvas.
0 active pixels / 784
Illustrates input conversion. The downloadable PDF runs the actual CNN.
How does a stroke become an MNIST image? I sample points along each line segment, paint a small soft disk around each point, find the ink's bounding box, and scale its longer side to 18 pixels inside a centered 28 × 28 image. The model sees values between zero and one, with white ink on a black background.
The 18-pixel span leaves a margin like the one in MNIST. It also means a tiny handwritten 7 and a large one can produce similar inputs. It cannot fix a badly shaped 7. This preprocessing is part of the classifier, and it deserves as much scrutiny as the network: a perfectly exported model will still misread an image that was rasterized differently from its training data.
What the network computes
The PDF stores weights; it does not store answers. I trained the model in PyTorch, exported its kernels and biases as flattened numeric arrays, and wrote the forward pass in Acrobat-compatible JavaScript. Every prediction starts with the current 784 pixels and performs the convolutions again.
The shape changes tell the story. The first 3 × 3 convolution maps one 28 × 28 image to eight 28 × 28 feature maps. ReLU removes negative responses; 2 × 2 max pooling takes them to eight 14 × 14 maps. A second convolution mixes those eight channels into sixteen 14 × 14 maps, and another pool makes sixteen 7 × 7 maps. Flattening gives 784 values. Dense layers map 784 → 32 → 10, then a stable softmax turns the ten scores into probabilities.
A convolution is a tiny dot product repeated across the image. At each location, one 3 × 3 kernel reads nine neighboring pixels, multiplies by nine learned weights, adds a bias, and writes one number. The second layer repeats that sum across eight incoming channels. Think of a stencil moving over paper: the stencil stays the same, while the patch underneath changes. The interactive figure below uses a simple edge filter so you can inspect one such sum; the trained PDF uses its own learned filters.
This figure uses a simple edge filter to make the arithmetic visible; these are not trained model weights.
Flattening the weights saved space, but it made one indexing error capable of ruining the whole model. The convolution kernel has to be read in output-channel, input-channel, row, column order. In the PDF code, the index for a 3 × 3 weight is:
wi = ((o * C + c) * 3 + ky) * 3 + kx;That line is small enough to overlook and important enough to test. I compared intermediate activations, tensor shapes, logits, and predictions against PyTorch on 100 MNIST test images. The JavaScript agreed on all 100 classes; its largest absolute final-logit difference was 9.94 × 10⁻⁶. Exporting to seven decimal places was enough to keep the numerical path close on that sample.
Does it really run?
Two checks answer two different questions. The model got 9,773 of 10,000 untouched MNIST test images right: 97.73% accuracy. That measures the trained classifier on MNIST. The 100-image PyTorch/JavaScript comparison measures whether my exported implementation computes the same function. It does not measure handwritten digits drawn with a mouse.
I also opened an autorun test PDF in desktop Acrobat. Its embedded example was a 7, and Acrobat's document JavaScript produced 7 at 99.99% confidence and filled the probability fields. That verifies an Acrobat execution path, beyond inspecting the PDF bytes. I have not completed a live drag-and-predict test of the newer Ink interface: the desktop controller could open the document but could not draw on Acrobat's page canvas. The downloadable PDF is the build to try; the interaction remains an explicit verification gap.
What remains unmeasured
This experiment targets desktop Acrobat Reader with document JavaScript enabled. Browser PDF viewers and Preview can display the page while ignoring the inference code. Finish an ink stroke before pressing Predict, because Acrobat owns the drawing tool during the stroke.
I have not measured accuracy on freehand digits or collected enough Acrobat timing samples for median and p95 latency. A Node.js timing number would say something about the loops, but not about how this PDF feels in Acrobat. The more useful next measurement is the mundane one: let people draw several digits, record where the rasterizer fails, and see whether changing the input pipeline helps more than retraining the network.
The network was an array-processing problem. The human interface was a PDF problem. I expected those difficulties to arrive in the opposite order.