▍ humdrum codex / glint v1.0.2
license AGPL-3.0

fix(export): embed configured fonts so they render in WebKit (TASK-021)

ef0997fe6fe0fd6c5a8902ba44657684cf30a3be
humdrum <me@humdrum.me> · 2026-06-29 21:01

parent 4a59f925

fix(export): embed configured fonts so they render in WebKit (TASK-021)

WebKit (Safari/Orion) refuses to render locally-installed user fonts
(~/Library/Fonts) referenced by name — an anti-fingerprinting policy —
so custom font_display/body/mono values fell back to the default serif.
The shared kit avoids this by embedding the font bytes; glint now does
the same.

- internal/export/fonts.go: scan user font dirs, parse each font's sfnt
  name table (golang.org/x/image/font/sfnt), inline matching faces as
  base64 @font-face. Bounded to regular+bold × roman+italic so a
  many-weight family stays ~3MB, not 10MB. Best-effort: fonts not found
  on disk fall back to name reference (works in Chrome/Firefox); nothing
  licensed is bundled in the binary.
- quote multi-word family names in the :root override (WebKit var quirk).

Default fonts (Georgia / system-ui / ui-monospace) are WebKit-allowed,
so only custom configured fonts were affected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

7 files changed

- → PDF-printable-export-in-the-house-style.md +4 −2
@@ -1,10 +1,10 @@
 ---
 id: TASK-021
 title: PDF / printable export in the house style
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 20:49'
-updated_date: '2026-06-30 03:23'
+updated_date: '2026-06-30 04:01'
 labels:
   - feature
   - release-1
@@ -58,4 +58,6 @@
 Packaging: glint must be self-contained. Copy/build the required kit pieces (doc.css + the @media print rules, the render HTML skeleton, only redistributable fonts) into the glint repo (e.g. internal/export/assets/) and embed them with go:embed so they ship inside the binary. Do NOT depend on /Users/kortum/Developer/Home/_shared-app-kit/markdown-doc-kit at runtime — that path doesn't exist on users' machines. Decide a vendor/sync story (a script that re-copies from the kit on update, or a one-time fork) so the embedded copy can be refreshed when the kit's house style changes; strip the licensed Awke/Untitled Sans/Name Mono @font-face blocks during that copy, leaving the --font-* tokens + fallbacks (ties to the configurable-fonts AC).
 
 Implemented: internal/export package (TDD, 17 tests). Document() renders md via goldmark GFM, strips YAML frontmatter, postprocesses (cover-wrap leading h1+subtitle, task-list-item class, {.page-break} heading suffix->class), embeds vendored doc.css (go:embed) + injects configured --font-* tokens. MapTheme glint->kit. Write()/OutputPath()/Title()/OpenInBrowser() for file+browser. Ctrl+E wired in app.exportPDF (writes <base>.html next to file, opens browser, graceful path-print fallback if open fails). config: font_display/body/mono keys w/ portable defaults (Georgia/system-ui/ui-monospace); wizard preserves them. assets/sync.sh re-vendors doc.css from kit + strips licensed faces (Awke/Untitled Sans/Name Mono). README Export section + help overlay updated. Fixed bug found in real output: heading {.class} regex used (?s) dotall and leaked page-break across to an earlier heading w/ mismatched close tag; removed dotall.
+
+Follow-up fix: WebKit (Safari/Orion) blocks locally-installed user fonts (~/Library/Fonts) from web content, so custom configured fonts rendered as default serif. Added font embedding (internal/export/fonts.go): scans user font dirs, parses sfnt name tables (golang.org/x/image/font/sfnt), inlines matching faces as base64 @font-face (bounded to regular+bold x roman+italic, ~3MB). Best-effort + portable: unfound fonts fall back to name reference; binary still bundles nothing licensed. Also quote multi-word family names in the CSS override (WebKit var() quirk). Default fonts (Georgia/system-ui/ui-monospace) are WebKit-allowed so default exports were unaffected.
 <!-- SECTION:NOTES:END -->
go.mod +2 −1
@@ -13,6 +13,7 @@ 	github.com/charmbracelet/huh v1.0.0
 	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
 	github.com/muesli/termenv v0.16.0
 	github.com/yuin/goldmark v1.7.13
