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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,7 @@ This fork adds several command-line options to enhance VHS functionality:
### Output Control

```sh
# Disable SVG optimization for smaller output files (enabled by default)
# Disable SVG minification and short class names (optimization is enabled by default)
vhs demo.tape --no-svg-opt

# Specify multiple output formats
Expand Down
22 changes: 12 additions & 10 deletions svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ func NewSVGGenerator(opts SVGConfig) *SVGGenerator {
// Get character dimensions from the first frame if available
charWidth := float64(opts.FontSize) * 0.55 // fallback
charHeight := float64(opts.FontSize) * 1.2 // fallback
if validSVGCellSize(opts.LineHeight) {
charHeight *= opts.LineHeight
}

if len(opts.Frames) > 0 {
if validSVGCellSize(opts.Frames[0].CharWidth) {
Expand Down Expand Up @@ -1227,11 +1230,7 @@ func (g *SVGGenerator) generateState(index int, state *TerminalState) string {
// Render if line has content, is cursor line, or has background colors
if strings.TrimSpace(line) != "" || isCursorLine || hasBackgroundColors {
// Render with colors using natural text flow
lineHeight := g.options.LineHeight
if lineHeight <= 0 {
lineHeight = 1.0
}
yPos := float64(y)*g.charHeight*lineHeight + g.charHeight*0.8
yPos := float64(y)*g.charHeight + g.charHeight*0.8

// First, render any background rectangles if we have color data
if hasColors && y < len(state.LineColors) {
Expand All @@ -1255,7 +1254,7 @@ func (g *SVGGenerator) generateState(index int, state *TerminalState) string {
}
charX := float64(x) * g.charWidth
_, _ = fmt.Fprintf(&sb, `<rect x="%s" y="%s" width="%s" height="%s" fill="%s" shape-rendering="crispEdges"/>`,
formatCoord(charX), formatCoord(float64(y)*g.charHeight*lineHeight), formatCoord(g.charWidth), formatCoord(g.charHeight), style.BgColor)
formatCoord(charX), formatCoord(float64(y)*g.charHeight), formatCoord(g.charWidth), formatCoord(g.charHeight), style.BgColor)
g.writeNewline(&sb)
}
}
Expand Down Expand Up @@ -1318,8 +1317,11 @@ func (g *SVGGenerator) generateState(index int, state *TerminalState) string {
cursorClass = g.cursorIdleClass
}

// Get cursor color (cursor is rendered as a block with foreground color)
cursorBgColor := g.options.Theme.Foreground
// Use the authored cursor color, then the text color as a fallback.
cursorBgColor := g.options.Theme.Cursor
if cursorBgColor == "" {
cursorBgColor = g.options.Theme.Foreground
}
if cursorBgColor == "" {
cursorBgColor = defaultCursorColor
}
Expand All @@ -1330,12 +1332,12 @@ func (g *SVGGenerator) generateState(index int, state *TerminalState) string {
_, _ = fmt.
// Use the cursor character from xterm.js (usually █)
Fprintf(&sb, `<tspan class="%s %s" style="fill:%s;">%s</tspan>`,
g.textClass, cursorClass, cursorBgColor, html.EscapeString(state.CursorChar))
g.textClass, cursorClass, html.EscapeString(cursorBgColor), html.EscapeString(state.CursorChar))
} else {
_, _ = fmt.
// Fallback to block character
Fprintf(&sb, `<tspan class="%s %s" style="fill:%s;">█</tspan>`,
g.textClass, cursorClass, cursorBgColor)
g.textClass, cursorClass, html.EscapeString(cursorBgColor))
}

// Render text after cursor
Expand Down
151 changes: 151 additions & 0 deletions svg_cursor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package main

import (
"context"
"encoding/xml"
"fmt"
"html"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
)

func TestSVGCursorThemeColor(t *testing.T) {
for _, tc := range []struct {
name, cursor, foreground, want string
}{
{"authored cursor", "#12ab34", "#abcdef", "#12ab34"},
{"authored cursor without foreground", "#12ab34", "", "#12ab34"},
{"foreground fallback", "", "#abcdef", "#abcdef"},
{"default fallback", "", "", defaultCursorColor},
} {
for _, character := range []string{"█", " "} {
t.Run(tc.name+"/"+character, func(t *testing.T) {
opts := createTestSVGConfig()
opts.Theme.Cursor, opts.Theme.Foreground = tc.cursor, tc.foreground
opts.CursorBlink = false
opts.Frames = []SVGFrame{{Lines: []string{"AB"}, CursorX: 1, CursorChar: character, CharWidth: 20, CharHeight: 24}}
svg := NewSVGGenerator(opts).Generate()
want := fmt.Sprintf(`style="fill:%s;">%s</tspan>`, tc.want, "█")
if !strings.Contains(svg, want) {
t.Fatalf("cursor does not use %s", tc.want)
}
})
}
}
}

func TestSVGCursorThemePixels(t *testing.T) {
if os.Getenv("VHS_TEST_BROWSER") == "" {
t.Skip("set VHS_TEST_BROWSER=1 for native SVG cursor 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 _, tc := range []struct {
name, cursor, foreground string
rgb [3]int
}{
{"authored cursor", "#12ab34", "#abcdef", [3]int{18, 171, 52}},
{"authored cursor without foreground", "#12ab34", "", [3]int{18, 171, 52}},
{"foreground fallback", "", "#abcdef", [3]int{171, 205, 239}},
{"default fallback", "", "", [3]int{221, 221, 221}},
} {
t.Run(tc.name, func(t *testing.T) {
opts := createTestSVGConfig()
opts.Theme.Cursor, opts.Theme.Foreground = tc.cursor, tc.foreground
opts.CursorBlink = false
opts.Frames = []SVGFrame{{Lines: []string{""}, CursorChar: "█", CharWidth: 20, CharHeight: 24}}
assertSVGCursorPixels(t, page, NewSVGGenerator(opts).Generate(), tc.rgb)
})
}
}

func TestSVGCursorThemeColorEscapesAttribute(t *testing.T) {
opts := createTestSVGConfig()
opts.Theme.Cursor = `#12" data-probe="&<>'`
opts.Frames = []SVGFrame{{Lines: []string{""}, CursorChar: "█"}}
svg := NewSVGGenerator(opts).Generate()
var root struct {
Name xml.Name `xml:"svg"`
}
if err := xml.Unmarshal([]byte(svg), &root); err != nil {
t.Fatalf("authored cursor color produced invalid XML: %v", err)
}
if !strings.Contains(svg, `style="fill:`+html.EscapeString(opts.Theme.Cursor)+`;"`) {
t.Fatal("cursor color does not use XML attribute escaping")
}
}

