Most text on a page is set in one face. The exceptions are the interesting ones: a paragraph with a bold lead-in, a sentence with an italic title in it, a definition whose term is set in small caps. A reader sees one paragraph. A textarea can hold only one font. So a paragraph that mixes faces needed its own editor.
The model: spans, not runs
When Edit My PDF reads a page it collects text runs, the pieces pdf.js reports, each in one font. For a line, adjacent runs in one face are joined into a span; a gap wider than a sliver becomes a space; whitespace collapses the way the text would read. A span records its family, weight, style, size, colour, the PDF font it came from, and its own letter spacing when the page tracked it apart from its neighbours. It also keeps the page box its glyphs occupy, because the colour is read from the rendered pixels under that box.
Then the lines of a block are joined, and neighbouring spans in the same face whose colours are as good as equal (within 20 on each channel) are merged, so a paragraph read line by line edits as a few runs rather than one per line.
Revenue. Revenue grew nine per cent to $4.2 billion, with growth in every region.
Deciding what belongs together
Which lines flow into one paragraph? Two lines can continue each other when they share a dominant face, or when the next line's face already appears in the previous one. That second rule is what joins a bold lead-in to the regular text that follows it.
The reverse pairing is deliberately left apart: a single-face line above a mixed line that merely contains its face. That is what a heading followed by a paragraph with a bold lead-in looks like, and pdf.js cannot tell a heading face from a bold one when the weight is baked into the font outlines. Getting this wrong would swallow headings into paragraphs.
The editor owns its DOM
The mixed-face editor is a contentEditable block with one <div> per line and one <span> per styled run. React does not render its contents; the component does, from the model, and it intercepts every edit before the browser applies it.
A beforeinput event tells us what the browser is about to do: insert typed text, paste, drop, insert a line break, delete backward or forward. The affected character range is read from the event's target ranges, the change is applied to the spans in the model, the lines are drawn again, and the caret is put back. Inserted text takes the face of the character before the caret, so a new word typed after a bold run stays bold, and a span that loses all its text disappears, except the last one, which stays so typing can continue.
Input methods that compose text (Japanese, Chinese, dead keys) cannot be intercepted this way. For those the editor lets the browser apply the change and then reconciles: it reads the DOM text back, finds the common prefix and suffix against the model, and applies the difference as one edit.
The editor keeps its own undo history of two hundred steps and handles Cmd+Z and the browser's historyUndo input itself, so undo moves through spans rather than through whatever the browser's contentEditable undo would do to our DOM.
Lines where the page put them
Because the component draws the lines, it can also place them. Each line <div> gets the page's indent for that line (as a text indent on the first line, as padding on the others), a top margin for its baseline offset from an even pitch, and the page's own word spacing for that line. A line the page gave a word gap is already exactly as wide as printed and is never stretched; only a line that flows again after an edit in a justified block is justified by the browser.
Caret positions are character offsets into the whole text. A small helper maps between those offsets and DOM selection points, with the one subtlety that the newline between two line <div>s has no node of its own.
Letting the text flow
A paragraph opens with every line break of the page. Breaks that only exist because the column ran out of room are marked soft. The first edit anywhere in the paragraph turns the soft breaks from that point onward into spaces, so the text reflows inside the paragraph's width, while the lines above the edit keep their printed breaks, gaps and positions. Offsets are preserved through this change so the caret does not jump.
Export
Each span's face is embedded separately on export, and each word is placed in its own span's face at the position the editor showed. The same layout code serves the editor, the painted fallback and the real-text export, which is the only reason they agree.