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)
+ }
+}