...

Source file src/oss.terrastruct.com/d2/d2renderers/d2svg/appendix/appendix.go

Documentation: oss.terrastruct.com/d2/d2renderers/d2svg/appendix

     1  // appendix.go writes appendices/footnotes to SVG
     2  // Intended to be run only for static exports, like PNG or PDF.
     3  // SVG exports are already interactive.
     4  
     5  package appendix
     6  
     7  import (
     8  	"fmt"
     9  	"regexp"
    10  	"sort"
    11  	"strconv"
    12  	"strings"
    13  
    14  	"oss.terrastruct.com/d2/d2graph"
    15  	"oss.terrastruct.com/d2/d2renderers/d2fonts"
    16  	"oss.terrastruct.com/d2/d2renderers/d2svg"
    17  	"oss.terrastruct.com/d2/d2target"
    18  	"oss.terrastruct.com/d2/d2themes"
    19  	"oss.terrastruct.com/d2/lib/color"
    20  	"oss.terrastruct.com/d2/lib/textmeasure"
    21  	"oss.terrastruct.com/util-go/go2"
    22  )
    23  
    24  //        ┌──────────────┐
    25  //        │              │
    26  //        │   DIAGRAM    │
    27  //        │              │
    28  // PAD_   │              │
    29  // SIDES  │              │
    30  //    │   │              │
    31  //    │   └──────────────┘
    32  //    ▼                   ◄──────  PAD_TOP
    33  //
    34  //    ─────────────────────────
    35  //
    36  //
    37  //         1. asdfasdf
    38  //
    39  //                        ◄──── SPACER
    40  //         2. qwerqwer
    41  //
    42  //
    43  
    44  const (
    45  	PAD_TOP   = 50
    46  	PAD_SIDES = 40
    47  	SPACER    = 20
    48  
    49  	FONT_SIZE   = 16
    50  	ICON_RADIUS = 16
    51  )
    52  
    53  var viewboxRegex = regexp.MustCompile(`viewBox=\"([0-9\- ]+)\"`)
    54  var widthRegex = regexp.MustCompile(`width=\"([.0-9]+)\"`)
    55  var heightRegex = regexp.MustCompile(`height=\"([.0-9]+)\"`)
    56  var svgRegex = regexp.MustCompile(`<svg(.*?)>`)
    57  
    58  func FindViewboxSlice(svg []byte) []string {
    59  	viewboxMatches := viewboxRegex.FindAllStringSubmatch(string(svg), 2)
    60  	viewboxMatch := viewboxMatches[1]
    61  	viewboxRaw := viewboxMatch[1]
    62  	return strings.Split(viewboxRaw, " ")
    63  }
    64  
    65  func Append(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []byte {
    66  	svg := string(in)
    67  
    68  	appendix, w, h := generateAppendix(diagram, ruler, svg)
    69  
    70  	if h == 0 {
    71  		return in
    72  	}
    73  
    74  	// match 1st two viewboxes, 1st is outer fit-to-screen viewbox="0 0 innerWidth innerHeight"
    75  	viewboxMatches := viewboxRegex.FindAllStringSubmatch(svg, 2)
    76  	viewboxMatch := viewboxMatches[1]
    77  	viewboxRaw := viewboxMatch[1]
    78  	viewboxSlice := strings.Split(viewboxRaw, " ")
    79  	viewboxPadLeft, _ := strconv.Atoi(viewboxSlice[0])
    80  	viewboxWidth, _ := strconv.Atoi(viewboxSlice[2])
    81  	viewboxHeight, _ := strconv.Atoi(viewboxSlice[3])
    82  
    83  	tl, br := diagram.BoundingBox()
    84  	separatorEl := d2themes.NewThemableElement("line")
    85  	separatorEl.X1 = float64(tl.X - PAD_SIDES)
    86  	separatorEl.Y1 = float64(br.Y + PAD_TOP)
    87  	separatorEl.X2 = float64(go2.IntMax(w, br.X) + PAD_SIDES)
    88  	separatorEl.Y2 = float64(br.Y + PAD_TOP)
    89  	separatorEl.Stroke = color.B2 // same as --color-border-muted in markdown
    90  	appendix = separatorEl.Render() + appendix
    91  
    92  	w -= viewboxPadLeft
    93  	w += PAD_SIDES * 2
    94  	if viewboxWidth < w {
    95  		viewboxWidth = w
    96  	}
    97  
    98  	viewboxHeight += h + PAD_TOP
    99  
   100  	newOuterViewbox := fmt.Sprintf(`viewBox="0 0 %d %d"`, viewboxWidth, viewboxHeight)
   101  	newViewbox := fmt.Sprintf(`viewBox="%s %s %s %s"`, viewboxSlice[0], viewboxSlice[1], strconv.Itoa(viewboxWidth), strconv.Itoa(viewboxHeight))
   102  
   103  	dimensionsToUpdate := 2
   104  	outerSVG := svgRegex.FindString(svg)
   105  	// if outer svg has dimensions set we also need to update it
   106  	if widthRegex.FindString(outerSVG) != "" {
   107  		dimensionsToUpdate++
   108  	}
   109  
   110  	// update 1st 3 matches of width and height 1st is outer svg (if dimensions are set), 2nd inner svg, 3rd is background color rect
   111  	widthMatches := widthRegex.FindAllStringSubmatch(svg, dimensionsToUpdate)
   112  	heightMatches := heightRegex.FindAllStringSubmatch(svg, dimensionsToUpdate)
   113  	newWidth := fmt.Sprintf(`width="%s"`, strconv.Itoa(viewboxWidth))
   114  	newHeight := fmt.Sprintf(`height="%s"`, strconv.Itoa(viewboxHeight))
   115  
   116  	svg = strings.Replace(svg, viewboxMatches[0][0], newOuterViewbox, 1)
   117  	svg = strings.Replace(svg, viewboxMatch[0], newViewbox, 1)
   118  	for i := 0; i < dimensionsToUpdate; i++ {
   119  		svg = strings.Replace(svg, widthMatches[i][0], newWidth, 1)
   120  		svg = strings.Replace(svg, heightMatches[i][0], newHeight, 1)
   121  	}
   122  
   123  	if !strings.Contains(svg, `font-family: "font-regular"`) {
   124  		appendix += fmt.Sprintf(`<style type="text/css"><![CDATA[
   125  .text {
   126  	font-family: "font-regular";
   127  }
   128  @font-face {
   129  	font-family: font-regular;
   130  	src: url("%s");
   131  }
   132  ]]></style>`, d2fonts.FontEncodings.Get(d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_REGULAR)))
   133  	}
   134  	if !strings.Contains(svg, `font-family: "font-bold"`) {
   135  		appendix += fmt.Sprintf(`<style type="text/css"><![CDATA[
   136  .text-bold {
   137  	font-family: "font-bold";
   138  }
   139  @font-face {
   140  	font-family: font-bold;
   141  	src: url("%s");
   142  }
   143  ]]></style>`, d2fonts.FontEncodings.Get(d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_BOLD)))
   144  	}
   145  
   146  	closingIndex := strings.LastIndex(svg, "</svg></svg>")
   147  	svg = svg[:closingIndex] + appendix + svg[closingIndex:]
   148  
   149  	// icons are numbered according to diagram.Shapes which is based on their order of definition,
   150  	// but they appear in the svg according to renderOrder so we have to replace in that order
   151  	type appendixIcon struct {
   152  		number    int
   153  		isTooltip bool
   154  		shape     d2target.Shape
   155  	}
   156  	var renderOrder []appendixIcon
   157  
   158  	i := 1
   159  	for _, s := range diagram.Shapes {
   160  		if s.Tooltip != "" {
   161  			renderOrder = append(renderOrder, appendixIcon{i, true, s})
   162  			i++
   163  		}
   164  		if s.Link != "" {
   165  			renderOrder = append(renderOrder, appendixIcon{i, false, s})
   166  			i++
   167  		}
   168  	}
   169  	// sort to match render order
   170  	sort.SliceStable(renderOrder, func(i, j int) bool {
   171  		iZIndex := renderOrder[i].shape.GetZIndex()
   172  		jZIndex := renderOrder[j].shape.GetZIndex()
   173  		if iZIndex != jZIndex {
   174  			return iZIndex < jZIndex
   175  		}
   176  		return renderOrder[i].shape.Level < renderOrder[j].shape.Level
   177  	})
   178  
   179  	// replace each rendered svg icon
   180  	for _, icon := range renderOrder {
   181  		// The clip-path has a unique ID, so this won't replace any user icons
   182  		// In the existing SVG, the transform places it top-left, so we adjust
   183  		var iconStr string
   184  		if icon.isTooltip {
   185  			iconStr = d2svg.TooltipIcon
   186  		} else {
   187  			iconStr = d2svg.LinkIcon
   188  		}
   189  		svg = strings.Replace(svg, iconStr, generateNumberedIcon(icon.number, 0, ICON_RADIUS), 1)
   190  	}
   191  
   192  	return []byte(svg)
   193  }
   194  
   195  func generateAppendix(diagram *d2target.Diagram, ruler *textmeasure.Ruler, svg string) (string, int, int) {
   196  	tl, br := diagram.BoundingBox()
   197  
   198  	maxWidth, totalHeight := 0, 0
   199  
   200  	var lines []string
   201  	i := 1
   202  
   203  	for _, s := range diagram.Shapes {
   204  		for _, txt := range []string{s.Tooltip, s.PrettyLink} {
   205  			if txt != "" {
   206  				line, w, h := generateLine(i, br.Y+(PAD_TOP*2)+totalHeight, txt, ruler)
   207  				i++
   208  				lines = append(lines, line)
   209  				maxWidth = go2.IntMax(maxWidth, w)
   210  				totalHeight += h + SPACER
   211  			}
   212  		}
   213  	}
   214  	if len(lines) == 0 {
   215  		return "", 0, 0
   216  	}
   217  	totalHeight += SPACER
   218  
   219  	return fmt.Sprintf(`<g class="appendix" x="%d" y="%d" width="%d" height="100%%">%s</g>
   220  `, tl.X, br.Y, (br.X - tl.X), strings.Join(lines, "\n")), maxWidth, totalHeight
   221  }
   222  
   223  func generateNumberedIcon(i, x, y int) string {
   224  	line := fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" fill="white" stroke="#DEE1EB" />`,
   225  		x+ICON_RADIUS, y, ICON_RADIUS)
   226  
   227  	line += fmt.Sprintf(`<text class="text-bold" x="%d" y="%d" style="font-size: %dpx;text-anchor:middle;">%d</text>`,
   228  		x+ICON_RADIUS, y+5, FONT_SIZE, i)
   229  
   230  	return line
   231  }
   232  
   233  func generateLine(i, y int, text string, ruler *textmeasure.Ruler) (string, int, int) {
   234  	mtext := &d2target.MText{
   235  		Text:     text,
   236  		FontSize: FONT_SIZE,
   237  	}
   238  
   239  	dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil)
   240  
   241  	line := fmt.Sprintf(`<g transform="translate(%d %d)" class="appendix-icon">%s</g>`,
   242  		0, y, generateNumberedIcon(i, 0, 0))
   243  
   244  	line += fmt.Sprintf(`<text class="text" x="%d" y="%d" style="font-size: %dpx;">%s</text>`,
   245  		ICON_RADIUS*3, y+5, FONT_SIZE, d2svg.RenderText(text, ICON_RADIUS*3, float64(dims.Height)))
   246  
   247  	return line, dims.Width + ICON_RADIUS*3, go2.IntMax(dims.Height, ICON_RADIUS*2)
   248  }
   249  

View as plain text