Skip to content
Open
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
39 changes: 38 additions & 1 deletion svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ type SVGConfig struct {
LoopOffset float64
OptimizeSize bool // Enable size optimizations for smaller output
Debug bool // Enable debug logging
FontData string // Base64-encoded font data for @font-face embedding
FontMIME string // MIME type for the embedded font (e.g. "font/truetype", "font/woff2")
}

// TerminalState represents a unique terminal state for deduplication.
Expand Down Expand Up @@ -959,7 +961,42 @@ func (g *SVGGenerator) generateStyles() string {

sb.WriteString("<style>")
g.writeNewline(&sb)


// Embed @font-face if font data is provided
if g.options.FontData != "" {
fontFamily := g.options.FontFamily
if fontFamily == "" {
fontFamily = svgDefaultFontFamily
}
mime := g.options.FontMIME
if mime == "" {
mime = "font/truetype"
}
// Determine format hint from MIME type
formatHint := "truetype"
if strings.Contains(mime, "woff2") {
formatHint = "woff2"
} else if strings.Contains(mime, "woff") {
formatHint = "woff"
} else if strings.Contains(mime, "opentype") {
formatHint = "opentype"
}
safeFontFamily := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\A `,
"\r", "",
`}`, `\}`,
`{`, `\{`,
`;`, `\;`,
`<`, `\3C `,
`>`, `\3E `,
).Replace(fontFamily)
sb.WriteString(fmt.Sprintf(`@font-face { font-family: "%s"; src: url("data:%s;base64,%s") format("%s"); }`,
safeFontFamily, mime, g.options.FontData, formatHint))
g.writeNewline(&sb)
}

// Generate typing animations for detected patterns
for i, pattern := range g.patterns {
switch pattern.Type {
Expand Down
172 changes: 172 additions & 0 deletions svg_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package main

import (
"encoding/base64"
"fmt"
"os"
"os/exec"
"regexp"
"runtime"
"strings"
Expand Down Expand Up @@ -1478,3 +1480,173 @@ func TestSVGGenerator_TypingAnimationCSS(t *testing.T) {
}
})
}

// Font Embedding Tests

func TestSVGGenerator_FontFaceEmbed(t *testing.T) {
t.Run("embeds @font-face when FontData is set", func(t *testing.T) {
opts := createTestSVGConfig()
opts.FontFamily = "TestFont"
opts.FontData = base64.StdEncoding.EncodeToString([]byte("fake-font-data"))
opts.FontMIME = "font/woff2"

gen := NewSVGGenerator(opts)
svg := gen.Generate()

assertContains(t, svg, `@font-face`, "Should contain @font-face rule")
assertContains(t, svg, `font-family: "TestFont"`, "Should use font family name")
assertContains(t, svg, `format("woff2")`, "Should have woff2 format hint")
assertContains(t, svg, `data:font/woff2;base64,`, "Should embed base64 data with MIME")
})

t.Run("no @font-face when FontData is empty", func(t *testing.T) {
opts := createTestSVGConfig()
opts.FontFamily = "TestFont"
opts.FontData = ""

gen := NewSVGGenerator(opts)
svg := gen.Generate()

assertNotContains(t, svg, `@font-face`, "Should not contain @font-face without FontData")
})

t.Run("defaults to monospace font family when FontFamily is empty", func(t *testing.T) {
opts := createTestSVGConfig()
opts.FontFamily = ""
opts.FontData = base64.StdEncoding.EncodeToString([]byte("fake"))
opts.FontMIME = "font/truetype"

gen := NewSVGGenerator(opts)
svg := gen.Generate()

assertContains(t, svg, `font-family: "monospace"`, "Should fall back to monospace")
})
}

func TestSVGGenerator_FontFormatHint(t *testing.T) {
tests := []struct {
mime string
expectedHint string
}{
{"font/woff2", "woff2"},
{"font/woff", "woff"},
{"font/opentype", "opentype"},
{"font/truetype", "truetype"},
{"", "truetype"}, // default
}

for _, tc := range tests {
t.Run(tc.mime, func(t *testing.T) {
opts := createTestSVGConfig()
opts.FontFamily = "TestFont"
opts.FontData = base64.StdEncoding.EncodeToString([]byte("fake"))
opts.FontMIME = tc.mime

gen := NewSVGGenerator(opts)
svg := gen.Generate()

expected := fmt.Sprintf(`format("%s")`, tc.expectedHint)
assertContains(t, svg, expected, "Format hint for MIME "+tc.mime)
})
}
}

func TestSVGGenerator_FontFamilyEscaping(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"escapes double quotes", `Evil"Font`, `Evil\"Font`},
{"escapes backslash", `Evil\Font`, `Evil\\Font`},
{"escapes braces", `Evil}Font`, `Evil\}Font`},
{"escapes semicolon", `Evil;Font`, `Evil\;Font`},
{"escapes angle brackets", `Evil</style>Font`, `Evil\3C /style\3E Font`},
{"escapes newlines", "Evil\nFont", `Evil\A Font`},
{"strips carriage returns", "Evil\rFont", `EvilFont`},
{"plain name unchanged", "JetBrains Mono", `JetBrains Mono`},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
opts := createTestSVGConfig()
opts.FontFamily = tc.input
opts.FontData = base64.StdEncoding.EncodeToString([]byte("fake"))
opts.FontMIME = "font/woff2"

gen := NewSVGGenerator(opts)
svg := gen.Generate()

expected := fmt.Sprintf(`font-family: "%s"`, tc.expected)
assertContains(t, svg, expected, "Escaped font family for "+tc.name)
})
}
}

