diff --git a/svg.go b/svg.go index 64828f84..44cc37cc 100644 --- a/svg.go +++ b/svg.go @@ -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. @@ -959,7 +961,42 @@ func (g *SVGGenerator) generateStyles() string { sb.WriteString("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) + } + } + }) +} diff --git a/video.go b/video.go index d90168b0..a5fae686 100644 --- a/video.go +++ b/video.go @@ -9,6 +9,7 @@ package main import ( + "encoding/base64" "fmt" "log" "os" @@ -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, @@ -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 @@ -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 +}