Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ci/release/changelogs/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@

#### Bugfixes ⛑️

- d2svg: reject padding that would produce invalid negative SVG dimensions
- d2elk: route ancestor-to-descendant connections around intermediate containers
- d2svg: render one-stop gradients with finite SVG offsets
- compiler: make suffix globs match only names with the requested suffix
Expand Down
123 changes: 102 additions & 21 deletions d2renderers/d2svg/d2svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,81 @@ type RenderOpts struct {
OmitVersion *bool
}

func dimensions(diagram *d2target.Diagram, pad int) (left, top, width, height int) {
tl, br := diagram.BoundingBox()
func invalidPaddingError(pad int64) error {
return fmt.Errorf("padding %d produces invalid SVG dimensions", pad)
}

func checkedIntAdd(a, b, minInt, maxInt int64) (int64, bool) {
if b > 0 && a > maxInt-b || b < 0 && a < minInt-b {
return 0, false
}
return a + b, true
}

func checkedIntSub(a, b, minInt, maxInt int64) (int64, bool) {
if b > 0 && a < minInt+b || b < 0 && a > maxInt+b {
return 0, false
}
return a - b, true
}

func validatePadding(tl, br d2target.Point, pad int64) (int, error) {
maxInt := int64(^uint(0) >> 1)
minInt := -maxInt - 1
if pad < minInt || pad > maxInt {
return 0, invalidPaddingError(pad)
}

doublePad, ok := checkedIntAdd(pad, pad, minInt, maxInt)
if !ok {
return 0, invalidPaddingError(pad)
}
for _, bounds := range [][2]int64{
{int64(tl.X), int64(br.X)},
{int64(tl.Y), int64(br.Y)},
} {
if _, ok := checkedIntSub(bounds[0], pad, minInt, maxInt); !ok {
return 0, invalidPaddingError(pad)
}
size, ok := checkedIntSub(bounds[1], bounds[0], minInt, maxInt)
if !ok {
return 0, invalidPaddingError(pad)
}
if _, ok := checkedIntAdd(size, doublePad, minInt, maxInt); !ok {
return 0, invalidPaddingError(pad)
}
}
return int(pad), nil
}

func expandDimensions(left, top, width, height, amount int) (int, int, int, int, bool) {
maxInt := int64(^uint(0) >> 1)
minInt := -maxInt - 1
amount64 := int64(amount)
doubleAmount, ok := checkedIntAdd(amount64, amount64, minInt, maxInt)
if !ok {
return 0, 0, 0, 0, false
}
left64, ok := checkedIntSub(int64(left), amount64, minInt, maxInt)
if !ok {
return 0, 0, 0, 0, false
}
top64, ok := checkedIntSub(int64(top), amount64, minInt, maxInt)
if !ok {
return 0, 0, 0, 0, false
}
width64, ok := checkedIntAdd(int64(width), doubleAmount, minInt, maxInt)
if !ok {
return 0, 0, 0, 0, false
}
height64, ok := checkedIntAdd(int64(height), doubleAmount, minInt, maxInt)
if !ok {
return 0, 0, 0, 0, false
}
return int(left64), int(top64), int(width64), int(height64), true
}

func dimensions(diagram *d2target.Diagram, pad int, tl, br d2target.Point) (left, top, width, height int) {
left = tl.X - pad
top = tl.Y - pad
width = br.X - tl.X + pad*2
Expand Down Expand Up @@ -2739,12 +2812,17 @@ var DEFAULT_DARK_THEME *int64 = nil // no theme selected
func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
sketch := false
pad := DEFAULT_PADDING
tl, br := diagram.BoundingBox()
themeID := d2themescatalog.NeutralDefault.ID
darkThemeID := DEFAULT_DARK_THEME
var scale *float64
if opts != nil {
if opts.Pad != nil {
pad = int(*opts.Pad)
var err error
pad, err = validatePadding(tl, br, *opts.Pad)
if err != nil {
return nil, err
}
}
if opts.Sketch != nil && *opts.Sketch {
sketch = true
Expand Down Expand Up @@ -2862,10 +2940,9 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
}

// Note: we always want this since we reference it on connections even if there end up being no masked labels
left, top, w, h := dimensions(diagram, pad)
left, top, w, h := dimensions(diagram, pad, tl, br)

if diagram.Legend != nil && (len(diagram.Legend.Shapes) > 0 || len(diagram.Legend.Connections) > 0) {
tl, br := diagram.BoundingBox()
totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING
maxLabelWidth := 0
itemCount := 0
Expand Down Expand Up @@ -2931,6 +3008,9 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
}
}
}
if w < 0 || h < 0 {
return nil, invalidPaddingError(int64(pad))
}
fmt.Fprint(buf, strings.Join([]string{
fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`,
isolatedDiagramHash, left, top, w, h,
Expand Down Expand Up @@ -2984,10 +3064,11 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
}

// This shift is for background el to envelop the diagram
left -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.))
top -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.))
w += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.)
h += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.)
strokePadding := int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.))
left, top, w, h, ok := expandDimensions(left, top, w, h, strokePadding)
if !ok {
return nil, invalidPaddingError(int64(pad))
}
backgroundEl := d2themes.NewThemableElement("rect", inlineTheme)
// We don't want to change the document viewbox, only the background el
backgroundEl.X = float64(left)
Expand All @@ -3005,19 +3086,19 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
backgroundEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, diagram.Root.StrokeWidth)

// This shift is for viewbox to envelop the background el
left -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.))
top -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.))
w += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.)
h += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.)
left, top, w, h, ok = expandDimensions(left, top, w, h, strokePadding)
if !ok {
return nil, invalidPaddingError(int64(pad))
}

doubleBorderElStr := ""
if diagram.Root.DoubleBorder {
offset := d2target.INNER_BORDER_OFFSET

left -= int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.)) + offset
top -= int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.)) + offset
w += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.)*2.) + 2*offset
h += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.)*2.) + 2*offset
left, top, w, h, ok = expandDimensions(left, top, w, h, strokePadding+offset)
if !ok {
return nil, invalidPaddingError(int64(pad))
}

backgroundEl2 := backgroundEl.Copy()
// No need to double-paint
Expand All @@ -3029,10 +3110,10 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
backgroundEl2.Height = float64(h)
doubleBorderElStr = backgroundEl2.Render()

left -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.))
top -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.))
w += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.)
h += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.)
left, top, w, h, ok = expandDimensions(left, top, w, h, strokePadding)
if !ok {
return nil, invalidPaddingError(int64(pad))
}
}

bufStr := buf.String()
Expand Down
92 changes: 92 additions & 0 deletions d2renderers/d2svg/padding_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package d2svg_test

import (
"fmt"
"math"
"strings"
"testing"

"github.com/d2lang/d2/d2renderers/d2fonts"
"github.com/d2lang/d2/d2renderers/d2svg"
"github.com/d2lang/d2/d2target"
)

func TestRenderValidatesPaddingDimensions(t *testing.T) {
t.Parallel()

diagram := d2target.NewDiagram()
fontFamily := d2fonts.SourceSansPro
monoFontFamily := d2fonts.SourceCodePro
diagram.FontFamily = &fontFamily
diagram.MonoFontFamily = &monoFontFamily
diagram.Root.StrokeWidth = 2
diagram.Shapes = []d2target.Shape{{
ID: "a",
Type: d2target.ShapeRectangle,
Pos: d2target.Point{X: 10, Y: 20},
Width: 100,
Height: 80,
Fill: "#ffffff",
Stroke: "#000000",
StrokeWidth: 2,
}}

tl, br := diagram.BoundingBox()
shortestSide := min(br.X-tl.X, br.Y-tl.Y)
zeroDimensionPad := int64(-shortestSide / 2)
maxInt := int64(^uint(0) >> 1)
postValidationOverflowPad := (maxInt - int64(br.X-tl.X)) / 2
doubleBorderDiagram := *diagram
doubleBorderDiagram.Root.StrokeWidth = 0
doubleBorderDiagram.Root.DoubleBorder = true

for _, tc := range []struct {
name string
pad int64
wantZero bool
}{
{name: "negative crop", pad: -1},
{name: "zero dimension", pad: zeroDimensionPad, wantZero: true},
} {
t.Run(tc.name, func(t *testing.T) {
pad := tc.pad
out, err := d2svg.Render(diagram, &d2svg.RenderOpts{Pad: &pad})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
if strings.Contains(string(out), `width="-`) || strings.Contains(string(out), `height="-`) {
t.Fatalf("Render() emitted a negative SVG dimension:\n%s", out)
}
if tc.wantZero && !strings.Contains(string(out), `height="0"`) && !strings.Contains(string(out), `width="0"`) {
t.Fatalf("Render() did not preserve a zero SVG dimension:\n%s", out)
}
})
}

for _, tc := range []struct {
name string
pad int64
target *d2target.Diagram
}{
{name: "negative dimension", pad: zeroDimensionPad - 1, target: diagram},
{name: "minimum integer", pad: math.MinInt64, target: diagram},
{name: "maximum integer", pad: math.MaxInt64, target: diagram},
{name: "root stroke overflow", pad: postValidationOverflowPad, target: diagram},
{name: "double border overflow", pad: postValidationOverflowPad, target: &doubleBorderDiagram},
} {
t.Run(tc.name, func(t *testing.T) {
pad := tc.pad
out, err := d2svg.Render(tc.target, &d2svg.RenderOpts{Pad: &pad})
if err == nil {
t.Fatalf("Render() succeeded with padding %d:\n%s", pad, out)
}
want := fmt.Sprintf("padding %d produces invalid SVG dimensions", pad)
if err.Error() != want {
t.Fatalf("Render() error = %q, want %q", err, want)
}
if out != nil {
t.Fatalf("Render() returned output on error: %q", out)
}
})
}
}