From 2df295fc13a81f3a03349177ef88ae5e7ca652a0 Mon Sep 17 00:00:00 2001 From: jackspirou Date: Thu, 8 Oct 2026 12:50:16 -0500 Subject: [PATCH] Preserve SVG cursor colors and captured line spacing --- README.md | 2 +- svg.go | 22 +++--- svg_cursor_test.go | 151 ++++++++++++++++++++++++++++++++++++++++ svg_line_height_test.go | 145 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 svg_cursor_test.go create mode 100644 svg_line_height_test.go 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) + } + } + } +}