func TestSVGCursorCapturedThemePixels(t *testing.T) {
if os.Getenv("VHS_TEST_BROWSER") == "" {
t.Skip("set VHS_TEST_BROWSER=1 for native terminal capture")
}
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
output := filepath.Join(t.TempDir(), "cursor.svg")
tape := fmt.Sprintf(`Output %q
Set Shell bash
Set Width 320
Set Height 200
Set FontSize 24
Set CursorBlink false
Set Theme {"foreground":"#abcdef","background":"#010203","cursor":"#12ab34"}
Sleep 500ms
`, filepath.ToSlash(output))
if errs := Evaluate(ctx, tape, io.Discard); len(errs) != 0 {
t.Fatalf("Evaluate failed: %v", errs)
}
content, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
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)
}
assertSVGCursorPixels(t, page, string(content), [3]int{18, 171, 52})
}

func assertSVGCursorPixels(t *testing.T, page *rod.Page, svg string, rgb [3]int) {
t.Helper()
result, err := page.Eval(`async (svg, rgb) => {
const url = URL.createObjectURL(new Blob([svg], {type: 'image/svg+xml'}));
try {
const image = new Image(); image.src = url; 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);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i] === rgb[0] && pixels[i+1] === rgb[1] && pixels[i+2] === rgb[2] && pixels[i+3] === 255) count++;
}
return count;
} finally { URL.revokeObjectURL(url); }
}`, svg, rgb)
if err != nil {
t.Fatal(err)
}
if result.Value.Int() == 0 {
t.Fatalf("SVG has no opaque cursor pixels with RGB %v", rgb)
}
}
145 changes: 145 additions & 0 deletions svg_line_height_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"context"
"encoding/xml"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

func TestSVGLineHeightMetrics(t *testing.T) {
for _, tc := range []struct {
name string
captured, lineHeight float64
want float64
}{
{"captured spacing", 30, 1.5, 30},
{"fallback spacing", 0, 1.5, 36},
{"fallback default", 0, 0, 24},
{"fallback negative", 0, -1, 24},
{"fallback nonfinite spacing", 0, math.NaN(), 24},
{"fallback invalid metric", math.NaN(), 1.5, 36},
} {
t.Run(tc.name, func(t *testing.T) {
opts := createTestSVGConfig()
opts.FontSize = 20
opts.LineHeight = tc.lineHeight
opts.Frames = []SVGFrame{{Lines: []string{"ROW1", "ROW2", "ROW3"}, CharHeight: tc.captured}}
gen := NewSVGGenerator(opts)
if gen.charHeight != tc.want {
t.Errorf("cell height=%g, want %g", gen.charHeight, tc.want)
}
assertSVGRowSpacing(t, gen.Generate(), tc.want, "")
})
}
}

