| 1 | package export |
| 2 | |
| 3 | import ( |
| 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. |
| 31 | const ( |
| 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. |
| 45 | var headingScale = map[int]float64{1: 1.6, 2: 1.3, 3: 1.1, 4: 1, 5: 1, 6: 1} |
| 46 | |
| 47 | const ( |
| 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. |
| 86 | const 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. |
| 94 | const 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. |
| 101 | func 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. |
| 115 | var 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. |
| 132 | var 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. |
| 155 | var 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. |
| 171 | func 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. |
| 180 | func 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. |
| 187 | func 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. |
| 200 | func 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. |
| 228 | func 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. |
| 242 | type Report struct { |
| 243 | // MissingRunes is sorted and deduplicated. |
| 244 | MissingRunes []rune |
| 245 | } |
| 246 | |
| 247 | // Empty reports whether the export was fully faithful. |
| 248 | func (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. |
| 260 | func (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. |
| 282 | func (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. |
| 300 | func 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. |
| 331 | type 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). |
| 340 | type 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. |
| 364 | func 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. |
| 402 | func (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 | |
| 415 | type 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 | |
| 475 | func (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 | |
| 490 | func (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. |
| 501 | func (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. |
| 511 | func (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. |
| 519 | func (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 | |
| 600 | func (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. |
| 655 | type 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. |
| 669 | func (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. |
| 741 | func (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. |
| 843 | func (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. |
| 858 | func (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. |
| 874 | func (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 | |
| 909 | type 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. |
| 917 | func 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. |
| 937 | func 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. |
| 957 | func (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. |
| 1040 | func 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. |
| 1052 | func 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. |
| 1063 | func halfLeading(st style, lineHeight float64) float64 { |
| 1064 | a, d := ascentDescent(st) |
| 1065 | return (lineHeight - (a + d)) / 2 |
| 1066 | } |
| 1067 | |