oficina / docs Public

A Google Docs blank line is one line, not two

Paul pasted One / Two / Three / blank / Four out of Google Docs. The
blank line came back two lines tall, and an extra blank line appeared
under the paste. Both symptoms, one cause each, and neither was where it
looked.

Google spells an empty paragraph as a bare <br> SIBLING of the <p>s,
inside the <b id="docs-internal-guid-…"> wrapper it puts around a copied
selection — not as an empty <p>, and not as a <br> inside one. That
distinction is the whole bug: four attempts to reproduce it failed
because they all had the <br> inside the paragraph. prosemirror-view's
clipboard parser ignores a <br> only when it is the last child of a
non-inline parent, so Google's is kept, schema-basic's hard_break rule
claims it, and normalizeSiblings wraps the now-stray inline node in a
paragraph. The blank line arrives as p(hard_break). Then
addTextblockHacks appends <br class="ProseMirror-trailingBreak"> to any
textblock whose last child is not text, because contenteditable needs
somewhere to put a cursor — so it draws as <p><br><br></p>.

The trailing blank line was not Google's at all. Paul's real clipboard
bytes end with TWO <br class="Apple-interchange-newline"> after the
closing </b>, which WebKit appends to mark where a copy stopped. The
parser's last-child rule disposes of exactly one; the first survives.

Only the editor was ever wrong. The no-JavaScript read view gets
<p><br></p>, and a lone <br> makes exactly one line box; pdf.go's
paragraph loop consumes a trailing break without emitting a line.

fixtures/paste.mjs fixes both on the way in. transformPasted replaces a
paragraph whose children are ALL hard_breaks with that many empty
paragraphs — the first moment the distinction is legible, since a
trailing <br> inside a <p> was already dropped and a paragraph the author
typed always has text in it. A parseDOM rule ignores WebKit's marker by
its class; discriminating on the class rather than on position is what
keeps a trailing blank line the author really did leave, which Google
puts inside its wrapper carrying no class.

Nothing new crosses the wire: the output is an ordinary empty paragraph,
which fragment.rs, pdf.go and markdown.go have each rendered since before
this. Unlike align.mjs, no vocabulary that four separate binaries must
keep in agreement.

Twelve tests in fixtures/paste.test.mjs, in the gate. The one that
matters guards the opposite mistake: a Shift+Enter break inside a line of
text must survive verbatim, and every other field of the hard_break spec
must come through the patch untouched, since the node's name and shape
are the wire format.

Also lands Plan 7 (images, links and tables), revision 2 after Fable's
review. Its load-bearing decision is that fragment_html's output is
template.HTML written unescaped, and is safe only because no byte from a
document reaches attribute position: tables keep that property, links and
images spend it, so the scheme allowlist is bought once in the shared
path — a rejected href emits the run with no link mark at all, so no
consumer can see one. Images render by decoration and the node is
inserted when the upload returns, per §6.1's rule that placeholders are
decorations rather than nodes. Two things it leaves open for Paul: the
sealing key, which does not exist anywhere in the app yet, and testing a
real googleusercontent fetch before Task 9.

Reasoning, the reversals included, is in discussion 23.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev dfb9271acf32b58586108e009905f2b2ef83124a parent 84c906c
7 files changed, +727 −13
  • .amadan/ci.d/10-gate +10 −0
  • .gitignore +1 −0
  • docs/superpowers/plans/2026-09-02-images-links-tables.md +393 −0
  • fixtures/editor-entry.js +14 −1
  • fixtures/paste.mjs +143 −0
  • fixtures/paste.test.mjs +154 −0
  • internal/docs/static/{editor.5b869295084ed7fb.js => editor.7b30ca1e00c791c0.js} +12 −12

CI passed — run details

landed from worktree-paste-fidelity

.amadan/ci.d/10-gate

Loading diff…

.gitignore

Loading diff…

docs/superpowers/plans/2026-09-02-images-links-tables.md

Loading diff…

fixtures/editor-entry.js

Loading diff…

fixtures/paste.mjs

Loading diff…

fixtures/paste.test.mjs

Loading diff…

internal/docs/static/{editor.5b869295084ed7fb.js => editor.7b30ca1e00c791c0.js}

Loading diff…