diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index 4b79ae4dd..34b549178 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -1,10 +1,11 @@ #### Features ๐Ÿš€ +- PDF exports support external links [#891](https://github.com/terrastruct/d2/issues/891) +- PDF exports support internal links [#891](https://github.com/terrastruct/d2/issues/966) - `border-radius` is now supported on connections (ELK and TALA only, since Dagre uses curves). [#913](https://github.com/terrastruct/d2/pull/913) #### Improvements ๐Ÿงน -- PDF exports now support external links on shapes [#891](https://github.com/terrastruct/d2/issues/891) - SVGs are fit to top left by default to avoid issues with zooming. [#954](https://github.com/terrastruct/d2/pull/954) - Person shapes now have labels below them and don't need to expand as much. [#960](https://github.com/terrastruct/d2/pull/960) diff --git a/d2cli/main.go b/d2cli/main.go index a96277ce3..024e98557 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -29,6 +29,7 @@ import ( "oss.terrastruct.com/d2/d2themes/d2themescatalog" "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/textmeasure" @@ -291,7 +292,8 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc var svg []byte if filepath.Ext(outputPath) == ".pdf" { - svg, err = renderPDF(ctx, ms, plugin, sketch, pad, themeID, outputPath, page, ruler, diagram, nil, nil) + pageMap := pdf.BuildPDFPageMap(diagram, nil, nil) + svg, err = renderPDF(ctx, ms, plugin, sketch, pad, themeID, outputPath, page, ruler, diagram, nil, nil, pageMap) } else { compileDur := time.Since(start) svg, err = render(ctx, ms, compileDur, plugin, sketch, pad, themeID, darkThemeID, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram) @@ -443,7 +445,7 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc return svg, nil } -func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch bool, pad, themeID int64, outputPath string, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, pdf *pdflib.GoFPDF, boardPath []string) (svg []byte, err error) { +func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch bool, pad, themeID int64, outputPath string, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, pdf *pdflib.GoFPDF, boardPath []string, pageMap map[string]int) (svg []byte, err error) { var isRoot bool if pdf == nil { pdf = pdflib.Init() @@ -504,26 +506,26 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, ske if err != nil { return svg, err } - err = pdf.AddPDFPage(pngImg, currBoardPath, themeID, rootFill, diagram.Shapes, pad, viewboxX, viewboxY) + err = pdf.AddPDFPage(pngImg, currBoardPath, themeID, rootFill, diagram.Shapes, pad, viewboxX, viewboxY, pageMap) if err != nil { return svg, err } } for _, dl := range diagram.Layers { - _, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath) + _, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) if err != nil { return nil, err } } for _, dl := range diagram.Scenarios { - _, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath) + _, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) if err != nil { return nil, err } } for _, dl := range diagram.Steps { - _, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath) + _, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) if err != nil { return nil, err } diff --git a/e2etests-cli/main_test.go b/e2etests-cli/main_test.go index 29529b867..e715449d9 100644 --- a/e2etests-cli/main_test.go +++ b/e2etests-cli/main_test.go @@ -122,6 +122,28 @@ scenarios: { assert.TestdataDir(t, filepath.Join(dir, "life")) }, }, + { + name: "internal_linked_pdf", + run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) { + writeFile(t, dir, "in.d2", `cat: how does the cat go? { + link: layers.cat +} +layers: { + cat: { + home: { + link: _ + } + the cat -> meow: goes + } +} +`) + err := runTestMain(t, ctx, dir, env, "in.d2", "out.pdf") + assert.Success(t, err) + + pdf := readFile(t, dir, "out.pdf") + testdataIgnoreDiff(t, ".pdf", pdf) + }, + }, } ctx := context.Background() diff --git a/e2etests-cli/testdata/TestCLI_E2E/hello_world_png_sketch.exp.png b/e2etests-cli/testdata/TestCLI_E2E/hello_world_png_sketch.exp.png index bef8fb071..242579ed1 100644 Binary files a/e2etests-cli/testdata/TestCLI_E2E/hello_world_png_sketch.exp.png and b/e2etests-cli/testdata/TestCLI_E2E/hello_world_png_sketch.exp.png differ diff --git a/e2etests-cli/testdata/TestCLI_E2E/internal_linked_pdf.exp.pdf b/e2etests-cli/testdata/TestCLI_E2E/internal_linked_pdf.exp.pdf new file mode 100644 index 000000000..1477cb823 Binary files /dev/null and b/e2etests-cli/testdata/TestCLI_E2E/internal_linked_pdf.exp.pdf differ diff --git a/lib/pdf/pdf.go b/lib/pdf/pdf.go index f92d9a197..7f1f25cc7 100644 --- a/lib/pdf/pdf.go +++ b/lib/pdf/pdf.go @@ -7,6 +7,7 @@ import ( "github.com/jung-kurt/gofpdf" + "oss.terrastruct.com/d2/d2parser" "oss.terrastruct.com/d2/d2renderers/d2fonts" "oss.terrastruct.com/d2/d2target" "oss.terrastruct.com/d2/d2themes" @@ -58,7 +59,7 @@ func (g *GoFPDF) GetFillRGB(themeID int64, fill string) (color.RGB, error) { return color.Hex2RGB(fill) } -func (g *GoFPDF) AddPDFPage(png []byte, boardPath []string, themeID int64, fill string, shapes []d2target.Shape, pad int64, viewboxX, viewboxY float64) error { +func (g *GoFPDF) AddPDFPage(png []byte, boardPath []string, themeID int64, fill string, shapes []d2target.Shape, pad int64, viewboxX, viewboxY float64, pageMap map[string]int) error { var opt gofpdf.ImageOptions opt.ImageType = "png" imageInfo := g.pdf.RegisterImageOptionsReader(strings.Join(boardPath, "/"), opt, bytes.NewReader(png)) @@ -122,14 +123,28 @@ func (g *GoFPDF) AddPDFPage(png []byte, boardPath []string, themeID int64, fill imageY := headerHeight + (pageHeight-imageHeight)/2 g.pdf.ImageOptions(strings.Join(boardPath, "/"), imageX, imageY, imageWidth, imageHeight, false, opt, 0, "") - // Draw external links + // Draw links for _, shape := range shapes { - if shape.Link != "" { - linkX := imageX + float64(shape.Pos.X) - viewboxX - float64(shape.StrokeWidth) - linkY := imageY + float64(shape.Pos.Y) - viewboxY - float64(shape.StrokeWidth) - linkWidth := float64(shape.Width) + float64(shape.StrokeWidth*2) - linkHeight := float64(shape.Height) + float64(shape.StrokeWidth*2) + if shape.Link == "" { + continue + } + + linkX := imageX + float64(shape.Pos.X) - viewboxX - float64(shape.StrokeWidth) + linkY := imageY + float64(shape.Pos.Y) - viewboxY - float64(shape.StrokeWidth) + linkWidth := float64(shape.Width) + float64(shape.StrokeWidth*2) + linkHeight := float64(shape.Height) + float64(shape.StrokeWidth*2) + + key, err := d2parser.ParseKey(shape.Link) + if err != nil || key.Path[0].Unbox().ScalarString() != "root" { + // External link g.pdf.LinkString(linkX, linkY, linkWidth, linkHeight, shape.Link) + } else { + // Internal link + if pageNum, ok := pageMap[shape.Link]; ok { + linkID := g.pdf.AddLink() + g.pdf.SetLink(linkID, 0, pageNum+1) + g.pdf.Link(linkX, linkY, linkWidth, linkHeight, linkID) + } } } @@ -149,3 +164,28 @@ 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 +}