Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/demo-capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
75 changes: 47 additions & 28 deletions svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d">`,
totalWidth, totalHeight)
Expand All @@ -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, `<svg x="%d" y="%d" width="%d" height="%d" viewBox="0 0 %s %s">`,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions svg_geometry.go
Original file line number Diff line number Diff line change
@@ -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)
}
101 changes: 101 additions & 0 deletions svg_geometry_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 2 additions & 0 deletions svg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
11 changes: 9 additions & 2 deletions sync_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/xml"
"errors"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -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) {
Expand Down
Loading