export · fonts · pdf-lib

Exporting edits as real text, in the page's own font

How Edit My PDF removes the original strings from a page's content stream, embeds a subset of the font pdf.js decoded, and writes your edit as positioned text rather than a picture.

Until recently, downloading an edited PDF from Edit My PDF gave you a page where every edit was a picture: the edited text was painted at three times resolution into a PNG and placed over a white backdrop that hid the original words. It looked right. It was also unsearchable, unselectable, blurry at high zoom, and it left the original words in the file underneath the backdrop.

Edits are now written as text, in the page's own font, with the original strings removed. Here is how.

Step one: take the original words out of the page

A PDF page's content stream cannot be edited by searching for a word, because the word may be five strings, and because moving one string moves everything drawn after it in the same text object. Edit My PDF's content-stream reader lexes the stream into operators with the byte range each one occupies, and tracks the text state as it goes: q/Q, cm, BT/ET, Tf, Tc, Tw, Tz, TL, Ts, Td, TD, T* and Tm. At every string shown by Tj, TJ, ' or " it knows where the first glyph will land on the page.

A string whose first glyph starts inside an edited element's box (with a margin of a point and a half) is the element's text. It is replaced by a number inside a TJ array equal to the string's advance, computed from the page's font resources: /Widths and /MissingWidth for simple fonts, /W and /DW for CID fonts, scaled by the font matrix for Type 3. The cursor ends up exactly where the string would have left it, so the text that follows in the same object stays put. A TJ that spans a whole table row loses only the strings inside the box.

' and " have side effects (a line feed, new word and character spacing) that are kept in front of the rewritten array. Inline images are skipped by their computed length, or by finding a free-standing EI when they are filtered. Streams are decoded with pdf-lib, joined, rewritten by splicing the byte ranges, with everything else copied verbatim, and written back as one flate stream.

Text drawn by a form XObject is not descended into. A Do whose bounding box meets an edited element marks that edit as uncertain, and the backdrop is kept for it.

Step two: embed the font that pdf.js already decoded

The exported text has to be set in the same face as the page, and that face is a font pdf.js converted to OpenType when it rendered the page. Edit My PDF asks pdf.js to keep those bytes (fontExtraProperties) and embeds them with pdf-lib and fontkit, taking the cmap-corrected copy when the editor registered one.

Subsetting is not optional. A converted font carries the original font's whole glyph table, around 800 kilobytes per face in our tests. With subset: true only the glyphs the edits use are written. A press release with a heading and a paragraph edited came out at 250 kilobytes against 232 for the original; without subsetting it would have gained about 350 kilobytes per face. If pdf-lib cannot subset a particular font, the whole font is embedded rather than giving up.

Step three: place each word where the editor showed it

The editor already knows where every word sits: the same layout function that draws the on-page editor returns each run's words with an absolute x position and a baseline. Export writes one text matrix and one TJ per run. The text matrix maps the editor's page coordinates onto the PDF page, including any page rotation. Tc is set to the run's letter spacing; Tz, Tw and Ts are reset so nothing inherited from the page leaks in.

Inside the TJ array, every word but the first is preceded by a kern that lands it exactly where the editor put it. No space glyph is written at all, because the converted fonts have none. A justified line therefore exports with the same word gaps the editor showed, computed rather than set.

When it still paints a picture

Three cases fall back to the old PNG path for that one edit:

  • the face has no bytes to embed (a Type 3 font, or a font the PDF did not embed and pdf.js matched to a system font);
  • pdf-lib cannot parse the converted font;
  • you typed a character the subset font has no glyph for. The editor already shows such a character in a fallback face, so nothing is hidden from you.

The white backdrop is only drawn when the strip removed nothing or was uncertain, when a tail to the right of the edit had to move, or when the run has an underline that the removed text no longer provides.

Checking the result

An export is verified by loading it back into the editor and running the same probe that checks the editor's fidelity. For the press release, pdf.js reads the edited words from the exported file, both edits open as editable text in the embedded face, and every other text item on both pages is at the position it had in the original. Every element in the re-opened export measured within two hundredths of a point of where the editor showed it.

What is left

Two gaps remain, both narrow. Text inside form XObjects is covered rather than removed, as above. And a font with no width information, such as one of the standard fourteen fonts used without embedded metrics, has its strings removed without the compensating advance, so later text in the same text object could shift. Standard-font metrics would close that gap, and it is on the list.