diff --git a/d2cli/main.go b/d2cli/main.go
index 1504d402b..3504ae8e0 100644
--- a/d2cli/main.go
+++ b/d2cli/main.go
@@ -21,6 +21,7 @@ import (
"oss.terrastruct.com/util-go/xmain"
"oss.terrastruct.com/d2/d2lib"
+ "oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/d2renderers/d2animate"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
@@ -32,7 +33,6 @@ import (
"oss.terrastruct.com/d2/lib/background"
"oss.terrastruct.com/d2/lib/imgbundler"
ctxlog "oss.terrastruct.com/d2/lib/log"
- "oss.terrastruct.com/d2/lib/pdf"
pdflib "oss.terrastruct.com/d2/lib/pdf"
"oss.terrastruct.com/d2/lib/png"
"oss.terrastruct.com/d2/lib/pptx"
@@ -189,7 +189,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
outputPath = ms.AbsPath(outputPath)
if *animateIntervalFlag > 0 {
// Not checking for extension == "svg", because users may want to write SVG data to a non-svg-extension file
- if filepath.Ext(outputPath) == ".png" || filepath.Ext(outputPath) == ".pdf" {
+ if filepath.Ext(outputPath) == ".png" || filepath.Ext(outputPath) == ".pdf" || filepath.Ext(outputPath) == ".pptx" {
return xmain.UsageErrorf("-animate-interval can only be used when exporting to SVG.\nYou provided: %s", filepath.Ext(outputPath))
}
}
@@ -351,7 +351,7 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende
switch filepath.Ext(outputPath) {
case ".pdf":
- pageMap := pdf.BuildPDFPageMap(diagram, nil, nil)
+ pageMap := buildBoardIdToIndex(diagram, nil, nil)
pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, nil, pageMap)
if err != nil {
return pdf, false, err
@@ -367,7 +367,9 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende
description := "Presentation auto-generated by D2 - https://d2lang.com/"
rootName := getFileName(outputPath)
p := pptx.NewPresentation(rootName, description, rootName, username, version.OnlyNumbers())
- err := renderPPTX(ctx, ms, p, plugin, renderOpts, outputPath, page, diagram, nil)
+
+ boardIdToIndex := buildBoardIdToIndex(diagram, nil, nil)
+ err := renderPPTX(ctx, ms, p, plugin, renderOpts, ruler, outputPath, page, diagram, nil, boardIdToIndex)
if err != nil {
return nil, false, err
}
@@ -756,7 +758,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt
return svg, nil
}
-func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Presentation, plugin d2plugin.Plugin, opts d2svg.RenderOpts, outputPath string, page playwright.Page, diagram *d2target.Diagram, boardPath []string) error {
+func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Presentation, plugin d2plugin.Plugin, opts d2svg.RenderOpts, ruler *textmeasure.Ruler, outputPath string, page playwright.Page, diagram *d2target.Diagram, boardPath []string, boardIdToIndex map[string]int) error {
var currBoardPath []string
// Root board doesn't have a name, so we use the output filename
if diagram.Name == "" {
@@ -792,31 +794,73 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
return bundleErr
}
+ svg = appendix.Append(diagram, ruler, svg)
+
+ // png.ConvertSVG scales the image by 2x
+ pngScale := 2.
pngImg, err := png.ConvertSVG(ms, page, svg)
if err != nil {
return err
}
- err = presentation.AddSlide(pngImg, currBoardPath)
+ slide, err := presentation.AddSlide(pngImg, diagram, currBoardPath)
if err != nil {
return err
}
+
+ viewboxSlice := appendix.FindViewboxSlice(svg)
+ viewboxX, err := strconv.ParseFloat(viewboxSlice[0], 64)
+ if err != nil {
+ return err
+ }
+ viewboxY, err := strconv.ParseFloat(viewboxSlice[1], 64)
+ if err != nil {
+ return err
+ }
+
+ // Draw links
+ for _, shape := range diagram.Shapes {
+ if shape.Link == "" {
+ continue
+ }
+
+ linkX := pngScale * (float64(shape.Pos.X) - viewboxX - float64(shape.StrokeWidth))
+ linkY := pngScale * (float64(shape.Pos.Y) - viewboxY - float64(shape.StrokeWidth))
+ linkWidth := pngScale * (float64(shape.Width) + float64(shape.StrokeWidth*2))
+ linkHeight := pngScale * (float64(shape.Height) + float64(shape.StrokeWidth*2))
+ link := &pptx.Link{
+ Left: int(linkX),
+ Top: int(linkY),
+ Width: int(linkWidth),
+ Height: int(linkHeight),
+ Tooltip: shape.Link,
+ }
+ slide.AddLink(link)
+ key, err := d2parser.ParseKey(shape.Link)
+ if err != nil || key.Path[0].Unbox().ScalarString() != "root" {
+ // External link
+ link.ExternalUrl = shape.Link
+ } else if pageNum, ok := boardIdToIndex[shape.Link]; ok {
+ // Internal link
+ link.SlideIndex = pageNum + 1
+ }
+ }
}
for _, dl := range diagram.Layers {
- err := renderPPTX(ctx, ms, presentation, plugin, opts, "", page, dl, currBoardPath)
+ err := renderPPTX(ctx, ms, presentation, plugin, opts, ruler, "", page, dl, currBoardPath, boardIdToIndex)
if err != nil {
return err
}
}
for _, dl := range diagram.Scenarios {
- err := renderPPTX(ctx, ms, presentation, plugin, opts, "", page, dl, currBoardPath)
+ err := renderPPTX(ctx, ms, presentation, plugin, opts, ruler, "", page, dl, currBoardPath, boardIdToIndex)
if err != nil {
return err
}
}
for _, dl := range diagram.Steps {
- err := renderPPTX(ctx, ms, presentation, plugin, opts, "", page, dl, currBoardPath)
+ err := renderPPTX(ctx, ms, presentation, plugin, opts, ruler, "", page, dl, currBoardPath, boardIdToIndex)
if err != nil {
return err
}
@@ -914,3 +958,28 @@ func loadFonts(ms *xmain.State, pathToRegular, pathToItalic, pathToBold string)
return d2fonts.AddFontFamily("custom", regularTTF, italicTTF, boldTTF)
}
+
+// buildBoardIdToIndex returns a map from board path to page int
+// To map correctly, it must follow the same traversal of PDF building
+func buildBoardIdToIndex(diagram *d2target.Diagram, dictionary map[string]int, path []string) map[string]int {
+ newPath := append(path, diagram.Name)
+ if dictionary == nil {
+ dictionary = map[string]int{}
+ newPath[0] = "root"
+ }
+
+ key := strings.Join(newPath, ".")
+ dictionary[key] = len(dictionary)
+
+ for _, dl := range diagram.Layers {
+ buildBoardIdToIndex(dl, dictionary, append(newPath, "layers"))
+ }
+ for _, dl := range diagram.Scenarios {
+ buildBoardIdToIndex(dl, dictionary, append(newPath, "scenarios"))
+ }
+ for _, dl := range diagram.Steps {
+ buildBoardIdToIndex(dl, dictionary, append(newPath, "steps"))
+ }
+
+ return dictionary
+}
diff --git a/lib/pdf/pdf.go b/lib/pdf/pdf.go
index 7f1f25cc7..0d90ff09b 100644
--- a/lib/pdf/pdf.go
+++ b/lib/pdf/pdf.go
@@ -164,28 +164,3 @@ func (g *GoFPDF) AddPDFPage(png []byte, boardPath []string, themeID int64, fill
func (g *GoFPDF) Export(outputPath string) error {
return g.pdf.OutputFileAndClose(outputPath)
}
-
-// BuildPDFPageMap returns a map from board path to page int
-// To map correctly, it must follow the same traversal of PDF building
-func BuildPDFPageMap(diagram *d2target.Diagram, dictionary map[string]int, path []string) map[string]int {
- newPath := append(path, diagram.Name)
- if dictionary == nil {
- dictionary = map[string]int{}
- newPath[0] = "root"
- }
-
- key := strings.Join(newPath, ".")
- dictionary[key] = len(dictionary)
-
- for _, dl := range diagram.Layers {
- BuildPDFPageMap(dl, dictionary, append(newPath, "layers"))
- }
- for _, dl := range diagram.Scenarios {
- BuildPDFPageMap(dl, dictionary, append(newPath, "scenarios"))
- }
- for _, dl := range diagram.Steps {
- BuildPDFPageMap(dl, dictionary, append(newPath, "steps"))
- }
-
- return dictionary
-}
diff --git a/lib/png/png.go b/lib/png/png.go
index 8f89c0b20..01d332e1a 100644
--- a/lib/png/png.go
+++ b/lib/png/png.go
@@ -83,6 +83,8 @@ var genPNGScript string
const pngPrefix = "data:image/png;base64,"
+// ConvertSVG converts the given SVG into a PNG.
+// Note that the resulting PNG has 2x the size (width and height) of the original SVG (see generate_png.js)
func ConvertSVG(ms *xmain.State, page playwright.Page, svg []byte) ([]byte, error) {
cancel := background.Repeat(func() {
ms.Log.Info.Printf("converting to PNG...")
diff --git a/lib/pptx/pptx.go b/lib/pptx/pptx.go
index 1d0c0f44c..d7e4b527d 100644
--- a/lib/pptx/pptx.go
+++ b/lib/pptx/pptx.go
@@ -49,27 +49,69 @@ const IMAGE_HEIGHT = SLIDE_HEIGHT - HEADER_HEIGHT
const IMAGE_WIDTH = 8_446_273
const IMAGE_ASPECT_RATIO = float64(IMAGE_WIDTH) / float64(IMAGE_HEIGHT)
-const RELS_SLIDE_XML = ``
+func getRelsSlideXml(slide *Slide) string {
+ var builder strings.Builder
+ builder.WriteString(``)
+ builder.WriteString(
+ fmt.Sprintf(
+ ``,
+ slide.ImageId,
+ slide.ImageId,
+ ),
+ )
+ for _, link := range slide.Links {
+ if link.isExternal() {
+ builder.WriteString(
+ fmt.Sprintf(
+ ``,
+ link.Id,
+ link.ExternalUrl,
+ ),
+ )
+ } else {
+ builder.WriteString(
+ fmt.Sprintf(
+ ``,
+ link.Id,
+ link.SlideIndex,
+ ),
+ )
+ }
+ }
-func getRelsSlideXml(imageId string) string {
- return fmt.Sprintf(RELS_SLIDE_XML, imageId, imageId)
+ builder.WriteString(``)
+ return builder.String()
}
-const SLIDE_XML = `%s`
-
-func getSlideXml(boardPath []string, imageId string, top, left, width, height int) string {
- var slideTitle string
- boardName := boardPath[len(boardPath)-1]
- prefixPath := boardPath[:len(boardPath)-1]
+func getSlideXml(slide *Slide) string {
+ var builder strings.Builder
+ builder.WriteString(``)
+ slideDescription := strings.Join(slide.BoardPath, " / ")
+ builder.WriteString(fmt.Sprintf(``, slideDescription, slideDescription))
+ builder.WriteString(``)
+ builder.WriteString(fmt.Sprintf(``, slide.ImageId))
+ builder.WriteString(``)
+ builder.WriteString(fmt.Sprintf(``, slide.ImageLeft, slide.ImageTop))
+ builder.WriteString(fmt.Sprintf(``, slide.ImageWidth, slide.ImageHeight))
+ builder.WriteString(``)
+ builder.WriteString(fmt.Sprintf(``, slideDescription))
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(fmt.Sprintf(``, HEADER_HEIGHT))
+ builder.WriteString(``)
+ boardName := slide.BoardPath[len(slide.BoardPath)-1]
+ prefixPath := slide.BoardPath[:len(slide.BoardPath)-1]
if len(prefixPath) > 0 {
prefix := strings.Join(prefixPath, " / ") + " / "
- slideTitle = fmt.Sprintf(`%s%s`, prefix, boardName)
+ builder.WriteString(fmt.Sprintf(`%s%s`, prefix, boardName))
} else {
- slideTitle = fmt.Sprintf(`%s`, boardName)
+ builder.WriteString(fmt.Sprintf(`%s`, boardName))
}
- slideDescription := strings.Join(boardPath, " / ")
- top += HEADER_HEIGHT
- return fmt.Sprintf(SLIDE_XML, slideDescription, slideDescription, imageId, left, top, width, height, slideDescription, HEADER_HEIGHT, slideTitle)
+ for _, link := range slide.Links {
+ builder.WriteString(getLinkXml(link))
+ }
+ builder.WriteString(``)
+ return builder.String()
}
func getPresentationXmlRels(slideFileNames []string) string {
@@ -113,7 +155,7 @@ func getPresentationXml(slideFileNames []string) string {
builder.WriteString("")
builder.WriteString(fmt.Sprintf(
- ``,
+ ``,
SLIDE_WIDTH,
SLIDE_HEIGHT,
))
@@ -198,3 +240,26 @@ func getAppXml(slides []*Slide, d2version string) string {
builder.WriteString(``)
return builder.String()
}
+
+func getLinkXml(link *Link) string {
+ var builder strings.Builder
+
+ builder.WriteString("")
+ builder.WriteString(fmt.Sprintf(``, link.Index, link.Tooltip))
+ var linkAction string
+ if !link.isExternal() {
+ linkAction = "ppaction://hlinksldjump"
+ }
+ builder.WriteString(
+ fmt.Sprintf(``,
+ link.Id,
+ linkAction,
+ link.Tooltip,
+ ),
+ )
+ builder.WriteString("")
+ builder.WriteString(fmt.Sprintf(``, link.Left, link.Top))
+ builder.WriteString(fmt.Sprintf(``, link.Width, link.Height))
+ builder.WriteString(``)
+ return builder.String()
+}
diff --git a/lib/pptx/presentation.go b/lib/pptx/presentation.go
index a79e6253d..c562d6115 100644
--- a/lib/pptx/presentation.go
+++ b/lib/pptx/presentation.go
@@ -16,6 +16,8 @@ import (
"fmt"
"image/png"
"os"
+
+ "oss.terrastruct.com/d2/d2target"
)
type Presentation struct {
@@ -29,12 +31,41 @@ type Presentation struct {
}
type Slide struct {
- BoardPath []string
- Image []byte
- ImageWidth int
- ImageHeight int
- ImageTop int
- ImageLeft int
+ BoardPath []string
+ Links []*Link
+ Image []byte
+ ImageId string
+ ImageWidth int
+ ImageHeight int
+ ImageTop int
+ ImageLeft int
+ ImageScaleFactor float64
+}
+
+func (s *Slide) AddLink(link *Link) {
+ link.Index = len(s.Links)
+ s.Links = append(s.Links, link)
+ link.Id = fmt.Sprintf("link%d", len(s.Links))
+ link.Height *= int(s.ImageScaleFactor)
+ link.Width *= int(s.ImageScaleFactor)
+ link.Top = s.ImageTop + int(float64(link.Top)*s.ImageScaleFactor)
+ link.Left = s.ImageLeft + int(float64(link.Left)*s.ImageScaleFactor)
+}
+
+type Link struct {
+ Id string
+ Index int
+ Top int
+ Left int
+ Width int
+ Height int
+ SlideIndex int
+ ExternalUrl string
+ Tooltip string
+}
+
+func (l *Link) isExternal() bool {
+ return l.ExternalUrl != ""
}
func NewPresentation(title, description, subject, creator, d2Version string) *Presentation {
@@ -47,16 +78,16 @@ func NewPresentation(title, description, subject, creator, d2Version string) *Pr
}
}
-func (p *Presentation) AddSlide(pngContent []byte, boardPath []string) error {
+func (p *Presentation) AddSlide(pngContent []byte, diagram *d2target.Diagram, boardPath []string) (*Slide, error) {
src, err := png.Decode(bytes.NewReader(pngContent))
if err != nil {
- return fmt.Errorf("error decoding PNG image: %v", err)
+ return nil, fmt.Errorf("error decoding PNG image: %v", err)
}
-
- var width, height int
srcSize := src.Bounds().Size()
srcWidth, srcHeight := float64(srcSize.X), float64(srcSize.Y)
+ var width, height int
+
// compute the size and position to fit the slide
// if the image is wider than taller and its aspect ratio is, at least, the same as the available image space aspect ratio
// then, set the image width to the available space and compute the height
@@ -73,22 +104,24 @@ func (p *Presentation) AddSlide(pngContent []byte, boardPath []string) error {
height = IMAGE_HEIGHT
width = int(float64(height) * (srcWidth / srcHeight))
}
- top := (IMAGE_HEIGHT - height) / 2
+ top := HEADER_HEIGHT + ((IMAGE_HEIGHT - height) / 2)
left := (SLIDE_WIDTH - width) / 2
slide := &Slide{
- BoardPath: make([]string, len(boardPath)),
- Image: pngContent,
- ImageWidth: width,
- ImageHeight: height,
- ImageTop: top,
- ImageLeft: left,
+ BoardPath: make([]string, len(boardPath)),
+ ImageId: fmt.Sprintf("slide%dImage", len(p.Slides)+1),
+ Image: pngContent,
+ ImageWidth: width,
+ ImageHeight: height,
+ ImageTop: top,
+ ImageLeft: left,
+ ImageScaleFactor: float64(width) / srcWidth,
}
// it must copy the board path to avoid slice reference issues
copy(slide.BoardPath, boardPath)
p.Slides = append(p.Slides, slide)
- return nil
+ return slide, nil
}
func (p *Presentation) SaveTo(filePath string) error {
@@ -106,11 +139,10 @@ func (p *Presentation) SaveTo(filePath string) error {
var slideFileNames []string
for i, slide := range p.Slides {
- imageId := fmt.Sprintf("slide%dImage", i+1)
slideFileName := fmt.Sprintf("slide%d", i+1)
slideFileNames = append(slideFileNames, slideFileName)
- imageWriter, err := zipWriter.Create(fmt.Sprintf("ppt/media/%s.png", imageId))
+ imageWriter, err := zipWriter.Create(fmt.Sprintf("ppt/media/%s.png", slide.ImageId))
if err != nil {
return err
}
@@ -119,7 +151,7 @@ func (p *Presentation) SaveTo(filePath string) error {
return err
}
- err = addFile(zipWriter, fmt.Sprintf("ppt/slides/_rels/%s.xml.rels", slideFileName), getRelsSlideXml(imageId))
+ err = addFile(zipWriter, fmt.Sprintf("ppt/slides/_rels/%s.xml.rels", slideFileName), getRelsSlideXml(slide))
if err != nil {
return err
}
@@ -127,7 +159,7 @@ func (p *Presentation) SaveTo(filePath string) error {
err = addFile(
zipWriter,
fmt.Sprintf("ppt/slides/%s.xml", slideFileName),
- getSlideXml(slide.BoardPath, imageId, slide.ImageTop, slide.ImageLeft, slide.ImageWidth, slide.ImageHeight),
+ getSlideXml(slide),
)
if err != nil {
return err