// Package d2isometricimg renders the D2 3D scene to SVG, PNG or GIF in pure Go. // It needs no browser, GPU, external executable, or system fonts.
package d2isometricimg import ( ) type Format string const ( SVG Format = "svg" PNG Format = "png" GIF Format = "gif" CycleSeconds = 25.0 / 3.0 FrameCount = 100 MaxDimension = 4096 MaxPixels = 12_000_000 MaxAnimationPixels = 100_000_000 MaxOutputBytes = 64 << 20 maxPNGBytes = 32 << 20 ) // Options uses fixed output pixels. Zero dimensions select 1600x1000 for PNG // or 1000x625 for GIF. // Timeout defaults to two minutes and cannot exceed two minutes. An earlier // caller deadline always wins. type Options struct { Render d2isometric.RenderOpts // Assets explicitly supplies bounded image loading. Rendering does not // otherwise read local files or fetch remote resources. Assets *d2scenebuild.AssetOptions // Fonts supplies optional bounded fallback discovery. Configured D2 font // families are retained even when no fallback resolver is provided. Fonts *d2scenebuild.FontFallbackOptions Format Format Width, Height int Timeout time.Duration // FitContent treats Width and Height as maximum dimensions and trims unused // canvas to the projected diagram's aspect ratio. Geometry is never resized. FitContent bool // LinkBudget bounds page annotations before they are accumulated. Nil uses // 4096 regions and 1 MiB of destination/tooltip text for RenderPage. LinkBudget *d2scenebuild.LinkBudget // A timeline shares one logical-pixel camera across all boards. camera *rasterCamera } func normalize( *Options) (Options, error) { := Options{} if != nil { = * } if .Format == "" { .Format = PNG } if .Format != SVG && .Format != PNG && .Format != GIF { return , fmt.Errorf("unsupported isometric image format %q (use svg, png or gif)", .Format) } if .Width == 0 && .Height == 0 { .Width, .Height = 1600, 1000 if .Format == GIF { .Width, .Height = 1000, 625 } } if .Width < 64 || .Height < 64 || .Width > MaxDimension || .Height > MaxDimension || int64(.Width)*int64(.Height) > MaxPixels { return , fmt.Errorf("isometric image dimensions %dx%d exceed limits (64..%d per side and at most %d pixels)", .Width, .Height, MaxDimension, MaxPixels) } if .Format == GIF && int64(.Width)*int64(.Height)*(FrameCount+4) > MaxAnimationPixels { return , fmt.Errorf("isometric GIF pixels exceed %d across the traffic cycle; reduce --scale", MaxAnimationPixels) } if .Timeout == 0 { .Timeout = 2 * time.Minute } if .Timeout < 0 || .Timeout > 2*time.Minute { return , fmt.Errorf("isometric image timeout must be positive and at most two minutes") } if .LinkBudget != nil && (.LinkBudget.MaxRegions <= 0 || .LinkBudget.MaxRegions > 4096 || .LinkBudget.MaxStringBytes <= 0 || .LinkBudget.MaxStringBytes > 1<<20) { return , fmt.Errorf("isometric page link budget must be within 4096 regions and 1 MiB of text") } return , nil } // Render returns a vector SVG or lossless PNG without traffic particles, or an // infinitely looping GIF with a fixed camera and deterministic traffic. Rendering stays // in memory and does not start external processes. Image I/O, when needed, // is performed only by the explicitly supplied asset resolver. func ( context.Context, *d2target.Diagram, *Options) ([]byte, error) { , := normalize() if != nil { return nil, } if .Format == SVG { return renderNativeSVG(, , ) } , := openCapture(, , ) if != nil { return nil, } defer .close() if .Format == PNG { return .frame(0, false) } return renderGIF() } // CaptureFrames delivers lossless frames at exact scene times, serially. It is // useful for deterministic comparison and other bounded image encoders. The // callback owns each PNG and may retain it; no frame cache is kept here. func ( context.Context, *d2target.Diagram, *Options, []float64, bool, func(int, []byte) error) error { , := normalize() if != nil { return } if len() == 0 || len() > FrameCount+4 || == nil { return fmt.Errorf("capture needs 1..%d times and a callback", FrameCount+4) } if int64(.Width)*int64(.Height)*int64(len()) > MaxAnimationPixels { return fmt.Errorf("capture aggregate pixels exceed limit") } for , := range { if math.IsNaN() || math.IsInf(, 0) || < 0 || > 86400 { return fmt.Errorf("capture time must be finite and in [0,86400]") } } , := openCapture(, , ) if != nil { return } defer .close() for , := range { , := .frame(, ) if != nil { return } if := (, ); != nil { return } if := .ctx.Err(); != nil { return } } return nil }