Skip to content

Commit cfbfcb6

Browse files
committed
Support text clipPaths in SVG rendering
1 parent 3751d9f commit cfbfcb6

3 files changed

Lines changed: 211 additions & 63 deletions

File tree

pdf/svg_render.go

Lines changed: 130 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,13 @@ func (r *svgRenderer) withClip(clipRef string, parentTransform svg.Transform, st
315315
return fn()
316316
}
317317
clipStyle := clip.Style.Resolve(style)
318+
if len(clip.Children) == 1 {
319+
if textNode, ok := clip.Children[0].(*svg.Text); ok {
320+
textStyle := textNode.Style.Resolve(clipStyle)
321+
textTransform := parentTransform.Mul(textNode.NodeCommon().Transform)
322+
return r.clipWithText(textNode, textStyle, textTransform, fn)
323+
}
324+
}
318325
var renderErr error
319326
err := r.pw.Path(func() {
320327
for _, child := range clip.Children {
@@ -339,6 +346,26 @@ func (r *svgRenderer) withClip(clipRef string, parentTransform svg.Transform, st
339346
return renderErr
340347
}
341348

349+
func (r *svgRenderer) clipWithText(text *svg.Text, style svg.Style, transform svg.Transform, fn func() error) error {
350+
layout, err := r.layoutText(text, style, transform)
351+
if err != nil || layout == nil {
352+
return err
353+
}
354+
return r.withPreparedTextState(style, func() error {
355+
r.pw.MoveTo(layout.startX, layout.mappedY)
356+
var renderErr error
357+
err := r.pw.ClipRichText(layout.rich, func() {
358+
if fn != nil {
359+
renderErr = fn()
360+
}
361+
})
362+
if err != nil {
363+
return err
364+
}
365+
return renderErr
366+
})
367+
}
368+
342369
func (r *svgRenderer) pathForNode(node svg.Node) []svg.Segment {
343370
switch n := node.(type) {
344371
case *svg.Line:
@@ -1253,68 +1280,28 @@ func normalizeFontWeight(style svg.Style) string {
12531280
}
12541281

12551282
func (r *svgRenderer) drawText(text *svg.Text, style svg.Style, transform svg.Transform) error {
1256-
if text.Body == "" {
1257-
return nil
1258-
}
1259-
if transform.B != 0 || transform.C != 0 || transform.A != 1 || transform.D != 1 {
1260-
logSVGWarnings([]svg.Warning{{Element: "text", Message: "text transforms beyond translation are not yet implemented"}})
1261-
}
1262-
point := transform.Apply(svg.Point{X: text.X, Y: text.Y})
1263-
mapped := r.mapPoint(point)
1264-
savedFonts := append([]*font.Font(nil), r.pw.fonts...)
1265-
savedFontSize := r.pw.fontSize
1266-
savedFontColor := r.pw.fontColor
1267-
prevUnits := r.pw.units
1268-
defer func() {
1269-
r.pw.fonts = savedFonts
1270-
r.pw.fontSize = savedFontSize
1271-
r.pw.fontColor = savedFontColor
1272-
r.pw.units = prevUnits
1273-
}()
1274-
r.pw.SetFontColor(style.Fill.Color)
1275-
r.pw.units = UnitConversions["pt"]
1276-
if _, err := r.pw.SetFont(style.FontFamily, style.FontSize*r.scaleY, options.Options{
1277-
"style": normalizeFontStyle(style),
1278-
"weight": normalizeFontWeight(style),
1279-
}); err != nil {
1280-
logSVGWarnings([]svg.Warning{{Element: "text", Attribute: "font-family", Message: fmt.Sprintf("font %q unavailable: %v", style.FontFamily, err)}})
1281-
return nil
1282-
}
1283-
rich, err := r.pw.richTextForString(text.Body)
1284-
if err != nil {
1283+
layout, err := r.layoutText(text, style, transform)
1284+
if err != nil || layout == nil {
12851285
return err
12861286
}
1287-
if rich == nil || rich.Len() == 0 {
1288-
return nil
1289-
}
1290-
startX := mapped.X
1291-
startXSVG := point.X
1292-
if style.TextAnchor != "" && style.TextAnchor != "start" {
1293-
switch strings.ToLower(style.TextAnchor) {
1294-
case "middle":
1295-
startX -= rich.Width() / 2
1296-
startXSVG -= r.textWidthSVG(rich) / 2
1297-
case "end":
1298-
startX -= rich.Width()
1299-
startXSVG -= r.textWidthSVG(rich)
1300-
}
1301-
}
13021287
blendMode := mapSVGBlendMode(style.BlendMode)
13031288
alpha := clamp01(style.Opacity * style.FillOpacity)
13041289
if style.Stroke.IsGradient() {
13051290
logSVGWarnings([]svg.Warning{{Element: "text", Attribute: "stroke", Message: "gradient stroke on text is not yet implemented"}})
13061291
}
13071292
if style.Fill.IsGradient() {
1308-
resolved, err := r.resolveTextGradient(style.Fill.Ref, alpha, rich, startXSVG, point.Y, transform)
1293+
resolved, err := r.resolveTextGradient(style.Fill.Ref, alpha, layout.rich, layout.startXSVG, layout.baselineY, transform)
13091294
if err != nil {
13101295
logSVGWarnings([]svg.Warning{{Element: "linearGradient", Message: err.Error()}})
13111296
if style.Fill.None {
13121297
return nil
13131298
}
13141299
return r.withScopedGraphicsState(alpha, 1, blendMode, nil, "", nil, func() error {
1315-
r.pw.SetFontColor(style.Fill.Color)
1316-
r.pw.MoveTo(startX, mapped.Y)
1317-
return r.pw.Print(text.Body)
1300+
return r.withPreparedTextState(style, func() error {
1301+
r.pw.SetFontColor(style.Fill.Color)
1302+
r.pw.MoveTo(layout.startX, layout.mappedY)
1303+
return r.pw.Print(text.Body)
1304+
})
13181305
})
13191306
}
13201307
if resolved.varyingAlpha {
@@ -1328,32 +1315,112 @@ func (r *svgRenderer) drawText(text *svg.Text, style svg.Style, transform svg.Tr
13281315
alpha = resolved.uniformAlpha
13291316
}
13301317
return r.withScopedGraphicsState(alpha, 1, blendMode, nil, "", nil, func() error {
1331-
r.pw.MoveTo(startX, mapped.Y)
1332-
var renderErr error
1333-
err := r.pw.ClipRichText(rich, func() {
1334-
switch {
1335-
case resolved.linear != nil:
1336-
renderErr = r.pw.PaintLinearGradient(resolved.linear)
1337-
case resolved.radial != nil:
1338-
renderErr = r.pw.PaintRadialGradient(resolved.radial)
1318+
return r.withPreparedTextState(style, func() error {
1319+
r.pw.MoveTo(layout.startX, layout.mappedY)
1320+
var renderErr error
1321+
err := r.pw.ClipRichText(layout.rich, func() {
1322+
switch {
1323+
case resolved.linear != nil:
1324+
renderErr = r.pw.PaintLinearGradient(resolved.linear)
1325+
case resolved.radial != nil:
1326+
renderErr = r.pw.PaintRadialGradient(resolved.radial)
1327+
}
1328+
})
1329+
if err != nil {
1330+
return err
13391331
}
1332+
return renderErr
13401333
})
1341-
if err != nil {
1342-
return err
1343-
}
1344-
return renderErr
13451334
})
13461335
}
13471336
if style.Fill.None {
13481337
return nil
13491338
}
13501339
return r.withScopedGraphicsState(alpha, 1, blendMode, nil, "", nil, func() error {
1351-
r.pw.SetFontColor(style.Fill.Color)
1352-
r.pw.MoveTo(startX, mapped.Y)
1353-
return r.pw.Print(text.Body)
1340+
return r.withPreparedTextState(style, func() error {
1341+
r.pw.SetFontColor(style.Fill.Color)
1342+
r.pw.MoveTo(layout.startX, layout.mappedY)
1343+
return r.pw.Print(text.Body)
1344+
})
13541345
})
13551346
}
13561347

1348+
type svgTextLayout struct {
1349+
rich *rich_text.RichText
1350+
startX float64
1351+
mappedY float64
1352+
startXSVG float64
1353+
baselineY float64
1354+
}
1355+
1356+
func (r *svgRenderer) layoutText(text *svg.Text, style svg.Style, transform svg.Transform) (*svgTextLayout, error) {
1357+
if text.Body == "" {
1358+
return nil, nil
1359+
}
1360+
if transform.B != 0 || transform.C != 0 || transform.A != 1 || transform.D != 1 {
1361+
logSVGWarnings([]svg.Warning{{Element: "text", Message: "text transforms beyond translation are not yet implemented"}})
1362+
}
1363+
point := transform.Apply(svg.Point{X: text.X, Y: text.Y})
1364+
mapped := r.mapPoint(point)
1365+
var rich *rich_text.RichText
1366+
err := r.withPreparedTextState(style, func() error {
1367+
var err error
1368+
rich, err = r.pw.richTextForString(text.Body)
1369+
return err
1370+
})
1371+
if err != nil {
1372+
return nil, err
1373+
}
1374+
if rich == nil || rich.Len() == 0 {
1375+
return nil, nil
1376+
}
1377+
startX := mapped.X
1378+
startXSVG := point.X
1379+
if style.TextAnchor != "" && style.TextAnchor != "start" {
1380+
switch strings.ToLower(style.TextAnchor) {
1381+
case "middle":
1382+
startX -= rich.Width() / 2
1383+
startXSVG -= r.textWidthSVG(rich) / 2
1384+
case "end":
1385+
startX -= rich.Width()
1386+
startXSVG -= r.textWidthSVG(rich)
1387+
}
1388+
}
1389+
return &svgTextLayout{
1390+
rich: rich,
1391+
startX: startX,
1392+
mappedY: mapped.Y,
1393+
startXSVG: startXSVG,
1394+
baselineY: point.Y,
1395+
}, nil
1396+
}
1397+
1398+
func (r *svgRenderer) withPreparedTextState(style svg.Style, fn func() error) error {
1399+
savedFonts := append([]*font.Font(nil), r.pw.fonts...)
1400+
savedFontSize := r.pw.fontSize
1401+
savedFontColor := r.pw.fontColor
1402+
prevUnits := r.pw.units
1403+
defer func() {
1404+
r.pw.fonts = savedFonts
1405+
r.pw.fontSize = savedFontSize
1406+
r.pw.fontColor = savedFontColor
1407+
r.pw.units = prevUnits
1408+
}()
1409+
r.pw.SetFontColor(style.Fill.Color)
1410+
r.pw.units = UnitConversions["pt"]
1411+
if _, err := r.pw.SetFont(style.FontFamily, style.FontSize*r.scaleY, options.Options{
1412+
"style": normalizeFontStyle(style),
1413+
"weight": normalizeFontWeight(style),
1414+
}); err != nil {
1415+
logSVGWarnings([]svg.Warning{{Element: "text", Attribute: "font-family", Message: fmt.Sprintf("font %q unavailable: %v", style.FontFamily, err)}})
1416+
return nil
1417+
}
1418+
if fn == nil {
1419+
return nil
1420+
}
1421+
return fn()
1422+
}
1423+
13571424
func (r *svgRenderer) resolveTextGradient(ref string, opacityScale float64, text *rich_text.RichText, startX, baselineY float64, transform svg.Transform) (*resolvedSVGGradient, error) {
13581425
if text == nil {
13591426
return nil, fmt.Errorf("gradient text has no content")
Lines changed: 27 additions & 0 deletions
Loading
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright 2026 Brent Rowland.
2+
// Use of this source code is governed by the Apache License, Version 2.0, as described in the LICENSE file.
3+
4+
package main
5+
6+
import (
7+
"fmt"
8+
9+
"github.com/rowland/leadtype/options"
10+
"github.com/rowland/leadtype/pdf"
11+
"github.com/rowland/leadtype/ttf_fonts"
12+
)
13+
14+
func init() {
15+
registerSample("test_026_svg_text_gradient_clip", "demonstrate SVG text used as a clip path for gradient paint", runTest026SVGTextGradientClip)
16+
}
17+
18+
func runTest026SVGTextGradientClip() (string, error) {
19+
return writeDoc("test_026_svg_text_gradient_clip.pdf", func(doc *pdf.DocWriter) error {
20+
doc.SetUnits("in")
21+
doc.NewPage()
22+
23+
ttfc, err := ttf_fonts.NewFromSystemFonts()
24+
if err == nil && ttfc.Len() > 0 {
25+
doc.AddFontSource(ttfc)
26+
if _, err := doc.SetFont("Arial", 12, options.Options{}); err == nil {
27+
doc.MoveTo(0.7, 0.6)
28+
fmt.Fprintln(doc, "SVG text as a clip path")
29+
}
30+
}
31+
32+
widthLarge := 4.8
33+
if _, _, err := doc.PrintSVGFile("pdf/testdata/test_scene_svg_text_gradient_clip.svg", 0.8, 1.0, &widthLarge, nil); err != nil {
34+
return err
35+
}
36+
37+
if _, err := doc.SetFont("Arial", 10, options.Options{}); err == nil {
38+
doc.MoveTo(1.0, 5.35)
39+
fmt.Fprintln(doc, "Top panel uses <text> inside <clipPath> to reveal the gradient; bottom panel uses the same-sized word without clipping.")
40+
}
41+
42+
widthSmall := 2.9
43+
if _, _, err := doc.PrintImageFile("pdf/testdata/test_scene_svg_text_gradient_clip.svg", 1.75, 5.8, &widthSmall, nil); err != nil {
44+
return err
45+
}
46+
47+
if _, err := doc.SetFont("Arial", 10, options.Options{}); err == nil {
48+
doc.MoveTo(1.9, 8.65)
49+
fmt.Fprintln(doc, "Same asset via SVG-compatible image placement.")
50+
}
51+
52+
return nil
53+
})
54+
}

0 commit comments

Comments
 (0)