oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package svg writes a paint list out as a picture.
2//
3// It is the second half of the one rule (spec §0.1, §4.2): internal/
4// slides/layout measures a slide into a flat list of rectangles,
5// ellipses, lines, images and positioned runs of text, and this turns
6// that list into SVG 1.1. Nothing here reads a deck — the paint list is
7// the whole input — which is what keeps a thumbnail on the editor strip
8// and a page in an exported PDF from being two opinions about one
9// slide.
10//
11// Two consumers, one serialiser:
12//
13// - A thumbnail, served from every surface that can show a deck
14// (internal/slides/thumbs.go). It goes into an `<img src>`, where a
15// browser renders the SVG in a restricted mode that fetches
16// NOTHING: no stylesheet, no font file, no external picture. So a
17// thumbnail inlines the images it can (Options.InlineImages) and
18// sets type in whatever the reader's machine has under the same
19// stack slides.css names — which is the one place a thumbnail is
20// knowingly not the slide, and why it is a thumbnail and not the
21// export.
22// - The PDF, where the same bytes go through krilla-svg. A WebP asset
23// is decoded and re-encoded as a PNG on its way in (webpAsPNG), so a
24// picture a person uploaded in the format their phone writes is in
25// both the thumbnail and the PDF rather than in neither.
26//
27// # What comes out
28//
29// Well-formed XML, always. Every string that reaches the output goes
30// through esc, which escapes the five predefined entities and DROPS the
31// characters XML 1.0 cannot carry — a document nothing can parse is
32// worse than a document missing a control character nobody can see.
33// svg_test.go parses every output it makes with encoding/xml, and
34// internal/slides/thumbs_test.go does the same for every slide of the
35// sample deck.
36//
37// The output is deliberately plain: no transforms, no groups, no
38// stylesheet. Every coordinate is already absolute in the 1920 by 1080
39// frame, so a reader of the file can put a number in it beside a number
40// in a paint list, and usvg has nothing to resolve before it draws.
41package svg
42
43import (
44 "bytes"
45 "encoding/base64"
46 "encoding/xml"
47 "errors"
48 "fmt"
49 "image/png"
50 "io"
51 "math"
52 "net/url"
53 "slices"
54 "strconv"
55 "strings"
56
57 "golang.org/x/image/webp"
58
59 "amadan.net/oficina/slides/internal/slides/fonts"
60 "amadan.net/oficina/slides/internal/slides/layout"
61)
62
63// Options is what the surface writing a picture knows that the paint
64// list does not: what to call the slide, and how to turn an asset id
65// into bytes or into an address.
66type Options struct {
67 // Title is the slide's label, written as the document's <title>.
68 // With role="img" on the root it is what a screen reader announces
69 // for the whole picture, and it is what an `<img alt>` around it
70 // should say too.
71 Title string
72
73 // InlineImages resolves an asset id to its bytes and its type, for
74 // a surface that can read them. ok false means "not this one" — a
75 // missing asset, a store this deployment does not have, a type
76 // this cannot use — and the picture falls back to AssetURL.
77 //
78 // PNG, JPEG, GIF and a sanitised SVG go in as they are. A WebP is
79 // decoded here and re-encoded as a PNG (golang.org/x/image/webp).
80 // Anything else falls back to AssetURL.
81 InlineImages func(asset string) (data []byte, mime string, ok bool)
82
83 // AssetURL is where an asset's bytes are served from on THIS
84 // surface — render.Options.AssetBase plus the ref. It is the
85 // fallback, and it only draws where the SVG is fetched as a
86 // document; inside an `<img>` a browser will not load it.
87 AssetURL func(asset string) string
88}
89
90// Encode writes p as an SVG document.
91//
92// It never fails. Everything a paint list can hold serialises as
93// something, and the two things that could not — a link to a scheme
94// nobody should follow, an embedded picture that will not parse — are
95// dropped, with the words they carried kept.
96func Encode(p layout.Paint, o Options) []byte {
97 w := &writer{o: o}
98 w.body(p)
99
100 var out bytes.Buffer
101 out.WriteString(xml.Header)
102 fmt.Fprintf(&out, `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" `+
103 `width="%s" height="%s" viewBox="0 0 %s %s" role="img">`+"\n",
104 num(p.W), num(p.H), num(p.W), num(p.H))
105 // The title first, because an assistive technology reads the first
106 // <title> child of the root as the name of the whole picture.
107 out.WriteString("<title>" + esc(o.Title) + "</title>\n")
108 if w.defs.Len() > 0 {
109 out.WriteString("<defs>\n")
110 out.Write(w.defs.Bytes())
111 out.WriteString("</defs>\n")
112 }
113 out.Write(w.out.Bytes())
114 out.WriteString("</svg>\n")
115 return out.Bytes()
116}
117
118// writer is one Encode: the picture so far, the definitions it needs
119// at the top, and the ids it has handed out.
120type writer struct {
121 o Options
122 out bytes.Buffer
123 defs bytes.Buffer
124 // arrows maps a stroke colour to the marker defined for it, so a
125 // slide of arrows in one colour defines one arrowhead.
126 arrows map[layout.Fill]string
127 n int
128}
129
130// id mints a document-unique id under this package's own prefix, so
131// nothing it defines can collide with an id inside an embedded
132// picture.
133func (w *writer) id(kind string) string {
134 w.n++
135 return "slides-" + kind + "-" + strconv.Itoa(w.n)
136}
137
138func (w *writer) body(p layout.Paint) {
139 // The background, and only when there is one: a slide with no
140 // background colour is transparent, and painting it black would be
141 // a decision this package has no business making.
142 if p.Background != "" {
143 fmt.Fprintf(&w.out, `<rect x="0" y="0" width="%s" height="%s" fill="%s"/>`+"\n",
144 num(p.W), num(p.H), esc(string(p.Background)))
145 }
146 for _, op := range p.Ops {
147 switch v := op.(type) {
148 case layout.Rect:
149 w.rect(v)
150 case layout.Ellipse:
151 w.ellipse(v)
152 case layout.Line:
153 w.line(v)
154 case layout.Image:
155 w.image(v)
156 case layout.Text:
157 w.text(v)
158 }
159 }
160}
161
162func (w *writer) rect(r layout.Rect) {
163 attrs := []string{
164 attr("x", num(r.X)), attr("y", num(r.Y)),
165 attr("width", num(r.W)), attr("height", num(r.H)),
166 }
167 if r.Radius > 0 {
168 attrs = append(attrs, attr("rx", num(r.Radius)))
169 }
170 attrs = append(attrs, w.paint(r.Fill, r.Stroke, r.StrokeW)...)
171 w.out.WriteString("<rect " + strings.Join(attrs, " ") + "/>\n")
172}
173
174func (w *writer) ellipse(e layout.Ellipse) {
175 // The paint list carries a box; SVG wants a centre and two radii.
176 attrs := []string{
177 attr("cx", num(e.X+e.W/2)), attr("cy", num(e.Y+e.H/2)),
178 attr("rx", num(e.W/2)), attr("ry", num(e.H/2)),
179 }
180 attrs = append(attrs, w.paint(e.Fill, e.Stroke, e.StrokeW)...)
181 w.out.WriteString("<ellipse " + strings.Join(attrs, " ") + "/>\n")
182}
183
184// paint is the fill and stroke every shape carries. `fill="none"` is
185// written out loud rather than left off, because SVG's initial fill is
186// black and an outline would come out solid.
187func (w *writer) paint(fill, stroke layout.Fill, strokeW float64) []string {
188 out := []string{attr("fill", fillOr(fill, "none"))}
189 if stroke != "" && strokeW > 0 {
190 out = append(out, attr("stroke", esc(string(stroke))), attr("stroke-width", num(strokeW)))
191 }
192 return out
193}
194
195func (w *writer) line(l layout.Line) {
196 attrs := []string{
197 attr("x1", num(l.X1)), attr("y1", num(l.Y1)),
198 attr("x2", num(l.X2)), attr("y2", num(l.Y2)),
199 attr("stroke", fillOr(l.Stroke, "none")),
200 attr("stroke-width", num(l.W)),
201 attr("stroke-linecap", "round"),
202 }
203 if l.Arrow && l.Stroke != "" {
204 attrs = append(attrs, attr("marker-end", "url(#"+w.arrowhead(l.Stroke)+")"))
205 }
206 w.out.WriteString("<line " + strings.Join(attrs, " ") + "/>\n")
207}
208
209// arrowhead defines a marker for one stroke colour, once, and returns
210// its id. A marker rather than a computed triangle because the marker
211// turns with the line and a triangle would have to be rotated here —
212// the same picture, with the trigonometry in the file instead of in the
213// renderer.
214//
215// markerUnits is strokeWidth, so a thick line gets a proportionate
216// head; orient auto turns it along the line. refX sits the point at the
217// line's end rather than a head-length past it.
218func (w *writer) arrowhead(stroke layout.Fill) string {
219 if id, ok := w.arrows[stroke]; ok {
220 return id
221 }
222 if w.arrows == nil {
223 w.arrows = map[layout.Fill]string{}
224 }
225 id := w.id("arrow")
226 w.arrows[stroke] = id
227 fmt.Fprintf(&w.defs, `<marker id="%s" viewBox="0 0 10 10" refX="9" refY="5" `+
228 `markerWidth="5" markerHeight="5" markerUnits="strokeWidth" orient="auto">`+
229 `<path d="M 0 0 L 10 5 L 0 10 z" fill="%s"/></marker>`+"\n",
230 id, esc(string(stroke)))
231 return id
232}
233
234// ── Text ─────────────────────────────────────────────────────────────
235
236// text writes one line: a <text> at the baseline, and a <tspan> per
237// run.
238//
239// Every run carries its own x, measured with fonts.Advance — the same
240// function the layout engine measured with. Letting the runs flow would
241// put each one wherever the renderer's own advances landed, and a
242// renderer that kerns (every browser) would drift from the paint list
243// within a sentence. Fixing each run's origin means the drift can only
244// ever be inside one run.
245//
246// xml:space="preserve" because a run's trailing space is layout: "the "
247// followed by "spec" is a word gap, and an XML processor is otherwise
248// entitled to collapse it.
249func (w *writer) text(t layout.Text) {
250 runs := make([]layout.Run, 0, len(t.Runs))
251 for _, r := range t.Runs {
252 if esc(r.S) != "" {
253 runs = append(runs, r)
254 }
255 }
256 if len(runs) == 0 {
257 return
258 }
259 fmt.Fprintf(&w.out, `<text x="%s" y="%s" xml:space="preserve">`, num(t.X), num(t.Y))
260 x := t.X
261 for _, r := range runs {
262 attrs := []string{
263 attr("x", num(x)),
264 attr("font-family", esc(r.Face.Family.Stack())),
265 attr("font-size", num(r.Size)),
266 }
267 if r.Face.Bold {
268 attrs = append(attrs, attr("font-weight", "700"))
269 }
270 if r.Face.Italic {
271 attrs = append(attrs, attr("font-style", "italic"))
272 }
273 if r.Fill != "" {
274 attrs = append(attrs, attr("fill", esc(string(r.Fill))))
275 }
276 span := "<tspan " + strings.Join(attrs, " ") + ">" + esc(r.S) + "</tspan>"
277 if href := safeHref(r.Href); href != "" {
278 span = `<a href="` + href + `">` + span + `</a>`
279 }
280 w.out.WriteString(span)
281 adv, _ := fonts.Advance(r.Face, r.Size, r.S)
282 x += adv
283 }
284 w.out.WriteString("</text>\n")
285}
286
287// linkSchemes are the schemes a run's href may carry into a picture.
288// The same three render.go allows, for the same reason: an SVG served
289// from this app's origin is a document a reader can click through, and
290// javascript: and data: are how that becomes a way to run code.
291var linkSchemes = []string{"http", "https", "mailto"}
292
293// safeHref is an escaped href, or "" for a link that is not followed.
294// The run's words are drawn either way — losing the sentence because
295// somebody typed a scheme nobody should follow would be a strange
296// trade.
297func safeHref(raw string) string {
298 if raw == "" {
299 return ""
300 }
301 u, err := url.Parse(strings.TrimSpace(raw))
302 if err != nil || !slices.Contains(linkSchemes, strings.ToLower(u.Scheme)) {
303 return ""
304 }
305 return esc(raw)
306}
307
308// ── Images ───────────────────────────────────────────────────────────
309
310// inlineTypes are the raster types that go in as bytes: the three every
311// renderer in this pipeline reads. A WebP is not one of them and is
312// converted to the first of them instead (webpAsPNG).
313var inlineTypes = []string{"image/png", "image/jpeg", "image/gif"}
314
315// webpAsPNG decodes a WebP and writes it back out as a PNG, which is
316// what both consumers of this package can actually draw.
317//
318// Re-encoding rather than refusing, because the alternative is a
319// picture that is simply missing: WebP is one of the four types the
320// upload path admits (assets.go's assetTypes), and it is what a phone
321// and a modern image editor write by default. The cost is a bigger data
322// URI — PNG is lossless and WebP usually is not — which is paid once per
323// export, against a picture that would otherwise not be on the slide at
324// all.
325//
326// The PDF converter has read WebP since svg2pdf was swapped for
327// krilla-svg (2026-09-09, spec §1 ruling 13), so on that side this is
328// now belt and braces. It is left in place because removing it is a
329// change to what the encoder emits, with its own tests, rather than a
330// consequence of changing the converter under it.
331//
332// A file that does not decode answers ok=false and the caller falls
333// back to the URL, exactly as an unknown type does.
334func webpAsPNG(data []byte) ([]byte, bool) {
335 img, err := webp.Decode(bytes.NewReader(data))
336 if err != nil {
337 return nil, false
338 }
339 var out bytes.Buffer
340 if err := png.Encode(&out, img); err != nil {
341 return nil, false
342 }
343 return out.Bytes(), true
344}
345
346func (w *writer) image(im layout.Image) {
347 // preserveAspectRatio is `object-fit` said in SVG: slice fills the
348 // box and overflows it, meet fits inside it.
349 fit := "xMidYMid meet"
350 if im.Fit == "cover" {
351 fit = "xMidYMid slice"
352 }
353
354 var href string
355 var nested []byte
356 if w.o.InlineImages != nil {
357 if data, mime, ok := w.o.InlineImages(im.Asset); ok && len(data) > 0 {
358 switch {
359 case slices.Contains(inlineTypes, mime):
360 href = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
361 case mime == "image/webp":
362 if pngBytes, converted := webpAsPNG(data); converted {
363 href = "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes)
364 }
365 case mime == "image/svg+xml":
366 nested = nestableSVG(data)
367 }
368 }
369 }
370 if href == "" && nested == nil && w.o.AssetURL != nil {
371 href = esc(w.o.AssetURL(im.Asset))
372 }
373 if href == "" && nested == nil {
374 // Nothing resolved it. layout already recorded the warning
375 // (WarnAsset); drawing a grey box here would put a thing on
376 // the slide that is not in the deck.
377 return
378 }
379
380 box := []string{
381 attr("x", num(im.X)), attr("y", num(im.Y)),
382 attr("width", num(im.W)), attr("height", num(im.H)),
383 }
384 // A cover picture is bigger than its box by definition, so it is
385 // clipped to it. An SVG viewport clips on its own, but usvg is
386 // happier told, and it costs four numbers.
387 if im.Fit == "cover" {
388 id := w.id("clip")
389 fmt.Fprintf(&w.defs, `<clipPath id="%s"><rect %s/></clipPath>`+"\n", id, strings.Join(box, " "))
390 box = append(box, attr("clip-path", "url(#"+id+")"))
391 }
392
393 if nested != nil {
394 // The asset's own document, inside a viewport at the box. Its
395 // <svg> keeps its viewBox and its own preserveAspectRatio, so
396 // a nested picture always fits rather than filling — the one
397 // place `fit` is not honoured, and a vector asset is a diagram
398 // far more often than it is a photograph.
399 w.out.WriteString("<svg " + strings.Join(box, " ") + ">\n")
400 w.out.Write(nested)
401 w.out.WriteString("\n</svg>\n")
402 return
403 }
404
405 attrs := append(box, attr("preserveAspectRatio", fit), attr("href", href))
406 if im.Alt == "" {
407 w.out.WriteString("<image " + strings.Join(attrs, " ") + "/>\n")
408 return
409 }
410 w.out.WriteString("<image " + strings.Join(attrs, " ") + "><title>" + esc(im.Alt) + "</title></image>\n")
411}
412
413// nestableSVG is an SVG asset's document, ready to be written inside
414// this one, or nil for one that must not be.
415//
416// Two refusals, and both of them are about a picture being a document:
417//
418// - A <style> element. CSS in an SVG is document-wide, so
419// `text { fill: red }` inside a logo would repaint the words of the
420// slide around it. Nothing this package could scope would fix that,
421// so the picture goes back to being its own document at its own
422// URL.
423// - Anything encoding/xml will not read, or that carries a directive
424// or a processing instruction. The output of this package is
425// promised to be well-formed XML; bytes that are not cannot be
426// spliced into it on the strength of having come from the store.
427//
428// What it does NOT re-check is safety: an asset is sanitised the moment
429// it is registered (internal/slides/svg.go, and the note there about
430// what an uploaded SVG can carry), and re-deriving that judgement here
431// from a second, weaker parser would be the way the two come to
432// disagree.
433func nestableSVG(raw []byte) []byte {
434 i := bytes.Index(raw, []byte("<svg"))
435 if i < 0 {
436 return nil
437 }
438 frag := raw[i:]
439 dec := xml.NewDecoder(bytes.NewReader(frag))
440 depth := 0
441 for {
442 tok, err := dec.Token()
443 if errors.Is(err, io.EOF) {
444 break
445 }
446 if err != nil {
447 return nil
448 }
449 switch t := tok.(type) {
450 case xml.ProcInst, xml.Directive:
451 return nil
452 case xml.StartElement:
453 if strings.EqualFold(t.Name.Local, "style") {
454 return nil
455 }
456 depth++
457 case xml.EndElement:
458 depth--
459 }
460 }
461 if depth != 0 {
462 return nil
463 }
464 return frag
465}
466
467// ── Writing it down ──────────────────────────────────────────────────
468
469// attr is one name="value" pair. The value is already escaped by the
470// caller; the name is a literal from this file.
471func attr(name, value string) string { return name + `="` + value + `"` }
472
473// fillOr is a colour, or the word for its absence.
474func fillOr(f layout.Fill, absent string) string {
475 if f == "" {
476 return absent
477 }
478 return esc(string(f))
479}
480
481// num writes a coordinate: four decimals at most, and never in
482// exponent form.
483//
484// The arithmetic upstream runs through ratios of a 16.8px unit, so a
485// coordinate that is 126.72 comes out of the floats as
486// 126.72000000000001 about as often as not. Nothing can see a
487// ten-thousandth of a pixel, and 'f' rather than 'g' because SVG does
488// not read "1e-9" as a number at all — it is a parse error, and the
489// shape carrying it does not draw.
490func num(v float64) string {
491 if math.IsNaN(v) || math.IsInf(v, 0) {
492 return "0"
493 }
494 return strconv.FormatFloat(math.Round(v*1e4)/1e4, 'f', -1, 64)
495}
496
497// escaper is the five predefined entities. Apostrophe and quote are
498// escaped everywhere rather than only in attributes: one function that
499// is safe in both places cannot be used in the wrong one.
500var escaper = strings.NewReplacer(
501 "&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
502
503// esc is every string that reaches the output, escaped — and stripped
504// of the characters XML 1.0 has no way to carry.
505//
506// The stripping is what makes Encode's "well-formed, always" true. A
507// deck's text arrives through the CRDT from a browser, and a stray
508// control character in it would otherwise produce a file no parser will
509// open: not a slide with a wrong glyph, a slide that does not render.
510// The lost character is invisible; the lost document is the whole
511// picture.
512func esc(s string) string {
513 if strings.IndexFunc(s, badForXML) >= 0 {
514 s = strings.Map(func(r rune) rune {
515 if badForXML(r) {
516 return -1
517 }
518 return r
519 }, s)
520 }
521 return escaper.Replace(s)
522}
523
524// badForXML is the XML 1.0 Char production, inverted: everything below
525// space except tab, newline and carriage return, the surrogates, and
526// the two permanently unassigned code points at the end of the BMP.
527//
528// U+FFFD is not among them. It is a legal character, and it is what
529// strings.Map produces for every invalid UTF-8 byte it meets — the one
530// place something is standing in for a character this cannot know, and
531// a visible box in the picture is the honest way to show that.
532func badForXML(r rune) bool {
533 switch {
534 case r == '\t' || r == '\n' || r == '\r':
535 return false
536 case r < 0x20:
537 return true
538 case r >= 0xd800 && r <= 0xdfff:
539 return true
540 case r == 0xfffe || r == 0xffff:
541 return true
542 }
543 return false
544}
545