How Text Replacement in a PDF Actually Works
Most "PDF editors" put a white box over the old words. Real replacement rewrites the drawing instructions inside the page. Here is the whole mechanism, end to end.
A page is a program
A PDF page is not a document with paragraphs. It is a list of drawing instructions โ a content stream. Here is a real one, decompressed:
BT /F1 11 Tf 60 720 Td (Acme Trading Ltd) Tj ET
Read it right to left of each operator:
| Token | Meaning |
|---|---|
BT โฆ ET | begin / end a text block |
/F1 11 Tf | use font F1 at 11 points |
60 720 Td | move the text cursor to x=60, y=720 |
(Acme Trading Ltd) Tj | draw this string, here |
There are no words, no lines, no flow. Just "draw these bytes at this position". That is why a PDF looks identical everywhere โ and why editing it is not like editing text.
Step 1: get to the instructions
Content streams are compressed with deflate (/Filter /FlateDecode). The stream's
dictionary says how many bytes; inflate them and you have the operators as plain bytes. Modern files
add a twist: objects themselves can live inside compressed object streams
(/Type /ObjStm), so sometimes you decompress a stream just to find the page that points
at another stream.
Step 2: find the text
Two operators draw text. Tj takes one string. TJ takes an array of
strings and numbers:
[(A) 74 (cme Trading Ltd)] TJ
The 74 pulls the next chunk left by 74 thousandths of the font size โ kerning. So one
visual word can be several fragments. A replacer has to treat the array as one run of text, or
"Acme" will never match anything.
Step 3: the bytes are not what they look like
This is the part everyone gets wrong. The bytes inside ( ) are not UTF-8.
They are codes in the font's encoding.
- With
/WinAnsiEncoding, "รฉ" is the single byte0xE9, written\351in octal. - A font can carry a
/Differencesarray that remaps any code to any glyph name. Byte0x41is only "A" until someone says otherwise. - CID/Type0 fonts use two bytes per character. Write single-byte codes into one and you get garbage that looks fine in your code and wrong on the page.
So replacement is really: decode the string through the font's encoding, match against what the user typed, encode the new text back through the same encoding. Both directions, same table.
Step 4: the trap โ subset fonts
PDFs embed only the glyphs a document uses. Ten different letters on the page? Ten glyphs in the font. If the replacement contains a letter the original never used, the font has no drawing for it.
Write it anyway and you get the nastiest failure in this whole area: the page shows empty boxes, but copy-paste and search still return the right characters. The data is correct. The pixels lie. Every automated test that checks extracted text passes.
The fix is to check before writing. TrueType fonts carry their character map in the
cmap table โ parse it (formats 0, 4, 6 and 12 cover what PDFs embed), build the set of
characters the font can draw, and if the new text needs one that is missing:
refuse, and name the missing characters. A refused edit costs the user ten seconds. A
document that looks broken to its reader costs much more, later, to someone else.
Longer write-up here.
Step 5: write, without pretending
Escape (, ) and \, splice the new string into the
operator, done? Almost. Two honesty rules matter more than the splicing:
Text does not reflow. The instruction says "draw at x=60". A longer replacement draws longer โ to the right, past where the old text ended. Nothing moves to make room. Short, same-length replacements (a name, a date, a reference) behave perfectly; rewriting a paragraph does not. Say so instead of letting users find out.
Count matched and changed separately. Occurrences found and occurrences actually rewritten differ whenever an edit was refused. Report both numbers. A tool that only says "done" is hiding the interesting column.
Step 6: rebuild the file
The stream is now a different length, so its /Length is wrong, so the byte offsets of
every object after it are wrong, so the cross-reference table that maps object numbers to offsets is
wrong. The simplest correct move: re-deflate every stream, re-emit every object, write a fresh xref
and trailer. Flatten object streams while you are at it. The output is a clean, classic PDF that any
reader from any decade can open.
What this cannot do
- Scans. A scanned page is one big image. There are no text operators to edit. Only OCR can invent some, with OCR's error rate.
- Reflow. See above. This is line surgery, not word processing.
- Exotic text paths. Text inside Form XObjects, multi-byte CID fonts โ detect them and report "not editable", rather than guessing.
The whole algorithm, in one paragraph
Inflate the content streams. Parse the text operators into runs, merging kerned fragments. Decode each run through its font's encoding. Match the user's text against the decoded runs. For each match, encode the replacement through the same encoding โ refusing if the subset font cannot draw it. Splice, re-deflate, rewrite offsets, emit a clean file. Then verify with a renderer that what changed is what you meant โ because in PDF, the data and the pixels can disagree in ways your tests will not see.
About 600 lines of dependency-free JavaScript, as it turns out. The editing is the small half. Knowing when to refuse is the rest.
RetypePDF edits the text inside a PDF in your browser. Nothing is uploaded. No account, no price.
Try it