diff --git a/d2cli/main.go b/d2cli/main.go
index b35547e84..58f867291 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"
@@ -190,7 +190,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))
}
}
@@ -352,7 +352,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
@@ -369,7 +369,9 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende
rootName := getFileName(outputPath)
// version must be only numbers to avoid issues with PowerPoint
p := pptx.NewPresentation(rootName, description, rootName, username, version.OnlyNumbers())
- svg, err := renderPPTX(ctx, ms, p, plugin, renderOpts, outputPath, page, diagram, nil)
+
+ boardIdToIndex := buildBoardIDToIndex(diagram, nil, nil)
+ svg, err := renderPPTX(ctx, ms, p, plugin, renderOpts, ruler, outputPath, page, diagram, nil, boardIdToIndex)
if err != nil {
return nil, false, err
}
@@ -758,7 +760,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) ([]byte, 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) ([]byte, error) {
var currBoardPath []string
// Root board doesn't have a name, so we use the output filename
if diagram.Name == "" {
@@ -796,31 +798,71 @@ func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Present
return nil, bundleErr
}
+ svg = appendix.Append(diagram, ruler, svg)
+
pngImg, err := png.ConvertSVG(ms, page, svg)
if err != nil {
return nil, err
}
- err = presentation.AddSlide(pngImg, currBoardPath)
+ slide, err := presentation.AddSlide(pngImg, currBoardPath)
if err != nil {
return nil, err
}
+
+ viewboxSlice := appendix.FindViewboxSlice(svg)
+ viewboxX, err := strconv.ParseFloat(viewboxSlice[0], 64)
+ if err != nil {
+ return nil, err
+ }
+ viewboxY, err := strconv.ParseFloat(viewboxSlice[1], 64)
+ if err != nil {
+ return nil, err
+ }
+
+ // Draw links
+ for _, shape := range diagram.Shapes {
+ if shape.Link == "" {
+ continue
+ }
+
+ linkX := png.SCALE * (float64(shape.Pos.X) - viewboxX - float64(shape.StrokeWidth))
+ linkY := png.SCALE * (float64(shape.Pos.Y) - viewboxY - float64(shape.StrokeWidth))
+ linkWidth := png.SCALE * (float64(shape.Width) + float64(shape.StrokeWidth*2))
+ linkHeight := png.SCALE * (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 nil, 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 nil, 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 nil, err
}
@@ -922,3 +964,28 @@ func loadFonts(ms *xmain.State, pathToRegular, pathToItalic, pathToBold, pathToS
return d2fonts.AddFontFamily("custom", regularTTF, italicTTF, boldTTF, semiboldTTF)
}
+
+// buildBoardIDToIndex returns a map from board path to page int
+// To map correctly, it must follow the same traversal of pdf/pptx 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/e2etests-cli/main_test.go b/e2etests-cli/main_test.go
index 3bf8d24a3..11e1b6f0e 100644
--- a/e2etests-cli/main_test.go
+++ b/e2etests-cli/main_test.go
@@ -249,7 +249,9 @@ layers: {
name: "how_to_solve_problems_pptx",
skipCI: true,
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
- writeFile(t, dir, "in.d2", `how to solve a hard problem?
+ writeFile(t, dir, "in.d2", `how to solve a hard problem? {
+ link: steps.2
+}
steps: {
1: {
w: write down the problem
@@ -261,6 +263,9 @@ steps: {
3: {
t -> w2
w2: write down the solution
+ w2: {
+ link: https://d2lang.com
+ }
}
}
`)
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/generate_png.js b/lib/png/generate_png.js
index 8c1c40806..31cf1e439 100644
--- a/lib/png/generate_png.js
+++ b/lib/png/generate_png.js
@@ -1,18 +1,18 @@
-async (imgString) => {
+async ({imgString, scale}) => {
const tempImg = new Image();
const loadImage = () => {
return new Promise((resolve, reject) => {
tempImg.onload = (event) => resolve(event.currentTarget);
tempImg.onerror = () => {
- reject("error loading string as an image");
+ reject("error loading string as an image:\n" + imgString);
};
tempImg.src = imgString;
});
};
const img = await loadImage();
const canvas = document.createElement("canvas");
- canvas.width = img.width * 2;
- canvas.height = img.height * 2;
+ canvas.width = img.width * scale;
+ canvas.height = img.height * scale;
const ctx = canvas.getContext("2d");
if (!ctx) {
return new Error("could not get canvas context");
diff --git a/lib/png/png.go b/lib/png/png.go
index 8f89c0b20..c0cc23687 100644
--- a/lib/png/png.go
+++ b/lib/png/png.go
@@ -19,6 +19,9 @@ import (
"oss.terrastruct.com/util-go/xmain"
)
+// ConvertSVG scales the image by 2x
+const SCALE = 2.
+
type Playwright struct {
PW *playwright.Playwright
Browser playwright.Browser
@@ -83,6 +86,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...")
@@ -90,7 +95,10 @@ func ConvertSVG(ms *xmain.State, page playwright.Page, svg []byte) ([]byte, erro
defer cancel()
encodedSVG := base64.StdEncoding.EncodeToString(svg)
- pngInterface, err := page.Evaluate(genPNGScript, "data:image/svg+xml;charset=utf-8;base64,"+encodedSVG)
+ pngInterface, err := page.Evaluate(genPNGScript, map[string]interface{}{
+ "imgString": "data:image/svg+xml;charset=utf-8;base64," + encodedSVG,
+ "scale": int(SCALE),
+ })
if err != nil {
return nil, fmt.Errorf("failed to generate png: %w", err)
}
diff --git a/lib/pptx/pptx.go b/lib/pptx/pptx.go
index 0063e0764..9bc885fd8 100644
--- a/lib/pptx/pptx.go
+++ b/lib/pptx/pptx.go
@@ -32,14 +32,38 @@ type Presentation struct {
Slides []*Slide
}
-
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 NewPresentation(title, description, subject, creator, d2Version string) *Presentation {
@@ -52,10 +76,10 @@ func NewPresentation(title, description, subject, creator, d2Version string) *Pr
}
}
-func (p *Presentation) AddSlide(pngContent []byte, boardPath []string) error {
+func (p *Presentation) AddSlide(pngContent []byte, 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
@@ -99,22 +123,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 {
@@ -145,10 +171,7 @@ func (p *Presentation) SaveTo(filePath string) error {
return err
}
- err = addFileFromTemplate(zipWriter, fmt.Sprintf("ppt/slides/_rels/%s.xml.rels", slideFileName), RELS_SLIDE_XML, RelsSlideXmlContent{
- FileName: imageID,
- RelationshipID: imageID,
- })
+ err = addFileFromTemplate(zipWriter, fmt.Sprintf("ppt/slides/_rels/%s.xml.rels", slideFileName), RELS_SLIDE_XML, getSlideXmlRelsContent(imageID, slide))
if err != nil {
return err
}
@@ -242,14 +265,49 @@ func copyPptxTemplateTo(w *zip.Writer) error {
//go:embed templates/slide.xml.rels
var RELS_SLIDE_XML string
+type RelsSlideXmlLinkContent struct {
+ RelationshipID string
+ ExternalUrl string
+ SlideIndex int
+}
+
type RelsSlideXmlContent struct {
FileName string
RelationshipID string
+ Links []RelsSlideXmlLinkContent
+}
+
+func getSlideXmlRelsContent(imageID string, slide *Slide) RelsSlideXmlContent {
+ content := RelsSlideXmlContent{
+ FileName: imageID,
+ RelationshipID: imageID,
+ }
+
+ for _, link := range slide.Links {
+ content.Links = append(content.Links, RelsSlideXmlLinkContent{
+ RelationshipID: link.ID,
+ ExternalUrl: link.ExternalUrl,
+ SlideIndex: link.SlideIndex,
+ })
+ }
+
+ return content
}
//go:embed templates/slide.xml
var SLIDE_XML string
+type SlideLinkXmlContent struct {
+ ID int
+ RelationshipID string
+ Name string
+ Action string
+ Left int
+ Top int
+ Width int
+ Height int
+}
+
type SlideXmlContent struct {
Title string
TitlePrefix string
@@ -260,6 +318,8 @@ type SlideXmlContent struct {
ImageTop int
ImageWidth int
ImageHeight int
+
+ Links []SlideLinkXmlContent
}
func getSlideXmlContent(imageID string, slide *Slide) SlideXmlContent {
@@ -270,17 +330,36 @@ func getSlideXmlContent(imageID string, slide *Slide) SlideXmlContent {
if len(prefixPath) > 0 {
prefix = strings.Join(prefixPath, " / ") + " / "
}
- return SlideXmlContent{
+ content := SlideXmlContent{
Title: boardName,
TitlePrefix: prefix,
Description: strings.Join(boardPath, " / "),
HeaderHeight: HEADER_HEIGHT,
ImageID: imageID,
ImageLeft: slide.ImageLeft,
- ImageTop: slide.ImageTop + HEADER_HEIGHT,
+ ImageTop: slide.ImageTop,
ImageWidth: slide.ImageWidth,
ImageHeight: slide.ImageHeight,
}
+
+ for _, link := range slide.Links {
+ var action string
+ if link.ExternalUrl == "" {
+ action = "ppaction://hlinksldjump"
+ }
+ content.Links = append(content.Links, SlideLinkXmlContent{
+ ID: link.Index,
+ RelationshipID: link.ID,
+ Name: link.Tooltip,
+ Action: action,
+ Left: link.Left,
+ Top: link.Top,
+ Width: link.Width,
+ Height: link.Height,
+ })
+ }
+
+ return content
}
//go:embed templates/rels_presentation.xml
diff --git a/lib/pptx/templates/slide.xml b/lib/pptx/templates/slide.xml
index 2721068a1..d138670ea 100644
--- a/lib/pptx/templates/slide.xml
+++ b/lib/pptx/templates/slide.xml
@@ -75,19 +75,44 @@
-
- {{if .TitlePrefix}}
-
+ {{if .TitlePrefix}}
{{.TitlePrefix}}
-
- {{end}}
-
+ {{end}}
{{.Title}}
+ {{range .Links}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{end}}
diff --git a/lib/pptx/templates/slide.xml.rels b/lib/pptx/templates/slide.xml.rels
index 92475c305..01d526e4c 100644
--- a/lib/pptx/templates/slide.xml.rels
+++ b/lib/pptx/templates/slide.xml.rels
@@ -6,4 +6,11 @@
-
\ No newline at end of file
+ {{range .Links}}
+ {{if .ExternalUrl}}
+
+ {{else}}
+
+ {{end}}
+ {{end}}
+