Export: centre and right in the PDF, dropped in Markdown
In a PDF alignment is arithmetic, not a property. drawLine already has the measured line, so an offset is all it takes — but the width it is offset within is the COLUMN, not the page: contentWidthPt minus ctx.indent, the identical expression paragraph wraps to. A line centred in one box and wrapped in another is centred wrong by exactly the difference, and a page-relative version puts a right-aligned line's right edge an inch off the paper. An earlier draft that offset against PageWidthPt was watched failing both position tests, which read the geometry back out of the file rather than restating pdf.go's own formula: the sample line's width is derived from where its three runs actually landed, so the test would have agreed with a wrong formula just as readily as a right one if it had recomputed instead. alignOf is the allowlist a second time on this side of the ABI, exact and unnormalised, for the reason headingLevel clamps twice. A case-folding version was written first and TestPDFDropsAnAlignmentIt- DoesNotKnow caught "CENTER" being centred here while the read view dropped it. Markdown drops alignment and says so in its own package comment: CommonMark has no syntax for it, an inline <p align> is HTML that non-rendering readers would show as a tag, and a :---: row would turn a paragraph into a table. The words survive unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+297
−2
internal/export/document.go+40 −1internal/export/markdown.go+12 −0internal/export/markdown_test.go+30 −0internal/export/pdf.go+45 −1internal/export/pdf_test.go+170 −0
Intent
diff --git a/internal/export/document.go b/internal/export/document.go| index 22b9189..78a7aab 100644 |
| --- a/internal/export/document.go |
| +++ b/internal/export/document.go |
| @@ -32,7 +32,8 @@ type Node struct { |
| // Attrs is the small, closed set of node attributes the core emits. |
| type Attrs struct { |
| - Level int `json:"level"` |
| + Level int `json:"level"` |
| + Align string `json:"align"` |
| } |
| // Mark is one mark on a text run. Only the name matters — every mark |
| @@ -115,6 +116,44 @@ func kindOf(n Node) blockKind { |
| // produce — but the two are separate binaries' worth of trust apart |
| // once the JSON has crossed the ABI, and a "#######" in a Markdown |
| // file is not a heading at all. |
| +// The two alignments that are not the default. core/src/fragment.rs's |
| +// ALIGNMENTS table is where this vocabulary is defined; these are the |
| +// same two values, spelled the same way, because they arrive from it |
| +// over the wire. |
| +const ( |
| + alignCentre = "center" |
| + alignRight = "right" |
| +) |
| + |
| +// alignOf is the allowlist a second time, on this side of the ABI, and |
| +// it is exact — no case folding, no trimming, no synonyms. |
| +// |
| +// It exists for the same reason headingLevel clamps a level the core |
| +// has already clamped: by the time the JSON reaches this package it has |
| +// crossed a binary boundary, and this package's own decoder is |
| +// deliberately loose (Node carries unknown types and unknown marks |
| +// through, so a newer editor cannot lose a reader's text). Loose about |
| +// what it CARRIES, strict about what it ACTS on: an alignment this |
| +// build does not know lays out as left rather than as a guess, and |
| +// "CENTER" is not "center" — a normalisation here would be this |
| +// package quietly deciding a question the schema and the core have both |
| +// already answered, and would disagree with the read view, which drops |
| +// it. A document must not paginate one way on screen and another in its |
| +// own export. |
| +// |
| +// Empty means left, which is both "no attribute" and "align=left" — |
| +// the default spends nothing, exactly as it spends no class in the read |
| +// view. |
| +func alignOf(n Node) string { |
| + switch n.Attrs.Align { |
| + case alignCentre: |
| + return alignCentre |
| + case alignRight: |
| + return alignRight |
| + } |
| + return "" |
| +} |
| + |
| func headingLevel(n Node) int { |
| l := n.Attrs.Level |
| if l < 1 { |
diff --git a/internal/export/markdown.go b/internal/export/markdown.go| index fa4ed2c..a3e2532 100644 |
| --- a/internal/export/markdown.go |
| +++ b/internal/export/markdown.go |
| @@ -17,6 +17,18 @@ import ( |
| // exporting to Markdown is asking for the words and their structure; |
| // the typography is what PDF is for. |
| // |
| +// Paragraph alignment falls outside it too, and this says so out loud |
| +// rather than letting it go missing quietly. CommonMark has no syntax |
| +// for alignment at all — not a weaker spelling of it, none — so a |
| +// centred paragraph exports as its own text, byte-identical to the same |
| +// paragraph unaligned, and writeBlock's paragraph case never reads |
| +// `align`. The alternatives fail the same way the font marks' would: an |
| +// inline `<p align="center">` or a `style` attribute is HTML, not |
| +// Markdown, and every reader that does not render raw HTML would show |
| +// the tag; a `:---:` alignment row would silently turn one paragraph |
| +// into a table. The words and the structure survive, which is the |
| +// promise; the layout is what the PDF is for. |
| +// |
| // The document title becomes an H1 at the top. It lives on the |
| // database row rather than inside the CRDT, so it is not in the tree, |
| // and a Markdown file that opens with nothing but the first paragraph |
diff --git a/internal/export/markdown_test.go b/internal/export/markdown_test.go| index 94a7f10..eae167f 100644 |
| --- a/internal/export/markdown_test.go |
| +++ b/internal/export/markdown_test.go |
| @@ -181,6 +181,36 @@ func TestMarkdownKeepsTextInsideUnknownNodes(t *testing.T) { |
| } |
| } |
| +// Alignment is outside Markdown's subset the same way the font and |
| +// size marks are, and for a harder reason: CommonMark has no syntax for |
| +// it at all. What must not happen — the rule this file has everywhere |
| +// else — is the words going with it. A centred paragraph exports as its |
| +// own text, byte-identical to the same paragraph unaligned. |
| +// |
| +// Asserted against the unaligned rendering rather than against a |
| +// literal, because the property is "alignment changes nothing here", |
| +// and a literal would let both sides drift together. |
| +func TestMarkdownDropsAlignmentAndKeepsTheText(t *testing.T) { |
| + plain := `{"type":"doc","content":[ |
| + {"type":"paragraph","content":[{"type":"text","text":"Centred on screen."}]}, |
| + {"type":"paragraph","content":[{"type":"text","text":"Right on screen."}]} |
| + ]}` |
| + aligned := `{"type":"doc","content":[ |
| + {"type":"paragraph","attrs":{"align":"center"},"content":[{"type":"text","text":"Centred on screen."}]}, |
| + {"type":"paragraph","attrs":{"align":"right"},"content":[{"type":"text","text":"Right on screen."}]} |
| + ]}` |
| + want := export.Markdown(mustParse(t, "Notes", plain)) |
| + got := export.Markdown(mustParse(t, "Notes", aligned)) |
| + if got != want { |
| + t.Errorf("aligned Markdown is %q, want the unaligned rendering %q", got, want) |
| + } |
| + for _, text := range []string{"Centred on screen.", "Right on screen."} { |
| + if !strings.Contains(got, text) { |
| + t.Errorf("the text of an aligned paragraph was lost: %q", got) |
| + } |
| + } |
| +} |
| + |
| func TestMarkdownOfAnEmptyDocumentIsJustItsTitle(t *testing.T) { |
| got := export.Markdown(mustParse(t, "Untitled document", `{"type":"doc","content":[]}`)) |
| if got != "# Untitled document\n" { |
diff --git a/internal/export/pdf.go b/internal/export/pdf.go| index e5b56a2..8ebccd3 100644 |
| --- a/internal/export/pdf.go |
| +++ b/internal/export/pdf.go |
| @@ -227,6 +227,11 @@ type blockContext struct { |
| // column a second time and wrap every line an inch early. |
| indent float64 |
| quoted bool |
| + // align is "", alignCentre or alignRight — the block's own |
| + // alignment, already through document.go's allowlist. Empty is |
| + // left, which is where text is drawn when nobody has said |
| + // otherwise. |
| + align string |
| // scale multiplies the base font size — headings set it for their |
| // own content. |
| scale float64 |
| @@ -356,7 +361,12 @@ func (l *layout) block(n Node, ctx blockContext) { |
| l.space(size * headingSpaceBelow) |
| case blockParagraph: |
| - l.paragraph(n.Content, ctx, BodySizePt*ctx.scale*BodyLeading) |
| + // Alignment is the paragraph's own, never inherited from the |
| + // block around it: a centred paragraph inside a quote is |
| + // centred, and its neighbours in that quote are not. |
| + pctx := ctx |
| + pctx.align = alignOf(n) |
| + l.paragraph(n.Content, pctx, BodySizePt*ctx.scale*BodyLeading) |
| l.space(BodySizePt * paragraphSpaceBelow) |
| case blockBulletList: |
| @@ -581,7 +591,41 @@ func (l *layout) drawLine(line []token, ctx blockContext, lineHeight float64) { |
| quoteRulePt, lineHeight, quoteRuleGray) |
| } |
| + // Alignment is arithmetic here, not a CSS property: the line has |
| + // already been measured, so an offset is all a centred or |
| + // right-aligned line is. |
| + // |
| + // The width it is offset within is the COLUMN, not the page: |
| + // contentWidthPt is already the box between the two margins, and |
| + // ctx.indent takes off whatever a quote or a list has inset on top |
| + // of that. It is the identical expression `paragraph` wraps to |
| + // (`maxWidth := contentWidthPt - ctx.indent`), and it has to be — |
| + // a line centred in one box and wrapped in another is centred |
| + // wrong by exactly the difference. Offsetting against PageWidthPt |
| + // instead would put a right-aligned line's right edge an inch off |
| + // the paper. |
| x := MarginPt + ctx.indent |
| + if ctx.align != "" { |
| + var lineWidth float64 |
| + for _, t := range line { |
| + lineWidth += t.width |
| + } |
| + free := (contentWidthPt - ctx.indent) - lineWidth |
| + // A line wider than its column (one unbreakable word that |
| + // splitToWidth could not make progress on) would otherwise be |
| + // pushed LEFT of the margin by a negative offset. Overflowing |
| + // to the right is what the left-aligned case already does with |
| + // it, and it keeps the rule that nothing is ever drawn in the |
| + // margin. |
| + if free > 0 { |
| + switch ctx.align { |
| + case alignCentre: |
| + x += free / 2 |
| + case alignRight: |
| + x += free |
| + } |
| + } |
| + } |
| for _, t := range line { |
| l.draw(l.page, x, baseline, t.st, t.text) |
| x += t.width |
diff --git a/internal/export/pdf_test.go b/internal/export/pdf_test.go| index f2bc556..bcbd904 100644 |
| --- a/internal/export/pdf_test.go |
| +++ b/internal/export/pdf_test.go |
| @@ -483,3 +483,173 @@ func TestPDFDrawsABlankLineForConsecutiveHardBreaks(t *testing.T) { |
| t.Errorf("the two halves are %v apart, want two lines (%v)", gap, want) |
| } |
| } |
| + |
| +// --- paragraph alignment --- |
| + |
| +// alignmentLine is the three paragraphs these tests lay out: the same |
| +// tokens three times, once per alignment, so every position below is |
| +// read off the file rather than recomputed from pdf.go's own |
| +// arithmetic. A test that restated the formula would agree with the |
| +// code by construction and would have agreed with the wrong formula |
| +// just as readily. |
| +// |
| +// Two identical words with a space between them is deliberate: it makes |
| +// three drawn runs per line, and three runs are what let the left- |
| +// aligned copy hand over the line's width without this file measuring a |
| +// font. See lineWidthFrom. |
| +const alignmentLine = `{"type":"text","text":"Widthful Widthful"}` |
| + |
| +// lineWidthFrom derives the drawn width of an alignmentLine from a |
| +// LEFT-aligned copy of it, using nothing but the positions in the file. |
| +// |
| +// The three runs are the word, the space and the word again, each drawn |
| +// at the x the one before it ended at. So the first gap is the word's |
| +// width and the second is the space's, and the line is word + space + |
| +// word wide. Nothing here knows what face or size that was set in, |
| +// which is the point. |
| +func lineWidthFrom(t *testing.T, line []run) float64 { |
| + t.Helper() |
| + if len(line) != 3 { |
| + t.Fatalf("expected 3 runs (word, space, word), got %d: %v", len(line), line) |
| + } |
| + word := line[1].x - line[0].x |
| + space := line[2].x - line[1].x |
| + return word + space + word |
| +} |
| + |
| +// Alignment in a PDF is arithmetic, not a property: a centred line has |
| +// to be measured and offset by hand. This is the test for the thing |
| +// that arithmetic is most likely to get wrong — offsetting against the |
| +// PAGE rather than against the text column. |
| +// |
| +// The two are an inch apart on each side, so a page-relative |
| +// implementation puts a right-aligned line's right edge an inch off the |
| +// right of the paper and every word of it into (or past) the margin. |
| +// Every assertion below is therefore about the content column: the box |
| +// between the two margins, which is the box the screen wraps text in |
| +// and the box `contentWidthPt` names. |
| +func TestPDFAlignsWithinTheContentWidthNotThePage(t *testing.T) { |
| + js := `{"type":"doc","content":[ |
| + {"type":"paragraph","content":[` + alignmentLine + `]}, |
| + {"type":"paragraph","attrs":{"align":"center"},"content":[` + alignmentLine + `]}, |
| + {"type":"paragraph","attrs":{"align":"right"},"content":[` + alignmentLine + `]} |
| + ]}` |
| + data, _ := mustPDF(t, mustParse(t, "", js)) |
| + f := readPDF(t, data) |
| + runs := f.runs(t, f.pages(t)[0]) |
| + if len(runs) != 9 { |
| + t.Fatalf("got %d drawn runs, want 9 (three lines of three): %v", len(runs), runs) |
| + } |
| + left, centre, right := runs[0:3], runs[3:6], runs[6:9] |
| + |
| + width := lineWidthFrom(t, left) |
| + content := export.PageWidthPt - 2*export.MarginPt |
| + if width <= 0 || width >= content { |
| + t.Fatalf("the sample line measures %v against a %v content width — it must fit on one line, unwrapped, for the offsets below to mean anything", width, content) |
| + } |
| + |
| + // Left is unchanged: the default alignment is where text already |
| + // was, and the whole feature has to cost an unaligned paragraph |
| + // nothing. |
| + if math.Abs(left[0].x-export.MarginPt) > 0.01 { |
| + t.Errorf("the left-aligned line starts at x=%v, want the page margin %v", left[0].x, export.MarginPt) |
| + } |
| + |
| + // Centred: equal air on both sides of the text column. Stated as |
| + // the two gaps rather than as a formula, because "the same distance |
| + // from each margin" is what centred MEANS and a formula is only one |
| + // way of arriving at it. |
| + leftGap := centre[0].x - export.MarginPt |
| + rightGap := (export.PageWidthPt - export.MarginPt) - (centre[0].x + width) |
| + if math.Abs(leftGap-rightGap) > 0.01 { |
| + t.Errorf("the centred line has %v of air on its left and %v on its right — centred against the page, not the text column?", leftGap, rightGap) |
| + } |
| + |
| + // Right: the line's right edge sits exactly on the right margin. |
| + // This is the assertion a page-relative implementation fails by a |
| + // whole inch. |
| + if edge := right[0].x + width; math.Abs(edge-(export.PageWidthPt-export.MarginPt)) > 0.01 { |
| + t.Errorf("the right-aligned line ends at x=%v, want the right margin %v", edge, export.PageWidthPt-export.MarginPt) |
| + } |
| + |
| + // And nothing ends up in either margin, on any of the three. |
| + for _, r := range runs { |
| + if r.x < export.MarginPt-0.01 { |
| + t.Errorf("%q is drawn at x=%v, inside the %v left margin", r.text, r.x, export.MarginPt) |
| + } |
| + if r.x > export.PageWidthPt-export.MarginPt+0.01 { |
| + t.Errorf("%q is drawn at x=%v, past the right margin %v", r.text, r.x, export.PageWidthPt-export.MarginPt) |
| + } |
| + } |
| +} |
| + |
| +// The column a line is centred in is the one it WRAPS in, which inside |
| +// a blockquote or a list is narrower than the content width and starts |
| +// further in. Getting this wrong is invisible at the top level and |
| +// obvious the moment anything is indented: text centred against the |
| +// full column inside a quote sits left of centre and can cross the |
| +// quote's own rule. |
| +// |
| +// The indent is read off the file too — it is whatever the quote's |
| +// left-aligned line was drawn at — so this stays a test of the |
| +// relationship rather than a second copy of the constant. |
| +func TestPDFAlignsInsideAQuoteAgainstTheIndentedColumn(t *testing.T) { |
| + js := `{"type":"doc","content":[{"type":"blockquote","content":[ |
| + {"type":"paragraph","content":[` + alignmentLine + `]}, |
| + {"type":"paragraph","attrs":{"align":"center"},"content":[` + alignmentLine + `]}, |
| + {"type":"paragraph","attrs":{"align":"right"},"content":[` + alignmentLine + `]} |
| + ]}]}` |
| + data, _ := mustPDF(t, mustParse(t, "", js)) |
| + f := readPDF(t, data) |
| + runs := f.runs(t, f.pages(t)[0]) |
| + if len(runs) != 9 { |
| + t.Fatalf("got %d drawn runs, want 9 (three lines of three): %v", len(runs), runs) |
| + } |
| + left, centre, right := runs[0:3], runs[3:6], runs[6:9] |
| + |
| + width := lineWidthFrom(t, left) |
| + indent := left[0].x - export.MarginPt |
| + if indent <= 0 { |
| + t.Fatalf("the quoted line starts at x=%v, which is not indented past the %v margin — this test can no longer see what it is checking", left[0].x, export.MarginPt) |
| + } |
| + // The column runs from the indented left edge to the right margin. |
| + columnStart := export.MarginPt + indent |
| + columnEnd := export.PageWidthPt - export.MarginPt |
| + |
| + leftGap := centre[0].x - columnStart |
| + rightGap := columnEnd - (centre[0].x + width) |
| + if math.Abs(leftGap-rightGap) > 0.01 { |
| + t.Errorf("inside the quote the centred line has %v of air on its left and %v on its right — centred against the full content width rather than the indented column?", leftGap, rightGap) |
| + } |
| + if edge := right[0].x + width; math.Abs(edge-columnEnd) > 0.01 { |
| + t.Errorf("inside the quote the right-aligned line ends at x=%v, want the right margin %v", edge, columnEnd) |
| + } |
| + if centre[0].x < columnStart-0.01 { |
| + t.Errorf("the centred line starts at x=%v, left of the quote's own indent %v", centre[0].x, columnStart) |
| + } |
| +} |
| + |
| +// The exporter is a second binary's worth of trust away from the core, |
| +// exactly as headingLevel's second clamp is — the JSON has crossed the |
| +// ABI by the time it gets here, and this package decodes whatever it is |
| +// handed. An alignment it does not know is dropped, and a dropped |
| +// alignment is left: the words land where they would have with no |
| +// attribute at all. |
| +func TestPDFDropsAnAlignmentItDoesNotKnow(t *testing.T) { |
| + js := `{"type":"doc","content":[ |
| + {"type":"paragraph","attrs":{"align":"justify"},"content":[` + alignmentLine + `]}, |
| + {"type":"paragraph","attrs":{"align":"CENTER"},"content":[` + alignmentLine + `]}, |
| + {"type":"paragraph","attrs":{"align":"left"},"content":[` + alignmentLine + `]} |
| + ]}` |
| + data, _ := mustPDF(t, mustParse(t, "", js)) |
| + f := readPDF(t, data) |
| + runs := f.runs(t, f.pages(t)[0]) |
| + if len(runs) != 9 { |
| + t.Fatalf("got %d drawn runs, want 9: %v", len(runs), runs) |
| + } |
| + for i, line := range [][]run{runs[0:3], runs[3:6], runs[6:9]} { |
| + if math.Abs(line[0].x-export.MarginPt) > 0.01 { |
| + t.Errorf("line %d starts at x=%v, want the page margin %v — an alignment this build does not know must lay out as left", i, line[0].x, export.MarginPt) |
| + } |
| + } |
| +} |