The rule we hold the editor to is simple to state: when you click a piece of text, nothing moves. Not the baseline, not the line ends, not the word gaps. The text under the cursor should look like the page until the moment you type.
It is a strict rule, and we check it with a probe that opens each of eleven test PDFs, clicks every text element on every page, and measures the editor's lines against the PDF's own text items. The tolerance is a few hundredths of a point. This post is what it takes to pass.
Reading the page twice
Everything starts from two things pdf.js can give for a page: its text items, with a transform and a width for each, and its operator list, the drawing instructions that produced the rendered canvas. Edit My PDF walks the operator list to build a glyph stream: every glyph the page drew, in order, with the Tc, Tw and font size in force when it was drawn. It then pairs that stream with the text items, so each item knows the state it was drawn in and the summed advance of the glyphs matched to its letters.
That pairing is what makes the spacing fit possible. Without it, a text item is only a string and a width.
Fitting each run
For every run, runSpacing answers one question: what letter spacing and word spacing make the browser set this string, in this face, exactly as wide as the page drew it?
- Letter spacing starts as the stream's
Tc, scaled from the operator's font size to the item's. If the page's glyph advances differ from the converted face's own (a substituted font, or one whose advances pdf.js adjusted), the mismatch is spread over the letters too, clamped to a few hundredths of an em. - Word spacing takes whatever is left once the letters are measured, spread over the spaces. It is clamped to what a space can reasonably absorb.
- A run with no spaces spreads the whole residual between its letters.
CSS letter spacing also trails the last glyph, while the page's width stops at its ink, so one letter-spacing is subtracted before the residual is computed. Details like this are most of the work.
Fitting each line
Runs are grouped into lines by baseline. A line is then fitted as a whole: its runs are measured in their faces with their letter spacing, and the difference from the line's drawn width lands on its spaces as one word-spacing value for the line. A justified paragraph's lines each get their own value, which is how a justified block opens with every line exactly as long as it prints without the editor having to justify anything.
A line with no spaces, such as a table of contents entry whose dot leader runs into its page number, spreads the difference over its letter gaps instead.
Keeping a block's layout
A multi-line block is more than its lines. blockFace picks the longest line's font as the block's face, the first line's baseline, the median baseline pitch as the line height, and infers the alignment from where the lines start and end. Then blockContent joins the lines into one editable text while recording, per line:
- its offset from an even pitch, so a block whose lines alternate 15.75 and 15.0 points opens with every baseline where the page put it;
- its indent from the block's left edge, for a first-line indent, a hanging indent or a ragged left margin;
- its word gap, the fitted word spacing from the previous step;
- whether its line break is hard or soft. A break the author chose (a centred or right-aligned block, a line that ends although the next word would still have fit, a hyphenated word) is a real newline. A break that only exists because the column ran out of room is soft: it is kept until you edit, and then becomes a space so the text can flow again.
Putting the editor on the baseline
The editor is a block of text laid out by the browser. For its first baseline to land on the PDF baseline, Edit My PDF needs the distance from the top of a CSS line box to the alphabetic baseline for this face at this line height. The browser rounds a font's ascent and descent to whole pixels when it builds a line box, so computing that distance from the font's metrics is wrong by up to a pixel at some zoom levels, and wrong in a way that changes as you zoom.
So the distance is measured. A hidden line containing "Hg" and a zero-size marker is set with the editor's exact font and line height, the marker's position is read, and the result is cached per face and line height once document.fonts.check says the face has finished loading. A face still loading measures with its fallback, and that measurement is thrown away.
Reading the pixels
Some things the content stream does not say plainly, and the rendered page does:
- Colour. The colour of a run is read from the canvas under its box: the darkest fifteen per cent of ink pixels, averaged. Operator-list colours do not line up with text items reliably enough to use.
- Backdrop. When you click, the background colour under the run is sampled from the edges of its box, so the edit's backdrop matches a tinted table cell as well as white paper.
- Underline. A single-line underline is found in the lower part of the run's box and reproduced, in its own colour, under the edited text.
- Tails. Runs on the same baseline immediately to the right of the clicked one are snapshotted, so when the edited text gets longer or shorter they move with it.
The pixel we lost to a border
For a while every edit sat two pixels off in the editor and nowhere else. The page frame had a one-pixel CSS border. A border shrinks the canvas inside the frame by two pixels while the overlays above it keep the page's full size, so the rendered page and the editor disagreed by exactly that much. The frame now draws its edge with a box-shadow ring outside the box, and the comment in the stylesheet explains why so nobody puts the border back.
What exact buys you
When all of this holds, clicking a line is undramatic. The text does not jump, the gaps do not change, and the caret appears where you clicked. Then you type, and from that point on the paragraph flows like any other text, inside its own column, in the page's own font. That quiet moment is the whole product.