From 6acc346bbf492aed506a913d2eaddf249d743efc Mon Sep 17 00:00:00 2001 From: Brent Rowland Date: Sun, 10 May 2026 10:28:24 -0700 Subject: [PATCH] Improve SVG font-family resolution and fallback --- pdf/svg_render.go | 72 +++++++++++++++-- pdf/svg_render_test.go | 81 +++++++++++++++++++ svg/parse.go | 155 ++++++++++++++++++++++-------------- svg/parse_test.go | 84 ++++++++++++++++++- svg/types.go | 5 ++ ttf_fonts/ttf_fonts.go | 65 ++++++++++++++- ttf_fonts/ttf_fonts_test.go | 34 ++++++++ 7 files changed, 429 insertions(+), 67 deletions(-) create mode 100644 pdf/svg_render_test.go diff --git a/pdf/svg_render.go b/pdf/svg_render.go index e61f554..0f65015 100644 --- a/pdf/svg_render.go +++ b/pdf/svg_render.go @@ -1402,17 +1402,75 @@ func (r *svgRenderer) withPreparedTextState(style svg.Style, fn func() error) er }() r.pw.SetFontColor(style.Fill.Color) r.pw.units = UnitConversions["pt"] - if _, err := r.pw.SetFont(style.FontFamily, style.FontSize*r.scaleY, options.Options{ + opts := options.Options{ "style": normalizeFontStyle(style), "weight": normalizeFontWeight(style), - }); err != nil { - logSVGWarnings([]svg.Warning{{Element: "text", Attribute: "font-family", Message: fmt.Sprintf("font %q unavailable: %v", style.FontFamily, err)}}) - return nil } - if fn == nil { - return nil + candidates := svgFontFamilyCandidates(style) + var lastErr error + for i, candidate := range candidates { + if _, err := r.pw.SetFont(candidate.family, style.FontSize*r.scaleY, opts); err != nil { + lastErr = err + continue + } + if i > 0 { + logSVGWarnings([]svg.Warning{{Element: "text", Attribute: "font-family", Message: fmt.Sprintf("font %q unavailable; using fallback %q", candidates[0].label, candidate.label)}}) + } + if fn == nil { + return nil + } + return fn() + } + if lastErr != nil { + labels := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + labels = append(labels, fmt.Sprintf("%q", candidate.label)) + } + logSVGWarnings([]svg.Warning{{Element: "text", Attribute: "font-family", Message: fmt.Sprintf("fonts unavailable: tried %s: %v", strings.Join(labels, ", "), lastErr)}}) + } else { + logSVGWarnings([]svg.Warning{{Element: "text", Attribute: "font-family", Message: "no usable font families"}}) + } + return nil +} + +type svgFontFamilyCandidate struct { + label string + family string +} + +func svgFontFamilyCandidates(style svg.Style) []svgFontFamilyCandidate { + families := style.FontFamilies + if len(families) == 0 && strings.TrimSpace(style.FontFamily) != "" { + families = []string{style.FontFamily} + } + candidates := make([]svgFontFamilyCandidate, 0, len(families)) + for _, family := range families { + family = strings.TrimSpace(family) + if family == "" { + continue + } + candidates = append(candidates, svgFontFamilyCandidate{ + label: family, + family: mapSVGGenericFontFamily(family), + }) + } + if len(candidates) == 0 { + candidates = append(candidates, svgFontFamilyCandidate{label: "Helvetica", family: "Helvetica"}) + } + return candidates +} + +func mapSVGGenericFontFamily(family string) string { + switch strings.ToLower(strings.TrimSpace(family)) { + case "sans-serif": + return "Helvetica" + case "serif": + return "Times" + case "monospace": + return "Courier" + default: + return family } - return fn() } func (r *svgRenderer) resolveTextGradient(ref string, opacityScale float64, text *rich_text.RichText, startX, baselineY float64, transform svg.Transform) (*resolvedSVGGradient, error) { diff --git a/pdf/svg_render_test.go b/pdf/svg_render_test.go new file mode 100644 index 0000000..dfb2085 --- /dev/null +++ b/pdf/svg_render_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 Brent Rowland. +// Use of this source code is governed by the Apache License, Version 2.0, as described in the LICENSE file. + +package pdf + +import ( + "bytes" + "strings" + "testing" + + "github.com/rowland/leadtype/options" +) + +func TestPageWriter_PrintSVG_TextFontFamilyQuotedName(t *testing.T) { + msg := captureStderr(t, func() { + dw := NewDocWriter() + dw.AddFontSource(testFontSource(t, "../ttf/testdata/minimal.ttf")) + pw := newPageWriter(dw, options.Options{"units": "pt"}) + data := []byte(`tiny`) + width := 80.0 + if _, _, err := pw.PrintSVG(data, 0, 0, &width, nil); err != nil { + t.Fatal(err) + } + pw.close() + var buf bytes.Buffer + if _, err := dw.WriteTo(&buf); err != nil { + t.Fatal(err) + } + }) + if strings.Contains(msg, "font-family") { + t.Fatalf("expected quoted font-family to resolve without warnings, got %q", msg) + } +} + +func TestPageWriter_PrintSVG_TextFontFamilyFallbackWarns(t *testing.T) { + msg := captureStderr(t, func() { + dw := NewDocWriter() + dw.AddFontSource(testFontSource(t, "../ttf/testdata/minimal.ttf")) + pw := newPageWriter(dw, options.Options{"units": "pt"}) + data := []byte(`tiny`) + width := 80.0 + if _, _, err := pw.PrintSVG(data, 0, 0, &width, nil); err != nil { + t.Fatal(err) + } + pw.close() + var buf bytes.Buffer + if _, err := dw.WriteTo(&buf); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(msg, `font "Missing" unavailable; using fallback "Minimal"`) { + t.Fatalf("expected fallback warning, got %q", msg) + } + if strings.Contains(msg, "fonts unavailable: tried") { + t.Fatalf("expected successful fallback, got %q", msg) + } +} + +func TestPageWriter_PrintSVG_TextFontFamilyAllMissingWarnsOnce(t *testing.T) { + msg := captureStderr(t, func() { + dw := NewDocWriter() + dw.AddFontSource(testFontSource(t, "../ttf/testdata/minimal.ttf")) + pw := newPageWriter(dw, options.Options{"units": "pt"}) + data := []byte(`tiny`) + width := 80.0 + if _, _, err := pw.PrintSVG(data, 0, 0, &width, nil); err != nil { + t.Fatal(err) + } + pw.close() + var buf bytes.Buffer + if _, err := dw.WriteTo(&buf); err != nil { + t.Fatal(err) + } + }) + if count := strings.Count(msg, "svg: font-family:"); count != 1 { + t.Fatalf("warning count = %d, want 1; msg %q", count, msg) + } + if !strings.Contains(msg, `fonts unavailable: tried "Missing", "AlsoMissing"`) { + t.Fatalf("expected consolidated missing-font warning, got %q", msg) + } +} diff --git a/svg/parse.go b/svg/parse.go index a5cae3d..3240bd1 100644 --- a/svg/parse.go +++ b/svg/parse.go @@ -377,6 +377,9 @@ func parseCommon(doc *Document, attrs map[string]string, viewport ViewBox, eleme common.Transform = transform } common.Style = parseStyleSpecFromProperties(props, viewport) + if warnings := fontFamilyWarnings(element, props["font-family"], common.Style.FontFamilies); len(warnings) > 0 { + doc.Warnings = append(doc.Warnings, warnings...) + } if clipPath := props["clip-path"]; clipPath != "" { common.ClipPathRef = parseURLReference(clipPath) } @@ -389,10 +392,6 @@ func parseCommon(doc *Document, attrs map[string]string, viewport ViewBox, eleme return common, nil } -func parseStyleSpec(attrs map[string]string, viewport ViewBox) StyleSpec { - return parseStyleSpecFromProperties(parseStyleProperties(nil, attrs), viewport) -} - func parseStyleSpecFromProperties(merged map[string]string, viewport ViewBox) StyleSpec { spec := StyleSpec{} if value := merged["fill"]; value != "" { @@ -450,7 +449,11 @@ func parseStyleSpecFromProperties(merged map[string]string, viewport ViewBox) St spec.FillRule = &value } if value := merged["font-family"]; value != "" { - spec.FontFamily = &value + families := parseFontFamilies(value) + if len(families) > 0 { + spec.FontFamilies = families + spec.FontFamily = &families[0] + } } if value := merged["font-size"]; value != "" { if f, err := parseLength(value, viewport.Height); err == nil { @@ -475,66 +478,102 @@ func parseStyleSpecFromProperties(merged map[string]string, viewport ViewBox) St return spec } -func mergeStyleSpec(base, extra StyleSpec) StyleSpec { - out := base - if extra.Fill != nil { - out.Fill = extra.Fill - } - if extra.Stroke != nil { - out.Stroke = extra.Stroke - } - if extra.StrokeWidth != nil { - out.StrokeWidth = extra.StrokeWidth - } - if extra.FillOpacity != nil { - out.FillOpacity = extra.FillOpacity - } - if extra.StrokeOpacity != nil { - out.StrokeOpacity = extra.StrokeOpacity - } - if extra.Opacity != nil { - out.Opacity = extra.Opacity - } - if extra.LineCap != nil { - out.LineCap = extra.LineCap - } - if extra.LineJoin != nil { - out.LineJoin = extra.LineJoin - } - if extra.MiterLimit != nil { - out.MiterLimit = extra.MiterLimit - } - if extra.DashArray != nil { - out.DashArray = extra.DashArray - } - if extra.DashOffset != nil { - out.DashOffset = extra.DashOffset - } - if extra.FillRule != nil { - out.FillRule = extra.FillRule - } - if extra.FontFamily != nil { - out.FontFamily = extra.FontFamily +func parseFontFamilies(value string) []string { + var families []string + for _, entry := range splitFontFamilyList(value) { + family := normalizeFontFamily(entry) + if family == "" { + continue + } + families = append(families, family) } - if extra.FontSize != nil { - out.FontSize = extra.FontSize + return families +} + +func splitFontFamilyList(value string) []string { + var entries []string + var b strings.Builder + var quote rune + escaped := false + for _, r := range value { + switch { + case escaped: + b.WriteRune(r) + escaped = false + case r == '\\' && quote != 0: + escaped = true + case quote != 0: + b.WriteRune(r) + if r == quote { + quote = 0 + } + case r == '\'' || r == '"': + quote = r + b.WriteRune(r) + case r == ',': + entries = append(entries, b.String()) + b.Reset() + default: + b.WriteRune(r) + } + } + entries = append(entries, b.String()) + return entries +} + +func normalizeFontFamily(value string) string { + value = strings.TrimSpace(value) + for { + if len(value) < 2 { + return value + } + first := value[0] + last := value[len(value)-1] + if (first != '\'' && first != '"') || first != last { + return value + } + value = strings.TrimSpace(value[1 : len(value)-1]) } - if extra.FontStyle != nil { - out.FontStyle = extra.FontStyle +} + +func fontFamilyWarnings(element, raw string, families []string) []Warning { + if strings.TrimSpace(raw) == "" { + return nil } - if extra.FontWeight != nil { - out.FontWeight = extra.FontWeight + entries := splitFontFamilyList(raw) + warnings := []Warning{} + if hasUnterminatedFontFamilyQuote(raw) { + warnings = append(warnings, Warning{Element: element, Attribute: "font-family", Message: fmt.Sprintf("malformed family list %q: unterminated quote", raw)}) } - if extra.TextAnchor != nil { - out.TextAnchor = extra.TextAnchor + for _, entry := range entries { + if strings.TrimSpace(entry) == "" || normalizeFontFamily(entry) == "" { + warnings = append(warnings, Warning{Element: element, Attribute: "font-family", Message: fmt.Sprintf("ignored empty family in %q", raw)}) + } } - if extra.BlendMode != nil { - out.BlendMode = extra.BlendMode + if len(families) == 0 { + warnings = append(warnings, Warning{Element: element, Attribute: "font-family", Message: fmt.Sprintf("no usable families in %q", raw)}) } - if extra.Display != nil { - out.Display = extra.Display + return warnings +} + +func hasUnterminatedFontFamilyQuote(value string) bool { + var quote rune + escaped := false + for _, r := range value { + switch { + case escaped: + escaped = false + case r == '\\' && quote != 0: + escaped = true + case quote != 0: + if r == quote { + quote = 0 + } + case r == '\'' || r == '"': + quote = r + } } - return out + return quote != 0 } var cssRuleRE = regexp.MustCompile(`(?s)\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}`) diff --git a/svg/parse_test.go b/svg/parse_test.go index c2c6e90..8b9202a 100644 --- a/svg/parse_test.go +++ b/svg/parse_test.go @@ -1,6 +1,9 @@ package svg -import "testing" +import ( + "strings" + "testing" +) func TestParseRootUsesViewBoxWhenSizeMissing(t *testing.T) { doc, warnings, err := Parse([]byte(``)) @@ -140,6 +143,85 @@ func TestParseInternalStyleClassFill(t *testing.T) { } } +func TestParseFontFamilyCandidates(t *testing.T) { + doc, warnings, err := Parse([]byte(`Hi`)) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %+v", warnings) + } + text, ok := doc.Children[0].(*Text) + if !ok { + t.Fatalf("child type = %T, want *Text", doc.Children[0]) + } + style := text.Style.Resolve(DefaultStyle()) + want := []string{"OpenSans-Regular", "Minimal", "serif"} + if style.FontFamily != want[0] { + t.Fatalf("FontFamily = %q, want %q", style.FontFamily, want[0]) + } + if len(style.FontFamilies) != len(want) { + t.Fatalf("FontFamilies = %#v, want %#v", style.FontFamilies, want) + } + for i := range want { + if style.FontFamilies[i] != want[i] { + t.Fatalf("FontFamilies = %#v, want %#v", style.FontFamilies, want) + } + } +} + +func TestParseFontFamilyDoubleWrappedQuotes(t *testing.T) { + doc, warnings, err := Parse([]byte(`Hi`)) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %+v", warnings) + } + text := doc.Children[0].(*Text) + style := text.Style.Resolve(DefaultStyle()) + if style.FontFamily != "Montserrat-Bold" { + t.Fatalf("FontFamily = %q, want Montserrat-Bold", style.FontFamily) + } +} + +func TestParseFontFamilyEmptyEntriesWarn(t *testing.T) { + doc, warnings, err := Parse([]byte(`Hi`)) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 1 { + t.Fatalf("warnings = %+v, want one empty-family warning", warnings) + } + if warnings[0].Attribute != "font-family" { + t.Fatalf("warning attribute = %q, want font-family", warnings[0].Attribute) + } + text := doc.Children[0].(*Text) + style := text.Style.Resolve(DefaultStyle()) + want := []string{"Missing", "Minimal"} + if len(style.FontFamilies) != len(want) { + t.Fatalf("FontFamilies = %#v, want %#v", style.FontFamilies, want) + } + for i := range want { + if style.FontFamilies[i] != want[i] { + t.Fatalf("FontFamilies = %#v, want %#v", style.FontFamilies, want) + } + } +} + +func TestParseFontFamilyMalformedQuoteWarns(t *testing.T) { + _, warnings, err := Parse([]byte(`Hi`)) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 1 { + t.Fatalf("warnings = %+v, want one malformed-family warning", warnings) + } + if warnings[0].Attribute != "font-family" || !strings.Contains(warnings[0].Message, "unterminated quote") { + t.Fatalf("warning = %+v, want unterminated font-family warning", warnings[0]) + } +} + func TestParseLinearGradientAndURLPaint(t *testing.T) { doc, warnings, err := Parse([]byte(``)) if err != nil { diff --git a/svg/types.go b/svg/types.go index 14082da..0d0e702 100644 --- a/svg/types.go +++ b/svg/types.go @@ -286,6 +286,7 @@ type StyleSpec struct { DashOffset *float64 FillRule *string FontFamily *string + FontFamilies []string FontSize *float64 FontStyle *string FontWeight *string @@ -308,6 +309,7 @@ type Style struct { DashOffset float64 FillRule string FontFamily string + FontFamilies []string FontSize float64 FontStyle string FontWeight string @@ -385,6 +387,9 @@ func (spec StyleSpec) Resolve(parent Style) Style { if spec.FontFamily != nil { out.FontFamily = *spec.FontFamily } + if spec.FontFamilies != nil { + out.FontFamilies = append([]string(nil), spec.FontFamilies...) + } if spec.FontSize != nil { out.FontSize = *spec.FontSize } diff --git a/ttf_fonts/ttf_fonts.go b/ttf_fonts/ttf_fonts.go index e028993..21aefb5 100644 --- a/ttf_fonts/ttf_fonts.go +++ b/ttf_fonts/ttf_fonts.go @@ -198,7 +198,7 @@ func (fc *TtfFonts) Select(family, weight, style string, ranges []string) (fontM } search: for _, f := range fc.FontInfos { - if strings.EqualFold(f.Family(), family) && strings.EqualFold(f.Style(), ws) { + if ttfFontInfoMatches(f, family, ws) { for _, r := range ranges { cpr, ok := ttf.CodepointRangesByName[r] if !ok || !f.CharRanges().IsSet(int(cpr.Bit)) { @@ -219,6 +219,69 @@ search: return } +func ttfFontInfoMatches(f *ttf.FontInfo, family, weightStyle string) bool { + if f == nil { + return false + } + if strings.EqualFold(f.Family(), family) && strings.EqualFold(f.Style(), weightStyle) { + return true + } + _, _, embedsStyle := splitPostScriptStyleName(family) + if (strings.EqualFold(f.PostScriptName(), family) || strings.EqualFold(f.FullName(), family)) && (styleCompatible(f.Style(), weightStyle) || embedsStyle) { + return true + } + base, inferredStyle, ok := splitPostScriptStyleName(family) + if !ok { + return false + } + return normalizedFontName(f.Family()) == normalizedFontName(base) && strings.EqualFold(f.Style(), inferredStyle) +} + +func styleCompatible(fontStyle, requestedStyle string) bool { + return requestedStyle == "" || strings.EqualFold(requestedStyle, "Regular") || strings.EqualFold(fontStyle, requestedStyle) +} + +func splitPostScriptStyleName(name string) (base, style string, ok bool) { + name = strings.TrimSpace(name) + for _, sep := range []string{"-", " "} { + for _, suffix := range []struct { + text string + style string + }{ + {"BoldItalic", "Bold Italic"}, + {"BoldOblique", "Bold Oblique"}, + {"Regular", "Regular"}, + {"Italic", "Italic"}, + {"Oblique", "Oblique"}, + {"Bold", "Bold"}, + } { + token := sep + suffix.text + if len(name) <= len(token) || !strings.EqualFold(name[len(name)-len(token):], token) { + continue + } + base = strings.TrimSpace(name[:len(name)-len(token)]) + if base == "" { + return "", "", false + } + return base, suffix.style, true + } + } + return "", "", false +} + +func normalizedFontName(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + switch r { + case ' ', '-', '_': + continue + default: + b.WriteRune(r) + } + } + return b.String() +} + func (fc *TtfFonts) SubType() string { return "TrueType" } diff --git a/ttf_fonts/ttf_fonts_test.go b/ttf_fonts/ttf_fonts_test.go index 06c320a..7de17f8 100644 --- a/ttf_fonts/ttf_fonts_test.go +++ b/ttf_fonts/ttf_fonts_test.go @@ -74,6 +74,40 @@ func TestTtfFonts(t *testing.T) { } } +func TestTtfFonts_SelectFixtureByPostScriptAndFullNames(t *testing.T) { + fc, err := New("../ttf/testdata/minimal.ttc") + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + family string + weight string + style string + postscriptName string + }{ + {"postscript regular", "Minimal", "", "", "Minimal"}, + {"postscript bold", "Minimal-Bold", "", "", "Minimal-Bold"}, + {"full name bold", "Minimal Bold", "", "", "Minimal-Bold"}, + {"postscript-style inferred", "Minimal-Bold", "Bold", "", "Minimal-Bold"}, + {"case-insensitive postscript", "minimal-bold", "", "", "Minimal-Bold"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f, err := fc.Select(test.family, test.weight, test.style, nil) + if err != nil { + t.Fatal(err) + } + if f.PostScriptName() != test.postscriptName { + t.Fatalf("PostScriptName = %q, want %q", f.PostScriptName(), test.postscriptName) + } + }) + } + if _, err := fc.Select("Minimal", "", "Italic", nil); err == nil { + t.Fatal("expected exact PostScript name match not to override explicitly requested Italic style") + } +} + // 81,980,000 ns // 45,763,220 ns // 44,562,080 ns