diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go index da137cc12..6ab44e7f1 100644 --- a/d2graph/d2graph.go +++ b/d2graph/d2graph.go @@ -857,7 +857,7 @@ func getMarkdownDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t return nil, fmt.Errorf("text not pre-measured and no ruler provided") } -func getTextDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t *d2target.MText, fontFamily *d2fonts.FontFamily) *d2target.TextDimensions { +func GetTextDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t *d2target.MText, fontFamily *d2fonts.FontFamily) *d2target.TextDimensions { if dims := findMeasured(mtexts, t); dims != nil { return dims } @@ -889,7 +889,7 @@ func getTextDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler, t *d2 } func appendTextDedup(texts []*d2target.MText, t *d2target.MText) []*d2target.MText { - if getTextDimensions(texts, nil, t, nil) == nil { + if GetTextDimensions(texts, nil, t, nil) == nil { return append(texts, t) } return texts @@ -922,13 +922,13 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler return err } } else { - dims = getTextDimensions(mtexts, ruler, obj.Text(), fontFamily) + dims = GetTextDimensions(mtexts, ruler, obj.Text(), fontFamily) } innerLabelPadding = 0 } else if obj.Attributes.Shape.Value == d2target.ShapeClass { - dims = getTextDimensions(mtexts, ruler, obj.Text(), go2.Pointer(d2fonts.SourceCodePro)) + dims = GetTextDimensions(mtexts, ruler, obj.Text(), go2.Pointer(d2fonts.SourceCodePro)) } else { - dims = getTextDimensions(mtexts, ruler, obj.Text(), fontFamily) + dims = GetTextDimensions(mtexts, ruler, obj.Text(), fontFamily) } if dims == nil { if obj.Attributes.Shape.Value == d2target.ShapeImage { @@ -982,7 +982,7 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler maxWidth := dims.Width for _, f := range obj.Class.Fields { - fdims := getTextDimensions(mtexts, ruler, f.Text(), go2.Pointer(d2fonts.SourceCodePro)) + fdims := GetTextDimensions(mtexts, ruler, f.Text(), go2.Pointer(d2fonts.SourceCodePro)) if fdims == nil { return fmt.Errorf("dimensions for class field %#v not found", f.Text()) } @@ -992,7 +992,7 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler } } for _, m := range obj.Class.Methods { - mdims := getTextDimensions(mtexts, ruler, m.Text(), go2.Pointer(d2fonts.SourceCodePro)) + mdims := GetTextDimensions(mtexts, ruler, m.Text(), go2.Pointer(d2fonts.SourceCodePro)) if mdims == nil { return fmt.Errorf("dimensions for class method %#v not found", m.Text()) } @@ -1011,7 +1011,7 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler } if anyRowText != nil { // 10px of padding top and bottom so text doesn't look squished - rowHeight := getTextDimensions(mtexts, ruler, anyRowText, go2.Pointer(d2fonts.SourceCodePro)).Height + 20 + rowHeight := GetTextDimensions(mtexts, ruler, anyRowText, go2.Pointer(d2fonts.SourceCodePro)).Height + 20 obj.Height = float64(rowHeight * (len(obj.Class.Fields) + len(obj.Class.Methods) + 2)) } // Leave room for padding @@ -1027,7 +1027,7 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler c := &obj.SQLTable.Columns[i] ctexts := c.Texts() - nameDims := getTextDimensions(mtexts, ruler, ctexts[0], fontFamily) + nameDims := GetTextDimensions(mtexts, ruler, ctexts[0], fontFamily) if nameDims == nil { return fmt.Errorf("dimensions for sql_table name %#v not found", ctexts[0].Text) } @@ -1037,7 +1037,7 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler maxNameWidth = nameDims.Width } - typeDims := getTextDimensions(mtexts, ruler, ctexts[1], fontFamily) + typeDims := GetTextDimensions(mtexts, ruler, ctexts[1], fontFamily) if typeDims == nil { return fmt.Errorf("dimensions for sql_table type %#v not found", ctexts[1].Text) } @@ -1072,7 +1072,7 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler for _, label := range endpointLabels { t := edge.Text() t.Text = label - dims := getTextDimensions(mtexts, ruler, t, fontFamily) + dims := GetTextDimensions(mtexts, ruler, t, fontFamily) edge.MinWidth += dims.Width // Some padding as it's not totally near the end edge.MinHeight += dims.Height + 5 @@ -1082,7 +1082,7 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler continue } - dims := getTextDimensions(mtexts, ruler, edge.Text(), fontFamily) + dims := GetTextDimensions(mtexts, ruler, edge.Text(), fontFamily) if dims == nil { return fmt.Errorf("dimensions for edge label %#v not found", edge.Text()) } diff --git a/d2renderers/d2svg/appendix/appendix.go b/d2renderers/d2svg/appendix/appendix.go new file mode 100644 index 000000000..368bbefc0 --- /dev/null +++ b/d2renderers/d2svg/appendix/appendix.go @@ -0,0 +1,134 @@ +// appendix.go writes appendices/footnotes to SVG +// Intended to be run only for static exports, like PNG or PDF. +// SVG exports are already interactive. + +package appendix + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/d2renderers/d2fonts" + "oss.terrastruct.com/d2/d2renderers/d2svg" + "oss.terrastruct.com/d2/d2target" + "oss.terrastruct.com/d2/lib/textmeasure" + "oss.terrastruct.com/util-go/go2" +) + +const ( + PAD_TOP = 50 + PAD_SIDES = 40 + FONT_SIZE = 16 + SPACER = 20 +) + +var viewboxRegex = regexp.MustCompile(`viewBox=\"([0-9\- ]+)\"`) +var widthRegex = regexp.MustCompile(`width=\"([0-9]+)\"`) +var heightRegex = regexp.MustCompile(`height=\"([0-9]+)\"`) + +func AppendTooltips(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []byte { + svg := string(in) + + appendix, w, h := generateTooltipAppendix(diagram, ruler, svg) + + if h == 0 { + return in + } + + viewboxMatch := viewboxRegex.FindStringSubmatch(svg) + viewboxRaw := viewboxMatch[1] + viewboxSlice := strings.Split(viewboxRaw, " ") + viewboxPadLeft, _ := strconv.Atoi(viewboxSlice[0]) + viewboxWidth, _ := strconv.Atoi(viewboxSlice[2]) + viewboxHeight, _ := strconv.Atoi(viewboxSlice[3]) + + tl, br := diagram.BoundingBox() + seperator := fmt.Sprintf(``, + tl.X-PAD_SIDES, br.Y+PAD_TOP, go2.IntMax(w+PAD_SIDES, br.Y)+PAD_SIDES, br.Y+PAD_TOP) + appendix = seperator + appendix + + w -= viewboxPadLeft + w += PAD_SIDES * 2 + if viewboxWidth < w { + viewboxWidth = w + PAD_SIDES + } + viewboxWidth += PAD_SIDES + + viewboxHeight += h + PAD_TOP + + newViewbox := fmt.Sprintf(`viewBox="%s %s %s %s"`, viewboxSlice[0], viewboxSlice[1], strconv.Itoa(viewboxWidth), strconv.Itoa(viewboxHeight)) + + widthMatch := widthRegex.FindStringSubmatch(svg) + heightMatch := heightRegex.FindStringSubmatch(svg) + + newWidth := fmt.Sprintf(`width="%s"`, strconv.Itoa(viewboxWidth)) + newHeight := fmt.Sprintf(`height="%s"`, strconv.Itoa(viewboxHeight)) + svg = strings.Replace(svg, viewboxMatch[0], newViewbox, 1) + svg = strings.Replace(svg, widthMatch[0], newWidth, 1) + svg = strings.Replace(svg, heightMatch[0], newHeight, 1) + + if !strings.Contains(svg, `font-family: "font-regular"`) { + appendix += fmt.Sprintf(``, d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_REGULAR)]) + } + + closingIndex := strings.LastIndex(svg, "") + svg = svg[:closingIndex] + appendix + svg[closingIndex:] + + return []byte(svg) +} + +func generateTooltipAppendix(diagram *d2target.Diagram, ruler *textmeasure.Ruler, svg string) (string, int, int) { + tl, br := diagram.BoundingBox() + + maxWidth, totalHeight := 0, 0 + + var tooltipLines []string + i := 1 + for _, s := range diagram.Shapes { + if s.Tooltip != "" { + line, w, h := generateTooltipLine(i, br.Y+(PAD_TOP*2)+totalHeight, s.Tooltip, ruler) + i++ + tooltipLines = append(tooltipLines, line) + maxWidth = go2.IntMax(maxWidth, w) + totalHeight += h + SPACER + } + } + + return fmt.Sprintf(`%s +`, tl.X, br.Y, (br.X - tl.X), strings.Join(tooltipLines, "\n")), maxWidth, totalHeight +} + +func generateTooltipLine(i, y int, text string, ruler *textmeasure.Ruler) (string, int, int) { + mtext := &d2target.MText{ + Text: text, + FontSize: FONT_SIZE, + IsBold: false, + IsItalic: false, + Language: "", + } + + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + + // TODO box-shadow: 0px 0px 32px rgba(31, 36, 58, 0.1); + line := fmt.Sprintf(``, + 0, y) + + line += fmt.Sprintf(`%d`, + 0, y+5, 16, i) + + line += fmt.Sprintf(`%s`, + 32, y, FONT_SIZE, d2svg.RenderText(text, 32, float64(dims.Height))) + + return line, dims.Width, dims.Height +} diff --git a/d2renderers/d2svg/appendix/appendix_test.go b/d2renderers/d2svg/appendix/appendix_test.go new file mode 100644 index 000000000..bb26a4372 --- /dev/null +++ b/d2renderers/d2svg/appendix/appendix_test.go @@ -0,0 +1,102 @@ +package appendix_test + +import ( + "context" + "encoding/xml" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "cdr.dev/slog" + + tassert "github.com/stretchr/testify/assert" + + "oss.terrastruct.com/util-go/assert" + "oss.terrastruct.com/util-go/diff" + + "oss.terrastruct.com/d2/d2layouts/d2dagrelayout" + "oss.terrastruct.com/d2/d2lib" + "oss.terrastruct.com/d2/d2renderers/d2svg" + "oss.terrastruct.com/d2/d2renderers/d2svg/appendix" + "oss.terrastruct.com/d2/lib/log" + "oss.terrastruct.com/d2/lib/textmeasure" +) + +func TestAppendix(t *testing.T) { + t.Parallel() + + tcs := []testCase{ + { + name: "basic", + script: `x: { tooltip: Total abstinence is easier than perfect moderation } +y: { tooltip: Gee, I feel kind of LIGHT in the head now,\nknowing I can't make my satellite dish PAYMENTS! } +x -> y +`, + }, + } + runa(t, tcs) +} + +type testCase struct { + name string + script string + skip bool +} + +func runa(t *testing.T, tcs []testCase) { + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if tc.skip { + t.Skip() + } + t.Parallel() + + run(t, tc) + }) + } +} + +func run(t *testing.T, tc testCase) { + ctx := context.Background() + ctx = log.WithTB(ctx, t, nil) + ctx = log.Leveled(ctx, slog.LevelDebug) + + ruler, err := textmeasure.NewRuler() + if !tassert.Nil(t, err) { + return + } + + diagram, _, err := d2lib.Compile(ctx, tc.script, &d2lib.CompileOptions{ + Ruler: ruler, + ThemeID: 0, + Layout: d2dagrelayout.Layout, + }) + if !tassert.Nil(t, err) { + return + } + + dataPath := filepath.Join("testdata", strings.TrimPrefix(t.Name(), "TestAppendix/")) + pathGotSVG := filepath.Join(dataPath, "sketch.got.svg") + + svgBytes, err := d2svg.Render(diagram, &d2svg.RenderOpts{ + Pad: d2svg.DEFAULT_PADDING, + }) + assert.Success(t, err) + svgBytes = appendix.AppendTooltips(diagram, ruler, svgBytes) + + err = os.MkdirAll(dataPath, 0755) + assert.Success(t, err) + err = ioutil.WriteFile(pathGotSVG, svgBytes, 0600) + assert.Success(t, err) + defer os.Remove(pathGotSVG) + + var xmlParsed interface{} + err = xml.Unmarshal(svgBytes, &xmlParsed) + assert.Success(t, err) + + err = diff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes) + assert.Success(t, err) +} diff --git a/d2renderers/d2svg/appendix/testdata/basic/sketch.exp.svg b/d2renderers/d2svg/appendix/testdata/basic/sketch.exp.svg new file mode 100644 index 000000000..f88e30ee3 --- /dev/null +++ b/d2renderers/d2svg/appendix/testdata/basic/sketch.exp.svg @@ -0,0 +1,68 @@ + +x + + + + + + + + + + + + +Total abstinence is easier than perfect moderationy + + + + + + + + + + + + +Gee, I feel kind of LIGHT in the head now, +knowing I can't make my satellite dish PAYMENTS! + + +1Total abstinence is easier than perfect moderation +2Gee, I feel kind of LIGHT in the head now,knowing I can't make my satellite dish PAYMENTS! + \ No newline at end of file diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index 0c6eecd35..2240669a5 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -442,7 +442,7 @@ func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Co fontClass, x, y, textStyle, - renderText(connection.Label, x, float64(connection.LabelHeight)), + RenderText(connection.Label, x, float64(connection.LabelHeight)), ) } @@ -478,7 +478,7 @@ func renderArrowheadLabel(connection d2target.Connection, text string, position, return fmt.Sprintf(`%s`, x, y, textStyle, - renderText(text, x, height), + RenderText(text, x, height), ) } @@ -833,7 +833,7 @@ func drawShape(writer io.Writer, targetShape d2target.Shape, sketchRunner *d2ske fontClass, x, y, textStyle, - renderText(targetShape.Label, x, float64(targetShape.LabelHeight)), + RenderText(targetShape.Label, x, float64(targetShape.LabelHeight)), ) if targetShape.Blend { labelMask = makeLabelMask(labelTL, targetShape.LabelWidth, targetShape.LabelHeight-d2graph.INNER_LABEL_PADDING) @@ -854,7 +854,7 @@ func drawShape(writer io.Writer, targetShape d2target.Shape, sketchRunner *d2ske return labelMask, nil } -func renderText(text string, x, height float64) string { +func RenderText(text string, x, height float64) string { if !strings.Contains(text, "\n") { return svg.EscapeText(text) } diff --git a/lib/svg/appendix.go b/lib/svg/appendix.go deleted file mode 100644 index e5aab7cde..000000000 --- a/lib/svg/appendix.go +++ /dev/null @@ -1,3 +0,0 @@ -// appendix.go writes appendices/footnotes to SVG - -package svg diff --git a/main.go b/main.go index 45d154f1f..e87b5149e 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "oss.terrastruct.com/d2/d2plugin" "oss.terrastruct.com/d2/d2renderers/d2fonts" "oss.terrastruct.com/d2/d2renderers/d2svg" + "oss.terrastruct.com/d2/d2renderers/d2svg/appendix" "oss.terrastruct.com/d2/d2themes" "oss.terrastruct.com/d2/d2themes/d2themescatalog" "oss.terrastruct.com/d2/lib/imgbundler" @@ -247,7 +248,8 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc out := svg if filepath.Ext(outputPath) == ".png" { - svg := svg + svg := appendix.AppendTooltips(diagram, ruler, svg) + if !bundle { var bundleErr2 error svg, bundleErr2 = imgbundler.BundleRemote(ctx, ms, svg)