I spent the last few months building a PDF editor. The feature that sounded easiest — change a word, leave everything else alone — turned out to be the one that ate most of the time. Here is what I learned, mostly by getting it wrong.
A PDF does not contain sentences
The first assumption to give up is that a PDF stores text. It stores drawing instructions. A line of text is typically a sequence like this:
BT
/F1 11 Tf
72 700 Td
[(W) -30 (e) 15 (l) -20 (come)] TJ
ET
That TJ array is one visual word split into fragments with kerning adjustments between them. There is no string "Welcome" anywhere in the file. There is no paragraph, no line, and no notion that these glyphs belong together. Everything a human sees as structure is inferred by whatever is reading the file.
So "find and replace" is not a string operation. It is: reconstruct the logical text from positioned glyphs, locate your target inside it, work out which drawing operations produced those particular glyphs, remove them, and draw something new in a way that looks like it was always there.
Replacing text means recovering four things
To make a replacement invisible you need the original font, size, baseline, and colour. None is reliably available.
Size is not simply the number after Tf. The text matrix can scale it. A heading declared as Tf 1 inside a matrix scaled by 24 renders at 24pt. Read the Tf value alone and you get 1.
Baseline matters more than people expect. A couple of points too high and the eye catches it instantly, even when the font and size are perfect.
Colour requires care in scanned documents. Sample one page-wide background colour and you will eventually paint white blocks onto cream paper.
I ended up estimating these from the surrounding line — a weighted median of the size, font and baseline of neighbouring runs — and using the estimate only when the exact run cannot be matched. That fallback path is where nearly every bug lived.
The bug that taught me the most
Replacements on academic papers came out in the wrong typeface: a light sans-serif dropped into a serif document. Obvious to any reader, and I could not reproduce it.
I built test PDFs that I was sure matched the failing case: embedded fonts, subsetted, bold headings. They all passed. I "fixed" the bug three times against those files. It kept failing on real documents.
Eventually I stopped generating test files and downloaded a real paper from arXiv. It failed immediately, every time. The cause was one line:
let serif = low.contains("times") || low.contains("roman") || ...
LaTeX embeds a Times clone named NimbusRomNo9L. It contains Rom. It does not contain roman. So the font was classified as sans-serif and replaced with Helvetica. Computer Modern has the same problem: CMR10 and CMBX12 never contain the word "roman" either.
The same naming assumption broke weight detection. Nimbus calls its bold weight -Medi, not -Bold, so headings also came out at regular weight.
Two string comparisons, and they only showed up on documents my generated corpus could not produce. My test files were written by a library that emits clean TrueType with tidy names. Real documents come from LaTeX, from Word, from twenty-year-old scanners, and they are full of abbreviations nobody documents.
Subset fonts, and why fallback is unavoidable
A PDF usually embeds only a subset of each font — just the glyphs already used on the page. That is why file sizes stay sane, and it is also a trap. If the original text never contained a z, the embedded font has no z. Replace a word with one that needs it and you cannot draw with the document's own font at all.
So you always need a fallback, and the fallback needs to look close. That means having real font families installed on the server. Ship a container with only one font family and every fallback lands on it, whatever the document looked like.
What actually helped
Start from text that already exists. Letting someone draw a box and drop text into it means guessing font, size and baseline from a rectangle. Detecting the text runs first and letting them pick one means those values are known rather than inferred. Same interface, far fewer guesses.
Test on documents you did not create. Generated test files share your assumptions, which is exactly what makes them useless for finding this class of bug. One real arXiv paper found more than a week of synthetic cases.
Measure the output, not the process. "Did the edit apply" is not the question. The question is whether the replacement is the right size, on the right baseline, and not overlapping its neighbours — which means rendering the result and comparing.
Try to break it
The editor is at marqpdf.com. Open a PDF and it outlines every line it finds; click one and type, or say what to change.
If you have a PDF that breaks it — an unusual font, a government form, a multi-column layout, something produced by software nobody has heard of — I would genuinely like to see it. Those are worth more than any test file I can write.
Top comments (7)
This matches the bit that always surprises people when they first touch PDFs. A PDF edit is closer to patching a little graphics program than changing text in a document. The painful cases for me are ligatures and copied text order, because the visual result can look fine while extraction turns into junk.
Ligatures turned out to be even nastier for editing than for extraction, because they fail one layer deeper. Extraction usually survives them — the ToUnicode map expands the fi glyph back to two characters, so search and copy look fine. But a replace has to find those glyphs in the raw content stream, and there is no f followed by i in there — just one subset-font code meaning both. So you get the cruel variant of what you're describing: the page looks fine, extraction looks fine, and only the edit comes out wrong. The fix is normalising both sides of the match through ToUnicode before comparing, which I'm mid-way through.
Reading order I mostly dodge by refusing to trust the stream at all — I re-sort glyphs geometrically (top-to-bottom, left-to-right, split rows on column-sized gaps) and treat paint order as noise. It's the only approach I've found that survives justified text drawn word-by-word and two-column layouts painted in whatever order the generator liked.
The case that still scares me is the combination: a confidently wrong text layer — missing or lying ToUnicode, geometry perfect, every extracted "word" private-use garbage. A scan at least knows it has no text and falls back to OCR; a lying text layer sails through looking authoritative. I'm planning to detect junk extraction (ratio of PUA codepoints, non-word sequences) and treat those pages as scans.
The container-fonts point lands the same way from the generation side, and it is quieter there. We render HTML to PDF in headless Chromium, so a font the image does not have gets substituted by the browser with no error, and then embedded. The artifact is wrong and every check passes.
Our image installs eight font packages on purpose, and the ones that earned their place are the non-Latin ones (Japanese, Chinese, Thai, Khmer, Arabic), because that is where a substitution stops being a slightly-off typeface and becomes boxes.
Did the NimbusRomNo9L case push you to an allowlist of known family names, or are you still classifying by substring with more substrings?
One of the most insightful post-mortems on PDF rendering I’ve read in a long time! PDF internals are a total black box to most web devs. The explanation of why "find and replace" is actually a reverse-engineering nightmare of font metrics and matrices was spot on. Congrats on launching marqpdf!
Thank You!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.