package imageprocessor import ( "bytes" "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "sync/atomic" "testing" "github.com/davidbyttow/govips/v2/vips" "sneak.berlin/go/pixa/internal/imagerequest" ) // testSVG is a 16x12 SVG, black on its left half and white on its right. const testSVG = `` + `` + `` // newSVGTestProcessor returns a processor for the SVG tests. func newSVGTestProcessor() *ImageProcessor { return New(Params{MaxInputBytes: 1 << 20, MaxConcurrentProcessing: 1}) } // processSVG processes svg, as an SVG source, with transform. func processSVG( t *testing.T, svg string, transform imagerequest.Transform, ) (*Result, []byte, error) { t.Helper() result, err := newSVGTestProcessor().Process(t.Context(), bytes.NewReader([]byte(svg)), imagerequest.FormatSVG, &transform) if err != nil { return nil, nil, err } output, err := io.ReadAll(result.Content) if err != nil { t.Fatalf("reading the output: %v", err) } return result, output, nil } // TestImageProcessor_SVGOrigIsTheSVG checks that orig of an SVG at the orig // size is the SVG itself, never another format, and that orig at another // size is ErrSVGNotOriginalSize. func TestImageProcessor_SVGOrigIsTheSVG(t *testing.T) { t.Parallel() result, output, err := processSVG(t, testSVG, imagerequest.Transform{Format: imagerequest.FormatOriginal}) if err != nil { t.Fatalf("orig of the SVG: %v", err) } t.Logf("orig of the SVG: %s, %dx%d", result.ContentType, result.Width, result.Height) if result.ContentType != "image/svg+xml" || string(output) != testSVG { t.Errorf("orig of the SVG = %s %q, want image/svg+xml %q", result.ContentType, output, testSVG) } _, _, err = processSVG(t, testSVG, imagerequest.Transform{ Size: imagerequest.Size{Width: 32, Height: 24}, Format: imagerequest.FormatOriginal, }) if !errors.Is(err, ErrSVGNotOriginalSize) { t.Errorf("orig of the SVG at 32x24: error = %v, want %v", err, ErrSVGNotOriginalSize) } } // TestImageProcessor_RendersSVGToEachRasterFormat checks that an SVG is // rendered to each raster format at the requested size. func TestImageProcessor_RendersSVGToEachRasterFormat(t *testing.T) { t.Parallel() for _, format := range []imagerequest.Format{ imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF, imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL, } { result, output, err := processSVG(t, testSVG, imagerequest.Transform{ Size: imagerequest.Size{Width: 40, Height: 30}, Format: format, }) if err != nil { t.Errorf("SVG as %s: %v", format, err) continue } t.Logf("SVG as %s: %s, %dx%d, %d bytes", format, result.ContentType, result.Width, result.Height, len(output)) if result.ContentType != format.MIMEType() || vips.DetermineImageType(output) != vipsImageType(format) || result.Width != 40 || result.Height != 30 { t.Errorf("SVG as %s = %s, %dx%d; want %s, 40x30", format, result.ContentType, result.Width, result.Height, format.MIMEType()) } } } // TestImageProcessor_RendersSVGSharpWhenEnlarged checks that a 16x12 SVG // asked for at 1600x1200 is rendered at that size, not rendered at 16x12 and // enlarged: 10 pixels either side of the edge between its black and white // halves are black and white, where an enlarged image is grey there. func TestImageProcessor_RendersSVGSharpWhenEnlarged(t *testing.T) { t.Parallel() _, output, err := processSVG(t, testSVG, imagerequest.Transform{ Size: imagerequest.Size{Width: 1600, Height: 1200}, Format: imagerequest.FormatPNG, }) if err != nil { t.Fatalf("SVG as PNG at 1600x1200: %v", err) } img, err := vips.NewImageFromBuffer(output) if err != nil { t.Fatalf("decoding the PNG: %v", err) } t.Cleanup(img.Close) for x, want := range map[int]float64{790: 0, 810: 255} { point, err := img.GetPoint(x, 600) if err != nil { t.Fatalf("GetPoint(%d, 600): %v", x, err) } t.Logf("pixel at %d,600: %v", x, point) if point[0] != want { t.Errorf("pixel at %d,600 = %v, want %v", x, point[0], want) } } } // TestImageProcessor_SVGFetchesNothing checks that rendering an SVG draws no // image it refers to: one at a URL on a local server, by href or xlink:href, // or a local file that exists. Each SVG renders without error, white where // the image would be. An SVG that uses an external entity at that server is // a separate case. No SVG makes a request to the server. func TestImageProcessor_SVGFetchesNothing(t *testing.T) { t.Parallel() var requests atomic.Int64 // Blue is 128 in every pixel of this PNG. png := createTestPNG(t, 4, 4) server := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { requests.Add(1) t.Logf("request for %s", r.URL) w.Header().Set("Content-Type", "image/png") _, _ = w.Write(png) })) t.Cleanup(server.Close) pngPath := filepath.Join(t.TempDir(), "image.png") err := os.WriteFile(pngPath, png, 0o600) if err != nil { t.Fatalf("writing the PNG file: %v", err) } for _, reference := range []string{ `href="` + server.URL + `/image.png"`, `xlink:href="` + server.URL + `/xlink.png"`, `href="file://` + pngPath + `"`, } { svg := `` + `` + `` _, output, err := processSVG(t, svg, imagerequest.Transform{ Format: imagerequest.FormatPNG, }) if err != nil { t.Errorf("SVG with image %s as PNG: %v", reference, err) continue } img, err := vips.NewImageFromBuffer(output) if err != nil { t.Fatalf("decoding the PNG: %v", err) } point, err := img.GetPoint(8, 6) img.Close() if err != nil { t.Fatalf("GetPoint(8, 6): %v", err) } t.Logf("SVG with image %s: pixel at 8,6 = %v", reference, point) if point[2] != 255 { t.Errorf("SVG with image %s: blue at 8,6 = %v, want 255: the "+ "image is drawn", reference, point[2]) } } svg := `` + `]>` + `` + `&outside;` // librsvg loads no external entity, so it refuses this SVG: outside is // not defined. _, _, err = processSVG(t, svg, imagerequest.Transform{ Format: imagerequest.FormatPNG, }) t.Logf("SVG with an external entity as PNG: error = %v", err) if requests.Load() != 0 { t.Errorf("rendering the SVGs made %d requests, want none", requests.Load()) } } // TestImageProcessor_RendersSVGLargerThanMaxInputDimension checks that an // SVG whose own size is above MaxInputDimension is limited only by the // output checks: it is rendered at a raster size, orig of it is the SVG // itself, and a raster format at its own size is ErrOutputTooLarge. The // 1000000x1000000 one is rendered at a ten-thousandth of its own size. func TestImageProcessor_RendersSVGLargerThanMaxInputDimension(t *testing.T) { t.Parallel() for _, svg := range []string{ `` + ``, ``, } { result, _, err := processSVG(t, svg, imagerequest.Transform{ Size: imagerequest.Size{Width: 100, Height: 100}, Format: imagerequest.FormatPNG, }) if err != nil { t.Errorf("%s as PNG at 100x100: %v", svg, err) } else { t.Logf("%s as PNG at 100x100: %dx%d", svg, result.Width, result.Height) if result.Width != 100 || result.Height != 100 { t.Errorf("%s as PNG at 100x100 = %dx%d, want 100x100", svg, result.Width, result.Height) } } result, output, err := processSVG(t, svg, imagerequest.Transform{Format: imagerequest.FormatOriginal}) if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() || string(output) != svg { t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err) } _, _, err = processSVG(t, svg, imagerequest.Transform{Format: imagerequest.FormatPNG}) if !errors.Is(err, ErrOutputTooLarge) { t.Errorf("%s as PNG at its own size: error = %v, want %v", svg, err, ErrOutputTooLarge) } } } // TestImageProcessor_RefusesSVGTooThin checks that an SVG with one side more // than MaxInputDimension times the other is ErrSVGTooThin at a raster size, // that one at exactly that proportion is rendered, and that orig of either // is the SVG itself. func TestImageProcessor_RefusesSVGTooThin(t *testing.T) { t.Parallel() tooThin := `` atTheLimit := `` size := imagerequest.Size{Width: 100, Height: 100} _, _, err := processSVG(t, tooThin, imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG}) if !errors.Is(err, ErrSVGTooThin) { t.Errorf("81930x10 SVG as PNG at 100x100: error = %v, want %v", err, ErrSVGTooThin) } result, _, err := processSVG(t, atTheLimit, imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG}) if err != nil { t.Errorf("81920x10 SVG as PNG at 100x100: %v", err) } else if result.Width != 100 || result.Height != 100 { t.Errorf("81920x10 SVG as PNG at 100x100 = %dx%d, want 100x100", result.Width, result.Height) } for _, svg := range []string{tooThin, atTheLimit} { result, output, err := processSVG(t, svg, imagerequest.Transform{Format: imagerequest.FormatOriginal}) if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() || string(output) != svg { t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err) } } } // TestSVGRenderSize checks the size at which an SVG is rendered to cover an // output size, and that it is held to MaxInputDimension. func TestSVGRenderSize(t *testing.T) { t.Parallel() tests := []struct { name string width, height int outputWidth, outputHeight int wantWidth, wantHeight int }{ {"own size", 16, 12, 16, 12, 16, 12}, {"100 times larger", 16, 12, 1600, 1200, 1600, 1200}, {"wider than the SVG's proportions", 16, 12, 160, 12, 160, 120}, {"10 times smaller", 100, 100, 10, 10, 10, 10}, {"held to MaxInputDimension", 8192, 1, 8192, 8192, 8192, 1}, {"held to MaxInputDimension when enlarged", 1024, 1, 8192, 8192, 8192, 8}, {"far above MaxInputDimension", 1000000, 1000000, 100, 100, 100, 100}, } for _, tt := range tests { gotWidth, gotHeight := svgRenderSize(tt.width, tt.height, tt.outputWidth, tt.outputHeight) if gotWidth != tt.wantWidth || gotHeight != tt.wantHeight { t.Errorf("%s: svgRenderSize(%d, %d, %d, %d) = %dx%d, want %dx%d", tt.name, tt.width, tt.height, tt.outputWidth, tt.outputHeight, gotWidth, gotHeight, tt.wantWidth, tt.wantHeight) } } }