| 1 | package svg_test |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | func 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. |
| 27 | func 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. |
| 74 | type 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. |
| 83 | func (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. |
| 96 | func (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. |
| 107 | func (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 | |
| 117 | func 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 | |
| 143 | func 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. |
| 159 | func 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 | |
| 168 | func 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 | |
| 191 | func 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. |
| 211 | func 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 | |
| 221 | func 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. |
| 263 | func 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 | |
| 283 | func 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 | |
| 295 | func 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 | |
| 308 | func 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 | |
| 338 | func 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. |
| 355 | func 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 | |
| 368 | func 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 | |
| 382 | func 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 | |
| 406 | var onePNG = []byte("\x89PNG\r\n\x1a\n" + "the rest is not a real PNG, and nothing here decodes it") |
| 407 | |
| 408 | func 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 | |
| 417 | func 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 | |
| 437 | func 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 | |
| 455 | func 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). |
| 466 | func 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. |
| 487 | const 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. |
| 492 | func 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. |
| 530 | func trunc(s string) string { |
| 531 | if len(s) > 80 { |
| 532 | return s[:80] + "…" |
| 533 | } |
| 534 | return s |
| 535 | } |
| 536 | |
| 537 | func 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 | |
| 546 | const 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 | |
| 549 | func 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. |
| 577 | func 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 | |
| 592 | func 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. |
| 606 | func 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. |
| 620 | func 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 | |