Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions pdf/svg_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
81 changes: 81 additions & 0 deletions pdf/svg_render_test.go
Original file line number Diff line number Diff line change
@@ -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(`<svg width="80" height="20" xmlns="http://www.w3.org/2000/svg"><text x="10" y="12" font-family="&quot;'Minimal'&quot;" font-size="12">tiny</text></svg>`)
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(`<svg width="80" height="20" xmlns="http://www.w3.org/2000/svg"><text x="10" y="12" font-family="'Missing', Minimal" font-size="12">tiny</text></svg>`)
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(`<svg width="80" height="20" xmlns="http://www.w3.org/2000/svg"><text x="10" y="12" font-family="'Missing', 'AlsoMissing'" font-size="12">tiny</text></svg>`)
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: <text> 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)
}
}
155 changes: 97 additions & 58 deletions svg/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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 {
Expand All @@ -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*\{([^}]*)\}`)
Expand Down
Loading
Loading