+	golang.org/x/image v0.43.0
 )
 
 require (
@@ -47,5 +48,5 @@ 	github.com/yuin/goldmark-emoji v1.0.6 // indirect
 	golang.org/x/net v0.38.0 // indirect
 	golang.org/x/sys v0.38.0 // indirect
 	golang.org/x/term v0.36.0 // indirect
-	golang.org/x/text v0.30.0 // indirect
+	golang.org/x/text v0.38.0 // indirect
 )
go.sum +4 −2
@@ -101,6 +101,8 @@ github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
 github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
+golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
 golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
 golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -109,5 +111,5 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
 golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
 golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
-golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
-golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
internal/export/export.go +27 −1
@@ -61,6 +61,9 @@ 	b.WriteString("<head>\n")
 	b.WriteString(`<meta charset="utf-8">` + "\n")
 	b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` + "\n")
 	fmt.Fprintf(&b, "<title>%s</title>\n", html.EscapeString(opts.Title))
+	if faces := embedFontFaces(opts); faces != "" {
+		fmt.Fprintf(&b, "<style>%s</style>\n", faces)
+	}
 	fmt.Fprintf(&b, "<style>%s</style>\n", docCSS)
 	fmt.Fprintf(&b, "<style>%s</style>\n", fontOverride(opts))
 	b.WriteString(`<style>html,body{margin:0;background:var(--bg);}</style>` + "\n")
@@ -94,8 +97,31 @@ // licensed faces.
 func fontOverride(opts Options) string {
 	return fmt.Sprintf(
 		":root{--font-display:%s;--font-body:%s;--font-mono:%s;}",
-		opts.FontDisplay, opts.FontBody, opts.FontMono,
+		cssFontStack(opts.FontDisplay), cssFontStack(opts.FontBody), cssFontStack(opts.FontMono),
 	)
+}
+
+// cssFontStack normalizes a CSS font-family stack so multi-word family names
+// are quoted. A bare `Untitled Sans` substituted through a custom property
+// fails to match in some engines (notably WebKit); quoting makes it robust.
+// Already-quoted segments and single-token names (incl. generics like
+// sans-serif, system-ui) are left as-is.
+func cssFontStack(stack string) string {
+	if strings.TrimSpace(stack) == "" {
+		return ""
+	}
+	var out []string
+	for _, seg := range strings.Split(stack, ",") {
+		seg = strings.TrimSpace(seg)
+		if seg == "" {
+			continue
+		}
+		if !strings.HasPrefix(seg, `"`) && !strings.HasPrefix(seg, `'`) && strings.ContainsAny(seg, " \t") {
+			seg = `"` + seg + `"`
+		}
+		out = append(out, seg)
+	}
+	return strings.Join(out, ", ")
 }
 
 // stripFrontmatter drops a leading YAML frontmatter block (--- ... ---) so it
internal/export/export_test.go +37 −1
@@ -130,7 +130,9 @@ 	html, err := Document("x", opts)
 	if err != nil {
 		t.Fatal(err)
 	}
