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 `

`), so it is one here too. l.applyPending() l.advance(lineHeight, ctx) return } maxWidth := contentWidthPt - ctx.indent var line []token var lineWidth float64 flush := func() { for len(line) > 0 && line[len(line)-1].space { line = line[:len(line)-1] } if len(line) == 0 { return } l.drawLine(line, ctx, lineHeight) line = nil lineWidth = 0 } for _, t := range tokens { if t.brk { // The line ends here and the block carries on. A break // with nothing on the line yet still consumes a line box — // that is what makes two breaks in a row a blank line, the // same as `

` on screen — and it goes through // applyPending/advance rather than flush, which draws // nothing when the line is empty. if len(line) == 0 { l.applyPending() l.advance(lineHeight, ctx) continue } flush() continue } if t.space { if len(line) == 0 { continue // a line never opens with a space } line = append(line, t) lineWidth += t.width continue } if lineWidth+t.width > maxWidth && len(line) > 0 { flush() } // A single word wider than the whole line: break it. Letting // it overflow would run it off the edge of the paper, where a // URL or a long identifier is exactly the case that produces // one. for t.width > maxWidth { head, rest := splitToWidth(t, maxWidth) if head.text == "" { break // cannot make progress; draw it and overflow } l.drawLine([]token{head}, ctx, lineHeight) t = rest } line = append(line, t) lineWidth += t.width } flush() } // drawLine places one assembled line and moves the cursor past it. func (l *layout) drawLine(line []token, ctx blockContext, lineHeight float64) { if l.stopped { return } l.applyPending() if l.cursor+lineHeight > contentBottom { l.newPage() if l.stopped { return } } if l.captureNextLine { l.capturedPage = l.page l.capturedTop = l.cursor l.captureNextLine = false } // The baseline sits where CSS puts it: the text's own content box // is centred in the line box (half the leading above, half below), // and the baseline is one ascent down from the top of that box. // Getting this from real font metrics rather than a fraction of // the line height is what makes a heading in the PDF sit where the // heading on screen sits. var ascent, descent float64 for _, t := range line { a, d := ascentDescent(t.st) if a > ascent { ascent = a } if d > descent { descent = d } } baseline := l.cursor + (lineHeight-(ascent+descent))/2 + ascent if ctx.quoted { // One rule segment per line, so a quote that breaks across a // page gets a rule on both halves and none in the gap. l.page.Rule(MarginPt+ctx.indent-BodySizePt*quoteIndentEm, PageHeightPt-l.cursor-lineHeight, 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 } } } if l.sink != nil { // Reported from inside drawLine, after alignment has been // resolved and before the pen moves, so what the sink hears is // where the line really went — not where a second pass would // calculate that it ought to have gone. var width, size float64 for _, t := range line { width += t.width if t.st.size > size { size = t.st.size } } l.sink(LineBox{Left: x, Top: l.cursor, Width: width, Height: lineHeight, Size: size}) } for _, t := range line { l.draw(l.page, x, baseline, t.st, t.text) x += t.width } l.cursor += lineHeight l.atPageTop = false } // advance moves past a line box without drawing anything — an empty // paragraph. func (l *layout) advance(lineHeight float64, ctx blockContext) { if l.stopped { return } if l.cursor+lineHeight > contentBottom { l.newPage() if l.stopped { return } } l.cursor += lineHeight l.atPageTop = false } // draw is the one place a top-down cursor becomes a PDF coordinate. func (l *layout) draw(page *pdf.Page, x, baselineFromTop float64, st style, s string) { missing, err := page.Text(x, PageHeightPt-baselineFromTop, st.face, st.size, st.gray, s) if err != nil { // Text only fails if a face will not parse, which // internal/fonts' own test proves cannot happen for the twelve // embedded ones. Dropping the run rather than failing the // whole export: a PDF missing one run is recoverable by the // person reading it, a 500 on export is not. return } for _, r := range missing { l.missing[r] = true } } // tokenize flattens inline content into words and spaces. func (l *layout) tokenize(content []Node, ctx blockContext) []token { var out []token var walk func(nodes []Node) walk = func(nodes []Node) { for _, n := range nodes { if n.Type == "hard_break" { out = append(out, token{brk: true, st: l.styleFor(n.Marks, ctx)}) // Children are impossible through the editor's schema // and possible through raw Yjs bytes; laid out rather // than lost, as everywhere else in this package. walk(n.Content) continue } if !n.IsText() { walk(n.Content) continue } if n.Text == "" { continue } st := l.styleFor(n.Marks, ctx) for _, piece := range splitWords(n.Text) { out = append(out, token{ text: piece.text, st: st, space: piece.space, width: textWidth(piece.text, st), }) } } } walk(content) return out } type piece struct { text string space bool } // splitWords divides a run into alternating words and whitespace, // keeping both — the whitespace has a width and has to be measured, // and its width depends on the style it is set in. func splitWords(s string) []piece { var out []piece var cur strings.Builder inSpace := false for _, r := range s { isSpace := r == ' ' || r == '\t' || r == '\n' || r == '\r' if cur.Len() > 0 && isSpace != inSpace { out = append(out, piece{text: cur.String(), space: inSpace}) cur.Reset() } inSpace = isSpace cur.WriteRune(r) } if cur.Len() > 0 { out = append(out, piece{text: cur.String(), space: inSpace}) } return out } // splitToWidth cuts a token at the last character that fits. func splitToWidth(t token, maxWidth float64) (head, rest token) { runes := []rune(t.text) for i := 1; i < len(runes); i++ { w := textWidth(string(runes[:i+1]), t.st) if w > maxWidth { h := token{text: string(runes[:i]), st: t.st} h.width = textWidth(h.text, h.st) r := token{text: string(runes[i:]), st: t.st} r.width = textWidth(r.text, r.st) return h, r } } return t, token{st: t.st} } // styleFor resolves a run's marks and its block context into a face, a // size and a colour. This is the mirror of show.html's stylesheet, and // the order matters: the block's own scale first, then a size mark, // then code's own reduction — the same order the cascade applies them // in. func (l *layout) styleFor(marks []Mark, ctx blockContext) style { has := func(name string) bool { for _, m := range marks { if m.Type == name { return true } } return false } // fonts.Default, not a face named here: an unmarked run is whatever // views/show.html's [docs-text] rule sets, and the two are held equal // by internal/docs's TestPageGeometryMatchesTheStylesheet through // FontCSSName above. Naming a face here instead would make this a // second opinion about the default, and the one that only shows up // on paper. // // All three marks are listed, the default's included. font_serif is // not redundant with that fallback: it means "serif, whatever the // default happens to be", which is what a document says when somebody // chose serif on purpose, and leaving it out would silently repaint // their text the day the default moves again. family := fonts.Default switch { case has("font_mono"): family = fonts.Mono case has("font_sans"): family = fonts.Sans case has("font_serif"): family = fonts.Serif } size := BodySizePt * ctx.scale // sizeOrder, not a range over the sizeScale map. A run carrying two // size marks is impossible through the editor's schema (they // exclude each other) but perfectly possible through // POST /d/{sqid}/updates, which takes raw Yjs bytes — and ranging a // map with a break picks whichever Go's randomised iteration // reached first, so the same document exported twice could differ. // A fixed order makes the choice arbitrary but stable, which is // what determinism needs. // // (The screen compounds such a run instead — nested spans multiply // — so the two disagree for a document neither can be produced by // the editor. Noted rather than reconciled: making them agree means // deciding what two sizes at once MEANS, which is a schema // question.) for _, mark := range sizeOrder { if has(mark) { // SET, not multiplied. A size mark now names an absolute // point size, so it REPLACES the size the context would // otherwise have given this run — including a heading's // scale. That is what a person means by setting a run to // 14pt, and it is the one behavioural difference between // this ladder and the four-step scale it replaced. size, _ = SizePt(mark) break } } bold := ctx.bold || has("strong") italic := ctx.italic || has("em") if has("code") { // A code run is monospace and smaller, whatever else it says — // exactly as the stylesheet's `[docs-text] code` rule wins over // the inherited family. The background tint the screen draws // behind it is not reproduced: a filled rectangle behind every // code run would have to be measured and drawn per line // fragment, and its absence costs a reader nothing a PDF is // for. family = fonts.Mono size *= codeScale } gray := 0.0 if ctx.quoted { gray = quoteGray } return style{face: fonts.Get(family, bold, italic), size: size, gray: gray} } // textWidth measures a string in points. func textWidth(s string, st style) float64 { font, err := st.face.Parsed() if err != nil { return 0 } w, _ := font.WidthEm(s) return w * st.size } // ascentDescent is the face's own vertical extent at this size, in // points. Descent comes back positive — the table stores it negative, // and every caller here wants a height. func ascentDescent(st style) (ascent, descent float64) { font, err := st.face.Parsed() if err != nil { return st.size * 0.8, st.size * 0.2 } upm := float64(font.UnitsPerEm) return float64(font.Ascender) / upm * st.size, -float64(font.Descender) / upm * st.size } // halfLeading is the space CSS puts above the text's content box // inside a taller line box. func halfLeading(st style, lineHeight float64) float64 { a, d := ascentDescent(st) return (lineHeight - (a + d)) / 2 }