package export
import (
"sort"
"strconv"
"strings"
"github.com/carlosframework/docs/internal/fonts"
"github.com/carlosframework/docs/internal/pdf"
)
// The page, in points. These are the numbers views/show.html sets on
// [docs-page] and [docs-text], restated here because the exporter has
// to lay text out in Go and the browser has to lay it out in CSS, and
// the two must agree about the shape of a page or the PDF is of a
// different document.
//
// Restated, not shared: there is no mechanism today that lets one
// definition drive both, and inventing one (rendering the stylesheet
// from these constants) would put a template expansion in the path of
// every page load to solve a problem a test can solve instead —
// internal/docs's TestPageGeometryMatchesTheStylesheet reads show.html
// and fails if the two ever drift.
//
// §11.3 wants the PDF to come from "the same box tree the screen
// renders". That box tree is the layout engine's, and the layout
// engine does not exist yet (spec §13). This is the honest version
// until it does: one set of numbers, asserted equal in both places,
// and line breaking that measures with the same font programs the
// browser is served.
const (
PageWidthPt = 8.5 * 72 // US Letter
PageHeightPt = 11 * 72
MarginPt = 72 // 1in
BodySizePt = 12
BodyLeading = 1.4 // line-height
contentWidthPt = PageWidthPt - 2*MarginPt
contentBottom = PageHeightPt - MarginPt
)
// Block-level proportions, all mirroring show.html's stylesheet. The
// heading scale is h1 1.6em down to h4-h6 at 1em italic; a heading's
// own margins are in its own em, exactly as CSS resolves them.
var headingScale = map[int]float64{1: 1.6, 2: 1.3, 3: 1.1, 4: 1, 5: 1, 6: 1}
const (
headingLeading = 1.3
headingSpaceAbove = 1.5 // em of the heading's own size
headingSpaceBelow = 0.5
// 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
quoteRulePt = 3 * 0.75 // 3px at 96dpi is 2.25pt
quoteGray = 0.29 // #4a4a4a
quoteRuleGray = 0.78 // #c8c8c8
codeScale = 0.85 // [docs-text] code { font-size: 0.85em }
)
// IndentStepPt is one indent level, in points: half an inch, which is
// what Docs, Word and Pages all use. views/show.html states the same
// step in inches (.docs-indent-N), and
// TestPageGeometryMatchesTheStylesheet is what holds the two equal.
// Written 36.0 rather than 36 so it is an untyped FLOAT constant. As an
// integer it made TestPageGeometryMatchesTheStylesheet's
// `IndentStepPt / 72` integer-divide to 0, and the test compared the
// stylesheet's real 0.5in against a zero — reporting a disagreement
// that did not exist, which is the more fortunate half of that mistake.
// The other half is what the same expression would do in layout code.
const IndentStepPt = 36.0
// MaxIndent is the deepest level this exporter lays out. The core
// clamps to the same number (core/src/fragment.rs's MAX_INDENT) and so
// does the editor (fixtures/indent.mjs); clamping again here is
// deliberate and matches what headingLevel already does — a document
// must not indent one distance on screen and another on paper, and the
// exporter does not get to assume the core it was fed by is this build.
const MaxIndent = 8
// IndentOf is the extra left inset a block asks for, in points.
//
// Exported for TestPageGeometryMatchesTheStylesheet, which compares it
// against the stylesheet's own inches. Zero for an un-indented block,
// which is every block in every document written before indent existed.
func IndentOf(n Node) float64 {
level := n.Attrs.Indent
if level < 1 {
return 0
}
if level > MaxIndent {
level = MaxIndent
}
return float64(level) * IndentStepPt
}
// sizeScale is what each size mark multiplies the body size by. The
// same numbers appear in show.html's .docs-size-* rules; the geometry
// test holds them equal.
var sizeScale = map[string]float64{"size_sm": 0.85, "size_lg": 1.25, "size_xl": 1.5}
// sizePt is what each size mark means, absolutely, in points.
//
// ABSOLUTE, where sizeScale above was a multiplier, and the difference
// is the point of the change rather than a tidy-up. A multiplier
// compounds with a heading's own scale, so 14pt inside a heading came
// out at 1.6 x 14 — which is not what anyone means by setting a run to
// 14pt. views/show.html states the same numbers as absolute pt, and
// TestPageGeometryMatchesTheStylesheet holds the two equal.
//
// The three legacy marks map onto the nearest ladder step: the old
// scale was 0.85, 1.25 and 1.5 of a 12pt body, so 10.2, 15 and 18pt.
// Ten and eighteen are exact; fifteen rounds to fourteen. sizeScale is
// kept above only because SizeScale() is still exported for the
// stylesheet comparison of the legacy rules, and deleting a used symbol
// is a separate change.
var sizePt = map[string]float64{
"size_8": 8,
"size_9": 9,
"size_10": 10,
"size_11": 11,
"size_14": 14,
"size_18": 18,
"size_24": 24,
"size_36": 36,
"size_48": 48,
"size_72": 72,
"size_sm": 10,
"size_lg": 14,
"size_xl": 18,
}
// sizeOrder is the order styleFor consults them in. A map has no order,
// and this decision has to be the same on every run — see styleFor.
//
// The ladder comes before the three legacy names, so a run carrying
// both a new mark and an old one is measured at the new one — the same
// rule fixtures/sizes.mjs's sizeForMarks applies, stated in both places
// because neither can import the other.
var sizeOrder = []string{
"size_8", "size_9", "size_10", "size_11", "size_14",
"size_18", "size_24", "size_36", "size_48", "size_72",
"size_sm", "size_lg", "size_xl",
}
// SizePt is one size mark's absolute size in points, and whether it is
// a mark this build knows.
//
// Two return values, unlike SizeScale below. That is BlockSpace's
// reasoning rather than symmetry-breaking: SizeScale could report an
// unknown mark as 0 because 0 is not a multiplier any real mark has, so
// a wrong answer was conspicuous. Nothing protects a size the same way
// — a misspelled mark returning a bare 0 would compare equal to nothing
// in particular, and the geometry test would report agreement while
// checking a size no document has.
func SizePt(mark string) (float64, bool) {
pt, ok := sizePt[mark]
return pt, ok
}
// SizeMarks is every size mark this build measures, in the order
// styleFor consults them. For internal/docs's tests, which hold this
// list against the stylesheet's own rules and against
// fixtures/sizes.mjs.
func SizeMarks() []string { return append([]string(nil), sizeOrder...) }
// SizeScale exposes one size mark's multiplier. It exists for
// internal/docs's TestPageGeometryMatchesTheStylesheet, which is the
// mechanism keeping these numbers equal to the ones in show.html —
// an unknown mark returns 0, so a renamed mark fails that test loudly
// rather than quietly comparing nothing.
func SizeScale(mark string) float64 { return sizeScale[mark] }
// BlockSpace exposes one block-level bottom margin, in em, for the same
// test and the same reason: these three numbers restate show.html's
// stylesheet and nothing but a test can hold them to it.
//
// Two return values, unlike SizeScale above, and that is not
// symmetry-breaking for its own sake. SizeScale can report an unknown
// mark as 0 because 0 is not a multiplier any real mark has, so the
// wrong answer is conspicuous. Every one of these three margins IS
// zero today, so a misspelled name returning a bare 0 would compare
// equal to the right answer and the test would report nothing while
// checking nothing.
func BlockSpace(name string) (float64, bool) {
switch name {
case "paragraph":
return paragraphSpaceBelow, true
case "list":
return listSpaceBelow, true
case "list_item":
return listItemSpace, true
}
return 0, false
}
// FontCSSName is the CSS family name of the face this exporter lays a
// run carrying these marks out in — no marks meaning an unmarked run,
// which is the case that matters most, since it is what most of the
// text in most documents is.
//
// It exists for internal/docs's TestPageGeometryMatchesTheStylesheet,
// which is the only thing holding these faces equal to the
// font-family declarations in views/show.html. Nothing else can: the
// stylesheet is CSS in a template and this is a switch in Go, they name
// the same faces in two languages, and a document paginates the same on
// screen and on paper only while they agree.
//
// It resolves through styleFor rather than restating the answer, so it
// reports what the exporter actually does and not what this file says
// elsewhere that it does. A block context of scale 1 with no marks is
// exactly an ordinary body paragraph.
func FontCSSName(marks ...string) string {
m := make([]Mark, 0, len(marks))
for _, name := range marks {
m = append(m, Mark{Type: name})
}
return (&layout{}).styleFor(m, blockContext{scale: 1}).face.Family.CSSName()
}
// Report is what an export could not do faithfully — §11.2's
// degrade-loudly rule, in the one form this exporter can currently
// fail: a character none of the core faces has a glyph for. A document
// in an unshipped script (§11.1 puts CJK and the wider locale set
// outside the core payload) exports with .notdef boxes, and the person
// who asked for it is entitled to know that rather than discover it.
type Report struct {
// MissingRunes is sorted and deduplicated.
MissingRunes []rune
}
// Empty reports whether the export was fully faithful.
func (r Report) Empty() bool { return len(r.MissingRunes) == 0 }
// String renders the report, naming the characters.
//
// NOT for a log line, and not for an error a handler returns. This
// renders document content, and spec §2.1's leak list (item 5) is
// categorical that neither may carry any: an instance's logs reach the
// platform's own store, and for a short document in an unshipped script
// the deduplicated set of its characters approximates the document
// itself. Use Blocks below for anything that leaves the process. This
// exists for a test, and for the import/export report §11.2 describes —
// a screen shown to the person whose document it already is.
func (r Report) String() string {
if r.Empty() {
return "no substitutions"
}
var b strings.Builder
b.WriteString("no glyph for: ")
for i, ru := range r.MissingRunes {
if i > 0 {
b.WriteString(" ")
}
b.WriteString(strconv.QuoteRune(ru))
}
return b.String()
}
// Blocks names the Unicode blocks the missing characters fall in,
// sorted and deduplicated — what an operator can act on (which faces to
// add) with nothing of the document in it.
//
// Coarse on purpose. A block name is a property of the script, not of
// the text: "CJK" says add a CJK face, and says it whether the document
// was one character or ten thousand.
func (r Report) Blocks() []string {
seen := map[string]bool{}
var out []string
for _, ru := range r.MissingRunes {
name := unicodeBlock(ru)
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// unicodeBlock is a deliberately coarse bucketing — enough to say which
// face is missing, not enough to say anything about the text. Ranges
// are the common scripts a document might arrive in; anything else is
// reported by plane, which still names no character.
func unicodeBlock(r rune) string {
switch {
case r < 0x0250:
return "Latin"
case r >= 0x0370 && r <= 0x03FF:
return "Greek"
case r >= 0x0400 && r <= 0x04FF:
return "Cyrillic"
case r >= 0x0590 && r <= 0x05FF:
return "Hebrew"
case r >= 0x0600 && r <= 0x06FF:
return "Arabic"
case r >= 0x0900 && r <= 0x097F:
return "Devanagari"
case r >= 0x0E00 && r <= 0x0E7F:
return "Thai"
case r >= 0x1100 && r <= 0x11FF, r >= 0xAC00 && r <= 0xD7AF:
return "Hangul"
case r >= 0x3040 && r <= 0x30FF:
return "Kana"
case r >= 0x3400 && r <= 0x9FFF, r >= 0xF900 && r <= 0xFAFF:
return "CJK"
case r >= 0x2000 && r <= 0x2BFF:
return "Punctuation and symbols"
case r > 0xFFFF:
return "Supplementary planes"
}
return "Other"
}
// style is one run's resolved typography.
type style struct {
face fonts.Face
size float64 // points
gray float64
}
// blockContext is what a block inherits from the blocks it sits
// inside: an indent, and whether it is inside a blockquote (which
// makes its text italic and grey, and draws a rule beside every line).
type blockContext struct {
// indent is EXTRA indent beyond the page margin, never an absolute
// x — a blockquote's or a list's own inset, and zero for ordinary
// body text. MarginPt is added at draw time, in the three places
// below that compute an x. Keeping it relative is what makes
// `maxWidth := contentWidthPt - ctx.indent` in paragraph correct:
// contentWidthPt is already the column between the two margins, so
// an absolute indent would subtract the left margin from the
// 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
bold bool
italic bool
}
// PDF lays a document out and returns the file.
func PDF(doc Document) ([]byte, Report, error) {
l := &layout{
b: pdf.New(PageWidthPt, PageHeightPt),
missing: map[rune]bool{},
}
l.b.Title = doc.Title
l.newPage()
l.document(doc)
out, err := l.b.Bytes()
if err != nil {
return nil, Report{}, err
}
return out, l.report(), nil
}
// document lays out a whole Document: its title, then its content.
//
// Shared by PDF and PageOne rather than written out in both, so a
// change to what a first page contains reaches the thumbnail too. The
// two did duplicate it, which meant something added here would have
// reached the thumbnail only if it happened to move a run in the
// agreement test's fixture.
//
// The title is set as a level-1 heading at the top of the first page.
// It is not part of the CRDT and so not part of the box tree the
// screen renders — the screen puts it in the shell bar beside the
// brand link, which is chrome around the paper rather than on it (it
// was a page header until the chrome moved to the topbar shell; either
// way it is not on the paper). A PDF has no chrome: a file that does
// not say which document it is, is worse than one that occasionally
// repeats a heading its author also typed. Markdown does the same
// thing for the same reason.
//
// A caller wanting the paper WITHOUT that chrome — the document list's
// thumbnail does, and preview.go says why — passes a Document with an
// empty Title, which is why this branches rather than always laying
// one.
func (l *layout) document(doc Document) {
if doc.Title != "" {
l.block(Node{
Type: "heading",
Attrs: Attrs{Level: 1},
Content: []Node{{Type: "text", Text: doc.Title}},
}, blockContext{scale: 1})
}
for _, n := range doc.Root.Content {
l.block(n, blockContext{scale: 1})
}
}
type layout struct {
b *pdf.Builder
page *pdf.Page
// cursor is the distance from the top of the page to the top of
// the next line box. Measuring downward from the top is how the
// document itself is shaped; PDF's own origin is at the bottom
// left, and the single conversion happens at draw time.
cursor float64
// pending is the collapsed margin waiting to be applied before the
// next block — CSS collapses adjacent vertical margins to the
// larger of the two, and a paragraph after a heading must not get
// both margins stacked.
pending float64
// atPageTop suppresses a collapsed margin at the top of a page and
// at the very start of the document, mirroring show.html's
// `[docs-text] > :first-child { margin-block-start: 0 }`.
atPageTop bool
// captureNextLine asks drawLine to record where the next line it
// draws actually landed — the page and the offset down it. A list
// item needs that to put its marker beside its own first line,
// which may be on a page the item did not start on. Cleared by the
// capture; capturedPage nil means no line was drawn.
captureNextLine bool
capturedPage *pdf.Page
capturedTop float64
// sink, when set, is told where every line box landed as it is
// placed (pageone.go). PDF export leaves it nil, and leaves
// stopAfterPage 0, so no guard below ever fires and no PDF byte
// moves.
//
// That is a claim by inspection, and it is worth being straight
// about what does and does not hold it. A test that exported the
// same document before and after a PageOne run was written for
// exactly this and then deleted: the two calls share no state, so
// only a package-level global could ever have made it red. It
// passed unchanged while a deliberate `l.cursor += 100` inside
// this block reddened the agreement test — a check that could not
// distinguish "no" from "I can't see it" (AGENTS.md). What covers
// the exporter is its own suite, which measures real output, and
// TestPageOneMatchesTheFirstPageOfThePDF for the reverse
// direction.
sink func(LineBox)
// pages counts the pages started so far, and stopAfterPage — zero
// for "never stop", which is every PDF export — ends the layout
// once that many are complete. Nothing is drawn or reported after
// stopped is set, so a caller that wants one page pays for one page
// of layout rather than for all of a long document's.
pages int
stopAfterPage int
stopped bool
missing map[rune]bool
}
func (l *layout) newPage() {
if l.stopAfterPage > 0 && l.pages >= l.stopAfterPage {
// The page just finished was the last one asked for. Stop
// before allocating another: every drawing path checks stopped
// and returns, so the layout unwinds without drawing again.
l.stopped = true
return
}
l.pages++
l.page = l.b.AddPage()
l.cursor = MarginPt
l.pending = 0
l.atPageTop = true
}
func (l *layout) report() Report {
out := Report{}
for r := range l.missing {
out.MissingRunes = append(out.MissingRunes, r)
}
sort.Slice(out.MissingRunes, func(i, j int) bool { return out.MissingRunes[i] < out.MissingRunes[j] })
return out
}
// space queues a vertical margin, collapsed against whatever is
// already queued.
func (l *layout) space(pt float64) {
if pt > l.pending {
l.pending = pt
}
}
// applyPending consumes the queued margin. At the top of a page it is
// dropped rather than applied, which is both what the stylesheet says
// for the first child and what stops a page break from opening with a
// band of white the reader cannot account for.
func (l *layout) applyPending() {
if !l.atPageTop {
l.cursor += l.pending
}
l.pending = 0
}
// block lays out one node.
func (l *layout) block(n Node, ctx blockContext) {
if l.stopped {
return
}
switch kindOf(n) {
case blockHeading:
level := headingLevel(n)
scale := headingScale[level]
size := BodySizePt * scale
l.space(size * headingSpaceAbove)
hctx := ctx
// The block's own indent, ADDED to whatever a surrounding quote
// or list has already inset. ctx.indent is extra indent beyond
// the page margin (see its own comment), so accumulating is
// exactly right: an indented paragraph inside a list sits in
// from the list's marker, not back at the page edge.
hctx.indent += IndentOf(n)
hctx.scale = scale
hctx.bold = true
// h4-h6 are italic in the stylesheet — they are the same size
// as body text, so slope is the only thing distinguishing
// them.
hctx.italic = ctx.italic || level >= 4
l.paragraph(n.Content, hctx, size*headingLeading)
l.space(size * headingSpaceBelow)
case blockParagraph:
// 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)
pctx.indent += IndentOf(n)
l.paragraph(n.Content, pctx, BodySizePt*ctx.scale*BodyLeading)
l.space(BodySizePt * paragraphSpaceBelow)
case blockBulletList:
l.list(n, ctx, false)
case blockOrderedList:
l.list(n, ctx, true)
case blockQuote:
l.space(BodySizePt * quoteSpaceAround)
inner := ctx
inner.indent += BodySizePt * quoteIndentEm
inner.quoted = true
inner.italic = true
for i, child := range n.Content {
l.block(child, inner)
if i == len(n.Content)-1 {
// The quote's own bottom margin replaces the last
// child's, rather than stacking with it.
l.pending = 0
}
}
l.space(BodySizePt * quoteSpaceAround)
case blockListItem:
// A list item outside a list — not producible through
// ProseMirror's schema, but a client can post any Yjs bytes it
// likes. Laid out as its own content rather than dropped.
for _, child := range n.Content {
l.block(child, ctx)
}
default:
if n.IsText() {
l.paragraph([]Node{n}, ctx, BodySizePt*ctx.scale*BodyLeading)
l.space(BodySizePt * paragraphSpaceBelow)
return
}
// An unknown element: lay out its children. Same rule as the
// core's renderers and the Markdown writer — never lose the
// words.
for _, child := range n.Content {
l.block(child, ctx)
}
}
}
func (l *layout) list(list Node, ctx blockContext, ordered bool) {
inner := ctx
inner.indent += BodySizePt * listIndentEm
for i, item := range list.Content {
marker := "• " // bullet
if ordered {
marker = strconv.Itoa(i+1) + ". "
}
l.applyPending()
// The marker sits in the list's own indent, to the left of the
// item's text, on the item's FIRST line — wherever that line
// actually ends up. captureNextLine asks the line-drawer to
// report the page and offset of the next line it draws, which
// is the only way to know: the item's content may push onto a
// new page before its first line is placed, and the marker has
// to follow it there.
//
// This was a guess before — the marker was drawn at the cursor
// remembered before the item was laid out, guarded by a
// comparison between an offset on the old page and one on the
// new. When an item's first block landed on the next page that
// guard failed and the marker was drawn nowhere at all: in an
// ordered list, a silently missing number, in a file whose own
// module rule is never to lose the words.
markerStyle := l.styleFor(nil, inner)
l.captureNextLine = true
for j, child := range item.Content {
l.block(child, inner)
if j == len(item.Content)-1 {
l.pending = 0
}
}
l.captureNextLine = false
// An item that drew no line at all (empty content) gets no
// marker, which is right: there is nothing for it to sit
// beside.
if l.capturedPage != nil {
asc, _ := ascentDescent(markerStyle)
baseline := l.capturedTop + halfLeading(markerStyle, BodySizePt*BodyLeading) + asc
l.draw(l.capturedPage, MarginPt+inner.indent-textWidth(marker, markerStyle), baseline, markerStyle, marker)
l.capturedPage = nil
}
l.space(BodySizePt * listItemSpace)
}
l.pending = 0
l.space(BodySizePt * listSpaceBelow)
}
// token is one word or one run of whitespace, with the style it is set
// in. Splitting into tokens before wrapping is what lets a line break
// fall between two runs with different marks — "very **bold** words"
// wraps between "very" and "bold" like any other text.
type token struct {
text string
st style
space bool
width float64
// brk is a hard break (core/src/fragment.rs's `hard_break`,
// Shift+Enter, `
` on screen): not a word and not whitespace,
// but a place the line must end. It carries a style because an
// empty line still has a height, and that height comes from the
// run the break sits in.
brk bool
}
// paragraph lays out a run of inline content as wrapped lines.
func (l *layout) paragraph(content []Node, ctx blockContext, lineHeight float64) {
tokens := l.tokenize(content, ctx)
if len(tokens) == 0 {
// An empty paragraph is a blank line on screen (fragment_html
// preserves `