oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package export
2
3import (
4 "sort"
5 "strconv"
6 "strings"
7
8 "github.com/carlosframework/docs/internal/fonts"
9 "github.com/carlosframework/docs/internal/pdf"
10)
11
12// The page, in points. These are the numbers views/show.html sets on
13// [docs-page] and [docs-text], restated here because the exporter has
14// to lay text out in Go and the browser has to lay it out in CSS, and
15// the two must agree about the shape of a page or the PDF is of a
16// different document.
17//
18// Restated, not shared: there is no mechanism today that lets one
19// definition drive both, and inventing one (rendering the stylesheet
20// from these constants) would put a template expansion in the path of
21// every page load to solve a problem a test can solve instead —
22// internal/docs's TestPageGeometryMatchesTheStylesheet reads show.html
23// and fails if the two ever drift.
24//
25// §11.3 wants the PDF to come from "the same box tree the screen
26// renders". That box tree is the layout engine's, and the layout
27// engine does not exist yet (spec §13). This is the honest version
28// until it does: one set of numbers, asserted equal in both places,
29// and line breaking that measures with the same font programs the
30// browser is served.
31const (
32 PageWidthPt = 8.5 * 72 // US Letter
33 PageHeightPt = 11 * 72
34 MarginPt = 72 // 1in
35 BodySizePt = 12
36 BodyLeading = 1.4 // line-height
37
38 contentWidthPt = PageWidthPt - 2*MarginPt
39 contentBottom = PageHeightPt - MarginPt
40)
41
42// Block-level proportions, all mirroring show.html's stylesheet. The
43// heading scale is h1 1.6em down to h4-h6 at 1em italic; a heading's
44// own margins are in its own em, exactly as CSS resolves them.
45var headingScale = map[int]float64{1: 1.6, 2: 1.3, 3: 1.1, 4: 1, 5: 1, 6: 1}
46
47const (
48 headingLeading = 1.3
49 headingSpaceAbove = 1.5 // em of the heading's own size
50 headingSpaceBelow = 0.5
51
52 // Zero, and named rather than deleted: a paragraph, a list and a
53 // list item all stack with no margin at all, the way show.html's
54 // own rules now say and for the reason its comment on them gives
55 // (Google Docs' "Normal text" is 0pt before and 0pt after, so
56 // consecutive paragraphs stack like lines). Keeping the constants
57 // and the l.space calls that read them keeps this file's mirror of
58 // the stylesheet complete — every block-level margin the screen has
59 // is named here — and keeps a future change to any of them one
60 // edit rather than a re-derivation.
61 paragraphSpaceBelow = 0.0 // em of body size
62
63 listIndentEm = 1.4 // padding-inline-start
64 listItemSpace = 0.0 // an li has no margin of its own
65 listSpaceBelow = 0.0
66
67 quoteSpaceAround = 1.0
68 quoteIndentEm = 1.0
69 quoteRulePt = 3 * 0.75 // 3px at 96dpi is 2.25pt
70 quoteGray = 0.29 // #4a4a4a
71 quoteRuleGray = 0.78 // #c8c8c8
72
73 codeScale = 0.85 // [docs-text] code { font-size: 0.85em }
74)
75
76// IndentStepPt is one indent level, in points: half an inch, which is
77// what Docs, Word and Pages all use. views/show.html states the same
78// step in inches (.docs-indent-N), and
79// TestPageGeometryMatchesTheStylesheet is what holds the two equal.
80// Written 36.0 rather than 36 so it is an untyped FLOAT constant. As an
81// integer it made TestPageGeometryMatchesTheStylesheet's
82// `IndentStepPt / 72` integer-divide to 0, and the test compared the
83// stylesheet's real 0.5in against a zero — reporting a disagreement
84// that did not exist, which is the more fortunate half of that mistake.
85// The other half is what the same expression would do in layout code.
86const IndentStepPt = 36.0
87
88// MaxIndent is the deepest level this exporter lays out. The core
89// clamps to the same number (core/src/fragment.rs's MAX_INDENT) and so
90// does the editor (fixtures/indent.mjs); clamping again here is
91// deliberate and matches what headingLevel already does — a document
92// must not indent one distance on screen and another on paper, and the
93// exporter does not get to assume the core it was fed by is this build.
94const MaxIndent = 8
95
96// IndentOf is the extra left inset a block asks for, in points.
97//
98// Exported for TestPageGeometryMatchesTheStylesheet, which compares it
99// against the stylesheet's own inches. Zero for an un-indented block,
100// which is every block in every document written before indent existed.
101func IndentOf(n Node) float64 {
102 level := n.Attrs.Indent
103 if level < 1 {
104 return 0
105 }
106 if level > MaxIndent {
107 level = MaxIndent
108 }
109 return float64(level) * IndentStepPt
110}
111
112// sizeScale is what each size mark multiplies the body size by. The
113// same numbers appear in show.html's .docs-size-* rules; the geometry
114// test holds them equal.
115var sizeScale = map[string]float64{"size_sm": 0.85, "size_lg": 1.25, "size_xl": 1.5}
116
117// sizePt is what each size mark means, absolutely, in points.
118//
119// ABSOLUTE, where sizeScale above was a multiplier, and the difference
120// is the point of the change rather than a tidy-up. A multiplier
121// compounds with a heading's own scale, so 14pt inside a heading came
122// out at 1.6 x 14 — which is not what anyone means by setting a run to
123// 14pt. views/show.html states the same numbers as absolute pt, and
124// TestPageGeometryMatchesTheStylesheet holds the two equal.
125//
126// The three legacy marks map onto the nearest ladder step: the old
127// scale was 0.85, 1.25 and 1.5 of a 12pt body, so 10.2, 15 and 18pt.
128// Ten and eighteen are exact; fifteen rounds to fourteen. sizeScale is
129// kept above only because SizeScale() is still exported for the
130// stylesheet comparison of the legacy rules, and deleting a used symbol
131// is a separate change.
132var sizePt = map[string]float64{
133 "size_8": 8,
134 "size_9": 9,
135 "size_10": 10,
136 "size_11": 11,
137 "size_14": 14,
138 "size_18": 18,
139 "size_24": 24,
140 "size_36": 36,
141 "size_48": 48,
142 "size_72": 72,
143 "size_sm": 10,
144 "size_lg": 14,
145 "size_xl": 18,
146}
147
148// sizeOrder is the order styleFor consults them in. A map has no order,
149// and this decision has to be the same on every run — see styleFor.
150//
151// The ladder comes before the three legacy names, so a run carrying
152// both a new mark and an old one is measured at the new one — the same
153// rule fixtures/sizes.mjs's sizeForMarks applies, stated in both places
154// because neither can import the other.
155var sizeOrder = []string{
156 "size_8", "size_9", "size_10", "size_11", "size_14",
157 "size_18", "size_24", "size_36", "size_48", "size_72",
158 "size_sm", "size_lg", "size_xl",
159}
160
161// SizePt is one size mark's absolute size in points, and whether it is
162// a mark this build knows.
163//
164// Two return values, unlike SizeScale below. That is BlockSpace's
165// reasoning rather than symmetry-breaking: SizeScale could report an
166// unknown mark as 0 because 0 is not a multiplier any real mark has, so
167// a wrong answer was conspicuous. Nothing protects a size the same way
168// — a misspelled mark returning a bare 0 would compare equal to nothing
169// in particular, and the geometry test would report agreement while
170// checking a size no document has.
171func SizePt(mark string) (float64, bool) {
172 pt, ok := sizePt[mark]
173 return pt, ok
174}
175
176// SizeMarks is every size mark this build measures, in the order
177// styleFor consults them. For internal/docs's tests, which hold this
178// list against the stylesheet's own rules and against
179// fixtures/sizes.mjs.
180func SizeMarks() []string { return append([]string(nil), sizeOrder...) }
181
182// SizeScale exposes one size mark's multiplier. It exists for
183// internal/docs's TestPageGeometryMatchesTheStylesheet, which is the
184// mechanism keeping these numbers equal to the ones in show.html —
185// an unknown mark returns 0, so a renamed mark fails that test loudly
186// rather than quietly comparing nothing.
187func SizeScale(mark string) float64 { return sizeScale[mark] }
188
189// BlockSpace exposes one block-level bottom margin, in em, for the same
190// test and the same reason: these three numbers restate show.html's
191// stylesheet and nothing but a test can hold them to it.
192//
193// Two return values, unlike SizeScale above, and that is not
194// symmetry-breaking for its own sake. SizeScale can report an unknown
195// mark as 0 because 0 is not a multiplier any real mark has, so the
196// wrong answer is conspicuous. Every one of these three margins IS
197// zero today, so a misspelled name returning a bare 0 would compare
198// equal to the right answer and the test would report nothing while
199// checking nothing.
200func BlockSpace(name string) (float64, bool) {
201 switch name {
202 case "paragraph":
203 return paragraphSpaceBelow, true
204 case "list":
205 return listSpaceBelow, true
206 case "list_item":
207 return listItemSpace, true
208 }
209 return 0, false
210}
211
212// FontCSSName is the CSS family name of the face this exporter lays a
213// run carrying these marks out in — no marks meaning an unmarked run,
214// which is the case that matters most, since it is what most of the
215// text in most documents is.
216//
217// It exists for internal/docs's TestPageGeometryMatchesTheStylesheet,
218// which is the only thing holding these faces equal to the
219// font-family declarations in views/show.html. Nothing else can: the
220// stylesheet is CSS in a template and this is a switch in Go, they name
221// the same faces in two languages, and a document paginates the same on
222// screen and on paper only while they agree.
223//
224// It resolves through styleFor rather than restating the answer, so it
225// reports what the exporter actually does and not what this file says
226// elsewhere that it does. A block context of scale 1 with no marks is
227// exactly an ordinary body paragraph.
228func FontCSSName(marks ...string) string {
229 m := make([]Mark, 0, len(marks))
230 for _, name := range marks {
231 m = append(m, Mark{Type: name})
232 }
233 return (&layout{}).styleFor(m, blockContext{scale: 1}).face.Family.CSSName()
234}
235
236// Report is what an export could not do faithfully — §11.2's
237// degrade-loudly rule, in the one form this exporter can currently
238// fail: a character none of the core faces has a glyph for. A document
239// in an unshipped script (§11.1 puts CJK and the wider locale set
240// outside the core payload) exports with .notdef boxes, and the person
241// who asked for it is entitled to know that rather than discover it.
242type Report struct {
243 // MissingRunes is sorted and deduplicated.
244 MissingRunes []rune
245}
246
247// Empty reports whether the export was fully faithful.
248func (r Report) Empty() bool { return len(r.MissingRunes) == 0 }
249
250// String renders the report, naming the characters.
251//
252// NOT for a log line, and not for an error a handler returns. This
253// renders document content, and spec §2.1's leak list (item 5) is
254// categorical that neither may carry any: an instance's logs reach the
255// platform's own store, and for a short document in an unshipped script
256// the deduplicated set of its characters approximates the document
257// itself. Use Blocks below for anything that leaves the process. This
258// exists for a test, and for the import/export report §11.2 describes —
259// a screen shown to the person whose document it already is.
260func (r Report) String() string {
261 if r.Empty() {
262 return "no substitutions"
263 }
264 var b strings.Builder
265 b.WriteString("no glyph for: ")
266 for i, ru := range r.MissingRunes {
267 if i > 0 {
268 b.WriteString(" ")
269 }
270 b.WriteString(strconv.QuoteRune(ru))
271 }
272 return b.String()
273}
274
275// Blocks names the Unicode blocks the missing characters fall in,
276// sorted and deduplicated — what an operator can act on (which faces to
277// add) with nothing of the document in it.
278//
279// Coarse on purpose. A block name is a property of the script, not of
280// the text: "CJK" says add a CJK face, and says it whether the document
281// was one character or ten thousand.
282func (r Report) Blocks() []string {
283 seen := map[string]bool{}
284 var out []string
285 for _, ru := range r.MissingRunes {
286 name := unicodeBlock(ru)
287 if !seen[name] {
288 seen[name] = true
289 out = append(out, name)
290 }
291 }
292 sort.Strings(out)
293 return out
294}
295
296// unicodeBlock is a deliberately coarse bucketing — enough to say which
297// face is missing, not enough to say anything about the text. Ranges
298// are the common scripts a document might arrive in; anything else is
299// reported by plane, which still names no character.
300func unicodeBlock(r rune) string {
301 switch {
302 case r < 0x0250:
303 return "Latin"
304 case r >= 0x0370 && r <= 0x03FF:
305 return "Greek"
306 case r >= 0x0400 && r <= 0x04FF:
307 return "Cyrillic"
308 case r >= 0x0590 && r <= 0x05FF:
309 return "Hebrew"
310 case r >= 0x0600 && r <= 0x06FF:
311 return "Arabic"
312 case r >= 0x0900 && r <= 0x097F:
313 return "Devanagari"
314 case r >= 0x0E00 && r <= 0x0E7F:
315 return "Thai"
316 case r >= 0x1100 && r <= 0x11FF, r >= 0xAC00 && r <= 0xD7AF:
317 return "Hangul"
318 case r >= 0x3040 && r <= 0x30FF:
319 return "Kana"
320 case r >= 0x3400 && r <= 0x9FFF, r >= 0xF900 && r <= 0xFAFF:
321 return "CJK"
322 case r >= 0x2000 && r <= 0x2BFF:
323 return "Punctuation and symbols"
324 case r > 0xFFFF:
325 return "Supplementary planes"
326 }
327 return "Other"
328}
329
330// style is one run's resolved typography.
331type style struct {
332 face fonts.Face
333 size float64 // points
334 gray float64
335}
336
337// blockContext is what a block inherits from the blocks it sits
338// inside: an indent, and whether it is inside a blockquote (which
339// makes its text italic and grey, and draws a rule beside every line).
340type blockContext struct {
341 // indent is EXTRA indent beyond the page margin, never an absolute
342 // x — a blockquote's or a list's own inset, and zero for ordinary
343 // body text. MarginPt is added at draw time, in the three places
344 // below that compute an x. Keeping it relative is what makes
345 // `maxWidth := contentWidthPt - ctx.indent` in paragraph correct:
346 // contentWidthPt is already the column between the two margins, so
347 // an absolute indent would subtract the left margin from the
348 // column a second time and wrap every line an inch early.
349 indent float64
350 quoted bool
351 // align is "", alignCentre or alignRight — the block's own
352 // alignment, already through document.go's allowlist. Empty is
353 // left, which is where text is drawn when nobody has said
354 // otherwise.
355 align string
356 // scale multiplies the base font size — headings set it for their
357 // own content.
358 scale float64
359 bold bool
360 italic bool
361}
362
363// PDF lays a document out and returns the file.
364func PDF(doc Document) ([]byte, Report, error) {
365 l := &layout{
366 b: pdf.New(PageWidthPt, PageHeightPt),
367 missing: map[rune]bool{},
368 }
369 l.b.Title = doc.Title
370 l.newPage()
371 l.document(doc)
372
373 out, err := l.b.Bytes()
374 if err != nil {
375 return nil, Report{}, err
376 }
377 return out, l.report(), nil
378}
379
380// document lays out a whole Document: its title, then its content.
381//
382// Shared by PDF and PageOne rather than written out in both, so a
383// change to what a first page contains reaches the thumbnail too. The
384// two did duplicate it, which meant something added here would have
385// reached the thumbnail only if it happened to move a run in the
386// agreement test's fixture.
387//
388// The title is set as a level-1 heading at the top of the first page.
389// It is not part of the CRDT and so not part of the box tree the
390// screen renders — the screen puts it in the shell bar beside the
391// brand link, which is chrome around the paper rather than on it (it
392// was a page header until the chrome moved to the topbar shell; either
393// way it is not on the paper). A PDF has no chrome: a file that does
394// not say which document it is, is worse than one that occasionally
395// repeats a heading its author also typed. Markdown does the same
396// thing for the same reason.
397//
398// A caller wanting the paper WITHOUT that chrome — the document list's
399// thumbnail does, and preview.go says why — passes a Document with an
400// empty Title, which is why this branches rather than always laying
401// one.
402func (l *layout) document(doc Document) {
403 if doc.Title != "" {
404 l.block(Node{
405 Type: "heading",
406 Attrs: Attrs{Level: 1},
407 Content: []Node{{Type: "text", Text: doc.Title}},
408 }, blockContext{scale: 1})
409 }
410 for _, n := range doc.Root.Content {
411 l.block(n, blockContext{scale: 1})
412 }
413}
414
415type layout struct {
416 b *pdf.Builder
417 page *pdf.Page
418
419 // cursor is the distance from the top of the page to the top of
420 // the next line box. Measuring downward from the top is how the
421 // document itself is shaped; PDF's own origin is at the bottom
422 // left, and the single conversion happens at draw time.
423 cursor float64
424
425 // pending is the collapsed margin waiting to be applied before the
426 // next block — CSS collapses adjacent vertical margins to the
427 // larger of the two, and a paragraph after a heading must not get
428 // both margins stacked.
429 pending float64
430
431 // atPageTop suppresses a collapsed margin at the top of a page and
432 // at the very start of the document, mirroring show.html's
433 // `[docs-text] > :first-child { margin-block-start: 0 }`.
434 atPageTop bool
435
436 // captureNextLine asks drawLine to record where the next line it
437 // draws actually landed — the page and the offset down it. A list
438 // item needs that to put its marker beside its own first line,
439 // which may be on a page the item did not start on. Cleared by the
440 // capture; capturedPage nil means no line was drawn.
441 captureNextLine bool
442 capturedPage *pdf.Page
443 capturedTop float64
444
445 // sink, when set, is told where every line box landed as it is
446 // placed (pageone.go). PDF export leaves it nil, and leaves
447 // stopAfterPage 0, so no guard below ever fires and no PDF byte
448 // moves.
449 //
450 // That is a claim by inspection, and it is worth being straight
451 // about what does and does not hold it. A test that exported the
452 // same document before and after a PageOne run was written for
453 // exactly this and then deleted: the two calls share no state, so
454 // only a package-level global could ever have made it red. It
455 // passed unchanged while a deliberate `l.cursor += 100` inside
456 // this block reddened the agreement test — a check that could not
457 // distinguish "no" from "I can't see it" (AGENTS.md). What covers
458 // the exporter is its own suite, which measures real output, and
459 // TestPageOneMatchesTheFirstPageOfThePDF for the reverse
460 // direction.
461 sink func(LineBox)
462
463 // pages counts the pages started so far, and stopAfterPage — zero
464 // for "never stop", which is every PDF export — ends the layout
465 // once that many are complete. Nothing is drawn or reported after
466 // stopped is set, so a caller that wants one page pays for one page
467 // of layout rather than for all of a long document's.
468 pages int
469 stopAfterPage int
470 stopped bool
471
472 missing map[rune]bool
473}
474
475func (l *layout) newPage() {
476 if l.stopAfterPage > 0 && l.pages >= l.stopAfterPage {
477 // The page just finished was the last one asked for. Stop
478 // before allocating another: every drawing path checks stopped
479 // and returns, so the layout unwinds without drawing again.
480 l.stopped = true
481 return
482 }
483 l.pages++
484 l.page = l.b.AddPage()
485 l.cursor = MarginPt
486 l.pending = 0
487 l.atPageTop = true
488}
489
490func (l *layout) report() Report {
491 out := Report{}
492 for r := range l.missing {
493 out.MissingRunes = append(out.MissingRunes, r)
494 }
495 sort.Slice(out.MissingRunes, func(i, j int) bool { return out.MissingRunes[i] < out.MissingRunes[j] })
496 return out
497}
498
499// space queues a vertical margin, collapsed against whatever is
500// already queued.
501func (l *layout) space(pt float64) {
502 if pt > l.pending {
503 l.pending = pt
504 }
505}
506
507// applyPending consumes the queued margin. At the top of a page it is
508// dropped rather than applied, which is both what the stylesheet says
509// for the first child and what stops a page break from opening with a
510// band of white the reader cannot account for.
511func (l *layout) applyPending() {
512 if !l.atPageTop {
513 l.cursor += l.pending
514 }
515 l.pending = 0
516}
517
518// block lays out one node.
519func (l *layout) block(n Node, ctx blockContext) {
520 if l.stopped {
521 return
522 }
523 switch kindOf(n) {
524 case blockHeading:
525 level := headingLevel(n)
526 scale := headingScale[level]
527 size := BodySizePt * scale
528 l.space(size * headingSpaceAbove)
529 hctx := ctx
530 // The block's own indent, ADDED to whatever a surrounding quote
531 // or list has already inset. ctx.indent is extra indent beyond
532 // the page margin (see its own comment), so accumulating is
533 // exactly right: an indented paragraph inside a list sits in
534 // from the list's marker, not back at the page edge.
535 hctx.indent += IndentOf(n)
536 hctx.scale = scale
537 hctx.bold = true
538 // h4-h6 are italic in the stylesheet — they are the same size
539 // as body text, so slope is the only thing distinguishing
540 // them.
541 hctx.italic = ctx.italic || level >= 4
542 l.paragraph(n.Content, hctx, size*headingLeading)
543 l.space(size * headingSpaceBelow)
544
545 case blockParagraph:
546 // Alignment is the paragraph's own, never inherited from the
547 // block around it: a centred paragraph inside a quote is
548 // centred, and its neighbours in that quote are not.
549 pctx := ctx
550 pctx.align = alignOf(n)
551 pctx.indent += IndentOf(n)
552 l.paragraph(n.Content, pctx, BodySizePt*ctx.scale*BodyLeading)
553 l.space(BodySizePt * paragraphSpaceBelow)
554
555 case blockBulletList:
556 l.list(n, ctx, false)
557
558 case blockOrderedList:
559 l.list(n, ctx, true)
560
561 case blockQuote:
562 l.space(BodySizePt * quoteSpaceAround)
563 inner := ctx
564 inner.indent += BodySizePt * quoteIndentEm
565 inner.quoted = true
566 inner.italic = true
567 for i, child := range n.Content {
568 l.block(child, inner)
569 if i == len(n.Content)-1 {
570 // The quote's own bottom margin replaces the last
571 // child's, rather than stacking with it.
572 l.pending = 0
573 }
574 }
575 l.space(BodySizePt * quoteSpaceAround)
576
577 case blockListItem:
578 // A list item outside a list — not producible through
579 // ProseMirror's schema, but a client can post any Yjs bytes it
580 // likes. Laid out as its own content rather than dropped.
581 for _, child := range n.Content {
582 l.block(child, ctx)
583 }
584
585 default:
586 if n.IsText() {
587 l.paragraph([]Node{n}, ctx, BodySizePt*ctx.scale*BodyLeading)
588 l.space(BodySizePt * paragraphSpaceBelow)
589 return
590 }
591 // An unknown element: lay out its children. Same rule as the
592 // core's renderers and the Markdown writer — never lose the
593 // words.
594 for _, child := range n.Content {
595 l.block(child, ctx)
596 }
597 }
598}
599
600func (l *layout) list(list Node, ctx blockContext, ordered bool) {
601 inner := ctx
602 inner.indent += BodySizePt * listIndentEm
603
604 for i, item := range list.Content {
605 marker := "• " // bullet
606 if ordered {
607 marker = strconv.Itoa(i+1) + ". "
608 }
609 l.applyPending()
610 // The marker sits in the list's own indent, to the left of the
611 // item's text, on the item's FIRST line — wherever that line
612 // actually ends up. captureNextLine asks the line-drawer to
613 // report the page and offset of the next line it draws, which
614 // is the only way to know: the item's content may push onto a
615 // new page before its first line is placed, and the marker has
616 // to follow it there.
617 //
618 // This was a guess before — the marker was drawn at the cursor
619 // remembered before the item was laid out, guarded by a
620 // comparison between an offset on the old page and one on the
621 // new. When an item's first block landed on the next page that
622 // guard failed and the marker was drawn nowhere at all: in an
623 // ordered list, a silently missing number, in a file whose own
624 // module rule is never to lose the words.
625 markerStyle := l.styleFor(nil, inner)
626 l.captureNextLine = true
627
628 for j, child := range item.Content {
629 l.block(child, inner)
630 if j == len(item.Content)-1 {
631 l.pending = 0
632 }
633 }
634 l.captureNextLine = false
635
636 // An item that drew no line at all (empty content) gets no
637 // marker, which is right: there is nothing for it to sit
638 // beside.
639 if l.capturedPage != nil {
640 asc, _ := ascentDescent(markerStyle)
641 baseline := l.capturedTop + halfLeading(markerStyle, BodySizePt*BodyLeading) + asc
642 l.draw(l.capturedPage, MarginPt+inner.indent-textWidth(marker, markerStyle), baseline, markerStyle, marker)
643 l.capturedPage = nil
644 }
645 l.space(BodySizePt * listItemSpace)
646 }
647 l.pending = 0
648 l.space(BodySizePt * listSpaceBelow)
649}
650
651// token is one word or one run of whitespace, with the style it is set
652// in. Splitting into tokens before wrapping is what lets a line break
653// fall between two runs with different marks — "very **bold** words"
654// wraps between "very" and "bold" like any other text.
655type token struct {
656 text string
657 st style
658 space bool
659 width float64
660 // brk is a hard break (core/src/fragment.rs's `hard_break`,
661 // Shift+Enter, `<br>` on screen): not a word and not whitespace,
662 // but a place the line must end. It carries a style because an
663 // empty line still has a height, and that height comes from the
664 // run the break sits in.
665 brk bool
666}
667
668// paragraph lays out a run of inline content as wrapped lines.
669func (l *layout) paragraph(content []Node, ctx blockContext, lineHeight float64) {
670 tokens := l.tokenize(content, ctx)
671 if len(tokens) == 0 {
672 // An empty paragraph is a blank line on screen (fragment_html
673 // preserves `<p></p>`), so it is one here too.
674 l.applyPending()
675 l.advance(lineHeight, ctx)
676 return
677 }
678
679 maxWidth := contentWidthPt - ctx.indent
680 var line []token
681 var lineWidth float64
682
683 flush := func() {
684 for len(line) > 0 && line[len(line)-1].space {
685 line = line[:len(line)-1]
686 }
687 if len(line) == 0 {
688 return
689 }
690 l.drawLine(line, ctx, lineHeight)
691 line = nil
692 lineWidth = 0
693 }
694
695 for _, t := range tokens {
696 if t.brk {
697 // The line ends here and the block carries on. A break
698 // with nothing on the line yet still consumes a line box —
699 // that is what makes two breaks in a row a blank line, the
700 // same as `<br><br>` on screen — and it goes through
701 // applyPending/advance rather than flush, which draws
702 // nothing when the line is empty.
703 if len(line) == 0 {
704 l.applyPending()
705 l.advance(lineHeight, ctx)
706 continue
707 }
708 flush()
709 continue
710 }
711 if t.space {
712 if len(line) == 0 {
713 continue // a line never opens with a space
714 }
715 line = append(line, t)
716 lineWidth += t.width
717 continue
718 }
719 if lineWidth+t.width > maxWidth && len(line) > 0 {
720 flush()
721 }
722 // A single word wider than the whole line: break it. Letting
723 // it overflow would run it off the edge of the paper, where a
724 // URL or a long identifier is exactly the case that produces
725 // one.
726 for t.width > maxWidth {
727 head, rest := splitToWidth(t, maxWidth)
728 if head.text == "" {
729 break // cannot make progress; draw it and overflow
730 }
731 l.drawLine([]token{head}, ctx, lineHeight)
732 t = rest
733 }
734 line = append(line, t)
735 lineWidth += t.width
736 }
737 flush()
738}
739
740// drawLine places one assembled line and moves the cursor past it.
741func (l *layout) drawLine(line []token, ctx blockContext, lineHeight float64) {
742 if l.stopped {
743 return
744 }
745 l.applyPending()
746 if l.cursor+lineHeight > contentBottom {
747 l.newPage()
748 if l.stopped {
749 return
750 }
751 }
752 if l.captureNextLine {
753 l.capturedPage = l.page
754 l.capturedTop = l.cursor
755 l.captureNextLine = false
756 }
757
758 // The baseline sits where CSS puts it: the text's own content box
759 // is centred in the line box (half the leading above, half below),
760 // and the baseline is one ascent down from the top of that box.
761 // Getting this from real font metrics rather than a fraction of
762 // the line height is what makes a heading in the PDF sit where the
763 // heading on screen sits.
764 var ascent, descent float64
765 for _, t := range line {
766 a, d := ascentDescent(t.st)
767 if a > ascent {
768 ascent = a
769 }
770 if d > descent {
771 descent = d
772 }
773 }
774 baseline := l.cursor + (lineHeight-(ascent+descent))/2 + ascent
775
776 if ctx.quoted {
777 // One rule segment per line, so a quote that breaks across a
778 // page gets a rule on both halves and none in the gap.
779 l.page.Rule(MarginPt+ctx.indent-BodySizePt*quoteIndentEm, PageHeightPt-l.cursor-lineHeight,
780 quoteRulePt, lineHeight, quoteRuleGray)
781 }
782
783 // Alignment is arithmetic here, not a CSS property: the line has
784 // already been measured, so an offset is all a centred or
785 // right-aligned line is.
786 //
787 // The width it is offset within is the COLUMN, not the page:
788 // contentWidthPt is already the box between the two margins, and
789 // ctx.indent takes off whatever a quote or a list has inset on top
790 // of that. It is the identical expression `paragraph` wraps to
791 // (`maxWidth := contentWidthPt - ctx.indent`), and it has to be —
792 // a line centred in one box and wrapped in another is centred
793 // wrong by exactly the difference. Offsetting against PageWidthPt
794 // instead would put a right-aligned line's right edge an inch off
795 // the paper.
796 x := MarginPt + ctx.indent
797 if ctx.align != "" {
798 var lineWidth float64
799 for _, t := range line {
800 lineWidth += t.width
801 }
802 free := (contentWidthPt - ctx.indent) - lineWidth
803 // A line wider than its column (one unbreakable word that
804 // splitToWidth could not make progress on) would otherwise be
805 // pushed LEFT of the margin by a negative offset. Overflowing
806 // to the right is what the left-aligned case already does with
807 // it, and it keeps the rule that nothing is ever drawn in the
808 // margin.
809 if free > 0 {
810 switch ctx.align {
811 case alignCentre:
812 x += free / 2
813 case alignRight:
814 x += free
815 }
816 }
817 }
818 if l.sink != nil {
819 // Reported from inside drawLine, after alignment has been
820 // resolved and before the pen moves, so what the sink hears is
821 // where the line really went — not where a second pass would
822 // calculate that it ought to have gone.
823 var width, size float64
824 for _, t := range line {
825 width += t.width
826 if t.st.size > size {
827 size = t.st.size
828 }
829 }
830 l.sink(LineBox{Left: x, Top: l.cursor, Width: width, Height: lineHeight, Size: size})
831 }
832
833 for _, t := range line {
834 l.draw(l.page, x, baseline, t.st, t.text)
835 x += t.width
836 }
837 l.cursor += lineHeight
838 l.atPageTop = false
839}
840
841// advance moves past a line box without drawing anything — an empty
842// paragraph.
843func (l *layout) advance(lineHeight float64, ctx blockContext) {
844 if l.stopped {
845 return
846 }
847 if l.cursor+lineHeight > contentBottom {
848 l.newPage()
849 if l.stopped {
850 return
851 }
852 }
853 l.cursor += lineHeight
854 l.atPageTop = false
855}
856
857// draw is the one place a top-down cursor becomes a PDF coordinate.
858func (l *layout) draw(page *pdf.Page, x, baselineFromTop float64, st style, s string) {
859 missing, err := page.Text(x, PageHeightPt-baselineFromTop, st.face, st.size, st.gray, s)
860 if err != nil {
861 // Text only fails if a face will not parse, which
862 // internal/fonts' own test proves cannot happen for the twelve
863 // embedded ones. Dropping the run rather than failing the
864 // whole export: a PDF missing one run is recoverable by the
865 // person reading it, a 500 on export is not.
866 return
867 }
868 for _, r := range missing {
869 l.missing[r] = true
870 }
871}
872
873// tokenize flattens inline content into words and spaces.
874func (l *layout) tokenize(content []Node, ctx blockContext) []token {
875 var out []token
876 var walk func(nodes []Node)
877 walk = func(nodes []Node) {
878 for _, n := range nodes {
879 if n.Type == "hard_break" {
880 out = append(out, token{brk: true, st: l.styleFor(n.Marks, ctx)})
881 // Children are impossible through the editor's schema
882 // and possible through raw Yjs bytes; laid out rather
883 // than lost, as everywhere else in this package.
884 walk(n.Content)
885 continue
886 }
887 if !n.IsText() {
888 walk(n.Content)
889 continue
890 }
891 if n.Text == "" {
892 continue
893 }
894 st := l.styleFor(n.Marks, ctx)
895 for _, piece := range splitWords(n.Text) {
896 out = append(out, token{
897 text: piece.text,
898 st: st,
899 space: piece.space,
900 width: textWidth(piece.text, st),
901 })
902 }
903 }
904 }
905 walk(content)
906 return out
907}
908
909type piece struct {
910 text string
911 space bool
912}
913
914// splitWords divides a run into alternating words and whitespace,
915// keeping both — the whitespace has a width and has to be measured,
916// and its width depends on the style it is set in.
917func splitWords(s string) []piece {
918 var out []piece
919 var cur strings.Builder
920 inSpace := false
921 for _, r := range s {
922 isSpace := r == ' ' || r == '\t' || r == '\n' || r == '\r'
923 if cur.Len() > 0 && isSpace != inSpace {
924 out = append(out, piece{text: cur.String(), space: inSpace})
925 cur.Reset()
926 }
927 inSpace = isSpace
928 cur.WriteRune(r)
929 }
930 if cur.Len() > 0 {
931 out = append(out, piece{text: cur.String(), space: inSpace})
932 }
933 return out
934}
935
936// splitToWidth cuts a token at the last character that fits.
937func splitToWidth(t token, maxWidth float64) (head, rest token) {
938 runes := []rune(t.text)
939 for i := 1; i < len(runes); i++ {
940 w := textWidth(string(runes[:i+1]), t.st)
941 if w > maxWidth {
942 h := token{text: string(runes[:i]), st: t.st}
943 h.width = textWidth(h.text, h.st)
944 r := token{text: string(runes[i:]), st: t.st}
945 r.width = textWidth(r.text, r.st)
946 return h, r
947 }
948 }
949 return t, token{st: t.st}
950}
951
952// styleFor resolves a run's marks and its block context into a face, a
953// size and a colour. This is the mirror of show.html's stylesheet, and
954// the order matters: the block's own scale first, then a size mark,
955// then code's own reduction — the same order the cascade applies them
956// in.
957func (l *layout) styleFor(marks []Mark, ctx blockContext) style {
958 has := func(name string) bool {
959 for _, m := range marks {
960 if m.Type == name {
961 return true
962 }
963 }
964 return false
965 }
966
967 // fonts.Default, not a face named here: an unmarked run is whatever
968 // views/show.html's [docs-text] rule sets, and the two are held equal
969 // by internal/docs's TestPageGeometryMatchesTheStylesheet through
970 // FontCSSName above. Naming a face here instead would make this a
971 // second opinion about the default, and the one that only shows up
972 // on paper.
973 //
974 // All three marks are listed, the default's included. font_serif is
975 // not redundant with that fallback: it means "serif, whatever the
976 // default happens to be", which is what a document says when somebody
977 // chose serif on purpose, and leaving it out would silently repaint
978 // their text the day the default moves again.
979 family := fonts.Default
980 switch {
981 case has("font_mono"):
982 family = fonts.Mono
983 case has("font_sans"):
984 family = fonts.Sans
985 case has("font_serif"):
986 family = fonts.Serif
987 }
988
989 size := BodySizePt * ctx.scale
990 // sizeOrder, not a range over the sizeScale map. A run carrying two
991 // size marks is impossible through the editor's schema (they
992 // exclude each other) but perfectly possible through
993 // POST /d/{sqid}/updates, which takes raw Yjs bytes — and ranging a
994 // map with a break picks whichever Go's randomised iteration
995 // reached first, so the same document exported twice could differ.
996 // A fixed order makes the choice arbitrary but stable, which is
997 // what determinism needs.
998 //
999 // (The screen compounds such a run instead — nested spans multiply
1000 // — so the two disagree for a document neither can be produced by
1001 // the editor. Noted rather than reconciled: making them agree means
1002 // deciding what two sizes at once MEANS, which is a schema
1003 // question.)
1004 for _, mark := range sizeOrder {
1005 if has(mark) {
1006 // SET, not multiplied. A size mark now names an absolute
1007 // point size, so it REPLACES the size the context would
1008 // otherwise have given this run — including a heading's
1009 // scale. That is what a person means by setting a run to
1010 // 14pt, and it is the one behavioural difference between
1011 // this ladder and the four-step scale it replaced.
1012 size, _ = SizePt(mark)
1013 break
1014 }
1015 }
1016
1017 bold := ctx.bold || has("strong")
1018 italic := ctx.italic || has("em")
1019
1020 if has("code") {
1021 // A code run is monospace and smaller, whatever else it says —
1022 // exactly as the stylesheet's `[docs-text] code` rule wins over
1023 // the inherited family. The background tint the screen draws
1024 // behind it is not reproduced: a filled rectangle behind every
1025 // code run would have to be measured and drawn per line
1026 // fragment, and its absence costs a reader nothing a PDF is
1027 // for.
1028 family = fonts.Mono
1029 size *= codeScale
1030 }
1031
1032 gray := 0.0
1033 if ctx.quoted {
1034 gray = quoteGray
1035 }
1036 return style{face: fonts.Get(family, bold, italic), size: size, gray: gray}
1037}
1038
1039// textWidth measures a string in points.
1040func textWidth(s string, st style) float64 {
1041 font, err := st.face.Parsed()
1042 if err != nil {
1043 return 0
1044 }
1045 w, _ := font.WidthEm(s)
1046 return w * st.size
1047}
1048
1049// ascentDescent is the face's own vertical extent at this size, in
1050// points. Descent comes back positive — the table stores it negative,
1051// and every caller here wants a height.
1052func ascentDescent(st style) (ascent, descent float64) {
1053 font, err := st.face.Parsed()
1054 if err != nil {
1055 return st.size * 0.8, st.size * 0.2
1056 }
1057 upm := float64(font.UnitsPerEm)
1058 return float64(font.Ascender) / upm * st.size, -float64(font.Descender) / upm * st.size
1059}
1060
1061// halfLeading is the space CSS puts above the text's content box
1062// inside a taller line box.
1063func halfLeading(st style, lineHeight float64) float64 {
1064 a, d := ascentDescent(st)
1065 return (lineHeight - (a + d)) / 2
1066}
1067