func TestSVGLineHeightCapturedRows(t *testing.T) {
if os.Getenv("VHS_TEST_BROWSER") == "" {
t.Skip("set VHS_TEST_BROWSER=1 for native line-height capture")
}
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
output := filepath.Join(t.TempDir(), "rows.svg")
tape := fmt.Sprintf("Output %q\nSet Shell bash\nSet Width 400\nSet Height 320\nSet Padding 10\nSet FontSize 20\nSet LineHeight 1.5\nSet TypingSpeed 0ms\nSet Theme {\"foreground\":\"#abcdef\",\"background\":\"#010203\",\"red\":\"#fa0102\"}\nType `printf '\\033[2J\\033[H\\033[41mROW1\\nROW2\\nROW3\\033[0m\\n'`\nEnter\nWait+Line />$/\nSleep 500ms\n", filepath.ToSlash(output))
var captured *VHS
if errs := Evaluate(ctx, tape, io.Discard, func(v *VHS) { captured = v }); len(errs) != 0 {
t.Fatalf("Evaluate failed: %v", errs)
}
if len(captured.svgFrames) == 0 {
t.Fatal("no terminal frames captured")
}
frame := captured.svgFrames[len(captured.svgFrames)-1]
if !strings.Contains(strings.Join(frame.Lines, "\n"), "ROW3") || !validSVGCellSize(frame.CharHeight) {
t.Fatalf("last frame lacks colored rows or a valid cell height: %+v", frame)
}
opts := createTestSVGConfig()
opts.Frames = []SVGFrame{frame}
opts.Style = captured.Options.Video.Style
opts.FontSize, opts.FontFamily = captured.Options.FontSize, captured.Options.FontFamily
opts.LineHeight, opts.Theme = captured.Options.LineHeight, captured.Options.Theme
svg := NewSVGGenerator(opts).Generate()
assertSVGRowSpacing(t, svg, frame.CharHeight, "#fa0102")
var root struct {
Height int `xml:"height,attr"`
}
if err := xml.Unmarshal([]byte(svg), &root); err != nil {
t.Fatal(err)
}
wantHeight := int(math.Ceil(float64(frame.TermRows)*frame.CharHeight)) + 2*opts.Style.Padding
if root.Height != wantHeight {
t.Fatalf("viewport height=%d, want captured grid height=%d", root.Height, wantHeight)
}
}

func assertSVGRowSpacing(t *testing.T, svg string, height float64, background string) {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(svg))
baselines := map[string]float64{}
backgroundRows := map[int]bool{}
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
start, ok := token.(xml.StartElement)
if !ok {
continue
}
switch start.Name.Local {
case "text":
var text struct {
Y float64 `xml:"y,attr"`
Inner string `xml:",innerxml"`
}
if err := decoder.DecodeElement(&text, &start); err != nil {
t.Fatal(err)
}
for _, row := range []string{"ROW1", "ROW2", "ROW3"} {
if strings.Contains(text.Inner, row) {
baselines[row] = text.Y
}
}
case "rect":
var rect struct {
Y float64 `xml:"y,attr"`
Height float64 `xml:"height,attr"`
Fill string `xml:"fill,attr"`
}
if err := decoder.DecodeElement(&rect, &start); err != nil {
t.Fatal(err)
}
if background != "" && rect.Fill == background {
row := int(math.Round(rect.Y / height))
if math.Abs(rect.Y-float64(row)*height) > 0.01 || math.Abs(rect.Height-height) > 0.01 {
t.Errorf("background y=%g height=%g does not match cell height=%g", rect.Y, rect.Height, height)
}
backgroundRows[row] = true
}
}
}
if len(baselines) != 3 {
t.Fatalf("expected three text rows, got %v", baselines)
}
for _, pair := range [][2]string{{"ROW1", "ROW2"}, {"ROW2", "ROW3"}} {
if delta := baselines[pair[1]] - baselines[pair[0]]; math.Abs(delta-height) > 0.01 {
t.Errorf("%s to %s baseline delta=%g, want %g", pair[0], pair[1], delta, height)
}
}
if background != "" {
for row := range 3 {
if !backgroundRows[row] {
t.Errorf("missing background at terminal row %d", row)
}
}
}
}
Loading