From 94d0961905c3a234537adb1e565ab133cb829944 Mon Sep 17 00:00:00 2001 From: Masaaki Goshima Date: Sat, 19 Oct 2024 15:32:43 +0900 Subject: [PATCH] support font --- alias.go | 1 + cgraph/attribute.go | 19 ++++- cmd/dot/go.mod | 2 + cmd/dot/go.sum | 4 + go.mod | 6 +- go.sum | 4 + graphviz.go | 5 -- gvc/gvc.go | 37 +++++++++ gvc/image_renderer.go | 176 +++++++++++++++++++++++++++++++++++------- gvc/render_plugin.go | 26 +++++-- 10 files changed, 239 insertions(+), 41 deletions(-) diff --git a/alias.go b/alias.go index 81dbc5e..318d174 100644 --- a/alias.go +++ b/alias.go @@ -332,6 +332,7 @@ var ( // functions from gvc package. var ( + SetFontLoader = gvc.SetFontLoader DefaultPlugins = gvc.DefaultPlugins DeviceQuality = gvc.WithDeviceQuality DeviceFeatures = gvc.WithDeviceFeatures diff --git a/cgraph/attribute.go b/cgraph/attribute.go index 690a976..753b0bb 100644 --- a/cgraph/attribute.go +++ b/cgraph/attribute.go @@ -1071,6 +1071,11 @@ func (g *Graph) SetInputScale(v float64) *Graph { return g } +// Label returns label attribute. +func (g *Graph) Label() (string, error) { + return g.GetStr(string(labelAttr)) +} + // SetLabel // Text label attached to objects. // If a node's shape is record, then the label can have a special format which describes the record layout. @@ -1080,10 +1085,15 @@ func (g *Graph) SetInputScale(v float64) *Graph { // To get an HTML-like label, the label attribute value itself must be an HTML string. // https://graphviz.gitlab.io/_pages/doc/info/attrs.html#a:label func (g *Graph) SetLabel(v string) *Graph { - g.SafeSet(string(labelAttr), v, "") + g.SafeSet(string(labelAttr), v, "\\G") return g } +// Label returns label attribute. +func (n *Node) Label() (string, error) { + return n.GetStr(string(labelAttr)) +} + // SetLabel // Text label attached to objects. // If a node's shape is record, then the label can have a special format which describes the record layout. @@ -1097,6 +1107,11 @@ func (n *Node) SetLabel(v string) *Node { return n } +// Label returns label attribute. +func (e *Edge) Label() (string, error) { + return e.GetStr(string(labelAttr)) +} + // SetLabel // Text label attached to objects. // If a node's shape is record, then the label can have a special format which describes the record layout. @@ -1106,7 +1121,7 @@ func (n *Node) SetLabel(v string) *Node { // To get an HTML-like label, the label attribute value itself must be an HTML string. // https://graphviz.gitlab.io/_pages/doc/info/attrs.html#a:label func (e *Edge) SetLabel(v string) *Edge { - e.SafeSet(string(labelAttr), v, "") + e.SafeSet(string(labelAttr), v, "\\E") return e } diff --git a/cmd/dot/go.mod b/cmd/dot/go.mod index e044f08..c9f1d80 100644 --- a/cmd/dot/go.mod +++ b/cmd/dot/go.mod @@ -11,9 +11,11 @@ require ( ) require ( + github.com/flopp/go-findfont v0.1.0 // indirect github.com/fogleman/gg v1.3.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/tetratelabs/wazero v1.8.1 // indirect golang.org/x/image v0.21.0 // indirect golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect ) diff --git a/cmd/dot/go.sum b/cmd/dot/go.sum index 3a130ff..f6bda59 100644 --- a/cmd/dot/go.sum +++ b/cmd/dot/go.sum @@ -1,5 +1,7 @@ github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= +github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= +github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -16,3 +18,5 @@ golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= diff --git a/go.mod b/go.mod index 5e5b01e..a07c4bc 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,14 @@ go 1.22.0 require ( github.com/corona10/goimagehash v1.1.0 + github.com/flopp/go-findfont v0.1.0 github.com/fogleman/gg v1.3.0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/tetratelabs/wazero v1.8.1 golang.org/x/image v0.21.0 ) -require github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect +require ( + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + golang.org/x/text v0.19.0 // indirect +) diff --git a/go.sum b/go.sum index 3ff5d5b..f7a2665 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= +github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= +github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -10,3 +12,5 @@ github.com/tetratelabs/wazero v1.8.1 h1:NrcgVbWfkWvVc4UtT4LRLDf91PsOzDzefMdwhLfA github.com/tetratelabs/wazero v1.8.1/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= diff --git a/graphviz.go b/graphviz.go index 0627154..d20371d 100644 --- a/graphviz.go +++ b/graphviz.go @@ -7,7 +7,6 @@ import ( "github.com/goccy/go-graphviz/cgraph" "github.com/goccy/go-graphviz/gvc" - "golang.org/x/image/font" ) type Graphviz struct { @@ -75,10 +74,6 @@ func (g *Graphviz) SetLayout(layout Layout) *Graphviz { return g } -func (g *Graphviz) SetFontFace(callback func(size float64) (font.Face, error)) { - gvc.SetFontFace(callback) -} - func (g *Graphviz) Render(ctx context.Context, graph *Graph, format Format, w io.Writer) (e error) { defer func() { if err := g.ctx.FreeLayout(ctx, graph); err != nil { diff --git a/gvc/gvc.go b/gvc/gvc.go index d540121..37c11c0 100644 --- a/gvc/gvc.go +++ b/gvc/gvc.go @@ -50,6 +50,9 @@ func (c *Context) Close() error { } func (c *Context) Layout(ctx context.Context, g *cgraph.Graph, engine string) error { + if err := c.setupNodeLabelIfEmpty(g); err != nil { + return err + } res, err := c.gvc.Layout(ctx, toGraphWasm(g), engine) if err != nil { return err @@ -111,6 +114,40 @@ func (c *Context) FreeClonedContext(ctx context.Context) error { return c.gvc.FreeClonedContext(ctx) } +func (c *Context) setupNodeLabelIfEmpty(g *cgraph.Graph) error { + n, err := g.FirstNode() + if err != nil { + return err + } + if err := c.setLabelIfEmpty(n); err != nil { + return err + } + for { + n, err = g.NextNode(n) + if err != nil { + return err + } + if n == nil { + break + } + if err := c.setLabelIfEmpty(n); err != nil { + return err + } + } + return nil +} + +func (c *Context) setLabelIfEmpty(n *cgraph.Node) error { + label, err := n.Label() + if err != nil { + return err + } + if label == "" { + n.SetLabel("\\N") + } + return nil +} + func newPlugins(ctx context.Context, plugins ...Plugin) ([]*wasm.SymList, error) { defaults, err := wasm.DefaultSymList(ctx) if err != nil { diff --git a/gvc/image_renderer.go b/gvc/image_renderer.go index 3a3c881..f9e960d 100644 --- a/gvc/image_renderer.go +++ b/gvc/image_renderer.go @@ -3,24 +3,30 @@ package gvc import ( "bytes" "context" + "fmt" "image/jpeg" "io" "os" + "strings" + "sync" + "github.com/flopp/go-findfont" "github.com/fogleman/gg" "github.com/golang/freetype/truetype" "golang.org/x/image/font" "golang.org/x/image/font/gofont/goregular" + "golang.org/x/image/font/opentype" + "golang.org/x/image/font/sfnt" +) + +var ( + fontMu sync.RWMutex + fontCache = make(map[string]font.Face) ) type ImageRenderer struct { *DefaultRenderEngine - ctx *gg.Context - fontFace func(float64) (font.Face, error) -} - -func (r *ImageRenderer) SetFontFace(fn func(size float64) (font.Face, error)) { - r.fontFace = fn + ctx *gg.Context } func (r *ImageRenderer) toX(job *Job, x float64) float64 { @@ -112,9 +118,14 @@ func (r *ImageRenderer) TextSpan(ctx context.Context, job *Job, p *PointFloat, s rgba := job.Object().PenColor().RGBAUint() r.ctx.SetRGB(float64(rgba[0])/255.0, float64(rgba[1])/255.0, float64(rgba[2])/255.0) - face, err := r.fontFace(r.toX(job, span.Font().Size())) - if err != nil { - return err + font := span.Font() + face, err := r.getFontFace(ctx, job, font) + if face == nil || err != nil { + defaultFont, err := r.defaultFontFace(ctx, job, font) + if err != nil { + return err + } + face = defaultFont } p.SetX(r.toX(job, p.X())) @@ -132,6 +143,128 @@ func (r *ImageRenderer) TextSpan(ctx context.Context, job *Job, p *PointFloat, s return nil } +func (r *ImageRenderer) getFontFace(ctx context.Context, job *Job, font *TextFont) (font.Face, error) { + return r.lookupFontWithCache(ctx, job, font) +} + +func (r *ImageRenderer) lookupFontWithCache(ctx context.Context, job *Job, font *TextFont) (font.Face, error) { + fontSize := font.Size() * job.Zoom() + fontName := font.Name() + cacheKey := fmt.Sprintf("%s:%f", fontName, fontSize) + fontMu.RLock() + if font, exists := fontCache[cacheKey]; exists { + fontMu.RUnlock() + return font, nil + } + fontMu.RUnlock() + + fontLoaderMu.RLock() + defer fontLoaderMu.RUnlock() + + if fontLoader != nil { + face, err := fontLoader(ctx, job, font) + if err != nil { + return nil, err + } + if face != nil { + return face, nil + } + } + + ft, err := r.lookupFont(fontName, fontSize, job.DPI()) + if err != nil { + return nil, err + } + fontMu.Lock() + fontCache[cacheKey] = ft + fontMu.Unlock() + return ft, nil +} + +func (r *ImageRenderer) lookupFont(fontName string, fontSize float64, dpi *PointFloat) (font.Face, error) { + fontPath, err := findfont.Find(fontName) + if err == nil { + return r.lookupFontFromTTFFile(fontName, fontSize, dpi, fontPath) + } + parts := strings.Split(fontName, "-") + for i := len(parts) - 1; i > 0; i-- { + baseName := strings.Join(parts[:len(parts)-1], "-") + ttfFace, err := r.lookupFontFromTTFFile(fontName, fontSize, dpi, baseName+".ttf") + if err != nil { + return nil, err + } + if ttfFace != nil { + return ttfFace, nil + } + ttcFace, err := r.lookupFontFromTTCFile(fontName, fontSize, dpi, baseName+".ttc") + if err != nil { + return nil, err + } + if ttcFace != nil { + return ttcFace, nil + } + } + return nil, fmt.Errorf("failed to find font by %s", fontName) +} + +func (r *ImageRenderer) lookupFontFromTTFFile(fontName string, fontSize float64, dpi *PointFloat, fontPath string) (font.Face, error) { + fontData, err := os.ReadFile(fontPath) + if err != nil { + return nil, nil + } + ft, err := truetype.Parse(fontData) + if err != nil { + return nil, err + } + return truetype.NewFace(ft, &truetype.Options{ + Size: fontSize, + }), nil +} + +func (r *ImageRenderer) lookupFontFromTTCFile(fontName string, fontSize float64, dpi *PointFloat, fontPath string) (font.Face, error) { + parts := strings.Split(fontName, "-") + fontPath, err := findfont.Find(fontPath) + if err != nil { + return nil, nil + } + fontData, err := os.ReadFile(fontPath) + if err != nil { + return nil, err + } + c, err := opentype.ParseCollection(fontData) + if err != nil { + return nil, err + } + for j := 0; j < c.NumFonts(); j++ { + ft, err := c.Font(j) + if err != nil { + return nil, err + } + var buf sfnt.Buffer + name, err := ft.Name(&buf, sfnt.NameIDFull) + if err != nil { + return nil, err + } + if strings.Join(parts, " ") == name { + return opentype.NewFace(ft, &opentype.FaceOptions{ + Size: fontSize, + DPI: dpi.X(), + }) + } + } + return nil, fmt.Errorf("failed to find %s font from %s file", fontName, fontPath) +} + +func (r *ImageRenderer) defaultFontFace(ctx context.Context, job *Job, font *TextFont) (font.Face, error) { + ft, err := truetype.Parse(goregular.TTF) + if err != nil { + return nil, err + } + return truetype.NewFace(ft, &truetype.Options{ + Size: font.Size() * job.Zoom(), + }), nil +} + func (r *ImageRenderer) Ellipse(ctx context.Context, job *Job, p []*PointFloat, filled bool) error { r.ctx.Push() defer r.ctx.Pop() @@ -227,24 +360,15 @@ func (r *ImageRenderer) BezierCurve(ctx context.Context, job *Job, a []*PointFlo return nil } +type FontLoader func(ctx context.Context, job *Job, font *TextFont) (font.Face, error) + var ( - fontFaceFn = func(size float64) (font.Face, error) { - ft, err := truetype.Parse(goregular.TTF) - if err != nil { - return nil, err - } - opt := &truetype.Options{ - Size: size, - DPI: 0, - Hinting: 0, - GlyphCacheEntries: 0, - SubPixelsX: 0, - SubPixelsY: 0, - } - return truetype.NewFace(ft, opt), nil - } + fontLoaderMu sync.RWMutex + fontLoader FontLoader ) -func SetFontFace(fn func(size float64) (font.Face, error)) { - fontFaceFn = fn +func SetFontLoader(loader FontLoader) { + fontLoaderMu.Lock() + defer fontLoaderMu.Unlock() + fontLoader = loader } diff --git a/gvc/render_plugin.go b/gvc/render_plugin.go index 9bf4b21..6af01f1 100644 --- a/gvc/render_plugin.go +++ b/gvc/render_plugin.go @@ -256,15 +256,11 @@ func defaultRenderPluginConfig(typ string, engine RenderEngine) *renderConfig { } func newPNGRenderEngine() *ImageRenderer { - renderer := &ImageRenderer{DefaultRenderEngine: new(DefaultRenderEngine)} - renderer.SetFontFace(fontFaceFn) - return renderer + return &ImageRenderer{DefaultRenderEngine: new(DefaultRenderEngine)} } func newJPGRenderEngine() *ImageRenderer { - renderer := &ImageRenderer{DefaultRenderEngine: new(DefaultRenderEngine)} - renderer.SetFontFace(fontFaceFn) - return renderer + return &ImageRenderer{DefaultRenderEngine: new(DefaultRenderEngine)} } type renderConfig struct { @@ -789,6 +785,14 @@ func (s *PostScriptAlias) SetSVGFontStyle(v string) { type Scale = PointFloat +func (j *Job) Zoom() float64 { + return j.wasm.GetZoom() +} + +func (j *Job) SetZoom(v float64) { + j.wasm.SetZoom(v) +} + func (j *Job) Scale() *Scale { return toPointFloat(j.wasm.GetScale()) } @@ -863,6 +867,14 @@ func (j *Job) Object() *ObjectState { return toObjectState(j.wasm.GetObj()) } +func (j *Job) DPI() *PointFloat { + return toPointFloat(j.wasm.GetDpi()) +} + +func (j *Job) SetDPI(v *PointFloat) { + j.wasm.SetDpi(v.getWasm()) +} + type ObjectState struct { wasm *wasm.ObjectState } @@ -997,7 +1009,7 @@ func (c *Color) HSVA() [4]float64 { return [4]float64{res[0], res[1], res[2], res[3]} } -func (c *Color) SetHsva(v [4]float64) { +func (c *Color) SetHSVA(v [4]float64) { c.wasm.SetHsva(v[:]) }