package export import ( "encoding/base64" "fmt" "os" "path/filepath" "strings" "golang.org/x/image/font/sfnt" ) // A fontFace is one resolved face of a configured family, ready to embed. type fontFace struct { family string weight int italic bool ext string // ".otf" / ".ttf" data []byte } // embedFontFaces returns base64 @font-face rules for every configured family // whose font files are found on disk. WebKit (Safari/Orion) refuses to render // locally-installed user fonts referenced only by name, so the export inlines // the files. Best-effort: families with no file found are simply skipped (the // name reference + fallbacks in fontOverride still apply). func embedFontFaces(opts Options) string { seen := map[string]bool{} var b strings.Builder for _, stack := range []string{opts.FontDisplay, opts.FontBody, opts.FontMono} { fam := primaryFamily(stack) if fam == "" || seen[strings.ToLower(fam)] { continue } seen[strings.ToLower(fam)] = true for _, f := range discoverFaces(fam) { b.WriteString(f.faceCSS()) } } return b.String() } // faceCSS renders one @font-face rule with the file inlined as a data URL. func (f fontFace) faceCSS() string { style := "normal" if f.italic { style = "italic" } mime, format := "font/ttf", "truetype" if f.ext == ".otf" { mime, format = "font/otf", "opentype" } enc := base64.StdEncoding.EncodeToString(f.data) return fmt.Sprintf( `@font-face{font-family:"%s";font-style:%s;font-weight:%d;font-display:swap;src:url("data:%s;base64,%s") format("%s");}`, f.family, style, f.weight, mime, enc, format, ) } // primaryFamily is the first real (non-generic) family name in a CSS stack, // unquoted — the family to look up and embed. Returns "" if the stack holds // only generic keywords or is empty. func primaryFamily(stack string) string { for _, seg := range strings.Split(stack, ",") { seg = strings.TrimSpace(seg) seg = strings.Trim(seg, `"'`) seg = strings.TrimSpace(seg) if seg == "" || isGenericFamily(seg) { continue } return seg } return "" } func isGenericFamily(name string) bool { switch strings.ToLower(name) { case "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "-apple-system", "blinkmacsystemfont", "math", "emoji", "fangsong": return true } return false } // userFontDirs are the macOS locations user/admin-installed fonts live. System // fonts are skipped: they're already on WebKit's allowed list, so they render // without embedding. func userFontDirs() []string { var dirs []string if home, err := os.UserHomeDir(); err == nil { dirs = append(dirs, filepath.Join(home, "Library", "Fonts")) } dirs = append(dirs, "/Library/Fonts") return dirs } // discoverFaces scans the user font directories for every face of family, // parsing each font's name table to match. Unreadable/unparsable files and // non-font files are skipped. func discoverFaces(family string) []fontFace { want := strings.ToLower(strings.TrimSpace(family)) var faces []fontFace dedup := map[string]bool{} for _, dir := range userFontDirs() { entries, err := os.ReadDir(dir) if err != nil { continue } for _, e := range entries { if e.IsDir() { continue } ext := strings.ToLower(filepath.Ext(e.Name())) if ext != ".otf" && ext != ".ttf" { continue } path := filepath.Join(dir, e.Name()) data, err := os.ReadFile(path) if err != nil { continue } f, err := sfnt.Parse(data) if err != nil { continue } fam := familyName(f) if strings.ToLower(strings.TrimSpace(fam)) != want { continue } sub := subfamilyName(f) face := fontFace{family: family, weight: weightOf(sub), italic: italicOf(sub), ext: ext, data: data} // Markdown only needs regular + bold (roman + italic). Skip the // other weights so a many-weight family doesn't bloat the export. if face.weight != 400 && face.weight != 700 { continue } key := fmt.Sprintf("%d/%v", face.weight, face.italic) if dedup[key] { continue } dedup[key] = true faces = append(faces, face) } } return faces } func familyName(f *sfnt.Font) string { if n, err := f.Name(nil, sfnt.NameIDTypographicFamily); err == nil && n != "" { return n } n, _ := f.Name(nil, sfnt.NameIDFamily) return n } func subfamilyName(f *sfnt.Font) string { if n, err := f.Name(nil, sfnt.NameIDTypographicSubfamily); err == nil && n != "" { return n } n, _ := f.Name(nil, sfnt.NameIDSubfamily) return n } // weightOf maps a subfamily/style string to a CSS numeric weight. func weightOf(sub string) int { s := strings.ToLower(sub) switch { case strings.Contains(s, "thin"), strings.Contains(s, "hairline"): return 100 case strings.Contains(s, "extralight"), strings.Contains(s, "ultralight"): return 200 case strings.Contains(s, "semibold"), strings.Contains(s, "demibold"): return 600 case strings.Contains(s, "extrabold"), strings.Contains(s, "ultrabold"): return 800 case strings.Contains(s, "black"), strings.Contains(s, "heavy"): return 900 case strings.Contains(s, "bold"): return 700 case strings.Contains(s, "medium"): return 500 case strings.Contains(s, "light"): return 300 default: return 400 } } func italicOf(sub string) bool { s := strings.ToLower(sub) return strings.Contains(s, "italic") || strings.Contains(s, "oblique") }