diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index abd4b28f7..24c43152b 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -2,6 +2,7 @@ For v0.0.99 we focused on X, Y and Z. Enjoy! #### Features 💸 +- Ability to export to PNG - Now you can easily do x, y and z #9999 #### Improvements 🔧 diff --git a/ci/release/template/man/d2.1 b/ci/release/template/man/d2.1 index 0583e90c3..1f86ecb6a 100644 --- a/ci/release/template/man/d2.1 +++ b/ci/release/template/man/d2.1 @@ -9,7 +9,9 @@ .Op Fl -watch Ar false .Op Fl -theme Em 0 .Ar file.d2 -.Op Ar file.svg +.Op Ar file.svg +| +.Op Ar file.png .Nm d2 .Op Fl -watch Ar false .Op Fl -theme Em 0 @@ -23,6 +25,8 @@ compiles and renders .Ar file.d2 to .Ar file.svg +| +.Ar file.png .Ns . .Pp Pass - to have diff --git a/cmd/d2/help.go b/cmd/d2/help.go index ccc85332f..98390495d 100644 --- a/cmd/d2/help.go +++ b/cmd/d2/help.go @@ -15,9 +15,9 @@ import ( func help(ms *xmain.State) { fmt.Fprintf(ms.Stdout, `Usage: - %s [--watch=false] [--theme=0] file.d2 [file.svg] + %s [--watch=false] [--theme=0] file.d2 [file.svg|file.png] -%[1]s compiles and renders file.d2 to file.svg +%[1]s compiles and renders file.d2 to file.svg|file.png. Use - to have d2 read from stdin or write to stdout. Flags: diff --git a/cmd/d2/main.go b/cmd/d2/main.go index 61107d6ea..a5e5c39e2 100644 --- a/cmd/d2/main.go +++ b/cmd/d2/main.go @@ -9,8 +9,7 @@ import ( "strings" "time" - _ "embed" - + "github.com/playwright-community/playwright-go" "github.com/spf13/pflag" "oss.terrastruct.com/d2" @@ -19,6 +18,7 @@ import ( "oss.terrastruct.com/d2/d2renderers/textmeasure" "oss.terrastruct.com/d2/d2themes" "oss.terrastruct.com/d2/d2themes/d2themescatalog" + "oss.terrastruct.com/d2/lib/png" "oss.terrastruct.com/d2/lib/version" "oss.terrastruct.com/d2/lib/xmain" ) @@ -38,7 +38,7 @@ func run(ctx context.Context, ms *xmain.State) (err error) { } hostFlag := ms.Opts.String("HOST", "host", "h", "localhost", "host listening address when used with watch") portFlag := ms.Opts.String("PORT", "port", "p", "0", "port listening address when used with watch") - bundleFlag, err := ms.Opts.Bool("D2_BUNDLE", "bundle", "b", true, "bundle all assets and layers into the output svg.") + bundleFlag, err := ms.Opts.Bool("D2_BUNDLE", "bundle", "b", true, "when outputting SVG, bundle all assets and layers into the output file.") if err != nil { return xmain.UsageErrorf(err.Error()) } @@ -127,12 +127,25 @@ func run(ctx context.Context, ms *xmain.State) (err error) { } ms.Log.Debug.Printf("using layout plugin %s (%s)", *layoutFlag, pluginLocation) + var pw png.Playwright + if filepath.Ext(outputPath) == ".png" { + pw, err = png.InitPlaywright() + if err != nil { + return err + } + defer func() { + cleanupErr := pw.Cleanup() + if err == nil { + err = cleanupErr + } + }() + } + if *watchFlag { if inputPath == "-" { return xmain.UsageErrorf("-w[atch] cannot be combined with reading input from stdin") } ms.Env.Setenv("LOG_TIMESTAMPS", "1") - w, err := newWatcher(ctx, ms, watcherOpts{ layoutPlugin: plugin, themeID: *themeFlag, @@ -140,6 +153,7 @@ func run(ctx context.Context, ms *xmain.State) (err error) { port: *portFlag, inputPath: inputPath, outputPath: outputPath, + pw: pw, }) if err != nil { return err @@ -154,7 +168,7 @@ func run(ctx context.Context, ms *xmain.State) (err error) { _ = 343 } - _, err = compile(ctx, ms, plugin, *themeFlag, inputPath, outputPath) + _, err = compile(ctx, ms, plugin, *themeFlag, inputPath, outputPath, pw.Page) if err != nil { return err } @@ -162,7 +176,7 @@ func run(ctx context.Context, ms *xmain.State) (err error) { return nil } -func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, themeID int64, inputPath, outputPath string) ([]byte, error) { +func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, themeID int64, inputPath, outputPath string, page playwright.Page) ([]byte, error) { input, err := ms.ReadPath(inputPath) if err != nil { return nil, err @@ -191,7 +205,15 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, theme return nil, err } - err = ms.WritePath(outputPath, svg) + out := svg + if filepath.Ext(outputPath) == ".png" { + out, err = png.ConvertSVG(ms, page, svg) + if err != nil { + return nil, err + } + } + + err = ms.WritePath(outputPath, out) if err != nil { return nil, err } diff --git a/cmd/d2/watch.go b/cmd/d2/watch.go index f4fef9a6d..b49489df6 100644 --- a/cmd/d2/watch.go +++ b/cmd/d2/watch.go @@ -20,6 +20,7 @@ import ( "nhooyr.io/websocket/wsjson" "oss.terrastruct.com/d2/d2plugin" + "oss.terrastruct.com/d2/lib/png" "oss.terrastruct.com/d2/lib/xbrowser" "oss.terrastruct.com/d2/lib/xhttp" "oss.terrastruct.com/d2/lib/xmain" @@ -41,6 +42,7 @@ type watcherOpts struct { port string inputPath string outputPath string + pw png.Playwright } type watcher struct { @@ -330,7 +332,20 @@ func (w *watcher) compileLoop(ctx context.Context) error { recompiledPrefix = "re" } - b, err := compile(ctx, w.ms, w.layoutPlugin, w.themeID, w.inputPath, w.outputPath) + if filepath.Ext(w.outputPath) == ".png" && !w.pw.Browser.IsConnected() { + newPW, err := w.pw.RestartBrowser() + if err != nil { + broadcastErr := fmt.Errorf("issue encountered with PNG exporter: %w", err) + w.ms.Log.Error.Print(broadcastErr) + w.broadcast(&compileResult{ + Err: broadcastErr.Error(), + }) + continue + } + w.pw = newPW + } + + b, err := compile(ctx, w.ms, w.layoutPlugin, w.themeID, w.inputPath, w.outputPath, w.pw.Page) if err != nil { err = fmt.Errorf("failed to %scompile: %w", recompiledPrefix, err) w.ms.Log.Error.Print(err) diff --git a/go.mod b/go.mod index 72c8feb60..c6a3a9e17 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mazznoer/csscolorparser v0.1.3 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 + github.com/playwright-community/playwright-go v0.2000.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.0 github.com/yuin/goldmark v1.5.2 @@ -33,13 +34,14 @@ require ( require ( cloud.google.com/go/compute v1.7.0 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/fatih/color v1.13.0 // indirect github.com/gin-gonic/gin v1.7.7 // indirect github.com/go-playground/validator/v10 v10.10.0 // indirect + github.com/go-stack/stack v1.8.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/gorilla/websocket v1.4.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.13.6 // indirect github.com/kr/pretty v0.3.0 // indirect @@ -57,6 +59,7 @@ require ( google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc // indirect google.golang.org/protobuf v1.28.1 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index a7e3307a5..1b89dac58 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -130,6 +132,8 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0= github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= +github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= @@ -223,6 +227,7 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/h2non/filetype v1.1.1/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -266,6 +271,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/playwright-community/playwright-go v0.2000.1 h1:2JViSHpJQ/UL/PO1Gg6gXV5IcXAAsoBJ3KG9L3wKXto= +github.com/playwright-community/playwright-go v0.2000.1/go.mod h1:1y9cM9b9dVHnuRWzED1KLM7FtbwTJC8ibDjI6MNqewU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -751,6 +758,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/lib/png/generate_png.js b/lib/png/generate_png.js new file mode 100644 index 000000000..133cadba8 --- /dev/null +++ b/lib/png/generate_png.js @@ -0,0 +1,22 @@ +async (imgString) => { + 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"); + }; + tempImg.src = imgString; + }); + }; + const img = await loadImage(); + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return new Error("could not get canvas context"); + } + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL("image/png"); +} diff --git a/lib/png/png.go b/lib/png/png.go new file mode 100644 index 000000000..0199c3c91 --- /dev/null +++ b/lib/png/png.go @@ -0,0 +1,92 @@ +package png + +import ( + "encoding/base64" + "fmt" + "strings" + + _ "embed" + + "github.com/playwright-community/playwright-go" + + "oss.terrastruct.com/d2/lib/xmain" +) + +type Playwright struct { + PW *playwright.Playwright + Browser playwright.Browser + Page playwright.Page +} + +func (pw *Playwright) RestartBrowser() (Playwright, error) { + if err := pw.Browser.Close(); err != nil { + return Playwright{}, fmt.Errorf("failed to close Playwright browser: %w", err) + } + return startPlaywright(pw.PW) +} + +func (pw *Playwright) Cleanup() error { + if err := pw.Browser.Close(); err != nil { + return fmt.Errorf("failed to close Playwright browser: %w", err) + } + if err := pw.PW.Stop(); err != nil { + return fmt.Errorf("failed to stop Playwright: %w", err) + } + return nil +} + +func startPlaywright(pw *playwright.Playwright) (Playwright, error) { + browser, err := pw.Chromium.Launch() + if err != nil { + return Playwright{}, fmt.Errorf("failed to launch Chromium: %w", err) + } + context, err := browser.NewContext() + if err != nil { + return Playwright{}, fmt.Errorf("failed to start new Playwright browser context: %w", err) + } + page, err := context.NewPage() + if err != nil { + return Playwright{}, fmt.Errorf("failed to start new Playwright page: %w", err) + } + return Playwright{ + PW: pw, + Browser: browser, + Page: page, + }, nil +} + +func InitPlaywright() (Playwright, error) { + err := playwright.Install(&playwright.RunOptions{Verbose: false}) + if err != nil { + return Playwright{}, fmt.Errorf("failed to install Playwright: %w", err) + } + + pw, err := playwright.Run() + if err != nil { + return Playwright{}, fmt.Errorf("failed to run Playwright: %w", err) + } + return startPlaywright(pw) +} + +//go:embed generate_png.js +var genPNGScript string + +const pngPrefix = "data:image/png;base64," + +func ConvertSVG(ms *xmain.State, page playwright.Page, svg []byte) ([]byte, error) { + encodedSVG := base64.StdEncoding.EncodeToString(svg) + pngInterface, err := page.Evaluate(genPNGScript, "data:image/svg+xml;charset=utf-8;base64,"+encodedSVG) + if err != nil { + return nil, fmt.Errorf("failed to generate png: %w", err) + } + + pngString := pngInterface.(string) + if !strings.HasPrefix(pngString, pngPrefix) { + if len(pngString) > 50 { + pngString = pngString[0:50] + "..." + } + return nil, fmt.Errorf("invalid PNG: %q", pngString) + } + splicedPNGString := pngString[len(pngPrefix):] + return base64.StdEncoding.DecodeString(splicedPNGString) +}