diff --git a/README.md b/README.md
index fb4f4fa1..c29f4cf0 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/svg.go b/svg.go
index fe0827d4..788b83a4 100644
--- a/svg.go
+++ b/svg.go
@@ -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) {
@@ -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) {
@@ -1255,7 +1254,7 @@ func (g *SVGGenerator) generateState(index int, state *TerminalState) string {
}
charX := float64(x) * g.charWidth
_, _ = fmt.Fprintf(&sb, ``,
- 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)
}
}
@@ -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
}
@@ -1330,12 +1332,12 @@ func (g *SVGGenerator) generateState(index int, state *TerminalState) string {
_, _ = fmt.
// Use the cursor character from xterm.js (usually █)
Fprintf(&sb, `%s`,
- 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, `█`,
- g.textClass, cursorClass, cursorBgColor)
+ g.textClass, cursorClass, html.EscapeString(cursorBgColor))
}
// Render text after cursor
diff --git a/svg_cursor_test.go b/svg_cursor_test.go
new file mode 100644
index 00000000..7618b739
--- /dev/null
+++ b/svg_cursor_test.go
@@ -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`, 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)
+ }
+}
diff --git a/svg_line_height_test.go b/svg_line_height_test.go
new file mode 100644
index 00000000..e8e61e13
--- /dev/null
+++ b/svg_line_height_test.go
@@ -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)
+ }
+ }
+ }
+}