Paragraphs stack like lines, not like blocks
Google Docs' "Normal text" is 0pt before, 0pt after, 1.15 line spacing:
consecutive paragraphs stack like lines, and a visible gap is an empty
paragraph the author put there. Ours put 1em under every paragraph, so a
list pasted out of Docs came back widely spaced, with a double gap
wherever the author's own blank line had been.
So `[docs-text] p` loses its margin and the body line-height comes down
from 1.6 to 1.4. `p:empty { min-block-size: 1lh }` is the part that
would otherwise ship broken: an empty paragraph reaches the read view as
`<p></p>` (fragment_html emits exactly that; core/tests/fragment.rs pins
it) and was visible only because of the margin that just went away. With
it gone the paragraph collapses to nothing — and only in the read view,
because ProseMirror puts a trailing `<br>` inside an empty textblock, so
:empty does not match while editing. A blank line would have gone on
looking fine to whoever typed it and disappeared for everyone reading.
Lists follow, because the change leaves them incoherent otherwise: a
`ul` keeping 1em below and nothing above carries a gap on one side of
itself only (the other side used to come from the paragraph before it),
and an `li` keeping 0.35em leaves items further apart than two
paragraphs are, inverting the relationship a reader can actually see.
Headings and blockquotes are untouched — their margins were never
supplied by a neighbouring paragraph, and Docs spaces headings too.
internal/export/pdf.go restates all of these numbers, so all of them
move with it or the PDF stops being the same document as the screen.
TestPageGeometryMatchesTheStylesheet is the mechanism for the one it can
see, and it did see it: "body line-height: the stylesheet says 1.4,
internal/export says 1.6". The three margin constants it does not read
are zeroed alongside, and kept named rather than deleted so this file's
mirror of the stylesheet stays complete.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+59
−12
internal/docs/views/show.html+44 −6internal/export/pdf.go+15 −6
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html| index 2d2b75c..1a89e1a 100644 |
| --- a/internal/docs/views/show.html |
| +++ b/internal/docs/views/show.html |
| @@ -219,7 +219,7 @@ |
| overflow-wrap: break-word; |
| font-family: "Docs Serif", Georgia, "Times New Roman", serif; |
| font-size: 12pt; |
| - line-height: 1.6; |
| + line-height: 1.4; |
| } |
| /* The font and size marks (core/src/fragment.rs's MARKS table, and |
| the same class names in fixtures/editor-entry.js's schema). These |
| @@ -254,8 +254,34 @@ |
| [docs-text] > :last-child { |
| margin-block-end: 0; |
| } |
| + /* Paragraphs stack like lines rather than like blocks: nothing |
| + before, nothing after. That is how Google Docs defines its own |
| + "Normal text" (0pt before, 0pt after, 1.15 line spacing), and a |
| + document pasted out of Docs is the ordinary case here. The 1em |
| + that used to sit under every paragraph turned a list of short |
| + paragraphs into a widely spaced document nobody asked for, and |
| + put a double gap wherever the author had left a blank line of |
| + their own. |
| + |
| + p:empty is what keeps that blank line, and it is load-bearing |
| + rather than tidying. An empty paragraph reaches the read view as |
| + `<p></p>` — core/src/fragment.rs's fragment_html emits exactly |
| + that, and core/tests/fragment.rs pins it — and it was visible on |
| + screen ONLY because it contributed the margin above. With that |
| + margin gone it collapses to zero height, so a blank line would |
| + still appear while editing, where ProseMirror puts a |
| + `<br class="ProseMirror-trailingBreak">` inside an empty |
| + textblock and :empty therefore does not match, and would then |
| + silently vanish for a reader with no JavaScript. The other two |
| + renderers already keep it: internal/export/pdf.go's paragraph |
| + gives an empty paragraph a whole line box, and in Markdown the |
| + paragraph break IS a blank line. This stylesheet was the one |
| + that needed telling. */ |
| [docs-text] p { |
| - margin-block: 0 1em; |
| + margin-block: 0; |
| + } |
| + [docs-text] p:empty { |
| + min-block-size: 1lh; |
| } |
| /* Headings clamp to level 1-6 (fragment.rs), but every seed document |
| today only ever produces <h2> (generate-seed.mjs's block |
| @@ -288,14 +314,26 @@ |
| font-size: 1em; |
| font-style: italic; |
| } |
| + /* A list stacks like the paragraphs around it, and its items stack |
| + like each other — both follow from the rule above rather than |
| + being a second opinion about density. A list keeping 1em below it |
| + and nothing above would carry a gap on one side of itself only, |
| + because the other side used to come from the paragraph before it; |
| + and an `li` keeping 0.35em would leave items further apart than |
| + two paragraphs are, inverting the one relationship a reader can |
| + actually see (a list is tighter than the prose around it, not |
| + looser). Headings and blockquotes below keep their own margins: |
| + those were never supplied by a neighbouring paragraph, so nothing |
| + here changes what they need. |
| + |
| + `li > :last-child` still earns its place with `p` at zero — a |
| + nested list, or a blockquote inside an item, has margins of its |
| + own for it to cancel. */ |
| [docs-text] ul, |
| [docs-text] ol { |
| - margin-block: 0 1em; |
| + margin-block: 0; |
| padding-inline-start: 1.4em; |
| } |
| - [docs-text] li { |
| - margin-block-end: 0.35em; |
| - } |
| [docs-text] li > :last-child { |
| margin-block-end: 0; |
| } |
diff --git a/internal/export/pdf.go b/internal/export/pdf.go| index c0b5c43..42b8e08 100644 |
| --- a/internal/export/pdf.go |
| +++ b/internal/export/pdf.go |
| @@ -33,7 +33,7 @@ const ( |
| PageHeightPt = 11 * 72 |
| MarginPt = 72 // 1in |
| BodySizePt = 12 |
| - BodyLeading = 1.6 // line-height |
| + BodyLeading = 1.4 // line-height |
| contentWidthPt = PageWidthPt - 2*MarginPt |
| contentBottom = PageHeightPt - MarginPt |
| @@ -49,11 +49,20 @@ const ( |
| headingSpaceAbove = 1.5 // em of the heading's own size |
| headingSpaceBelow = 0.5 |
| - paragraphSpaceBelow = 1.0 // em of body size |
| - |
| - listIndentEm = 1.4 // padding-inline-start |
| - listItemSpace = 0.35 // margin-block-end on an li |
| - listSpaceBelow = 1.0 |
| + // Zero, and named rather than deleted: a paragraph, a list and a |
| + // list item all stack with no margin at all, the way show.html's |
| + // own rules now say and for the reason its comment on them gives |
| + // (Google Docs' "Normal text" is 0pt before and 0pt after, so |
| + // consecutive paragraphs stack like lines). Keeping the constants |
| + // and the l.space calls that read them keeps this file's mirror of |
| + // the stylesheet complete — every block-level margin the screen has |
| + // is named here — and keeps a future change to any of them one |
| + // edit rather than a re-derivation. |
| + paragraphSpaceBelow = 0.0 // em of body size |
| + |
| + listIndentEm = 1.4 // padding-inline-start |
| + listItemSpace = 0.0 // an li has no margin of its own |
| + listSpaceBelow = 0.0 |
| quoteSpaceAround = 1.0 |
| quoteIndentEm = 1.0 |