Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion d2cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1511,13 +1511,21 @@ func ConvertSVG(ms *xmain.State, browser playwright.Browser, svg []byte, animInt
}, time.Second*5)
defer cancel()

if animIntervalMs > 0 {
if animIntervalMs > 0 && svgLooksAnimated(svg) {
return xgif.ConvertAnimatedSVGToPNGs(browser, svg, animIntervalMs)
}
out, err := png.ConvertSVG(browser, svg)
return [][]byte{out}, err
}

func svgLooksAnimated(svg []byte) bool {
s := string(svg)
return strings.Contains(s, "<animate") ||
strings.Contains(s, "<animateTransform") ||
strings.Contains(s, "@keyframes") ||
strings.Contains(s, "animation:")
}

func AnimatePNGs(ms *xmain.State, pngs [][]byte, animIntervalMs int) ([]byte, error) {
cancel := background.Repeat(func() {
ms.Log.Info.Printf("generating GIF...")
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions lib/imgbundler/imgbundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package imgbundler

import (
"bytes"
"compress/flate"
"compress/gzip"
"compress/zlib"
"context"
"encoding/base64"
"fmt"
Expand All @@ -18,6 +21,7 @@ import (
"sync"
"time"

"github.com/andybalholm/brotli"
"golang.org/x/xerrors"

"oss.terrastruct.com/d2/lib/simplelog"
Expand Down Expand Up @@ -235,11 +239,62 @@ func httpGet(ctx context.Context, l simplelog.Logger, href string) ([]byte, stri
return nil, "", err
}
contentType := resp.Header.Get("Content-Type")
contentEncoding := resp.Header.Get("Content-Encoding")
if contentEncoding != "" {
buf, err = decodeContentEncoding(buf, contentEncoding)
if err != nil {
return nil, "", fmt.Errorf("failed to decode %q response for %s: %w", contentEncoding, href, err)
}
}
l.Debug(fmt.Sprintf("fetched content type: %s, Content length: %d bytes", contentType, len(buf)))

return buf, contentType, nil
}

func decodeContentEncoding(buf []byte, contentEncoding string) ([]byte, error) {
encodings := strings.Split(contentEncoding, ",")
for i := len(encodings) - 1; i >= 0; i-- {
encoding := strings.TrimSpace(strings.ToLower(encodings[i]))
if encoding == "" || encoding == "identity" {
continue
}
var err error
switch encoding {
case "gzip", "x-gzip":
buf, err = gunzip(buf)
case "br":
buf, err = io.ReadAll(brotli.NewReader(bytes.NewReader(buf)))
case "deflate":
buf, err = inflate(buf)
default:
return nil, fmt.Errorf("unsupported content encoding %q", encoding)
}
if err != nil {
return nil, err
}
}
return buf, nil
}

func gunzip(buf []byte) ([]byte, error) {
r, err := gzip.NewReader(bytes.NewReader(buf))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}

func inflate(buf []byte) ([]byte, error) {
if zr, err := zlib.NewReader(bytes.NewReader(buf)); err == nil {
defer zr.Close()
return io.ReadAll(zr)
}
fr := flate.NewReader(bytes.NewReader(buf))
defer fr.Close()
return io.ReadAll(fr)
}

// sniffMimeType sniffs the mime type of href based on its file extension and contents.
func sniffMimeType(href, buf []byte, isRemote bool) string {
p := string(href)
Expand Down
87 changes: 87 additions & 0 deletions lib/imgbundler/imgbundler_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package imgbundler

import (
"bytes"
"compress/gzip"
"context"
"crypto/rand"
_ "embed"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
Expand All @@ -12,6 +15,7 @@ import (
"sync"
"testing"

"github.com/andybalholm/brotli"
tassert "github.com/stretchr/testify/assert"

"oss.terrastruct.com/d2/lib/log"
Expand Down Expand Up @@ -308,6 +312,89 @@ width="328" height="587" viewBox="-100 -131 328 587"><style type="text/css">
tassert.Equal(t, 2, strings.Count(string(out), "image/svg+xml"))
}

func TestInlineRemoteCompressedSVG(t *testing.T) {
imgCache = sync.Map{}
ctx := log.WithTB(context.Background(), t)
svgURL := "https://icons.terrastruct.com/essentials/004-picture.svg"
rawSVG := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>`)

sampleSVG := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg"><image href="%s" x="0" y="0" width="10" height="10" /></svg>
`, svgURL)

for _, tc := range []struct {
name string
contentEncoding string
encode func([]byte) []byte
}{
{
name: "gzip",
contentEncoding: "gzip",
encode: func(in []byte) []byte {
var b bytes.Buffer
zw := gzip.NewWriter(&b)
_, err := zw.Write(in)
if err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return b.Bytes()
},
},
{
name: "brotli",
contentEncoding: "br",
encode: func(in []byte) []byte {
var b bytes.Buffer
zw := brotli.NewWriter(&b)
_, err := zw.Write(in)
if err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return b.Bytes()
},
},
} {
t.Run(tc.name, func(t *testing.T) {
httpClient.Transport = roundTripFunc(func(req *http.Request) *http.Response {
respRecorder := httptest.NewRecorder()
respRecorder.Header().Set("Content-Type", "image/svg+xml")
respRecorder.Header().Set("Content-Encoding", tc.contentEncoding)
respRecorder.WriteHeader(200)
_, _ = respRecorder.Write(tc.encode(rawSVG))
return respRecorder.Result()
})

l := simplelog.FromLibLog(ctx)
out, err := BundleRemote(ctx, l, []byte(sampleSVG), false)
if err != nil {
t.Fatal(err)
}

match := imageRegex.FindSubmatch(out)
if len(match) != 2 {
t.Fatalf("expected bundled image href, got %s", out)
}
const prefix = `data:image/svg+xml;base64,`
href := string(match[1])
if !strings.HasPrefix(href, prefix) {
t.Fatalf("expected normalized svg data uri, got %s", href)
}
b64 := strings.TrimPrefix(href, prefix)
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
t.Fatal(err)
}
tassert.Equal(t, string(rawSVG), string(decoded))
})
}
}

func TestImgCache(t *testing.T) {
imgCache = sync.Map{}
ctx := log.WithTB(context.Background(), t)
Expand Down
5 changes: 5 additions & 0 deletions lib/png/png.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
exifcommon "github.com/dsoprea/go-exif/v3/common"
pngstruct "github.com/dsoprea/go-png-image-structure/v2"
"github.com/playwright-community/playwright-go"
"golang.org/x/term"

"oss.terrastruct.com/d2/lib/compression"
"oss.terrastruct.com/d2/lib/version"
Expand Down Expand Up @@ -104,6 +105,10 @@ func InitPlaywrightWithPrompt() (Playwright, error) {
return startPlaywright(pw)
}

if !term.IsTerminal(int(os.Stdin.Fd())) {
return InitPlaywright()
}

fmt.Print("D2 needs to install Chromium v130.0.6723.19 to render non-SVG images. Continue? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
Expand Down
Binary file modified lib/xgif/test_output.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 13 additions & 2 deletions lib/xgif/xgif.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const fps = 30
const workers = 16

func ConvertAnimatedSVGToPNGs(browser playwright.Browser, svg []byte, durationMs int) ([][]byte, error) {
totalFrames := (durationMs / 1000) * fps
totalFrames := frameCountForDuration(durationMs)
out := make([][]byte, totalFrames)

batchSize := int(math.Ceil(float64(totalFrames) / float64(workers)))
Expand Down Expand Up @@ -92,6 +92,10 @@ func ConvertAnimatedSVGToPNGs(browser playwright.Browser, svg []byte, durationMs
return out, nil
}

func frameCountForDuration(durationMs int) int {
return max(1, int(math.Ceil(float64(durationMs)*float64(fps)/1000.0)))
}

func AnimatePNGs(pngs [][]byte, animIntervalMs int) ([]byte, error) {
var width, height int
pngImgs := make([]image.Image, len(pngs))
Expand All @@ -106,7 +110,7 @@ func AnimatePNGs(pngs [][]byte, animIntervalMs int) ([]byte, error) {
height = go2.Max(height, bounds.Dy())
}

interval := int(math.Round(100.0 / float64(fps)))
interval := frameDelayForPNGs(animIntervalMs, len(pngs))
anim := &gif.GIF{
LoopCount: INFINITE_LOOP,
Config: image.Config{
Expand Down Expand Up @@ -186,6 +190,13 @@ func AnimatePNGs(pngs [][]byte, animIntervalMs int) ([]byte, error) {
return buf.Bytes(), nil
}

func frameDelayForPNGs(animIntervalMs, frameCount int) int {
if frameCount > 1 && frameCount == frameCountForDuration(animIntervalMs) {
return int(math.Round(100.0 / float64(fps)))
}
return max(1, int(math.Round(float64(animIntervalMs)/10.0)))
}

func findWhiteIndex(palette color.Palette) int {
nearestIndex := 0
nearestScore := 0.
Expand Down
13 changes: 13 additions & 0 deletions lib/xgif/xgif_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,16 @@ func TestPngToGif(t *testing.T) {

assert.Equal(t, test_output, gifBytes)
}

func TestFrameCountForDuration(t *testing.T) {
assert.Equal(t, 1, frameCountForDuration(10))
assert.Equal(t, 1, frameCountForDuration(999/fps))
assert.Equal(t, 30, frameCountForDuration(1000))
assert.Equal(t, 31, frameCountForDuration(1001))
}

func TestFrameDelayForPNGs(t *testing.T) {
assert.Equal(t, 1, frameDelayForPNGs(10, 1))
assert.Equal(t, 1, frameDelayForPNGs(10, 4))
assert.Equal(t, 3, frameDelayForPNGs(1000, frameCountForDuration(1000)))
}
Loading