From 6012a84bd9c21c3e358deb0843231731ba501116 Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Fri, 16 Oct 2026 06:11:06 +0800 Subject: [PATCH] watch: embed local images in PNG previews --- ci/release/changelogs/next.md | 2 ++ d2cli/main.go | 5 +++-- d2cli/raster_png_output.go | 19 ++++++++++++---- d2cli/raster_png_output_test.go | 4 ++-- d2cli/raster_test.go | 39 +++++++++++++++++++++++++++++++++ 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index d291ced933..926807f941 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -33,6 +33,8 @@ #### Bugfixes ⛑️ +- watch: embed local images in PNG watch previews instead of treating their URLs + as board navigation. [#2945](https://github.com/d2lang/d2/issues/2945) - compiler: - Limit combined map/array nesting to 128. [#2894](https://github.com/d2lang/d2/pull/2894) - Honor cancellation during parsing and IR compilation, and prevent `d2 fmt` diff --git a/d2cli/main.go b/d2cli/main.go index 3dad6e771b..e30d41c189 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -1019,7 +1019,8 @@ func _renderWithPNGEncoder(ctx context.Context, ms *xmain.State, opts d2svg.Rend cacheImages := ms.Env.Getenv("IMG_CACHE") == "1" if opts.MasterID == "" { written, err := writePNGWithStatus(ctx, ms, outputPath, func(output io.Writer) error { - return renderPNGToWriter(ctx, inputPath, cacheImages, diagram, *renderOpts, pngEncoder, output) + svg, err = renderPNGToWriter(ctx, inputPath, cacheImages, diagram, *renderOpts, pngEncoder, output, svg) + return err }) if err != nil { return svg, written, err @@ -1027,7 +1028,7 @@ func _renderWithPNGEncoder(ctx context.Context, ms *xmain.State, opts d2svg.Rend return svg, written, nil } // Animated SVG composition only needs validation, not retained PNG bytes. - err = renderPNGToWriter(ctx, inputPath, cacheImages, diagram, *renderOpts, pngEncoder, io.Discard) + svg, err = renderPNGToWriter(ctx, inputPath, cacheImages, diagram, *renderOpts, pngEncoder, io.Discard, svg) return svg, false, err } diff --git a/d2cli/raster_png_output.go b/d2cli/raster_png_output.go index e24c2cf6a1..a5176da2e0 100644 --- a/d2cli/raster_png_output.go +++ b/d2cli/raster_png_output.go @@ -28,17 +28,28 @@ func pngFrameOptions() d2raster.FrameOptions { return options } -func renderPNGToWriter(ctx context.Context, inputPath string, cacheImages bool, diagram *d2target.Diagram, opts d2svg.RenderOpts, encoder *rasterPNGEncoder, output io.Writer) error { - document, err := buildScene(ctx, inputPath, cacheImages, diagram, opts) +func renderPNGToWriter(ctx context.Context, inputPath string, cacheImages bool, diagram *d2target.Diagram, opts d2svg.RenderOpts, encoder *rasterPNGEncoder, output io.Writer, preview []byte) ([]byte, error) { + assetOptions, err := sceneAssetOptions(ctx, inputPath, cacheImages) if err != nil { - return err + return preview, err + } + defer assetOptions.Resolver.CloseIdleConnections() + document, err := buildSceneWithAssets(ctx, diagram, opts, assetOptions) + if err != nil { + return preview, err } var localEncoder rasterPNGEncoder if encoder == nil { encoder = &localEncoder defer localEncoder.close() } - return encodePNGBands(ctx, output, document, &encoder.bands) + if err := encodePNGBands(ctx, output, document, &encoder.bands); err != nil { + return preview, err + } + if len(preview) != 0 { + return bundleRasterPreview(ctx, assetOptions.Resolver, preview) + } + return preview, nil } func encodePNGBands(ctx context.Context, output io.Writer, document *d2scene.Document, encoder *rasterPNGBandEncoder) error { diff --git a/d2cli/raster_png_output_test.go b/d2cli/raster_png_output_test.go index fc05fa6e42..e4e31eb540 100644 --- a/d2cli/raster_png_output_test.go +++ b/d2cli/raster_png_output_test.go @@ -42,7 +42,7 @@ func TestPNGStreamMatchesCompleteFrame(t *testing.T) { t.Fatal(err) } var got bytes.Buffer - if err := renderPNGToWriter(context.Background(), "-", false, diagram, opts, &encoder, &got); err != nil { + if _, err := renderPNGToWriter(context.Background(), "-", false, diagram, opts, &encoder, &got, nil); err != nil { t.Fatalf("shadow=%v scale=%v: %v", shadow, scale, err) } // Identical encoded bytes also verify PNG filtering across strip @@ -90,7 +90,7 @@ func TestPNGStreamDiagramFixtures(t *testing.T) { t.Fatal(err) } var got bytes.Buffer - if err := renderPNGToWriter(context.Background(), "-", false, &diagram, opts, nil, &got); err != nil { + if _, err := renderPNGToWriter(context.Background(), "-", false, &diagram, opts, nil, &got, nil); err != nil { t.Fatal(err) } assertPNGOutputPixels(t, got.Bytes(), want) diff --git a/d2cli/raster_test.go b/d2cli/raster_test.go index 7f11f0e831..bdcf34462d 100644 --- a/d2cli/raster_test.go +++ b/d2cli/raster_test.go @@ -634,6 +634,45 @@ func TestFolderOnlyPNGReturnsFirstBoardPreview(t *testing.T) { } } +func TestPNGWatchPreviewEmbedsLocalImages(t *testing.T) { + directory := t.TempDir() + inputPath := filepath.Join(directory, "input.d2") + asset := testSolidPNG(t, color.NRGBA{R: 0x12, G: 0x34, B: 0x56, A: 0xff}) + if err := os.WriteFile(filepath.Join(directory, "asset.png"), asset, 0o644); err != nil { + t.Fatal(err) + } + assetURL, err := url.Parse("asset.png") + if err != nil { + t.Fatal(err) + } + env := xos.NewEnv(nil) + state := &xmain.State{Env: env, Log: cmdlog.NewTB(env, t), PWD: directory} + outputPath := filepath.Join(directory, "output.png") + boards, written, err := render( + context.Background(), state, 0, + d2svg.RenderOpts{Pad: go2.Pointer(int64(0)), Scale: go2.Pointer(1.0)}, + inputPath, outputPath, false, false, nil, + rasterImageDiagram(assetURL), PNG, "", true, + ) + if err != nil { + t.Fatal(err) + } + if !written || len(boards) != 1 { + t.Fatalf("PNG written/preview count = %t/%d", written, len(boards)) + } + want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(asset) + if !bytes.Contains(boards[0], []byte(want)) || bytes.Contains(boards[0], []byte(`href="asset.png"`)) { + t.Fatal("PNG watch preview did not embed its local image") + } + output, err := os.ReadFile(outputPath) + if err != nil { + t.Fatal(err) + } + if _, err := png.Decode(bytes.NewReader(output)); err != nil { + t.Fatalf("PNG export: %v", err) + } +} + func TestBundleGIFPreviewEmbedsLocalImages(t *testing.T) { directory := t.TempDir() inputPath := filepath.Join(directory, "input.d2")