diff --git a/docs/demo-capture.md b/docs/demo-capture.md index 696dbe8c..09513a3b 100644 --- a/docs/demo-capture.md +++ b/docs/demo-capture.md @@ -137,3 +137,5 @@ Review the explicit font option with the existing font PR. Run the repository checks and structured review before publishing a revision. Publish a tested release, then bind demo generation to its immutable revision and binary hash. This review does not publish a PR, push a branch, merge changes, or create a release. + +SVG dimensions fit the recorded terminal rows and columns. Fractional cell sizes round up to a full pixel. Padding, window bars, and margins remain in the output. SVG sizing preserves capture settings and rounded corner transparency. diff --git a/svg.go b/svg.go index 515b8aef..09a05421 100644 --- a/svg.go +++ b/svg.go @@ -40,6 +40,8 @@ type SVGFrame struct { CharWidth float64 CharHeight float64 CursorChar string // The cursor character (e.g., '█' for block) + TermCols int // Number of terminal columns from xterm.js + TermRows int // Number of terminal rows from xterm.js } // CharStyle represents the style of a character. @@ -147,10 +149,13 @@ func NewSVGGenerator(opts SVGConfig) *SVGGenerator { charWidth := float64(opts.FontSize) * 0.55 // fallback charHeight := float64(opts.FontSize) * 1.2 // fallback - if len(opts.Frames) > 0 && opts.Frames[0].CharWidth > 0 { - // Use actual dimensions from xterm.js - charWidth = opts.Frames[0].CharWidth - charHeight = opts.Frames[0].CharHeight + if len(opts.Frames) > 0 { + if validSVGCellSize(opts.Frames[0].CharWidth) { + charWidth = opts.Frames[0].CharWidth + } + if validSVGCellSize(opts.Frames[0].CharHeight) { + charHeight = opts.Frames[0].CharHeight + } } // Get style for calculating frame spacing @@ -207,6 +212,10 @@ func (g *SVGGenerator) Generate() string { style = DefaultStyleOptions() } + localStyle := *style + style = &localStyle + g.options.Style = style + // Process frames to extract unique states g.processFrames() @@ -216,15 +225,39 @@ func (g *SVGGenerator) Generate() string { g.fontSize = 20 } - var sb strings.Builder + // Calculate inner terminal area + barHeight := 0 + if style.WindowBar != "" { + barHeight = style.WindowBarSize + } + + padding := style.Padding + innerX := padding + innerY := barHeight + padding + innerWidth := style.Width - (padding * 2) + innerHeight := style.Height - barHeight - (padding * 2) + + if len(g.options.Frames) > 0 { + frame := g.options.Frames[0] + innerWidth = svgGridDimension(frame.TermCols, g.charWidth, innerWidth) + innerHeight = svgGridDimension(frame.TermRows, g.charHeight, innerHeight) + } + + // Update frame spacing and outer dimensions to match snapped inner area + g.frameSpacing = float64(innerWidth) + style.Width = innerWidth + (padding * 2) + style.Height = innerHeight + barHeight + (padding * 2) + g.options.Width = style.Width + g.options.Height = style.Height - // Calculate total dimensions including margins totalWidth := style.Width totalHeight := style.Height if style.Margin > 0 { totalWidth += style.Margin * 2 totalHeight += style.Margin * 2 } + var sb strings.Builder + // SVG root element _, _ = fmt.Fprintf(&sb, ``, totalWidth, totalHeight) @@ -246,28 +279,8 @@ func (g *SVGGenerator) Generate() string { // Terminal window sb.WriteString(g.generateTerminalWindow()) - // Calculate inner terminal area - barHeight := 0 - if style.WindowBar != "" { - barHeight = style.WindowBarSize - } - - padding := style.Padding - innerX := padding - innerY := barHeight + padding - innerWidth := style.Width - (padding * 2) - innerHeight := style.Height - barHeight - (padding * 2) - - // Inner terminal SVG with viewBox for animation - // Calculate actual terminal content height - maxLines := 0 - for _, state := range g.states { - if len(state.Lines) > maxLines { - maxLines = len(state.Lines) - } - } // viewBox width should match frame spacing (one frame width), height matches terminal - viewBoxWidth := g.frameSpacing + viewBoxWidth := float64(innerWidth) viewBoxHeight := float64(innerHeight) // Create inner SVG with viewBox that shows one frame at a time _, _ = fmt.Fprintf(&sb, ``, @@ -1943,7 +1956,9 @@ func CaptureSVGFrame(page *rod.Page, timestamp float64) (*SVGFrame, error) { charWidth: charWidth, charHeight: charHeight, lineColors: lineColors, - cursorChar: cursorChar + cursorChar: cursorChar, + termCols: cols, + termRows: term.rows }; }`) if err != nil { @@ -1989,6 +2004,8 @@ func CaptureSVGFrame(page *rod.Page, timestamp float64) (*SVGFrame, error) { charWidth := termInfo.Value.Get("charWidth").Num() charHeight := termInfo.Value.Get("charHeight").Num() cursorChar := termInfo.Value.Get("cursorChar").Str() + termCols := termInfo.Value.Get("termCols").Int() + termRows := termInfo.Value.Get("termRows").Int() // Parse line colors lineColors := [][]CharStyle{} @@ -2033,6 +2050,8 @@ func CaptureSVGFrame(page *rod.Page, timestamp float64) (*SVGFrame, error) { CharHeight: charHeight, Timestamp: timestamp, CursorChar: cursorChar, + TermCols: termCols, + TermRows: termRows, } return svgFrame, nil diff --git a/svg_geometry.go b/svg_geometry.go new file mode 100644 index 00000000..83f2c344 --- /dev/null +++ b/svg_geometry.go @@ -0,0 +1,19 @@ +package main + +import "math" + +func validSVGCellSize(size float64) bool { + return size > 0 && !math.IsInf(size, 0) && !math.IsNaN(size) +} + +// svgGridDimension retains all measured cells inside an integer pixel viewport. +func svgGridDimension(cells int, size float64, fallback int) int { + if cells <= 0 || !validSVGCellSize(size) { + return fallback + } + extent := math.Ceil(float64(cells) * size) + if extent < 1 || extent >= float64(int(^uint(0)>>1)) { + return fallback + } + return int(extent) +} diff --git a/svg_geometry_test.go b/svg_geometry_test.go new file mode 100644 index 00000000..99b90437 --- /dev/null +++ b/svg_geometry_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "encoding/xml" + "math" + "os" + "strings" + "testing" + "time" + + "github.com/go-rod/rod/lib/proto" +) + +func TestSVGGridGeometry(t *testing.T) { + for _, tc := range []struct { + name string + cols, rows int + charWidth, charHeight float64 + bar string + margin int + width, height int + }{ + {"full grid", 70, 20, 8.8, 20, "Colorful", 7, 650, 464}, + {"fractional cells", 3, 3, 8.25, 20.25, "", 0, 45, 81}, + {"columns only", 70, 0, 8.8, 20, "", 0, 636, 600}, + {"rows only", 0, 20, 8.8, 20, "Colorful", 0, 1200, 450}, + {"pixel fallback", 0, 0, 8.8, 20, "", 0, 1200, 600}, + {"invalid metric", 0, 0, math.Inf(1), math.NaN(), "", 0, 1200, 600}, + {"fallback metric grid", 3, 3, math.Inf(1), math.NaN(), "", 0, 47, 78}, + } { + t.Run(tc.name, func(t *testing.T) { + opts := createTestSVGConfig() + opts.Style.Width, opts.Style.Height = 1200, 600 + opts.Style.Padding, opts.Style.Margin = 10, tc.margin + opts.Style.WindowBar, opts.Style.WindowBarSize = tc.bar, 30 + opts.Style.BorderRadius = 20 + opts.Frames[0].TermCols, opts.Frames[0].TermRows = tc.cols, tc.rows + opts.Frames[0].CharWidth, opts.Frames[0].CharHeight = tc.charWidth, tc.charHeight + before := *opts.Style + svg := NewSVGGenerator(opts).Generate() + if *opts.Style != before { + t.Fatalf("Generate changed caller style: before=%+v after=%+v", before, *opts.Style) + } + var root struct { + Width int `xml:"width,attr"` + Height int `xml:"height,attr"` + } + if err := xml.Unmarshal([]byte(svg), &root); err != nil { + t.Fatal(err) + } + if root.Width != tc.width || root.Height != tc.height { + t.Fatalf("dimensions=%dx%d, want %dx%d", root.Width, root.Height, tc.width, tc.height) + } + if strings.Contains(strings.SplitN(svg, ">", 2)[0], "background:") { + t.Fatal("root fills rounded corner cutouts") + } + }) + } +} + +func TestSVGGridRoundedCornerPixels(t *testing.T) { + if os.Getenv("VHS_TEST_BROWSER") == "" { + t.Skip("set VHS_TEST_BROWSER=1 for native SVG pixels") + } + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + browser, closeBrowser, err := startBrowser(ctx) + if err != nil { + t.Fatal(err) + } + defer closeBrowser() + page, err := browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) + if err != nil { + t.Fatal(err) + } + for _, margin := range []int{0, 7} { + opts := createTestSVGConfig() + opts.Style.Padding, opts.Style.BorderRadius = 10, 20 + opts.Style.Margin, opts.Style.MarginFill = margin, "#112233" + opts.Style.WindowBar = "Colorful" + opts.Frames[0].TermCols, opts.Frames[0].TermRows = 70, 20 + svg := NewSVGGenerator(opts).Generate() + result, err := page.Eval(`async (svg, margin) => { + const image = new Image(); image.src = URL.createObjectURL(new Blob([svg], {type: 'image/svg+xml'})); await image.decode(); + const canvas = document.createElement('canvas'); canvas.width=image.width; canvas.height=image.height; + const context=canvas.getContext('2d'); context.drawImage(image,0,0); + return Array.from(context.getImageData(margin+1,margin+1,1,1).data); + }`, svg, margin) + if err != nil { + t.Fatal(err) + } + pixel := result.Value.Arr() + if margin == 0 && pixel[3].Int() != 0 { + t.Fatalf("rounded corner alpha=%d", pixel[3].Int()) + } + if margin > 0 && (pixel[0].Int() != 17 || pixel[1].Int() != 34 || pixel[2].Int() != 51 || pixel[3].Int() != 255) { + t.Fatalf("corner lost margin fill: %v", result.Value) + } + } +} diff --git a/svg_test.go b/svg_test.go index 54421706..47a004f4 100644 --- a/svg_test.go +++ b/svg_test.go @@ -113,6 +113,8 @@ func TestSVGGenerator_StyleOptions(t *testing.T) { svg := gen.Generate() // Check dimensions with margins + // Width: TermCols=0 in test so unchanged: 1024 + 2*10 = 1044 + // Height: TermRows=0 in test so unchanged: 768 + 2*10 = 788 assertContains(t, svg, "1044", "Total width with margins") assertContains(t, svg, "788", "Total height with margins") diff --git a/sync_contract_test.go b/sync_contract_test.go index dd38c798..b7e40ece 100644 --- a/sync_contract_test.go +++ b/sync_contract_test.go @@ -6,6 +6,7 @@ import ( "encoding/xml" "errors" "fmt" + "math" "os" "os/exec" "path/filepath" @@ -125,8 +126,14 @@ func TestBrowserGridWithExactSVGFont(t *testing.T) { if err := xml.Unmarshal(data, &dimensions); err != nil { t.Fatal(err) } - if dimensions.Width != v.Options.Video.Style.Width || dimensions.Height != v.Options.Video.Style.Height { - t.Fatalf("SVG dimensions = %dx%d, want %dx%d", dimensions.Width, dimensions.Height, v.Options.Video.Style.Width, v.Options.Video.Style.Height) + frame := v.svgFrames[0] + if frame.TermCols != dims.Value.Get("cols").Int() || frame.TermRows != dims.Value.Get("rows").Int() { + t.Fatalf("capture grid = %dx%d, want %s", frame.TermCols, frame.TermRows, dims.Value.JSON("", "")) + } + wantWidth := int(math.Ceil(float64(frame.TermCols)*frame.CharWidth)) + 2*v.Options.Video.Style.Padding + wantHeight := int(math.Ceil(float64(frame.TermRows)*frame.CharHeight)) + 2*v.Options.Video.Style.Padding + v.Options.Video.Style.WindowBarSize + if dimensions.Width != wantWidth || dimensions.Height != wantHeight { + t.Fatalf("SVG dimensions = %dx%d, want measured grid %dx%d", dimensions.Width, dimensions.Height, wantWidth, wantHeight) } cancel() if err := v.Setup(); !errors.Is(err, context.Canceled) {