func TestResolveFont_EarlyReturns(t *testing.T) {
frames := []SVGFrame{{Lines: []string{"Hello"}}}

t.Run("empty font family", func(t *testing.T) {
data, mime := resolveFont("", frames)
if data != "" || mime != "" {
t.Errorf("Expected empty results for empty font family, got %q %q", data, mime)
}
})

t.Run("monospace font family", func(t *testing.T) {
data, mime := resolveFont("monospace", frames)
if data != "" || mime != "" {
t.Errorf("Expected empty results for monospace, got %q %q", data, mime)
}
})

t.Run("comma-separated font list", func(t *testing.T) {
data, mime := resolveFont("JetBrains Mono,DejaVu Sans Mono,monospace", frames)
if data != "" || mime != "" {
t.Errorf("Expected empty results for comma-separated list, got %q %q", data, mime)
}
})

t.Run("default font family", func(t *testing.T) {
data, mime := resolveFont(defaultFontFamily, frames)
if data != "" || mime != "" {
t.Errorf("Expected empty results for default font family, got %q %q", data, mime)
}
})
}

func TestResolveFont_WithFcMatch(t *testing.T) {
if _, err := exec.LookPath("fc-match"); err != nil {
t.Skip("fc-match not available")
}

frames := []SVGFrame{{Lines: []string{"Hello World"}}}

t.Run("resolves a real font", func(t *testing.T) {
// Use a font that fc-match is very likely to resolve on any system
data, mime := resolveFont("DejaVu Sans Mono", frames)
if data == "" {
t.Skip("fc-match could not resolve DejaVu Sans Mono on this system")
}
if mime == "" {
t.Error("Expected non-empty MIME type")
}
// Verify it's valid base64
if _, err := base64.StdEncoding.DecodeString(data); err != nil {
t.Errorf("FontData is not valid base64: %v", err)
}
})

t.Run("returns empty for nonexistent font", func(t *testing.T) {
// fc-match always returns *something* (a fallback), so this tests
// that at least the function doesn't crash with a weird name
data, _ := resolveFont("ZZZNonexistentFont999", frames)
// fc-match may still resolve to a fallback — that's fine, just
// verify no panic occurred. If it returned data, it should be valid.
if data != "" {
if _, err := base64.StdEncoding.DecodeString(data); err != nil {
t.Errorf("FontData is not valid base64: %v", err)
}
}
})
}
132 changes: 132 additions & 0 deletions video.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
package main

