diff --git a/d2renderers/d2svgimport/gradient.go b/d2renderers/d2svgimport/gradient.go index 41d107cf8f..85e4b4f2ce 100644 --- a/d2renderers/d2svgimport/gradient.go +++ b/d2renderers/d2svgimport/gradient.go @@ -1,6 +1,7 @@ package d2svgimport import ( + "context" "fmt" "image/color" "math" @@ -87,43 +88,122 @@ func (i *svgImporter) compileGradientElement(element *svgElement) error { } } -func (i *svgImporter) compileLinearGradient(element *svgElement) error { - for _, name := range []string{"x1", "y1", "x2", "y2", "gradientUnits"} { - if _, ok := element.attrs[name]; !ok { - return i.errorf("element is missing required attribute %q", name) - } - } - units, err := trimSVGSpace(i.ctx, element.attrs["gradientUnits"]) +func parseObjectBoundingBoxCoordinate(ctx context.Context, raw string) (float64, error) { + trimmed, err := trimSVGSpace(ctx, raw) if err != nil { - return err + return 0, err } - if units != "userSpaceOnUse" { - return i.errorf("element supports only gradientUnits=\"userSpaceOnUse\"") + if len(trimmed) > 512 { + return 0, fmt.Errorf("number is too long") } - - coordinate := func(name string) (float64, error) { - value, err := parseSVGLength(element.attrs[name], true) + if trimmed == "" { + return 0, fmt.Errorf("empty number") + } + if strings.HasSuffix(trimmed, "%") { + numberPart := strings.TrimSpace(trimmed[:len(trimmed)-1]) + if numberPart == "" { + return 0, fmt.Errorf("empty percentage") + } + if strings.Contains(numberPart, "%") { + return 0, fmt.Errorf("invalid percentage coordinate") + } + number, err := parseSVGNumber(numberPart) if err != nil { - return 0, i.propertyError(element, name, err) + return 0, err + } + value := number / 100.0 + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0, fmt.Errorf("coordinate must be finite") } return value, nil } - x1, err := coordinate("x1") - if err != nil { - return err + if strings.Contains(trimmed, "%") { + return 0, fmt.Errorf("percentages must end with %%") } - y1, err := coordinate("y1") + number, err := parseSVGNumber(trimmed) if err != nil { - return err + return 0, err } - x2, err := coordinate("x2") - if err != nil { - return err + if math.IsNaN(number) || math.IsInf(number, 0) { + return 0, fmt.Errorf("coordinate must be finite") } - y2, err := coordinate("y2") - if err != nil { - return err + return number, nil +} + +func (i *svgImporter) compileLinearGradient(element *svgElement) error { + units := "objectBoundingBox" + if raw, ok := element.attrs["gradientUnits"]; ok { + trimmed, err := trimSVGSpace(i.ctx, raw) + if err != nil { + return err + } + units = trimmed + } + + var paintUnits d2scene.PaintUnits + switch units { + case "objectBoundingBox": + paintUnits = d2scene.ObjectBoundingBox + case "userSpaceOnUse": + paintUnits = d2scene.UserSpaceOnUse + default: + return i.errorf("element supports only gradientUnits=\"userSpaceOnUse\" or \"objectBoundingBox\"") } + + var x1, y1, x2, y2 float64 + if paintUnits == d2scene.UserSpaceOnUse { + for _, name := range []string{"x1", "y1", "x2", "y2"} { + if _, ok := element.attrs[name]; !ok { + return i.errorf("element is missing required attribute %q", name) + } + } + coordinate := func(name string) (float64, error) { + value, err := parseSVGLength(element.attrs[name], true) + if err != nil { + return 0, i.propertyError(element, name, err) + } + return value, nil + } + var err error + if x1, err = coordinate("x1"); err != nil { + return err + } + if y1, err = coordinate("y1"); err != nil { + return err + } + if x2, err = coordinate("x2"); err != nil { + return err + } + if y2, err = coordinate("y2"); err != nil { + return err + } + } else { + coordinate := func(name string, defaultValue float64) (float64, error) { + raw, ok := element.attrs[name] + if !ok { + return defaultValue, nil + } + value, err := parseObjectBoundingBoxCoordinate(i.ctx, raw) + if err != nil { + return 0, i.propertyError(element, name, err) + } + return value, nil + } + var err error + if x1, err = coordinate("x1", 0); err != nil { + return err + } + if y1, err = coordinate("y1", 0); err != nil { + return err + } + if x2, err = coordinate("x2", 1); err != nil { + return err + } + if y2, err = coordinate("y2", 0); err != nil { + return err + } + } + start := d2scene.Point{X: x1, Y: y1} end := d2scene.Point{X: x2, Y: y2} if start == end { @@ -172,7 +252,7 @@ func (i *svgImporter) compileLinearGradient(element *svgElement) error { } element.gradient = &d2scene.LinearGradient{ - Start: start, End: end, Units: d2scene.UserSpaceOnUse, + Start: start, End: end, Units: paintUnits, Transform: transform, Spread: d2scene.SpreadPad, } return nil diff --git a/d2renderers/d2svgimport/gradient_test.go b/d2renderers/d2svgimport/gradient_test.go index e54535f496..3864f0445d 100644 --- a/d2renderers/d2svgimport/gradient_test.go +++ b/d2renderers/d2svgimport/gradient_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/d2lang/d2/d2renderers/d2raster" "github.com/d2lang/d2/d2renderers/d2scene" ) @@ -108,8 +109,9 @@ func TestImportNodeRejectsUnsupportedLinearGradientForms(t *testing.T) { {"outside-defs", ``, "direct child of "}, {"missing-id", ``, "must declare an id"}, {"missing-coordinate", ``, `missing required attribute "x1"`}, - {"default-object-bounds", ``, `missing required attribute "gradientUnits"`}, - {"object-bounds", ``, "userSpaceOnUse"}, + {"invalid-gradient-units", ``, "gradientUnits"}, + {"object-bounds-invalid-coordinate", ``, "invalid x1"}, + {"object-bounds-zero-vector", ``, "zero-length"}, {"percentage-coordinate", ``, "percentages are unsupported"}, {"repeat-spread", ``, "only the pad"}, {"href-inheritance", ``, `unsupported attribute "href"`}, @@ -191,3 +193,133 @@ func nearMatrix(got, want d2scene.Matrix) bool { return nearFloat(got.A, want.A) && nearFloat(got.B, want.B) && nearFloat(got.C, want.C) && nearFloat(got.D, want.D) && nearFloat(got.E, want.E) && nearFloat(got.F, want.F) } + +func TestImportNodeLinearGradientObjectBoundingBoxDefaults(t *testing.T) { + result := mustImport(t, ` + + + + + + + +`) + + if len(result.Root.Children) != 1 || result.Root.Children[0].ID != "target" { + t.Fatalf("rendered gradient tree = %#v", result.Root.Children) + } + rect := result.Root.Children[0].Primitive.(d2scene.Rect) + fill, ok := rect.Fill.(d2scene.LinearGradient) + if !ok { + t.Fatalf("fill = %T, want linear gradient", rect.Fill) + } + if fill.Start != (d2scene.Point{X: 0, Y: 0}) || fill.End != (d2scene.Point{X: 1, Y: 0}) || + fill.Units != d2scene.ObjectBoundingBox || fill.Spread != d2scene.SpreadPad || + fill.Transform != d2scene.Identity() { + t.Fatalf("default linear gradient geometry = %#v", fill) + } + if len(fill.Stops) != 2 || fill.Stops[0].Offset != 0 || fill.Stops[1].Offset != 1 { + t.Fatalf("default linear gradient stops = %#v", fill.Stops) + } +} + +func TestImportNodeLinearGradientObjectBoundingBoxCoordinateVariants(t *testing.T) { + tests := []struct { + name string + svg string + wantStart d2scene.Point + wantEnd d2scene.Point + wantUnits d2scene.PaintUnits + }{ + { + name: "explicit units with percentages", + svg: ` + + + `, + wantStart: d2scene.Point{X: 0.1, Y: 0.2}, + wantEnd: d2scene.Point{X: 0.9, Y: 0.8}, + wantUnits: d2scene.ObjectBoundingBox, + }, + { + name: "explicit units with unitless numbers", + svg: ` + + + `, + wantStart: d2scene.Point{X: 0.25, Y: 0.5}, + wantEnd: d2scene.Point{X: 0.75, Y: 0.5}, + wantUnits: d2scene.ObjectBoundingBox, + }, + { + name: "omitted units with partial coordinates", + svg: ` + + + `, + wantStart: d2scene.Point{X: 0, Y: 0}, + wantEnd: d2scene.Point{X: 1, Y: 1}, + wantUnits: d2scene.ObjectBoundingBox, + }, + { + name: "omitted units with unitless coordinates", + svg: ` + + + `, + wantStart: d2scene.Point{X: 0, Y: 0}, + wantEnd: d2scene.Point{X: 1, Y: 1}, + wantUnits: d2scene.ObjectBoundingBox, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := mustImport(t, tc.svg) + rect := result.Root.Children[0].Primitive.(d2scene.Rect) + fill := rect.Fill.(d2scene.LinearGradient) + if !nearFloat(fill.Start.X, tc.wantStart.X) || !nearFloat(fill.Start.Y, tc.wantStart.Y) || + !nearFloat(fill.End.X, tc.wantEnd.X) || !nearFloat(fill.End.Y, tc.wantEnd.Y) || + fill.Units != tc.wantUnits { + t.Fatalf("geometry = %#v; want start=%v end=%v units=%v", fill, tc.wantStart, tc.wantEnd, tc.wantUnits) + } + }) + } +} + +func TestImportNodeLinearGradientObjectBoundingBoxRasterize(t *testing.T) { + // SVG icon from issue #2937 + svgSource := ` + + + + + + + + ` + + result := mustImport(t, svgSource) + document := d2scene.NewDocument(d2scene.Box{Width: 10, Height: 10}, result.Root) + frame, err := d2raster.Render(context.Background(), document, d2raster.FrameOptions{ + Scale: 1, MaxWidth: 10, MaxHeight: 10, MaxPixels: 100, + MaxNodes: 10, MaxDepth: 10, MaxPathCommands: 20, + MaxAnimationTracks: 1, MaxAnimationKeyframes: 1, + MaxAssets: 1, MaxAssetBytes: 1, MaxDecodedAssetBytes: 1, MaxImportDepth: 10, + MaxOffscreenBytes: 1 << 20, MaxEvenOddClipWork: 1 << 20, + }) + if err != nil { + t.Fatalf("d2raster.Render failed: %v", err) + } + + leftPixel := color.NRGBAModel.Convert(frame.At(0, 5)).(color.NRGBA) + rightPixel := color.NRGBAModel.Convert(frame.At(9, 5)).(color.NRGBA) + + if leftPixel.R < 200 || leftPixel.B > 50 || leftPixel.A != 255 { + t.Fatalf("left pixel = %+v, want mostly red", leftPixel) + } + if rightPixel.B < 200 || rightPixel.R > 50 || rightPixel.A != 255 { + t.Fatalf("right pixel = %+v, want mostly blue", rightPixel) + } +} +