A PDF that draws Pictures
Type a word, press a button drawn on the page, and a denoising diffusion model runs in the PDF viewer's own JavaScript engine. No server, no plugin, nothing downloaded.
ts is amazing
The last two weeks i've been messing around with pdfs. I made slop pdf, a pdf that generates random ai slop stories when you open it, and I was showing that off to one of my friends who suggested I get the LLM to actually work inside the pdf, without an API call to a relay.
I laughed at the idea, but then we did some research together, and we managed to find EvanZhouDev's llm.pdf. Because it existed, I didn't want to waste my time or my tokens building something like it. So I pivoted, eventually deciding to make a diffusion image model work fully inside a pdf.
I eventually managed to get it working (with the help of claude) and here's the finished version.
proof of conceptness warning
This model only has 131 Known Objects. If you don't use one of these objects, it will try to map your input to a known object, terribly.
Requirements
- Chrome, or anything else built on PDFium. That is the viewer whose JavaScript implementation this targets.
- Patience for a 75 MB download, and a little RAM.
- No network connection. Genuinely — once the file is on your disk it never talks to anything.
Two builds exist. The character build paints the drawing as text and is the one that works end to end today; the colour-grid build paints 784 button backgrounds and is blocked on a viewer bug (§12).
A PDF can run code
This is the part most people do not know, and everything else rests on it.
PDF is not only a page-description format. Since Acrobat 3 it has carried a scripting layer, and viewers implement enough of it to be interesting: a document can declare JavaScript that runs when a page opens or when a widget is clicked.
Two mechanisms matter here. The page-open action: a
page object can carry an /AA ("additional actions")
dictionary, and its /O entry names an action to run when
the page is displayed. Ours is a /JavaScript action whose
/JS value is the entire program.
2 0 obj
<< /Type /Page
/AA << /O << /S /JavaScript /JS ( ...75 million characters... ) >> >>
/Annots [ ... 805 widgets ... ] >>
Widget actions: form fields are annotations. Each
has its own /AA, whose /U entry fires on
mouse-up. Ours contain one expression each, e.g.
dsBegin(). Those functions were defined by the page-open
script and are still in scope, because a document shares one
JavaScript context for its whole session. The page-open action defines
the program; the buttons call into it.
The weights ride along inside that same /JS string as
base64. There is no separate resource, no attachment, no stream —
one enormous PDF literal string.
builds/pythia-llm.pdf in the repository is 51 MB of
exactly this, so the approach is known to scale.
Note
This trick is inherited from llm.pdf, which this repository is a fork of, and before that from ading2210's DoomPDF and LinuxPDF. What is new here is what the program does.
What the program is
A denoising diffusion model: the same family as Stable Diffusion, five orders of magnitude smaller.
The idea is simpler than its reputation. Take a drawing, add a little Gaussian noise, then more, then more, until after enough steps nothing is left but noise. That destruction process is fixed and known. Now train a network to undo one step of it: given a noisy image and a number saying how noisy, predict what was added.
Once you have that, you can generate. Start from pure noise — which costs nothing, it is just random numbers — and repeatedly ask the network to take one step back toward a clean image. After enough steps you have a drawing that never existed. Ours is class-conditional: the network also receives which of 131 objects it should be denoising toward. That is what makes typing a word do anything.
The schedule
The noise level at step t is described by
abar[t], the fraction of the original signal still
present:
x_t = sqrt(abar[t]) * x_0 + sqrt(1 - abar[t]) * noise
abar[0] = 0.9938 (nearly clean) down to
abar[63] = 1.9e-9 (pure noise). The curve between them is
the cosine schedule, with one adjustment: the standard version
is calibrated for 32x32 images and up. At a coarser grid each pixel
carries more of the picture, so a given noise level destroys less, and
much of the schedule gets spent somewhere trivially easy. We divide the
signal-to-noise ratio by (64/grid)^2, which shifts the
whole schedule toward noisier. On 16x16 that single line moved class
accuracy from 0.978 to 0.995.
There are 64 trained noise levels, not the usual 1000. Fewer levels means the timestep conditioning can be a lookup table (§6), and the shipped schedule is 64 numbers instead of a thousand.
v-prediction
The network could predict the noise that was added
(eps), the clean image (x0), or a particular
blend of the two called v:
v = sqrt(abar) * eps - sqrt(1 - abar) * x0
We predict v, and it is not a close call — measured
on an identical network and seed, class accuracy was 0.978 for
v against 0.716 for eps.
The reason is worth understanding. At high noise x_t is
nearly pure noise, so eps is nearly x_t:
predicting it is almost the identity function, and the model burns
capacity learning to copy its input. Then recovering the image requires
dividing by sqrt(abar), a number approaching zero, which
amplifies whatever error remains. v has bounded variance
across the whole schedule, and recovering x0 from it is a
blend with no division at all.
Sampling: DDIM
Generation runs 8 steps of DDIM with eta = 0 —
fully deterministic. Each step asks the network for v at
the current noise level, derives the implied clean image
x0 and clamps it to [-1, 1], re-derives
the noise eps from the clamped x0, and
re-noises to the next, lower level.
for (var j = 0; j < NPIX; j++) {
var val = a * S.x[j] - bb * v[j];
if (val < -1) val = -1; else if (val > 1) val = 1;
x0[j] = val;
}
// eps is re-derived from the clipped x0, not taken from the model.
for (j = 0; j < NPIX; j++) {
var eps = (S.x[j] - a * x0[j]) / bb;
S.x[j] = ap * x0[j] + bp * eps;
}
the worst bug this project had
That re-derivation is not optional. DDIM's update assumes
x_t = sqrt(abar)*x0 + sqrt(1-abar)*eps. Clamping
x0 while keeping the model's original eps
breaks the identity — the pair no longer describes the state
the sampler is actually in — and the error compounds. The x0
prediction began at correlation 1.000 with a correct drawing and
decayed to 0.368, producing noise whose mean and variance still
looked entirely reasonable. Every training metric was
healthy throughout.
Determinism matters beyond reproducibility: it is what lets the whole trajectory be checked against a reference implementation (§11).
Classifier-free guidance
Each step actually runs the network twice: once told
the class, once told nothing (a dedicated null class the model was
trained on 10% of the time). The two are then extrapolated apart, with
w = 2.5:
v = v_null + w * (v_class - v_null)
This doubles the compute and is worth it: it moves class accuracy from 0.703 to 0.969. Past about 2.5 the metric keeps climbing while the drawings visibly collapse toward a single prototype per class — the accuracy number alone would pick a worse model.
The constraint that shapes everything
The program has to produce the same numbers in the PDF viewer as in numpy, or there is no way to know it is correct.
IEEE-754 requires +, -, *,
/ and sqrt to be correctly rounded:
given the same inputs, every conforming implementation returns
bit-identical results. It requires nothing of the sort for
log, exp, cos, pow
or tanh — the ECMAScript spec explicitly permits
implementation-defined approximations, and V8's differ from numpy's in
the last bits.
So: nothing on the sampling path may call a transcendental function. That one rule explains most of the model's more unusual choices.
| Choice | Why |
|---|---|
| ReLU everywhere | max(0,x) is exact. SiLU measured slightly better
but needs ~41,000 Math.exp calls per image |
| Learned timestep table, not sinusoidal embeddings | no sin/cos. Cheaper too, and legal
because DDIM only ever uses the 64 trained indices |
| Inverse-CDF table for Gaussian noise | Box–Muller needs log and cos.
A 2049-entry table plus linear interpolation is index
arithmetic and one multiply-add |
| xorshift32 for randomness | shifts and xors only. No multiply that could exceed 2^53 and diverge between JS numbers and Python integers |
| Schedule shipped as decimals | cos is called once, offline; the 64 resulting
values are emitted as decimal literals that both languages
parse identically |
LayerNorm is allowed, which surprises people: mean,
variance, sqrt and divide are all correctly rounded, so it
is exact.
the payoff
Verification asserts equality, not a tolerance. The one place a tolerance survives is the trajectory comparison, and only because numpy uses BLAS whose summation order differs from a naive JS loop. Observed: 3.5e-14 over a full 8-step trajectory.
The network
A residual MLP, 2048 wide, six blocks. No convolutions (§12).
The class and timestep are summed into a single 128-dimensional conditioning vector, which is projected into the trunk at the input and again inside every block. Injecting it once is not enough: with only one entry point the class signal has to survive six layers, and high guidance scales produce artifacts. Both embeddings are lookups, not matrix multiplies, so they cost bytes but no arithmetic.
| Tensor | Shape | Bytes |
|---|---|---|
temb | 64 x 128 | 8,192 |
cemb | 133 x 128 | 17,024 |
inp | 2048 x 784 | 1,605,632 |
a_i, b_i (x6 each) | 2048 x 2048 | 50,331,648 |
c_i (x6) + input c1 | 2048 x 128 | 1,835,008 |
out | 784 x 2048 | 1,605,632 |
| total | 55,403,136 |
The grid is 28x28, which is Quick, Draw!'s native resolution. That is deliberate: the dataset ships 784-byte bitmaps, so at this size there is no resampling at all. Earlier attempts at 16x16 had to downsample, and a 1-pixel doodle stroke either averages into grey mush or barely survives — most of the "the model can't draw" problem turned out to be a resampling artifact.
Getting 55 million weights into a text file
Weights are quantised to int8, symmetric, with one scale per output row. A row is one output neuron's weight vector, which is exactly the unit the forward pass consumes, so the scale folds into the accumulator once instead of touching every weight. An embedding table is a Linear with a one-hot input, so a row is one embedding vector and the identical code applies with no special case.
function matvec(name, x, out, relu) {
var w = W[name], s = SCALES[name], b = BIASES[name];
var rows = SHAPE[name][0], cols = SHAPE[name][1];
for (var i = 0; i < rows; i++) {
var acc = 0, o = i * cols;
for (var j = 0; j < cols; j++) acc += w[o + j] * x[j];
acc = acc * s[i] + (b ? b[i] : 0);
out[i] = relu && acc < 0 ? 0 : acc;
}
return out;
}
Quantisation error was the risk I expected to bite hardest, because a
diffusion sampler feeds its own output back in eight times, so errors
have the opportunity to compound in a way they cannot in a single
forward pass. Measured on a smaller model, it does not: FD 5.87 float32
against 5.89 int8, class accuracy unchanged. Three reasons — DDIM
is contractive toward the data manifold, the x0 clamp is a hard bound
on per-step drift, and v-prediction has no 1/sqrt(abar)
division to amplify anything.
Every tensor goes into one concatenated byte buffer, base64'd
exactly once. This matters: base64 pads to a multiple of
three, so encoding tensors separately would sprinkle padding through
the file. One buffer whose length is divisible by three encodes with
zero padding. The class embedding is sized to make
that true — cond_rows() adds spare rows until
total % 3 == 0, which is why cemb has 133
rows for 131 categories plus one null class.
Scales, biases and LayerNorm parameters stay as plain JSON numbers. They are a few percent of the file, and quantising them would buy a second dequantisation path in the JavaScript for nothing.
The ASCII rule
The payload must be 7-bit ASCII, and the builder asserts it.
how a 75 MB PDF becomes 150 MB
pdfrw writes a Python string as a PDF literal (...)
when it can. If any character is not encodable in PDFDocEncoding it
silently falls back to UTF-16 hex, which doubles the
file. A single smart quote in a comment would double the document
with no error message. This fired once for real: a literal U+FE0F
variation selector inside a regex. Emoji in the vocabulary are safe
only because json.dumps escapes them to
\uXXXX — ASCII source characters that JavaScript
reads back as the original codepoint.
What happens when you click Generate
At page open, once: the base64 blob is decoded to an
Int8Array by a hand-rolled decoder (atob is
not guaranteed in a PDF sandbox); the tensor list is walked and each
name bound to a subarray view of that one buffer, so
nothing is copied; scratch buffers are allocated once and reused for
every step of every run; and the scheduler is chosen by feature
detection.
Weights stay Int8Array for the life of the document.
Materialising a float64 copy of 55M weights would be 440 MB of viewer
heap, and this repository already carries a commit titled "Reduce
memory use slightly to prevent Chrome memory capping".
On click: the typed word is resolved to a class
(§10); the word and the seed field are hashed
into a 32-bit PRNG state and 784 Gaussian samples are drawn —
this is x_T, the starting noise; eight DDIM steps run,
each evaluating the network twice for guidance; the result is
painted.
the seed box says auto
Everything above is a pure function of the word and the seed, so
the same two inputs draw the same picture forever — which
reads as a lookup table rather than a model. So the seed box ships
holding auto: a fresh seed off the clock, and a
different drawing on every click. That clock read is the only thing
in the document that varies between runs. The sampler stays
bit-deterministic, the seed it picked is printed to the console, and
typing any number into the box restores the reproducible case
exactly.
The sampler is a state machine, not a loop
dsStep() performs exactly one denoising step and never
touches the UI. dsPump() decides how to keep going. That
separation exists because a PDF viewer's threading model is not
something you get to assume:
| Mode | Condition | Behaviour |
|---|---|---|
TIMER_ACRO | app.setTimeOut exists | run a ~50 ms chunk, paint, reschedule |
TIMER_DOM | setTimeout exists | same, via the DOM timer |
SYNC | neither | run straight through, painting every Nth step |
Acrobat's setTimeOut takes a string expression,
not a function, and will garbage-collect a timer object nobody holds a
reference to — so the handle is kept in a global and the callback
is passed as "dsPump()".
the Step button
There is also a Step button that advances exactly one denoising step per click. It is the fallback if no scheduler yields to the compositor, but it is also a better demo than an animation: you watch noise resolve into a drawing at your own pace, and it is the debugging tool you want anyway.
Painting: the picture is 784 buttons
The drawing surface is not an image. PDF has no way for a script to draw arbitrary pixels. What it has is form widgets, and a widget's background colour is scriptable.
So the page carries 784 borderless pushbuttons in a
28x28 grid. A pushbutton's entire appearance is its
/MK/BG entry — no font, no text layout, no value
string — which makes it the cheapest widget to repaint.
/H /N suppresses the click-flash so the picture does not
invert under the cursor; /F 4 lets it print.
The build bakes a reference render into
/MK/BG, so the grid shows a real drawing the
instant the file opens, before any script runs. That gives a free
end-to-end check: type 7 into the seed box — the
seed the appearance was baked from — click Generate, and if the
picture does not visibly change, the JavaScript agrees with numpy.
That used to need no typing, because the box shipped holding that
number; now it ships holding auto, so the first click is
meant to draw something else and the check costs one keystroke.
Painting is isolated in exactly one function, because whether
assigning fillColor actually repaints is
viewer-dependent and, in Chrome, currently does not work
(§12). Everything else —
dirty-pixel skipping, painting every Nth step, the runtime nudge cycler
— exists to keep that one function cheap and patchable.
The character fallback
--paint-mode chars renders the drawing as 28 rows of text
instead, two characters per pixel through the ramp
" .:-=+*#%@". Courier advances 0.6 em, so doubling each
character makes the result roughly square.
This is not a consolation prize. Writing .value on a text
field is the update path llm.pdf's entire console depends on, so it is
known to work in this viewer — which is why it is the
build that currently works end to end.
one trap, which bit
The grey grid inverts and the character ramp must
not. Ink is a high value; on a white page the grey
grid has to flip it so ink paints dark. But text is already dark on
white, so ink takes the dense end of the ramp and background
stays blank. Inverting both produced a negative image — a page
full of @ with the drawing carved out in
whitespace.
From a word to a category
The model knows 131 objects. A typed word has to become one of them.
- strip the U+FE0F variation selector, then check the raw input against the category list and a hand-written table of 209 words and 161 emoji
- if that fails, walk the string by codepoint looking for a known emoji — surrogate pairs are two UTF-16 units, so this steps by codepoint, not index
- normalise to letters and spaces, retry, then try crude
singularisation (
cats→cat,boxes→box) - try each token in turn, so
a big red carfindscar - otherwise pick a category by hashing the word — and say so in the status line
That last point is a deliberate honesty constraint. The real ceiling here is "map the word to the nearest of 131 known objects and draw it" — this is not open-vocabulary generation, and the interface should not imply otherwise.
The vocabulary is metadata, not weights, so
tools/update_vocab.py rewrites it in an existing model
file without retraining.
How we know it is right
The builder writes the fully-substituted JavaScript to
out/diffusion.js from the same Python string it
hands to the PDF. The tested code is therefore byte-identical
to the shipped code by construction — no PDF parser, no
possibility of drift.
tools/harness.mjs runs that file in Node under
vm.createContext, with the viewer's API stubbed just
deeply enough: getField returns recorders that log every
write, app.setTimeOut queues expressions drained in order,
and app.alert is a hard failure —
the payload's outer catch calls it, so every in-document exception
surfaces as a non-zero exit.
python3 -m train.train_diffusion # writes train/model.json
python3 scripts/generateDiffusionPDF.py --bake-initial
node tools/harness.mjs # in-PDF JS vs numpy
Five gates, hardest first:
| Gate | Check | Result |
|---|---|---|
| fingerprint | payload and reference are the same model | SHA-256 prefix |
| G1 | PRNG stream, 512 draws | exact integer equality |
| G2 | initial noise x_T | bit-identical float64 |
| G3 | every intermediate x_t, all 8 steps | 3.5e-14 (tol 1e-9) |
| G4 | the painted greys / character rows | exact |
G3 is per-step rather than end-to-end so a divergence localises to one step and one pixel instead of producing a single unhelpful diff. The fingerprint gate exists because of a real failure: rebuilding the PDF after a retrain without regenerating the reference makes G3 report a numeric divergence, which reads exactly like a sampler bug and sends you hunting one.
what this does not prove
The harness reimplements the character-ramp formula in order to check it, so for a while both copies were inverted together and G4 passed on a wrong rendering. A gate that reimplements what it checks only proves the two copies agree. That is now cross-checked against the independent Python renderer.
Two further checks run outside the harness: training reloads what it
just wrote and asserts the numpy forward pass reproduces the torch one
(a tensor-order bug fails the build), and
tools/test_builder_identical.sh proves the shared-helper
refactor left the original llm.pdf builder emitting identical
bytes.
Things that are wrong or unfinished
A tensor name collision
tensor_order() emits c1 twice — once
as the input conditioning projection, once as block 1's. Because the
model builds blocks with setattr(self, f"c{i}"),
self.c1 is overwritten, so the input projection and block
1 silently share one weight matrix. It is consistent
across torch, numpy and JavaScript, so everything trains and every gate
passes, but it is an unintended weight tie and it writes 262,144 bytes
into the blob twice. Fixing it means renaming the input projection and
retraining.
The colour grid does not repaint in Chrome
fillColor writes are accepted and never reach the screen.
The ReadOnly flag has been ruled out. The character build works and is
what ships.
The compute budget is unverified
Everything is sized against 729M MAC/s measured in Node, where the JIT
is fully on. PDFium's policy is unknown. At that rate an image takes
1.2 s; ten times slower is 12 s, thirty times is 36 s — all
survivable against llm.pdf's ~50 s precedent, but nobody has measured
it. out/probe.pdf exists to answer this and has never been
run.
Convolutions were never tried properly
They are far more parameter-efficient for images and are the obvious next architecture. They were ruled out early on a compute argument that later measurement invalidated — the real budget turned out to be one to two orders of magnitude larger than assumed. A comparison was started and killed once the MLP result settled the immediate question.
Two things the original plan got wrong
This project began from an idea.md arguing that a
diffusion model in a PDF was the wrong shape. Two of its load-bearing
claims turned out to be false, and the corrections are what made this
work.
"The iterative sampler is the fatal part"
True at Stable Diffusion's scale. False here, for a reason the document itself supplies: it establishes file size as the binding constraint. An iterative sampler multiplies compute but costs zero extra file size — the same weights are reused at every step. Diffusion spends the abundant resource to buy the scarce one, which is the opposite of the trade it was assumed to be making.
"File size is ceiling #1," framed around 179 kB
That framing came from a much smaller sibling artifact. This repository ships PDFs of 18, 25 and 51 MB, and llm.pdf computes for roughly 50 seconds per click. The budget was always one to two orders of magnitude larger than assumed — which is how a 75 MB model became reasonable.
Its open question about the execution substrate was also already
answered, in the repository: llama/llama.js asserts
Math.imul at startup and binds Float64Array,
Float32Array, Int32Array and
Int8Array. The Chromium target is a modern V8. Typed
arrays are available and asm.js is unnecessary.
The numbers
| Parameters | 55,403,136 int8 |
| Base64 | 73,870,848 characters, zero padding |
| 75 MB | |
| Vocabulary | 131 categories, 209 words, 161 emoji |
| Grid | 28 x 28 = 784 widgets |
| Sampling | 8 DDIM steps, guidance 2.5 |
| Compute | 886M MAC per image |
| Speed | 1.2 s per image in Node (729M MAC/s) |
| Quality | 0.874 class accuracy, against 0.790 for a classifier on real drawings |
| Training | 45 epochs, 327,500 drawings, final loss 0.2476 |
| Agreement with numpy | 3.5e-14 over the full trajectory |
The quality figure deserves a note: samples score higher than the classifier manages on real human doodles. That is not the model beating reality, it is what classifier-free guidance does — the drawings come out more canonical than the real thing, which is the same knob that destroys diversity if you push it further.
Every word it knows
All 131 of them. Reading down a column of the list on page 2 of the document gives the same order as reading along here — the categories are grouped by theme, not alphabetised, because that is how the training set was assembled.
apple · pizza · donut · ice cream · hamburger · carrot · strawberry · watermelon · bread · cookie · lollipop · popsicle · wine glass · coffee cup · birthday cake · mug · teapot · fork · spoon · knife · tree · flower · cactus · leaf · palm tree · pineapple · cloud · lightning · moon · star · sun · rainbow · tornado · snowflake · cat · dog · bird · fish · butterfly · bee · snail · snake · spider · octopus · crab · whale · shark · elephant · giraffe · penguin · owl · duck · frog · sea turtle · door · house · chair · table · bed · clock · television · telephone · key · candle · hourglass · scissors · hammer · screwdriver · paintbrush · pencil · broom · bucket · ladder · umbrella · book · envelope · camera · headphones · eyeglasses · light bulb · car · bicycle · airplane · sailboat · train · bus · helicopter · submarine · canoe · motorbike · wheel · t-shirt · pants · shoe · sock · hat · crown · sword · necklace · wristwatch · castle · church · lighthouse · bridge · tent · windmill · skyscraper · The Eiffel Tower · stairs · mountain · eye · hand · foot · ear · nose · tooth · skull · moustache · diamond · triangle · hexagon · smiley face · snowman · traffic light · stop sign · guitar · violin · piano · drums · trumpet · saxophone
Another 370 synonyms and emoji resolve to one of these, and a typed
phrase is searched token by token, so a big red car finds
car. Anything that still misses is hashed to a category
and the document says so in its status line.
Credits & licence
Built by me, Alex Van Doren, one of the developers behind Hack Club's Stardance Challenge, and super cool sailor.
This is a fork of llm.pdf by Evan Zhou, which runs an entire LLM inside a PDF and supplies the JavaScript-injection trick everything here stands on (watch the video). That in turn descends from linuxpdf and DoomPDF by ading2210, which are both 100% worth your time.
The model is trained on Google's Quick, Draw! dataset — 327,500 human doodles across 131 categories.
Copyright © 2026 vandorena. Source on GitHub.