import (
"encoding/base64"
"fmt"
"log"
"os"
Expand Down Expand Up @@ -186,6 +187,11 @@ func MakeSVG(v *VHS) error {
// Calculate total duration based on frame count and framerate
duration := float64(len(v.svgFrames)) / float64(v.Options.Video.Framerate)

// Try to embed the font for portable SVG rendering.
// Uses fc-match to find the font file, then base64-encodes it.
// If pyftsubset is available, subset the font to only the glyphs used.
fontData, fontMIME := resolveFont(v.Options.FontFamily, v.svgFrames)

// Create SVG config
svgOpts := SVGConfig{
Width: v.Options.Video.Style.Width,
Expand All @@ -202,6 +208,8 @@ func MakeSVG(v *VHS) error {
LoopOffset: v.Options.LoopOffset,
OptimizeSize: v.Options.SVG.OptimizeSize,
Debug: v.Options.DebugConsole,
FontData: fontData,
FontMIME: fontMIME,
}

// Generate SVG
Expand All @@ -215,3 +223,127 @@ func MakeSVG(v *VHS) error {

return nil
}

// resolveFont finds the font file for the given family using fc-match,
// subsets it to only the glyphs used in the SVG frames (if pyftsubset
// is available), and returns the base64-encoded data with its MIME type.
// Returns empty strings if the font cannot be resolved.
func resolveFont(fontFamily string, frames []SVGFrame) (string, string) {
// Only embed a single, explicitly-set font family. The default font stack
// is a comma-separated fallback list for browser rendering — embedding the
// first match would produce a @font-face name that doesn't match the CSS
// font-family property on text elements.
if fontFamily == "" || fontFamily == "monospace" || strings.Contains(fontFamily, ",") {
return "", ""
}

// Use fc-match to find the font file path
out, err := exec.Command("fc-match", fontFamily, "--format=%{file}").Output()
if err != nil {
log.Printf("fc-match failed for %q: %v", fontFamily, err)
return "", ""
}

fontPath := strings.TrimSpace(string(out))
if fontPath == "" {
return "", ""
}
if _, err := os.Stat(fontPath); err != nil {
log.Printf("Font file not found: %s", fontPath)
return "", ""
}

// Try subsetting with pyftsubset for smaller output
if data, ok := subsetFont(fontPath, frames); ok {
encoded := base64.StdEncoding.EncodeToString(data)
log.Printf("Embedding subset font (%d KB, woff2)", len(data)/1024)
return encoded, "font/woff2"
}

// Fallback: embed the full font file
data, err := os.ReadFile(fontPath)
if err != nil {
log.Printf("Failed to read font file %s: %v", fontPath, err)
return "", ""
}

mime := "font/truetype"
switch strings.ToLower(filepath.Ext(fontPath)) {
case ".woff2":
mime = "font/woff2"
case ".woff":
mime = "font/woff"
case ".otf":
mime = "font/opentype"
}

encoded := base64.StdEncoding.EncodeToString(data)
log.Printf("Embedding full font %s (%s, %d KB)", fontPath, mime, len(data)/1024)

return encoded, mime
}

// subsetFont uses pyftsubset to create a woff2 subset of the font
// containing only the glyphs used in the given SVG frames.
// Returns the woff2 data and true on success, or nil and false on failure.
func subsetFont(fontPath string, frames []SVGFrame) ([]byte, bool) {
if _, err := exec.LookPath("pyftsubset"); err != nil {
return nil, false
}

// Collect unique codepoints from all frame text
codepoints := make(map[rune]struct{})
for _, frame := range frames {
for _, line := range frame.Lines {
for _, r := range line {
codepoints[r] = struct{}{}
}
}
if frame.CursorChar != "" {
for _, r := range frame.CursorChar {
codepoints[r] = struct{}{}
}
}
}

if len(codepoints) == 0 {
return nil, false
}

// Build Unicode range string for pyftsubset (e.g. "U+0041,U+0042")
unicodes := make([]string, 0, len(codepoints))
for r := range codepoints {
unicodes = append(unicodes, fmt.Sprintf("U+%04X", r))
}

// Create temp file for output
tmpFile, err := os.CreateTemp("", "vhs-font-*.woff2")
if err != nil {
return nil, false
}
tmpPath := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpPath)

// Run pyftsubset
//nolint:gosec
cmd := exec.Command("pyftsubset", fontPath,
"--unicodes="+strings.Join(unicodes, ","),
"--flavor=woff2",
"--output-file="+tmpPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("pyftsubset failed: %v: %s", err, out)
return nil, false
}

data, err := os.ReadFile(tmpPath)
if err != nil {
return nil, false
}

log.Printf("Font subset: %d glyphs, %d KB woff2 (from %s)",
len(codepoints), len(data)/1024, fontPath)

return data, true
}