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(``)
+ 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(``)
+ 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(``)
+ 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(``))
+ 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(``))
+ 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(``))
+ 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(``))
+ 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