fonts · pdf

Why editing text inside a PDF is hard

No paragraphs, subset fonts with no space glyph, spacing operators that leak, phantom spaces, mixed faces and browsers that round: the real reasons a click can shift a line.

Ask a PDF where its paragraphs are and it has no answer. A page is a list of drawing instructions. Some of those instructions place glyphs. That is all a PDF knows about text, and it is why "edit the text in a PDF" is a much harder promise than "put a text box on top of a PDF".

This post is about the specific places where that promise breaks, found while building Edit My PDF. Every one of them is a case where clicking a line would make it visibly shift, and a shift on click is exactly what we treat as a bug.

There are no words, only placed glyphs

Here is what a line of text looks like inside a page's content stream:

BT
/F1 10.5 Tf
72 700 Td
[(Full-year) -278 (re) 15 (v) 10 (enue) -278 (gre) 20 (w)] TJ
ET

The TJ operator shows strings and, between them, numbers that nudge the cursor left or right in thousandths of the font size. A word can be one string or five. A space can be a space character, a nudge of a few hundred units, or a fresh Td that starts the next word somewhere else on the line. A justified paragraph is often set with no spaces at all, every word positioned on its own.

So the first job is to read the page as a reader does: group placed glyphs into runs, runs into lines, lines into blocks, and decide which gaps are spaces. pdf.js does part of this and hands over text items with positions. Edit My PDF walks the page's operator list alongside those items, glyph by glyph, so that every item also knows the character spacing (Tc), word spacing (Tw) and font size it was drawn with. Spaces are skipped on both sides while pairing, because pdf.js adds and drops them freely, and a short look-ahead resynchronises the two when a character has no glyph.

The fonts are not the fonts you think

A PDF embeds its fonts, usually as subsets with only the glyphs the document used, often with a private encoding. The browser cannot use those directly, so pdf.js converts each one to OpenType and registers it as a font face.

In pdfjs-dist 6 those converted fonts put every glyph at a private-use code point. Unicode entries are added only as a courtesy, and they are not always right: a ligature glyph can end up in a plain letter's slot. Rendering is unaffected, because pdf.js draws by glyph code. Typing is affected, because when you type an "f" the browser looks the character up in the font's Unicode table.

Edit My PDF fixes this by building a corrected copy of each face. It collects, from the page's own showText operations, which glyph the page used for each Unicode character, rewrites the font's cmap table so every character maps to that glyph, and registers the copy under its own family name. Only characters whose Unicode entry pointed somewhere else need fixing, so pages that agree with the font share one registered face.

The space that is not there

The converted fonts have no space glyph. When the browser lays out "grew nine per cent" in such a face it draws the letters from the embedded font and the spaces from the fallback family. A fallback space is about 250 units wide; the spaces in the documents we tested were 318 to 348. Every line with spaces came out several points narrower than it printed, and the whole paragraph looked a little off the moment you clicked it. This single quirk caused most of the "the styling changes when I click" reports.

The pageRevenue grew nine per cent to $4.2 billion
Fallback spaceRevenue grew nine per cent to $4.2 billion
FittedRevenue grew nine per cent to $4.2 billion
The same line set three ways. With the fallback font's narrow space the line ends early; after fitting, its spaces take exactly the width the page gave them.

The fix is to stop trusting the font for spaces. For each text item Edit My PDF measures the letters alone in the page's face, compares the result with the width the page actually drew, and spreads the difference over the spaces as CSS word spacing. Letter spacing takes the stream's Tc plus any mismatch between the page's glyph advances and the face's own, spread over the letters. A run with no spaces puts the whole residual between its letters, which is also where a dot leader and its page number leave it.

Spacing is state, and state leaks

Tc and Tw are part of the graphics state. q saves it and Q restores it. A document that sets a wide Tc inside one q … Q block and then draws a normal paragraph after Q is perfectly valid, and a reader that forgets to pop the state will track the whole paragraph. We forgot, once.

Two more spacing traps turned up in real files:

  • Tracked small caps reach pdf.js as a space after nearly every letter, because the wide Tc looks like a gap. The glyph stream knows which spaces the page really drew, so the tracking is turned back into letter spacing and the fake spaces disappear.
  • Two-byte spaces in Identity-H fonts get word spacing added by pdf.js's text extraction but not by its renderer. The reported positions drift right by Tw for every such space before the item, so they have to be pulled back by the count of those spaces in the glyph stream. The NMDC press release in our test set has exactly this.

One paragraph, several faces, no reliable colours

A paragraph that starts with a bold lead-in is one paragraph to a reader and two fonts to a PDF. Worse, a font with bold baked into its outlines reports weight 400 to pdf.js, so bold cannot be told from a heading face by weight alone. Colour is even less reliable: the colours in the operator list do not line up one-to-one with the text items pdf.js reports. Edit My PDF reads each run's colour from the rendered page instead, averaging the darkest ink pixels under the run's box.

Mixed-face paragraphs got their own editor; that story is in Editing a paragraph that mixes faces as one block.

Pages are not set on a grid, and browsers round

Lines in a block are rarely pitched evenly. A Wikipedia print alternates 15.75 and 15.0 point line spacing. A list item's bullet is drawn in a smaller font whose box sorts after the item's first line. So a block records an offset for every line against an even pitch, and an indent for every line against the block's left edge.

Then the browser has its own opinion. Chrome rounds a font's ascent and descent to whole pixels when it builds a line box, so a baseline computed from the font's metrics lands a pixel off at some zoom levels. Edit My PDF measures the baseline instead, on a hidden line set exactly like the editor's, and caches the result once the face has finished loading.

What this adds up to

None of these problems is exotic. They are what ordinary PDFs look like: a LaTeX paper, a Word export, a press release, a printed web page. Editing one in place means reconstructing a reading of the page that is exact to a fraction of a point, and then handing that reconstruction to a browser text editor without it changing anything. The next post walks through how Edit My PDF does that: Opening an element exactly as the page prints it.