1package fonts
2
3import (
4 "bufio"
5 "context"
6 "os/exec"
7 "strings"
8 "sync"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/logging"
12)
13
14// Fallback chains for each font category (unexported to prevent modification).
15var (
16 sansSerifFallbackChain = []string{
17 "Fira Sans",
18 "Noto Sans",
19 "DejaVu Sans",
20 "Liberation Sans",
21 "FreeSans",
22 }
23
24 serifFallbackChain = []string{
25 "Noto Serif",
26 "DejaVu Serif",
27 "Liberation Serif",
28 "FreeSerif",
29 }
30
31 monospaceFallbackChain = []string{
32 "Fira Code",
33 "JetBrains Mono",
34 "Noto Sans Mono",
35 "DejaVu Sans Mono",
36 "Liberation Mono",
37 "FreeMono",
38 }
39)
40
41// SansSerifFallbackChain returns the fallback chain for sans-serif fonts.
42func SansSerifFallbackChain() []string {
43 result := make([]string, len(sansSerifFallbackChain))
44 copy(result, sansSerifFallbackChain)
45 return result
46}
47
48// SerifFallbackChain returns the fallback chain for serif fonts.
49func SerifFallbackChain() []string {
50 result := make([]string, len(serifFallbackChain))
51 copy(result, serifFallbackChain)
52 return result
53}
54
55// MonospaceFallbackChain returns the fallback chain for monospace fonts.
56func MonospaceFallbackChain() []string {
57 result := make([]string, len(monospaceFallbackChain))
58 copy(result, monospaceFallbackChain)
59 return result
60}
61
62// Generic CSS fallbacks when no fonts from the chain are found.
63const (
64 genericSansSerif = "sans-serif"
65 genericSerif = "serif"
66 genericMonospace = "monospace"
67)
68
69// Detector implements port.FontDetector using fontconfig's fc-list command.
70type Detector struct {
71 mu sync.RWMutex
72 cachedFonts []string
73 cachePopulated bool
74}
75
76// NewDetector creates a new font detector.
77func NewDetector() *Detector {
78 return &Detector{}
79}
80
81// IsAvailable implements port.FontDetector.
82// Returns true if fc-list command is available on the system.
83func (*Detector) IsAvailable(_ context.Context) bool {
84 _, err := exec.LookPath("fc-list")
85 return err == nil
86}
87
88// GetAvailableFonts implements port.FontDetector.
89// Returns a list of font family names installed on the system.
90func (d *Detector) GetAvailableFonts(ctx context.Context) ([]string, error) {
91 log := logging.FromContext(ctx)
92
93 d.mu.RLock()
94 if d.cachePopulated {
95 fonts := d.cachedFonts
96 d.mu.RUnlock()
97 return fonts, nil
98 }
99 d.mu.RUnlock()
100
101 d.mu.Lock()
102 defer d.mu.Unlock()
103
104 // Double-check after acquiring write lock.
105 if d.cachePopulated {
106 return d.cachedFonts, nil
107 }
108
109 fonts, err := d.queryFonts(ctx)
110 if err != nil {
111 log.Debug().Err(err).Msg("failed to query system fonts")
112 return nil, err
113 }
114
115 d.cachedFonts = fonts
116 d.cachePopulated = true
117 log.Debug().Int("count", len(fonts)).Msg("cached system fonts")
118
119 return fonts, nil
120}
121
122// SelectBestFont implements port.FontDetector.
123// Returns the first available font from the fallback chain for the given category.
124func (d *Detector) SelectBestFont(
125 ctx context.Context,
126 category port.FontCategory,
127 fallbackChain []string,
128) string {
129 log := logging.FromContext(ctx)
130
131 availableFonts, err := d.GetAvailableFonts(ctx)
132 if err != nil {
133 log.Debug().
134 Str("category", string(category)).
135 Err(err).
136 Msg("font detection unavailable, using generic fallback")
137 return d.genericFallback(category)
138 }
139
140 // Build a set for O(1) lookup.
141 fontSet := make(map[string]struct{}, len(availableFonts))
142 for _, f := range availableFonts {
143 fontSet[f] = struct{}{}
144 }
145
146 // Find the first available font in the chain.
147 for _, font := range fallbackChain {
148 if _, exists := fontSet[font]; exists {
149 log.Debug().
150 Str("category", string(category)).
151 Str("font", font).
152 Msg("selected font from fallback chain")
153 return font
154 }
155 }
156
157 // No fonts from chain found, use generic fallback.
158 generic := d.genericFallback(category)
159 log.Debug().
160 Str("category", string(category)).
161 Str("fallback", generic).
162 Msg("no fonts from fallback chain available, using generic")
163 return generic
164}
165
166// queryFonts executes fc-list and parses the output.
167func (*Detector) queryFonts(ctx context.Context) ([]string, error) {
168 cmd := exec.CommandContext(ctx, "fc-list", ":", "family")
169 output, err := cmd.Output()
170 if err != nil {
171 return nil, err
172 }
173
174 fontSet := make(map[string]struct{})
175 scanner := bufio.NewScanner(strings.NewReader(string(output)))
176 for scanner.Scan() {
177 line := strings.TrimSpace(scanner.Text())
178 if line == "" {
179 continue
180 }
181 // fc-list may return comma-separated families for fonts with aliases.
182 // e.g., "DejaVu Sans,DejaVu Sans Light"
183 families := strings.Split(line, ",")
184 for _, family := range families {
185 family = strings.TrimSpace(family)
186 if family != "" {
187 fontSet[family] = struct{}{}
188 }
189 }
190 }
191
192 if err := scanner.Err(); err != nil {
193 return nil, err
194 }
195
196 // Convert set to slice.
197 fonts := make([]string, 0, len(fontSet))
198 for font := range fontSet {
199 fonts = append(fonts, font)
200 }
201
202 return fonts, nil
203}
204
205// genericFallback returns the CSS generic fallback for the given category.
206func (*Detector) genericFallback(category port.FontCategory) string {
207 switch category {
208 case port.FontCategorySansSerif:
209 return genericSansSerif
210 case port.FontCategorySerif:
211 return genericSerif
212 case port.FontCategoryMonospace:
213 return genericMonospace
214 default:
215 return genericSansSerif
216 }
217}