| 1 | // export.go turns a deck into a PDF (spec §4.3): one 1920 by 1080 pt |
| 2 | // page per slide, and when asked, the speaker notes as a page after |
| 3 | // each slide. |
| 4 | // |
| 5 | // The pipeline is the one the plan's global constraints lay down and |
| 6 | // nothing here shortcuts it: layout.Layout draws each slide into a |
| 7 | // paint list, svg.Encode writes that paint list as an SVG, and the Rust |
| 8 | // module under internal/slides/pdfcore turns the SVGs into a PDF with |
| 9 | // the text as glyphs from the same Liberation faces the layout engine |
| 10 | // measured with. The PDF path never reads a deck; a notes page is a |
| 11 | // paint list too, built here from the notes text and typeset with the |
| 12 | // same fonts package, so it goes through the same two steps. |
| 13 | // |
| 14 | // # Self-contained |
| 15 | // |
| 16 | // A PDF is a file somebody emails. Every image is inlined into its |
| 17 | // page's SVG before the module sees it, and an image that cannot be |
| 18 | // inlined — the bytes are not there, or are not a picture this build |
| 19 | // can decode, or are bigger than the cap — is left out with a Warning |
| 20 | // that says so, never replaced by a URL the reader's machine would have |
| 21 | // to fetch. Images are downscaled to maxImageEdge on the long edge |
| 22 | // first (§4.3), because a 6000 px photograph on a 1920 pt page is bytes |
| 23 | // nobody sees. |
| 24 | // |
| 25 | // The routes, the job and the status page are in the export handlers; |
| 26 | // this file is the function they call. |
| 27 | package slides |
| 28 | |
| 29 | import ( |
| 30 | "bytes" |
| 31 | "context" |
| 32 | "errors" |
| 33 | "fmt" |
| 34 | "image" |
| 35 | "image/draw" |
| 36 | "image/jpeg" |
| 37 | "image/png" |
| 38 | "strings" |
| 39 | |
| 40 | // The decoders Assets can hand back. Registering them is what makes |
| 41 | // image.Decode recognise the bytes; a format nobody registered is |
| 42 | // "not a picture this build can decode" and is dropped with a |
| 43 | // warning rather than sent to the module, which would fail the |
| 44 | // whole render on it (core/pdf/src/lib.rs). |
| 45 | _ "image/gif" |
| 46 | |
| 47 | // WebP is one of the four types the upload path admits (§2.7): |
| 48 | // registering the decoder is what sends it down the |
| 49 | // decode-and-re-encode path below, where it comes out as a PNG. |
| 50 | // Without it a picture somebody uploaded from a phone was simply |
| 51 | // missing from their handout. (krilla-svg does decode WebP, unlike |
| 52 | // the svg2pdf it replaced, so the re-encode is belt and braces on |
| 53 | // the PDF side now; dropping it is its own change, with its own |
| 54 | // tests, not a side effect of swapping the converter.) |
| 55 | _ "golang.org/x/image/webp" |
| 56 | |
| 57 | "amadan.net/oficina/slides/internal/slides/deck" |
| 58 | "amadan.net/oficina/slides/internal/slides/fonts" |
| 59 | "amadan.net/oficina/slides/internal/slides/layout" |
| 60 | "amadan.net/oficina/slides/internal/slides/pdfcore" |
| 61 | "amadan.net/oficina/slides/internal/slides/svg" |
| 62 | ) |
| 63 | |
| 64 | // MaxExportSlides is the most slides one export will take (§4.3). A |
| 65 | // deck past it is refused before any work is done, with |
| 66 | // ErrTooManySlides. |
| 67 | const MaxExportSlides = 200 |
| 68 | |
| 69 | // ErrTooManySlides is Export's refusal of a deck over MaxExportSlides. |
| 70 | var ErrTooManySlides = errors.New("slides: the deck has more slides than an export will take") |
| 71 | |
| 72 | // maxExportAsset bounds one image's stored bytes: 20 MiB, the plan's |
| 73 | // cap. Bigger is dropped with a warning, not decoded. |
| 74 | const maxExportAsset = 20 << 20 |
| 75 | |
| 76 | // maxImagePixels bounds what one image is allowed to decode to. The |
| 77 | // byte cap above says nothing about pixels — a 20 MiB PNG can be a |
| 78 | // hundred thousand pixels a side — and decoding is where the memory |
| 79 | // goes. Fifty megapixels is an 8000 by 6000 photograph; anything larger |
| 80 | // is dropped with a warning. |
| 81 | const maxImagePixels = 50_000_000 |
| 82 | |
| 83 | // maxImageEdge is the long edge an inlined image is scaled down to |
| 84 | // (§4.3). Slightly over the page's own 1920, so a full-bleed picture is |
| 85 | // not upscaled by the reader. |
| 86 | const maxImageEdge = 2000 |
| 87 | |
| 88 | // ExportOptions is what Export needs beyond the deck. |
| 89 | type ExportOptions struct { |
| 90 | // WithNotes puts a page of speaker notes after every slide (a slide |
| 91 | // with long notes gets more than one). Every slide gets one, notes |
| 92 | // or not, so the pages pair up slide, notes, slide, notes all the |
| 93 | // way through and a printed handout is predictable. |
| 94 | WithNotes bool |
| 95 | |
| 96 | // Assets returns an image's stored bytes and MIME type by the |
| 97 | // deck's asset id (layout.Image.Asset). It is the caller's because |
| 98 | // where the bytes live — the object store, a directory — is the |
| 99 | // app's business and not the PDF's. ok=false is "not available", |
| 100 | // which is a warning and an omitted image. nil means no image can |
| 101 | // be fetched at all: every image is omitted, each with a warning. |
| 102 | Assets func(asset string) (data []byte, mime string, ok bool) |
| 103 | |
| 104 | // Runtime is the compiled PDF module this export renders through. |
| 105 | // Required. Compiling one costs the best part of a second and a |
| 106 | // Runtime is safe for concurrent use, so it belongs to whatever |
| 107 | // owns the process — the app builds one and closes it (exports.go's |
| 108 | // pdfRuntime), a test builds one per package — and never to a |
| 109 | // single export. Nil is an error rather than a module compiled |
| 110 | // here: a function that quietly compiles a wasm module per call is |
| 111 | // a function whose cost nobody can see. |
| 112 | Runtime *pdfcore.Runtime |
| 113 | |
| 114 | // Progress, when set, is called before each slide is laid out, with |
| 115 | // the slide's 1-based number and the total. It is how an export |
| 116 | // running as a job tells its status page how far along it is. The |
| 117 | // words are the caller's: they are a person's words, and this |
| 118 | // package has no locale. |
| 119 | Progress func(slide, total int) |
| 120 | } |
| 121 | |
| 122 | // Export renders d as a PDF. The warnings are everything worth telling |
| 123 | // the person who asked — a block that overflowed, an image that was |
| 124 | // left out, a character no face can draw — and never stop the export; |
| 125 | // the error is what does: a deck over MaxExportSlides, a renderer that |
| 126 | // refused or trapped, a deadline. |
| 127 | func Export(ctx context.Context, d *deck.Deck, o ExportOptions) ([]byte, []layout.Warning, error) { |
| 128 | if d == nil { |
| 129 | return nil, nil, errors.New("slides: export: no deck") |
| 130 | } |
| 131 | if len(d.Slides) > MaxExportSlides { |
| 132 | return nil, nil, fmt.Errorf("%w: %d slides, the most is %d", ErrTooManySlides, len(d.Slides), MaxExportSlides) |
| 133 | } |
| 134 | if len(d.Slides) == 0 { |
| 135 | return nil, nil, errors.New("slides: export: the deck has no slides") |
| 136 | } |
| 137 | if o.Runtime == nil { |
| 138 | return nil, nil, errors.New("slides: export: no PDF runtime (ExportOptions.Runtime)") |
| 139 | } |
| 140 | |
| 141 | x := &exporter{deck: d, opts: o} |
| 142 | var pages [][]byte |
| 143 | for i := range d.Slides { |
| 144 | if err := ctx.Err(); err != nil { |
| 145 | return nil, x.warnings, err |
| 146 | } |
| 147 | // The slide every warning from here on is about — the ones the |
| 148 | // layout engine stamps for itself, and the ones this file makes |
| 149 | // about an image it could not use. |
| 150 | x.slide = d.Slides[i].ID |
| 151 | if o.Progress != nil { |
| 152 | o.Progress(i+1, len(d.Slides)) |
| 153 | } |
| 154 | p, warnings, err := layout.Layout(d, i) |
| 155 | if err != nil { |
| 156 | return nil, x.warnings, fmt.Errorf("slides: export: slide %d: %w", i+1, err) |
| 157 | } |
| 158 | x.warnings = append(x.warnings, warnings...) |
| 159 | title := slideTitle(d.Slides[i]) |
| 160 | if title == "" { |
| 161 | title = d.Meta.Title |
| 162 | } |
| 163 | pages = append(pages, x.encode(p, title)) |
| 164 | if o.WithNotes { |
| 165 | for _, np := range x.notesPages(d.Slides[i], title) { |
| 166 | pages = append(pages, x.encode(np, title)) |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | var faces [][]byte |
| 172 | for _, f := range fonts.All() { |
| 173 | faces = append(faces, fonts.Data(f)) |
| 174 | } |
| 175 | pdf, err := o.Runtime.Render(ctx, pages, faces) |
| 176 | if err != nil { |
| 177 | return nil, x.warnings, fmt.Errorf("slides: export: %w", err) |
| 178 | } |
| 179 | return pdf, x.warnings, nil |
| 180 | } |
| 181 | |
| 182 | // exporter is one Export call: the deck, the options, and what has |
| 183 | // been noticed so far. |
| 184 | type exporter struct { |
| 185 | deck *deck.Deck |
| 186 | opts ExportOptions |
| 187 | warnings []layout.Warning |
| 188 | |
| 189 | // slide is the id of the slide being rendered, stamped onto every |
| 190 | // warning made here so the status page can say which slide lost |
| 191 | // its picture (layout.Warning.Slide). |
| 192 | slide string |
| 193 | |
| 194 | // inlined caches an asset's inlined form for the length of one |
| 195 | // export, so a picture on ten slides is fetched and scaled once. |
| 196 | inlined map[string]inlinedImage |
| 197 | } |
| 198 | |
| 199 | type inlinedImage struct { |
| 200 | data []byte |
| 201 | mime string |
| 202 | ok bool |
| 203 | } |
| 204 | |
| 205 | func (x *exporter) warn(kind, block, detail string) { |
| 206 | x.warnings = append(x.warnings, layout.Warning{ |
| 207 | Kind: kind, Slide: x.slide, Block: block, Detail: detail, |
| 208 | }) |
| 209 | } |
| 210 | |
| 211 | // encode writes one paint list as a self-contained SVG page. |
| 212 | func (x *exporter) encode(p layout.Paint, title string) []byte { |
| 213 | return svg.Encode(p, svg.Options{ |
| 214 | Title: title, |
| 215 | InlineImages: x.inline, |
| 216 | // No AssetURL: an image that is not inlined is not drawn. A URL |
| 217 | // in a PDF is a picture the reader's machine has to fetch, and |
| 218 | // the module would refuse it anyway. |
| 219 | }) |
| 220 | } |
| 221 | |
| 222 | // slideTitle is the slide's first heading, as plain text, or "". |
| 223 | func slideTitle(s deck.Slide) string { |
| 224 | for _, b := range s.Blocks { |
| 225 | if b.Type == "heading" { |
| 226 | return strings.TrimSpace(b.Text.Plain()) |
| 227 | } |
| 228 | } |
| 229 | return "" |
| 230 | } |
| 231 | |
| 232 | // ── Images ─────────────────────────────────────────────────────────── |
| 233 | |
| 234 | // inline is the svg.Options.InlineImages hook: the asset's bytes, |
| 235 | // decoded, scaled down to maxImageEdge if they are bigger, and handed |
| 236 | // back as a picture the PDF module accepts. ok=false is "leave it out", |
| 237 | // and every path to it has already recorded why. |
| 238 | func (x *exporter) inline(asset string) ([]byte, string, bool) { |
| 239 | if x.inlined == nil { |
| 240 | x.inlined = map[string]inlinedImage{} |
| 241 | } |
| 242 | if c, done := x.inlined[asset]; done { |
| 243 | return c.data, c.mime, c.ok |
| 244 | } |
| 245 | data, mime, ok := x.fetchImage(asset) |
| 246 | x.inlined[asset] = inlinedImage{data, mime, ok} |
| 247 | return data, mime, ok |
| 248 | } |
| 249 | |
| 250 | func (x *exporter) fetchImage(asset string) ([]byte, string, bool) { |
| 251 | if x.opts.Assets == nil { |
| 252 | x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q: no way to read assets was given; left out of the PDF", asset)) |
| 253 | return nil, "", false |
| 254 | } |
| 255 | data, mime, ok := x.opts.Assets(asset) |
| 256 | if !ok { |
| 257 | x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q could not be read; left out of the PDF", asset)) |
| 258 | return nil, "", false |
| 259 | } |
| 260 | if len(data) > maxExportAsset { |
| 261 | x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q is %d bytes, over the %d byte cap; left out of the PDF", asset, len(data), maxExportAsset)) |
| 262 | return nil, "", false |
| 263 | } |
| 264 | if mime == "image/svg+xml" { |
| 265 | // A vector picture is already what the page is made of: the |
| 266 | // encoder nests it into the slide (it was sanitised on |
| 267 | // registration, svg.go). The one it will not nest is a picture |
| 268 | // carrying a stylesheet, which would restyle the slide around |
| 269 | // it; the encoder falls back to the asset's URL, and this |
| 270 | // export gives it none, so say why the picture is missing |
| 271 | // rather than letting it vanish. |
| 272 | if bytes.Contains(bytes.ToLower(data), []byte("<style")) { |
| 273 | x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q is an SVG with a stylesheet, which cannot be nested into a page; left out of the PDF", asset)) |
| 274 | return nil, "", false |
| 275 | } |
| 276 | return data, mime, true |
| 277 | } |
| 278 | |
| 279 | cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) |
| 280 | if err != nil { |
| 281 | x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q (%s) is not a picture this build can decode; left out of the PDF", asset, mime)) |
| 282 | return nil, "", false |
| 283 | } |
| 284 | if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxImagePixels { |
| 285 | x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q is %d by %d pixels, more than the PDF will take; left out", asset, cfg.Width, cfg.Height)) |
| 286 | return nil, "", false |
| 287 | } |
| 288 | if max(cfg.Width, cfg.Height) <= maxImageEdge && (format == "png" || format == "jpeg" || format == "gif") { |
| 289 | // Small enough and in a format the module reads: the stored |
| 290 | // bytes go in untouched, which for a JPEG means DCTDecode |
| 291 | // passthrough rather than a second lossy generation. |
| 292 | return data, "image/" + format, true |
| 293 | } |
| 294 | |
| 295 | img, _, err := image.Decode(bytes.NewReader(data)) |
| 296 | if err != nil { |
| 297 | x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q (%s) could not be decoded: %v; left out of the PDF", asset, mime, err)) |
| 298 | return nil, "", false |
| 299 | } |
| 300 | scaled := downscale(img, maxImageEdge) |
| 301 | var buf bytes.Buffer |
| 302 | if format == "jpeg" { |
| 303 | // A photograph stays a photograph: JPEG in, JPEG out, at a |
| 304 | // quality the eye does not separate from the original on a |
| 305 | // page. |
| 306 | if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 88}); err != nil { |
| 307 | x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q could not be re-encoded: %v; left out of the PDF", asset, err)) |
| 308 | return nil, "", false |
| 309 | } |
| 310 | return buf.Bytes(), "image/jpeg", true |
| 311 | } |
| 312 | if err := png.Encode(&buf, scaled); err != nil { |
| 313 | x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q could not be re-encoded: %v; left out of the PDF", asset, err)) |
| 314 | return nil, "", false |
| 315 | } |
| 316 | return buf.Bytes(), "image/png", true |
| 317 | } |
| 318 | |
| 319 | // downscale returns img no larger than edge on its long side, or img |
| 320 | // itself if it already is. |
| 321 | // |
| 322 | // A box filter, written here because golang.org/x/image/draw is not |
| 323 | // the standard library and the stdlib's image/draw only copies. Each |
| 324 | // target pixel is the mean of the source rectangle it covers, in |
| 325 | // premultiplied RGBA so a transparent edge does not bleed its colour |
| 326 | // into its neighbours. For a reduction — the only direction this is |
| 327 | // used in — a box filter is the right filter: it is what "average the |
| 328 | // pixels you are merging" means, and it does not ring. |
| 329 | func downscale(img image.Image, edge int) image.Image { |
| 330 | b := img.Bounds() |
| 331 | sw, sh := b.Dx(), b.Dy() |
| 332 | if sw <= edge && sh <= edge { |
| 333 | return img |
| 334 | } |
| 335 | tw, th := edge, edge |
| 336 | if sw >= sh { |
| 337 | th = max(1, sh*edge/sw) |
| 338 | } else { |
| 339 | tw = max(1, sw*edge/sh) |
| 340 | } |
| 341 | |
| 342 | src := image.NewRGBA(image.Rect(0, 0, sw, sh)) |
| 343 | draw.Draw(src, src.Bounds(), img, b.Min, draw.Src) |
| 344 | dst := image.NewRGBA(image.Rect(0, 0, tw, th)) |
| 345 | for ty := 0; ty < th; ty++ { |
| 346 | y0, y1 := ty*sh/th, (ty+1)*sh/th |
| 347 | if y1 <= y0 { |
| 348 | y1 = y0 + 1 |
| 349 | } |
| 350 | for tx := 0; tx < tw; tx++ { |
| 351 | x0, x1 := tx*sw/tw, (tx+1)*sw/tw |
| 352 | if x1 <= x0 { |
| 353 | x1 = x0 + 1 |
| 354 | } |
| 355 | var r, g, bl, a uint64 |
| 356 | for y := y0; y < y1; y++ { |
| 357 | row := src.Pix[y*src.Stride+x0*4 : y*src.Stride+x1*4] |
| 358 | for i := 0; i < len(row); i += 4 { |
| 359 | r += uint64(row[i]) |
| 360 | g += uint64(row[i+1]) |
| 361 | bl += uint64(row[i+2]) |
| 362 | a += uint64(row[i+3]) |
| 363 | } |
| 364 | } |
| 365 | n := uint64((y1 - y0) * (x1 - x0)) |
| 366 | o := ty*dst.Stride + tx*4 |
| 367 | dst.Pix[o] = uint8((r + n/2) / n) |
| 368 | dst.Pix[o+1] = uint8((g + n/2) / n) |
| 369 | dst.Pix[o+2] = uint8((bl + n/2) / n) |
| 370 | dst.Pix[o+3] = uint8((a + n/2) / n) |
| 371 | } |
| 372 | } |
| 373 | return dst |
| 374 | } |
| 375 | |
| 376 | // ── Notes pages ────────────────────────────────────────────────────── |
| 377 | |
| 378 | // The notes page's geometry, in the layout engine's own units (1920 by |
| 379 | // 1080, the padded scale from layout/sizes.go: 1 cqw = 16.8 px inside a |
| 380 | // section with the stylesheet's 6.25% padding). The sizes are the |
| 381 | // stylesheet's t-3 and body-m, so a notes page reads as the deck's own |
| 382 | // type rather than as a different document stapled to it. |
| 383 | const ( |
| 384 | notesPad = 0.0625 * layout.FrameW // --slides-pad |
| 385 | notesCqw = (layout.FrameW - 2*notesPad) / 100 |
| 386 | notesTitle = 4.0 * notesCqw // --slides-t-3 |
| 387 | notesBody = 2.8 * notesCqw // --slides-body-m |
| 388 | notesGap = 2.6 * notesCqw // --slides-gap, title to text |
| 389 | notesLead = 1.3 // the section's line-height |
| 390 | notesLeadT = 1.1 // a heading's line-height |
| 391 | notesParaGap = 0.6 // em between paragraphs, as slides.css |
| 392 | notesBg = layout.Fill("#ffffff") |
| 393 | notesFg = layout.Fill("#111111") |
| 394 | ) |
| 395 | |
| 396 | // notesPages typesets s's speaker notes: the slide's title at t-3, then |
| 397 | // the notes at body-m in Liberation Sans, greedily wrapped and broken |
| 398 | // across as many 1920 by 1080 pages as they need. A slide with no notes |
| 399 | // still gets its page (see ExportOptions.WithNotes). |
| 400 | // |
| 401 | // Plain text: marks are dropped, as §2.6's notes are read aloud, not |
| 402 | // looked at. Nothing is ever clipped — a word wider than the page runs |
| 403 | // past the margin, the same rule the layout engine keeps. |
| 404 | func (x *exporter) notesPages(s deck.Slide, title string) []layout.Paint { |
| 405 | titleFace := fonts.Face{Family: fonts.Sans, Bold: true} |
| 406 | bodyFace := fonts.Face{Family: fonts.Sans} |
| 407 | colW := layout.FrameW - 2*notesPad |
| 408 | bottom := layout.FrameH - notesPad |
| 409 | |
| 410 | var pages []layout.Paint |
| 411 | cur := layout.Paint{W: layout.FrameW, H: layout.FrameH, Background: notesBg} |
| 412 | top := notesPad |
| 413 | |
| 414 | // place puts one wrapped block on the page, breaking to a new page |
| 415 | // wherever the next line would cross the bottom margin. |
| 416 | place := func(lines []string, face fonts.Face, size, lead float64) { |
| 417 | asc, desc, _ := fonts.Metrics(face, size) |
| 418 | lineH := lead * size |
| 419 | for _, ln := range lines { |
| 420 | if top+lineH > bottom && len(cur.Ops) > 0 { |
| 421 | pages = append(pages, cur) |
| 422 | cur = layout.Paint{W: layout.FrameW, H: layout.FrameH, Background: notesBg} |
| 423 | top = notesPad |
| 424 | } |
| 425 | baseline := top + (lineH-(asc+desc))/2 + asc |
| 426 | if ln != "" { |
| 427 | cur.Ops = append(cur.Ops, layout.Text{X: notesPad, Y: baseline, Runs: []layout.Run{ |
| 428 | {S: ln, Face: face, Size: size, Fill: notesFg}, |
| 429 | }}) |
| 430 | } |
| 431 | top += lineH |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | if title != "" { |
| 436 | place(x.wrapNotes(s.ID, title, titleFace, notesTitle, colW), titleFace, notesTitle, notesLeadT) |
| 437 | top += notesGap |
| 438 | } |
| 439 | for i, para := range s.Notes { |
| 440 | if i > 0 { |
| 441 | top += notesParaGap * notesBody |
| 442 | } |
| 443 | text := strings.TrimSpace(plainParagraph(para)) |
| 444 | if text == "" { |
| 445 | // An empty paragraph is an empty line box, as <p></p> is. |
| 446 | top += notesLead * notesBody |
| 447 | continue |
| 448 | } |
| 449 | place(x.wrapNotes(s.ID, text, bodyFace, notesBody, colW), bodyFace, notesBody, notesLead) |
| 450 | } |
| 451 | return append(pages, cur) |
| 452 | } |
| 453 | |
| 454 | // plainParagraph is one paragraph's runs concatenated, marks dropped. |
| 455 | func plainParagraph(para []deck.Run) string { |
| 456 | var b strings.Builder |
| 457 | for _, r := range para { |
| 458 | b.WriteString(r.T) |
| 459 | } |
| 460 | return b.String() |
| 461 | } |
| 462 | |
| 463 | // wrapNotes breaks text into lines no wider than w, greedily, measured |
| 464 | // with the same fonts package the layout engine measures with. A rune |
| 465 | // no face has a glyph for is reported once per slide. |
| 466 | func (x *exporter) wrapNotes(block, text string, face fonts.Face, size, w float64) []string { |
| 467 | space, _ := fonts.Advance(face, size, " ") |
| 468 | var lines []string |
| 469 | var cur strings.Builder |
| 470 | var curW float64 |
| 471 | var missing []rune |
| 472 | for _, word := range strings.Fields(text) { |
| 473 | ww, miss := fonts.Advance(face, size, word) |
| 474 | missing = append(missing, miss...) |
| 475 | switch { |
| 476 | case cur.Len() == 0: |
| 477 | cur.WriteString(word) |
| 478 | curW = ww |
| 479 | case curW+space+ww > w: |
| 480 | lines = append(lines, cur.String()) |
| 481 | cur.Reset() |
| 482 | cur.WriteString(word) |
| 483 | curW = ww |
| 484 | default: |
| 485 | cur.WriteByte(' ') |
| 486 | cur.WriteString(word) |
| 487 | curW += space + ww |
| 488 | } |
| 489 | } |
| 490 | if cur.Len() > 0 { |
| 491 | lines = append(lines, cur.String()) |
| 492 | } |
| 493 | if len(missing) > 0 { |
| 494 | seen := map[rune]bool{} |
| 495 | var fresh []string |
| 496 | for _, r := range missing { |
| 497 | if !seen[r] { |
| 498 | seen[r] = true |
| 499 | fresh = append(fresh, fmt.Sprintf("%q", r)) |
| 500 | } |
| 501 | } |
| 502 | x.warn(layout.WarnGlyph, block, fmt.Sprintf( |
| 503 | "the notes use %s, which no embedded face has a glyph for; drawn at the width of a box", |
| 504 | strings.Join(fresh, ", "))) |
| 505 | } |
| 506 | return lines |
| 507 | } |
| 508 | |