-	for _, want := range []string{"Charter, serif", "Inter, sans-serif", "Fira Code, monospace"} {
+	// Multi-word names are quoted (Fira Code → "Fira Code"); single-word names
+	// and generics stay bare.
+	for _, want := range []string{"Charter, serif", "Inter, sans-serif", `"Fira Code", monospace`} {
 		if !strings.Contains(html, want) {
 			t.Errorf("configured font %q not injected", want)
 		}
@@ -154,6 +156,40 @@ 		t.Errorf("YAML frontmatter rendered into body:\n%s", html)
 	}
 	if !strings.Contains(html, "Real Title") {
 		t.Errorf("content after frontmatter lost")
+	}
+}
+
+func TestCSSFontStackQuotesMultiWordNames(t *testing.T) {
+	cases := map[string]string{
+		"Awke":                                 "Awke",                       // single ident — fine bare
+		"Untitled Sans":                        `"Untitled Sans"`,            // space → must quote
+		"Maple Mono":                           `"Maple Mono"`,               // space → must quote
+		"Inter, system-ui, sans-serif":         "Inter, system-ui, sans-serif",
+		"Maple Mono, ui-monospace, monospace":  `"Maple Mono", ui-monospace, monospace`,
+		`"Untitled Sans", sans-serif`:          `"Untitled Sans", sans-serif`, // already quoted — leave
+		"'Already Quoted'":                     "'Already Quoted'",            // single-quoted — leave
+		"":                                     "",
+	}
+	for in, want := range cases {
+		if got := cssFontStack(in); got != want {
+			t.Errorf("cssFontStack(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestDocumentQuotesMultiWordFonts(t *testing.T) {
+	opts := testOpts()
+	opts.FontBody = "Untitled Sans"
+	opts.FontMono = "Maple Mono"
+	html, err := Document("x", opts)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(html, `--font-body:"Untitled Sans"`) {
+		t.Errorf("multi-word body font not quoted in override:\n%s", firstLines(between(html, "--font-display", "</style>"), 1))
+	}
+	if !strings.Contains(html, `--font-mono:"Maple Mono"`) {
+		t.Errorf("multi-word mono font not quoted in override")
 	}
 }
 
internal/export/fonts.go +193 −0
@@ -0,0 +1,193 @@
+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")
+}
internal/export/fonts_test.go +79 −0
@@ -0,0 +1,79 @@
+package export
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestPrimaryFamily(t *testing.T) {
+	cases := map[string]string{
+		"Awke":                                 "Awke",
+		"Untitled Sans":                        "Untitled Sans",
+		"Inter, system-ui, sans-serif":         "Inter",
+		`"Untitled Sans", sans-serif`:          "Untitled Sans",
+		"'Maple Mono', ui-monospace":           "Maple Mono",
+		"  Spaced  ,  x":                       "Spaced",
+		"":                                     "",
+		"system-ui":                            "", // generic-only → nothing to embed
+		"Georgia, serif":                       "Georgia",
+	}
+	for in, want := range cases {
+		if got := primaryFamily(in); got != want {
+			t.Errorf("primaryFamily(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestWeightAndItalicOf(t *testing.T) {
+	cases := []struct {
+		sub    string
+		weight int
+		italic bool
+	}{
+		{"Regular", 400, false},
+		{"Bold", 700, false},
+		{"Italic", 400, true},
+		{"Bold Italic", 700, true},
+		{"Thin", 100, false},
+		{"Light", 300, false},
+		{"Medium", 500, false},
+		{"SemiBold", 600, false},
+		{"Black", 900, false},
+		{"Bold Oblique", 700, true},
+	}
+	for _, c := range cases {
+		if w := weightOf(c.sub); w != c.weight {
+			t.Errorf("weightOf(%q) = %d, want %d", c.sub, w, c.weight)
+		}
+		if it := italicOf(c.sub); it != c.italic {
+			t.Errorf("italicOf(%q) = %v, want %v", c.sub, it, c.italic)
+		}
+	}
+}
+
+func TestFontFaceCSS(t *testing.T) {
+	f := fontFace{family: "Untitled Sans", weight: 700, italic: true, ext: ".otf", data: []byte("FAKE")}
+	css := f.faceCSS()
+	for _, want := range []string{
+		`font-family:"Untitled Sans"`,
+		"font-weight:700",
+		"font-style:italic",
+		`format("opentype")`,
+		"data:font/otf;base64,RkFLRQ==", // base64("FAKE")
+	} {
+		if !strings.Contains(css, want) {
+			t.Errorf("@font-face missing %q:\n%s", want, css)
+		}
+	}
+}
+
+func TestFontFaceCSSTruetype(t *testing.T) {
+	f := fontFace{family: "Maple Mono", weight: 400, italic: false, ext: ".ttf", data: []byte("x")}
+	css := f.faceCSS()
+	if !strings.Contains(css, `format("truetype")`) {
+		t.Errorf("ttf should be truetype format:\n%s", css)
+	}
+	if !strings.Contains(css, "data:font/ttf;base64,") {
+		t.Errorf("ttf mime wrong:\n%s", css)
+	}
+}