oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package svg_test
2
3import (
4 "bytes"
5 "encoding/base64"
6 "encoding/hex"
7 "encoding/xml"
8 "image/png"
9 "io"
10 "strings"
11 "testing"
12
13 "amadan.net/oficina/slides/internal/slides/fonts"
14 "amadan.net/oficina/slides/internal/slides/layout"
15 "amadan.net/oficina/slides/internal/slides/svg"
16)
17
18// frame is the empty slide every test starts from: the 1920 by 1080
19// frame and a background, with whatever ops the test is about.
20func frame(ops ...layout.Op) layout.Paint {
21 return layout.Paint{W: layout.FrameW, H: layout.FrameH, Background: "#faf7f0", Ops: ops}
22}
23
24// parse is the guard every test in this file leans on: the bytes are
25// XML, or they are not a picture. Returns the document as a tree so a
26// test can ask what is in it rather than grepping the source.
27func parse(t *testing.T, out []byte) node {
28 t.Helper()
29 dec := xml.NewDecoder(bytes.NewReader(out))
30 root := node{}
31 stack := []*node{&root}
32 for {
33 tok, err := dec.Token()
34 if err == io.EOF {
35 break
36 }
37 if err != nil {
38 t.Fatalf("the output is not well-formed XML: %v\n%s", err, out)
39 }
40 switch tk := tok.(type) {
41 case xml.StartElement:
42 n := &node{name: tk.Name.Local, attr: map[string]string{}}
43 for _, a := range tk.Attr {
44 name := a.Name.Local
45 // encoding/xml hands a prefixed attribute back under
46 // its namespace URL. Only one of those is written
47 // here, and it reads better under the prefix it was
48 // written with.
49 switch a.Name.Space {
50 case "":
51 case "xml", "http://www.w3.org/XML/1998/namespace":
52 name = "xml:" + a.Name.Local
53 default:
54 name = a.Name.Space + ":" + a.Name.Local
55 }
56 n.attr[name] = a.Value
57 }
58 top := stack[len(stack)-1]
59 top.kids = append(top.kids, n)
60 stack = append(stack, n)
61 case xml.EndElement:
62 stack = stack[:len(stack)-1]
63 case xml.CharData:
64 stack[len(stack)-1].text += string(tk)
65 }
66 }
67 if len(root.kids) != 1 || root.kids[0].name != "svg" {
68 t.Fatalf("the document is not rooted at one <svg>:\n%s", out)
69 }
70 return *root.kids[0]
71}
72
73// node is one element of the parsed document.
74type node struct {
75 name string
76 attr map[string]string
77 text string
78 kids []*node
79}
80
81// all returns every descendant with this element name, in document
82// order, the node itself included.
83func (n node) all(name string) []*node {
84 var out []*node
85 if n.name == name {
86 c := n
87 out = append(out, &c)
88 }
89 for _, k := range n.kids {
90 out = append(out, k.all(name)...)
91 }
92 return out
93}
94
95// first is all()[0], or a fatal.
96func (n node) first(t *testing.T, name string) *node {
97 t.Helper()
98 got := n.all(name)
99 if len(got) == 0 {
100 t.Fatalf("no <%s> in the document", name)
101 }
102 return got[0]
103}
104
105// words is every character of text under this node, joined — what a
106// reader would hear.
107func (n node) words() string {
108 out := n.text
109 for _, k := range n.kids {
110 out += k.words()
111 }
112 return out
113}
114
115// ── The document ─────────────────────────────────────────────────────
116
117func TestTheRootIsAnAccessibleSVGOfTheFrame(t *testing.T) {
118 doc := parse(t, svg.Encode(frame(), svg.Options{Title: "Renderer fixture"}))
119 for _, c := range []struct{ attr, want string }{
120 {"xmlns", "http://www.w3.org/2000/svg"},
121 {"version", "1.1"},
122 {"viewBox", "0 0 1920 1080"},
123 {"role", "img"},
124 } {
125 // encoding/xml reports xmlns as the namespace rather than as
126 // an attribute, so the default namespace is checked through
127 // the element's own name resolution instead.
128 if c.attr == "xmlns" {
129 continue
130 }
131 if got := doc.attr[c.attr]; got != c.want {
132 t.Errorf("the root's %s is %q, want %q", c.attr, got, c.want)
133 }
134 }
135 if title := doc.first(t, "title"); title.words() != "Renderer fixture" {
136 t.Errorf("the title says %q, want the slide's label", title.words())
137 }
138 if !bytes.Contains(svg.Encode(frame(), svg.Options{}), []byte(`xmlns="http://www.w3.org/2000/svg"`)) {
139 t.Error("the root does not declare the SVG namespace, so nothing will draw it")
140 }
141}
142
143func TestTheBackgroundIsPaintedFirst(t *testing.T) {
144 out := svg.Encode(frame(layout.Rect{X: 10, Y: 10, W: 20, H: 20, Fill: "#000000"}), svg.Options{})
145 doc := parse(t, out)
146 rects := doc.all("rect")
147 if len(rects) < 2 {
148 t.Fatalf("want a background rect and the op's rect, got %d", len(rects))
149 }
150 bg := rects[0]
151 if bg.attr["fill"] != "#faf7f0" || bg.attr["width"] != "1920" || bg.attr["height"] != "1080" {
152 t.Errorf("the first rect is not the background: %v", bg.attr)
153 }
154}
155
156// A slide with no background colour paints no rectangle rather than a
157// black one: a transparent slide is a real value (Fill's own comment),
158// and #000000 would be a decision this package has no business making.
159func TestNoBackgroundPaintsNoRectangle(t *testing.T) {
160 p := frame()
161 p.Background = ""
162 doc := parse(t, svg.Encode(p, svg.Options{}))
163 if got := len(doc.all("rect")); got != 0 {
164 t.Errorf("a slide with no background painted %d rects", got)
165 }
166}
167
168func TestOpsAreDrawnInPaintOrder(t *testing.T) {
169 out := svg.Encode(frame(
170 layout.Rect{W: 10, H: 10, Fill: "#111111"},
171 layout.Ellipse{W: 10, H: 10, Fill: "#222222"},
172 layout.Line{X2: 10, Stroke: "#333333", W: 2},
173 layout.Text{X: 1, Y: 2, Runs: []layout.Run{{S: "words", Size: 20, Fill: "#444444"}}},
174 ), svg.Options{})
175 doc := parse(t, out)
176 var order []string
177 for _, k := range doc.kids {
178 if k.name == "title" || k.name == "defs" {
179 continue
180 }
181 order = append(order, k.name)
182 }
183 want := []string{"rect", "rect", "ellipse", "line", "text"}
184 if strings.Join(order, ",") != strings.Join(want, ",") {
185 t.Errorf("the ops came out as %v, want %v (background first, then paint order)", order, want)
186 }
187}
188
189// ── Text ─────────────────────────────────────────────────────────────
190
191func TestTextIsEscapedAndComesBackWhole(t *testing.T) {
192 const nasty = `Tom & Jerry's <script>alert("hi")</script> "quoted"`
193 out := svg.Encode(frame(layout.Text{X: 100, Y: 200, Runs: []layout.Run{
194 {S: nasty, Face: fonts.Face{Family: fonts.Sans}, Size: 40, Fill: "#1c1a17"},
195 }}), svg.Options{Title: nasty})
196 if bytes.Contains(out, []byte("<script>")) {
197 t.Errorf("a run's text was written as markup:\n%s", out)
198 }
199 doc := parse(t, out)
200 if got := doc.first(t, "text").words(); got != nasty {
201 t.Errorf("the text reads back as %q, want %q", got, nasty)
202 }
203 if got := doc.first(t, "title").words(); got != nasty {
204 t.Errorf("the title reads back as %q, want %q", got, nasty)
205 }
206}
207
208// A character XML 1.0 cannot carry is dropped rather than written: a
209// document nothing can parse is worse than a document missing a
210// control character nobody can see.
211func TestACharacterXMLCannotCarryIsDropped(t *testing.T) {
212 out := svg.Encode(frame(layout.Text{Y: 10, Runs: []layout.Run{
213 {S: "before\x00\x08after", Size: 20},
214 }}), svg.Options{})
215 doc := parse(t, out)
216 if got := doc.first(t, "text").words(); got != "beforeafter" {
217 t.Errorf("the text reads back as %q, want %q", got, "beforeafter")
218 }
219}
220
221func TestARunCarriesItsFace(t *testing.T) {
222 out := svg.Encode(frame(layout.Text{X: 100, Y: 200, Runs: []layout.Run{
223 {S: "Regular ", Face: fonts.Face{Family: fonts.Sans}, Size: 40, Fill: "#111111"},
224 {S: "bold ", Face: fonts.Face{Family: fonts.Serif, Bold: true}, Size: 40, Fill: "#222222"},
225 {S: "italic", Face: fonts.Face{Family: fonts.Mono, Italic: true}, Size: 30, Fill: "#333333"},
226 }}), svg.Options{})
227 doc := parse(t, out)
228 spans := doc.all("tspan")
229 if len(spans) != 3 {
230 t.Fatalf("want one tspan per run, got %d", len(spans))
231 }
232 if got := doc.first(t, "text").attr["xml:space"]; got != "preserve" {
233 t.Errorf(`the <text> carries xml:space=%q, want "preserve" — a run's trailing space is layout`, got)
234 }
235 for _, c := range []struct {
236 span int
237 attr, want string
238 }{
239 {0, "font-family", "Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"},
240 {0, "font-size", "40"},
241 {0, "fill", "#111111"},
242 {1, "font-family", "Liberation Serif, Georgia, serif"},
243 {1, "font-weight", "700"},
244 {2, "font-style", "italic"},
245 {2, "font-size", "30"},
246 } {
247 if got := spans[c.span].attr[c.attr]; got != c.want {
248 t.Errorf("run %d's %s is %q, want %q", c.span, c.attr, got, c.want)
249 }
250 }
251 if _, ok := spans[0].attr["font-weight"]; ok {
252 t.Error("a regular run carries a font-weight; only bold needs one")
253 }
254 if _, ok := spans[0].attr["font-style"]; ok {
255 t.Error("an upright run carries a font-style; only italic needs one")
256 }
257}
258
259// Every run is placed at its own x, measured with the same function the
260// layout engine measured with. Letting the runs flow would put them
261// wherever the renderer's own advances landed, and the whole point of a
262// paint list is that the thumbnail and the page agree.
263func TestEachRunIsPlacedAtItsOwnX(t *testing.T) {
264 face := fonts.Face{Family: fonts.Sans}
265 first, _ := fonts.Advance(face, 40, "Hello ")
266 out := svg.Encode(frame(layout.Text{X: 100, Y: 200, Runs: []layout.Run{
267 {S: "Hello ", Face: face, Size: 40},
268 {S: "world", Face: face, Size: 40},
269 }}), svg.Options{})
270 doc := parse(t, out)
271 spans := doc.all("tspan")
272 if len(spans) != 2 {
273 t.Fatalf("want two tspans, got %d", len(spans))
274 }
275 if spans[0].attr["x"] != "100" {
276 t.Errorf("the first run is at x=%q, want the line's own x", spans[0].attr["x"])
277 }
278 if got, want := spans[1].attr["x"], num(100+first); got != want {
279 t.Errorf("the second run is at x=%q, want %q (the first run's measured width)", got, want)
280 }
281}
282
283func TestAnEmptyRunDrawsNothing(t *testing.T) {
284 doc := parse(t, svg.Encode(frame(
285 layout.Text{Y: 10, Runs: []layout.Run{{S: "", Size: 20}}},
286 layout.Text{Y: 20},
287 ), svg.Options{}))
288 if got := len(doc.all("text")); got != 0 {
289 t.Errorf("%d empty <text> elements were written", got)
290 }
291}
292
293// ── Links ────────────────────────────────────────────────────────────
294
295func TestALinkedRunIsAnAnchor(t *testing.T) {
296 doc := parse(t, svg.Encode(frame(layout.Text{Y: 10, Runs: []layout.Run{
297 {S: "the spec", Size: 20, Href: "https://example.test/spec?a=1&b=2"},
298 }}), svg.Options{}))
299 a := doc.first(t, "a")
300 if got := a.attr["href"]; got != "https://example.test/spec?a=1&b=2" {
301 t.Errorf("the link's href is %q", got)
302 }
303 if len(a.all("tspan")) != 1 {
304 t.Error("the run is not inside its anchor")
305 }
306}
307
308func TestOnlyWebAndMailLinksSurvive(t *testing.T) {
309 for _, href := range []string{
310 "javascript:alert(1)",
311 "data:text/html,<script>alert(1)</script>",
312 "file:///etc/passwd",
313 "vbscript:msgbox",
314 } {
315 out := svg.Encode(frame(layout.Text{Y: 10, Runs: []layout.Run{
316 {S: "click", Size: 20, Href: href},
317 }}), svg.Options{})
318 doc := parse(t, out)
319 if got := len(doc.all("a")); got != 0 {
320 t.Errorf("%q became a link", href)
321 }
322 if got := doc.first(t, "text").words(); got != "click" {
323 t.Errorf("%q lost its words as well as its link: %q", href, got)
324 }
325 }
326 for _, href := range []string{"https://example.test/", "http://example.test/", "mailto:paul@example.test"} {
327 doc := parse(t, svg.Encode(frame(layout.Text{Y: 10, Runs: []layout.Run{
328 {S: "click", Size: 20, Href: href},
329 }}), svg.Options{}))
330 if got := len(doc.all("a")); got != 1 {
331 t.Errorf("%q did not become a link", href)
332 }
333 }
334}
335
336// ── Shapes ───────────────────────────────────────────────────────────
337
338func TestARectangleCarriesItsFillStrokeAndRadius(t *testing.T) {
339 doc := parse(t, svg.Encode(frame(layout.Rect{
340 X: 10, Y: 20, W: 300, H: 200, Fill: "#e0d7e8", Stroke: "#75608a", StrokeW: 4, Radius: 12,
341 }), svg.Options{}))
342 r := doc.all("rect")[1]
343 for _, c := range []struct{ attr, want string }{
344 {"x", "10"}, {"y", "20"}, {"width", "300"}, {"height", "200"},
345 {"fill", "#e0d7e8"}, {"stroke", "#75608a"}, {"stroke-width", "4"}, {"rx", "12"},
346 } {
347 if got := r.attr[c.attr]; got != c.want {
348 t.Errorf("the rect's %s is %q, want %q", c.attr, got, c.want)
349 }
350 }
351}
352
353// An unfilled shape is an outline, and "no fill" in SVG is the word
354// none: leaving the attribute off would fill it black.
355func TestAnUnfilledShapeSaysSoOutLoud(t *testing.T) {
356 doc := parse(t, svg.Encode(frame(
357 layout.Rect{W: 10, H: 10, Stroke: "#000000", StrokeW: 1},
358 layout.Ellipse{W: 10, H: 10, Stroke: "#000000", StrokeW: 1},
359 ), svg.Options{}))
360 if got := doc.all("rect")[1].attr["fill"]; got != "none" {
361 t.Errorf("an unfilled rect's fill is %q, want none", got)
362 }
363 if got := doc.first(t, "ellipse").attr["fill"]; got != "none" {
364 t.Errorf("an unfilled ellipse's fill is %q, want none", got)
365 }
366}
367
368func TestAnEllipseIsItsBoxAsACentreAndTwoRadii(t *testing.T) {
369 doc := parse(t, svg.Encode(frame(layout.Ellipse{
370 X: 100, Y: 200, W: 300, H: 100, Fill: "#000000",
371 }), svg.Options{}))
372 e := doc.first(t, "ellipse")
373 for _, c := range []struct{ attr, want string }{
374 {"cx", "250"}, {"cy", "250"}, {"rx", "150"}, {"ry", "50"},
375 } {
376 if got := e.attr[c.attr]; got != c.want {
377 t.Errorf("the ellipse's %s is %q, want %q", c.attr, got, c.want)
378 }
379 }
380}
381
382func TestAnArrowGetsAMarkerInTheColourItIsDrawnIn(t *testing.T) {
383 doc := parse(t, svg.Encode(frame(
384 layout.Line{X1: 0, Y1: 0, X2: 100, Y2: 100, Stroke: "#8a5410", W: 4, Arrow: true},
385 layout.Line{X1: 0, Y1: 0, X2: 100, Y2: 0, Stroke: "#8a5410", W: 4, Arrow: true},
386 layout.Line{X1: 0, Y1: 0, X2: 0, Y2: 100, Stroke: "#46617a", W: 4},
387 ), svg.Options{}))
388 markers := doc.all("marker")
389 if len(markers) != 1 {
390 t.Fatalf("want one marker for the one arrow colour, got %d", len(markers))
391 }
392 if got := markers[0].first(t, "path").attr["fill"]; got != "#8a5410" {
393 t.Errorf("the arrowhead is %q, want the line's own stroke", got)
394 }
395 lines := doc.all("line")
396 if got := lines[0].attr["marker-end"]; got != "url(#"+markers[0].attr["id"]+")" {
397 t.Errorf("the arrow points at %q, want the marker", got)
398 }
399 if _, ok := lines[2].attr["marker-end"]; ok {
400 t.Error("a plain line grew an arrowhead")
401 }
402}
403
404// ── Images ───────────────────────────────────────────────────────────
405
406var onePNG = []byte("\x89PNG\r\n\x1a\n" + "the rest is not a real PNG, and nothing here decodes it")
407
408func inline(mime string, data []byte) func(string) ([]byte, string, bool) {
409 return func(asset string) ([]byte, string, bool) {
410 if asset != "asset0001aaa" {
411 return nil, "", false
412 }
413 return data, mime, true
414 }
415}
416
417func TestAnImageInlinesAsADataURIWhenTheCallerCan(t *testing.T) {
418 doc := parse(t, svg.Encode(frame(layout.Image{
419 X: 10, Y: 20, W: 300, H: 200, Asset: "asset0001aaa", Fit: "contain", Alt: "A bar chart",
420 }), svg.Options{
421 InlineImages: inline("image/png", onePNG),
422 AssetURL: func(string) string { return "/d/abc/assets/deadbeef" },
423 }))
424 img := doc.first(t, "image")
425 want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(onePNG)
426 if got := img.attr["href"]; got != want {
427 t.Errorf("the image's href is %q, want the inlined bytes", got)
428 }
429 if got := img.attr["preserveAspectRatio"]; got != "xMidYMid meet" {
430 t.Errorf("a contain image's preserveAspectRatio is %q", got)
431 }
432 if got := img.first(t, "title").words(); got != "A bar chart" {
433 t.Errorf("the alternative text is %q", got)
434 }
435}
436
437func TestACoverImageIsSlicedAndClipped(t *testing.T) {
438 doc := parse(t, svg.Encode(frame(layout.Image{
439 X: 0, Y: 0, W: 1920, H: 1080, Asset: "asset0001aaa", Fit: "cover",
440 }), svg.Options{InlineImages: inline("image/png", onePNG)}))
441 img := doc.first(t, "image")
442 if got := img.attr["preserveAspectRatio"]; got != "xMidYMid slice" {
443 t.Errorf("a cover image's preserveAspectRatio is %q", got)
444 }
445 clip := img.attr["clip-path"]
446 if clip == "" {
447 t.Fatal("a cover image is not clipped, so it paints over the rest of the slide")
448 }
449 paths := doc.all("clipPath")
450 if len(paths) != 1 || "url(#"+paths[0].attr["id"]+")" != clip {
451 t.Errorf("the clip path %q is not the one that was defined", clip)
452 }
453}
454
455func TestAnImageFallsBackToTheSurfacesOwnURL(t *testing.T) {
456 doc := parse(t, svg.Encode(frame(layout.Image{
457 W: 100, H: 100, Asset: "asset0001aaa", Fit: "contain",
458 }), svg.Options{AssetURL: func(a string) string { return "/d/abc/assets/" + a }}))
459 if got := doc.first(t, "image").attr["href"]; got != "/d/abc/assets/asset0001aaa" {
460 t.Errorf("the image's href is %q, want the surface's asset URL", got)
461 }
462}
463
464// The three raster types every consumer reads go in as they are; a
465// WebP is transcoded rather than inlined (see webpAsPNG).
466func TestOnlyTypesEverythingCanReadAreInlined(t *testing.T) {
467 for _, mime := range []string{"image/png", "image/jpeg", "image/gif"} {
468 doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}),
469 svg.Options{InlineImages: inline(mime, onePNG)}))
470 if got := doc.first(t, "image").attr["href"]; !strings.HasPrefix(got, "data:"+mime+";base64,") {
471 t.Errorf("%s did not inline: %q", mime, got)
472 }
473 }
474 doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}), svg.Options{
475 InlineImages: inline("image/octet-stream", onePNG),
476 AssetURL: func(string) string { return "/assets/x" },
477 }))
478 if got := doc.first(t, "image").attr["href"]; got != "/assets/x" {
479 t.Errorf("a type nothing here decodes inlined as %q; it should fall back to the URL", got)
480 }
481}
482
483// aWebP is a 4 by 3 lossless WebP, one flat colour: the smallest real
484// file of the one raster type nothing downstream can draw. Written as
485// hex rather than as a testdata file because it is thirty-eight bytes
486// and the test reads better with its own fixture in it.
487const aWebP = "524946461e000000574542505650384c110000002f0380000007508f2217a5ff8188e87f0000"
488
489// A WebP is re-encoded as a PNG on its way into the picture. It is one
490// of the four types the upload path admits and neither consumer of this
491// package can draw one, so the choice is convert it or lose it.
492func TestAWebPIsInlinedAsAPNG(t *testing.T) {
493 data, err := hex.DecodeString(aWebP)
494 if err != nil {
495 t.Fatalf("the fixture is not hex: %v", err)
496 }
497 doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}), svg.Options{
498 InlineImages: inline("image/webp", data),
499 AssetURL: func(string) string { return "/assets/x" },
500 }))
501 href := doc.first(t, "image").attr["href"]
502 prefix := "data:image/png;base64,"
503 if !strings.HasPrefix(href, prefix) {
504 t.Fatalf("a WebP came out as %q, want a PNG data URI", trunc(href))
505 }
506 raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(href, prefix))
507 if err != nil {
508 t.Fatalf("the data URI is not base64: %v", err)
509 }
510 img, err := png.Decode(bytes.NewReader(raw))
511 if err != nil {
512 t.Fatalf("the inlined bytes are not a PNG: %v", err)
513 }
514 if b := img.Bounds(); b.Dx() != 4 || b.Dy() != 3 {
515 t.Errorf("the PNG is %d by %d, want the WebP's 4 by 3", b.Dx(), b.Dy())
516 }
517
518 // Bytes that claim to be a WebP and are not fall back to the URL,
519 // like any other picture this package cannot read.
520 broken := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}), svg.Options{
521 InlineImages: inline("image/webp", []byte("RIFF\x00\x00\x00\x00WEBPnot really")),
522 AssetURL: func(string) string { return "/assets/x" },
523 }))
524 if got := broken.first(t, "image").attr["href"]; got != "/assets/x" {
525 t.Errorf("junk claiming to be a WebP inlined as %q", trunc(got))
526 }
527}
528
529// trunc keeps a failure message about a data URI readable.
530func trunc(s string) string {
531 if len(s) > 80 {
532 return s[:80] + "…"
533 }
534 return s
535}
536
537func TestAnImageNothingCanResolveIsNotDrawn(t *testing.T) {
538 doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "gone"}), svg.Options{}))
539 if got := len(doc.all("image")); got != 0 {
540 t.Errorf("%d images were written for an asset nothing resolved", got)
541 }
542}
543
544// ── SVG assets ───────────────────────────────────────────────────────
545
546const anSVGAsset = `<?xml version="1.0"?>
547<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="4" fill="#8a5410"/></svg>`
548
549func TestAnSVGAssetIsNestedRatherThanLinked(t *testing.T) {
550 doc := parse(t, svg.Encode(frame(layout.Image{
551 X: 100, Y: 200, W: 300, H: 300, Asset: "asset0001aaa", Alt: "A circle",
552 }), svg.Options{InlineImages: inline("image/svg+xml", []byte(anSVGAsset))}))
553 // Three: the document, the viewport this package puts the asset in
554 // (which is where the box goes), and the asset's own root.
555 inner := doc.all("svg")
556 if len(inner) != 3 {
557 t.Fatalf("want the document, the asset's viewport and the asset, got %d <svg> elements", len(inner))
558 }
559 if got := inner[1].attr["x"]; got != "100" {
560 t.Errorf("the nested picture is at x=%q, want its box", got)
561 }
562 if got := inner[2].attr["viewBox"]; got != "0 0 10 10" {
563 t.Errorf("the asset's own viewBox is %q; it should be untouched", got)
564 }
565 if len(doc.all("circle")) != 1 {
566 t.Error("the asset's own drawing did not come through")
567 }
568 if got := len(doc.all("image")); got != 0 {
569 t.Errorf("the SVG was written as an <image> as well (%d)", got)
570 }
571}
572
573// An SVG asset carrying a stylesheet is not nested: CSS in an SVG is
574// document-wide, so `text { fill: red }` inside a picture would repaint
575// the words of the slide around it. The picture falls back to its URL,
576// where it is a separate document again.
577func TestAnSVGAssetWithAStylesheetIsNotNested(t *testing.T) {
578 const styled = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">` +
579 `<style>text { fill: red }</style><circle cx="5" cy="5" r="4"/></svg>`
580 doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}), svg.Options{
581 InlineImages: inline("image/svg+xml", []byte(styled)),
582 AssetURL: func(string) string { return "/assets/x" },
583 }))
584 if got := len(doc.all("circle")); got != 0 {
585 t.Error("a picture with a stylesheet was nested into the slide")
586 }
587 if got := doc.first(t, "image").attr["href"]; got != "/assets/x" {
588 t.Errorf("it did not fall back to its URL: %q", got)
589 }
590}
591
592func TestAnSVGAssetThatWillNotParseIsSkipped(t *testing.T) {
593 doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}), svg.Options{
594 InlineImages: inline("image/svg+xml", []byte(`<svg><rect></svg>`)),
595 }))
596 if got := len(doc.all("rect")); got != 1 {
597 t.Errorf("a malformed picture reached the output (%d rects, want only the background)", got)
598 }
599}
600
601// ── The numbers ──────────────────────────────────────────────────────
602
603// A coordinate is written as a plain decimal, four places at most: the
604// arithmetic upstream runs through ratios of a 16.8px unit, and
605// "126.72000000000001" is a longer file that draws the same picture.
606func TestCoordinatesAreRoundedAndPlain(t *testing.T) {
607 out := svg.Encode(frame(layout.Rect{X: 126.72000000000001, Y: 1e-9, W: 10, H: 10, Fill: "#000000"}), svg.Options{})
608 if bytes.Contains(out, []byte("126.72000000000001")) || bytes.Contains(out, []byte("e-")) {
609 t.Errorf("a coordinate was written in full or in exponent form:\n%s", out)
610 }
611 doc := parse(t, out)
612 r := doc.all("rect")[1]
613 if r.attr["x"] != "126.72" || r.attr["y"] != "0" {
614 t.Errorf("the rect is at %q,%q", r.attr["x"], r.attr["y"])
615 }
616}
617
618// num is the test's own copy of the writer's number format, so a test
619// comparing against a measured width does not have to guess at it.
620func num(v float64) string {
621 out := svg.Encode(frame(layout.Rect{X: v, W: 1, H: 1, Fill: "#000000"}), svg.Options{})
622 const key = `<rect x="`
623 i := bytes.LastIndex(out, []byte(key)) + len(key)
624 j := bytes.IndexByte(out[i:], '"')
625 return string(out[i : i+j])
626}
627