caption: overridable default
This increases performance in the typical use case.
This commit is contained in:
parent
734c4e7324
commit
8e6affff41
40
caption.go
40
caption.go
@ -2,9 +2,49 @@
|
|||||||
package caption
|
package caption
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"golang.org/x/image/font"
|
||||||
"golang.org/x/image/font/gofont/goregular"
|
"golang.org/x/image/font/gofont/goregular"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// package-wide default font
|
||||||
defaultFont = goregular.TTF
|
defaultFont = goregular.TTF
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A Context is used for efficient line rendering.
|
||||||
|
// The zero value is ready to use. Do not copy a non-zero Context.
|
||||||
|
type Context struct {
|
||||||
|
defaultFont font.Face
|
||||||
|
defaultFontErr error
|
||||||
|
defaultFontOnce sync.Once
|
||||||
|
defaultFontData []byte
|
||||||
|
defaultFontOpts *truetype.Options
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
NewContext initialises Context with a custom default truetype font and returns its address.
|
||||||
|
|
||||||
|
Defaults baked into Context will only take effect if both Line.Font and Line.Options are zero.
|
||||||
|
*/
|
||||||
|
func NewContext(v []byte, opts *truetype.Options) *Context {
|
||||||
|
return &Context{defaultFontData: v, defaultFontOpts: opts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFont is called before using Context.defaultFont.
|
||||||
|
func (ctx *Context) parseFont() error {
|
||||||
|
ctx.defaultFontOnce.Do(func() {
|
||||||
|
if len(ctx.defaultFontData) == 0 {
|
||||||
|
ctx.defaultFontData = defaultFont
|
||||||
|
}
|
||||||
|
if f, err := truetype.Parse(ctx.defaultFontData); err != nil {
|
||||||
|
ctx.defaultFontErr = err
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
ctx.defaultFont = truetype.NewFace(f, ctx.defaultFontOpts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return ctx.defaultFontErr
|
||||||
|
}
|
||||||
|
@ -12,13 +12,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
beforeCheck = append(beforeCheck, func(t *testing.T, name string, got image.Image) {
|
beforeCheck = append(beforeCheck, func(t *testing.T, name string, redraw bool, got image.Image) {
|
||||||
t.Run("export", func(t *testing.T) {
|
suffix := ""
|
||||||
|
if redraw {
|
||||||
|
suffix = " redraw"
|
||||||
|
}
|
||||||
|
t.Run("export"+suffix, func(t *testing.T) {
|
||||||
outPath := strings.TrimSpace(os.Getenv("TEST_RENDER_OUT_PATH"))
|
outPath := strings.TrimSpace(os.Getenv("TEST_RENDER_OUT_PATH"))
|
||||||
if len(outPath) == 0 {
|
if len(outPath) == 0 {
|
||||||
outPath = os.TempDir()
|
outPath = os.TempDir()
|
||||||
}
|
}
|
||||||
outPath = path.Join(outPath, "overlay-"+name+".png")
|
outPath = path.Join(outPath, "overlay-"+strings.Join(strings.Fields(name+suffix), "-")+".png")
|
||||||
if re, err := os.Create(outPath); err != nil {
|
if re, err := os.Create(outPath); err != nil {
|
||||||
t.Fatalf("cannot create: %v", err)
|
t.Fatalf("cannot create: %v", err)
|
||||||
} else if err = (&png.Encoder{CompressionLevel: png.NoCompression}).Encode(re, got); err != nil {
|
} else if err = (&png.Encoder{CompressionLevel: png.NoCompression}).Encode(re, got); err != nil {
|
||||||
|
@ -11,36 +11,72 @@ import (
|
|||||||
|
|
||||||
"git.gensokyo.uk/yonah/caption"
|
"git.gensokyo.uk/yonah/caption"
|
||||||
"github.com/golang/freetype/truetype"
|
"github.com/golang/freetype/truetype"
|
||||||
|
"golang.org/x/image/font/gofont/gobolditalic"
|
||||||
|
"golang.org/x/image/font/gofont/gomono"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
beforeCheck []func(t *testing.T, name string, got image.Image)
|
beforeCheck []func(t *testing.T, name string, redraw bool, got image.Image)
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRender(t *testing.T) {
|
func TestRender(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
line *caption.Line
|
line *caption.Line
|
||||||
|
ctx *caption.Context
|
||||||
x, y int
|
x, y int
|
||||||
want string
|
want string
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{"bad font", &caption.Line{Font: []byte{0xfd}},
|
{"bad line font", &caption.Line{Font: []byte{0xfd}}, nil,
|
||||||
0, 0, "", truetype.FormatError("TTF data is too short")},
|
0, 0, "", truetype.FormatError("TTF data is too short")},
|
||||||
{"colors", &caption.Line{Data: []caption.Segment{
|
{"bad fast path font", &caption.Line{}, caption.NewContext([]byte{0xfd}, nil),
|
||||||
|
0, 0, "", truetype.FormatError("TTF data is too short")},
|
||||||
|
{"custom line options", &caption.Line{
|
||||||
|
Data: []caption.Segment{{"Beispieltext", color.Black}},
|
||||||
|
Options: &truetype.Options{Size: 24},
|
||||||
|
}, nil,
|
||||||
|
0, 1 << 8, "0f4077326dc233331c00ee30b06ba76e2ad3a09a", nil},
|
||||||
|
{"custom line font", &caption.Line{
|
||||||
|
Data: []caption.Segment{{"Beispieltext", color.Black}},
|
||||||
|
Font: gomono.TTF,
|
||||||
|
}, nil,
|
||||||
|
0, 1 << 8, "af983600732f6c78a6123e1dc1033cdbab9e06b5", nil},
|
||||||
|
{"custom fast path font", &caption.Line{
|
||||||
|
Data: []caption.Segment{{"Beispieltext", color.Black}},
|
||||||
|
}, caption.NewContext(gomono.TTF, nil),
|
||||||
|
0, 1 << 8, "af983600732f6c78a6123e1dc1033cdbab9e06b5", nil},
|
||||||
|
{"alternate custom line font", &caption.Line{
|
||||||
|
Data: []caption.Segment{{"Beispieltext", color.Black}},
|
||||||
|
Font: gobolditalic.TTF,
|
||||||
|
}, nil,
|
||||||
|
0, 1 << 8, "2371b3458a12ca5b4657e4a46b84ca8c6c2c9142", nil},
|
||||||
|
{"alternate custom fast path font", &caption.Line{
|
||||||
|
Data: []caption.Segment{{"Beispieltext", color.Black}},
|
||||||
|
}, caption.NewContext(gobolditalic.TTF, nil),
|
||||||
|
0, 1 << 8, "2371b3458a12ca5b4657e4a46b84ca8c6c2c9142", nil},
|
||||||
|
{"fast path colors", &caption.Line{Data: []caption.Segment{
|
||||||
{"Blue", c(0x55, 0xcd, 0xfc)},
|
{"Blue", c(0x55, 0xcd, 0xfc)},
|
||||||
{"Pink", c(0xf7, 0xa8, 0xb8)},
|
{"Pink", c(0xf7, 0xa8, 0xb8)},
|
||||||
{"White", color.White},
|
{"White", color.White},
|
||||||
{"Pink", c(0xf7, 0xa8, 0xb8)},
|
{"Pink", c(0xf7, 0xa8, 0xb8)},
|
||||||
{"Blue", c(0x55, 0xcd, 0xfc)},
|
{"Blue", c(0x55, 0xcd, 0xfc)},
|
||||||
}}, 7 << 6, 1 << 8, "2af4b09361aeb3a9befc79cc0b28dd3874657fa0", nil},
|
}}, nil, 7 << 6, 1 << 8, "2af4b09361aeb3a9befc79cc0b28dd3874657fa0", nil},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sharedCtx := new(caption.Context)
|
||||||
var digest []byte
|
var digest []byte
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx := tc.ctx
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = sharedCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw := ""
|
||||||
|
redraw:
|
||||||
got := image.NewRGBA64(image.Rectangle{Max: image.Point{X: 1 << 10, Y: 1 << 9}})
|
got := image.NewRGBA64(image.Rectangle{Max: image.Point{X: 1 << 10, Y: 1 << 9}})
|
||||||
err := tc.line.Render(got, tc.x, tc.y)
|
err := tc.line.Render(ctx, got, tc.x, tc.y)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, tc.wantErr) {
|
if !errors.Is(err, tc.wantErr) {
|
||||||
t.Errorf("Render: error = %q, want %q",
|
t.Errorf("Render: error = %q, want %q",
|
||||||
@ -50,10 +86,10 @@ func TestRender(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range beforeCheck {
|
for _, f := range beforeCheck {
|
||||||
f(t, tc.name, got)
|
f(t, tc.name, redraw == "", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("hash", func(t *testing.T) {
|
t.Run("hash"+redraw, func(t *testing.T) {
|
||||||
var wantDigest []byte
|
var wantDigest []byte
|
||||||
if wantDigest, err = hex.DecodeString(tc.want); err != nil {
|
if wantDigest, err = hex.DecodeString(tc.want); err != nil {
|
||||||
t.Errorf("cannot parse expected hash: %v", err)
|
t.Errorf("cannot parse expected hash: %v", err)
|
||||||
@ -68,8 +104,29 @@ func TestRender(t *testing.T) {
|
|||||||
t.Errorf("Render: %x, want %x", digest, wantDigest)
|
t.Errorf("Render: %x, want %x", digest, wantDigest)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if redraw == "" {
|
||||||
|
redraw = " redraw"
|
||||||
|
tc.line.SetFont(tc.line.Font, tc.line.Options)
|
||||||
|
goto redraw
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("bad global default", func(t *testing.T) {
|
||||||
|
wantErr := truetype.FormatError("TTF data is too short")
|
||||||
|
caption.OverrideGlobalDefault(t, []byte{0xfd})
|
||||||
|
err := (&caption.Line{Options: new(truetype.Options)}).
|
||||||
|
Render(
|
||||||
|
new(caption.Context),
|
||||||
|
image.NewRGBA64(image.Rectangle{Max: image.Point{X: 1 << 10, Y: 1 << 9}}),
|
||||||
|
0, 1<<8,
|
||||||
|
)
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Errorf("Render: error = %q, want %q",
|
||||||
|
err, wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func c(r, g, b uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: 0xff} }
|
func c(r, g, b uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: 0xff} }
|
||||||
|
9
caption_util_test.go
Normal file
9
caption_util_test.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package caption
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func OverrideGlobalDefault(t *testing.T, v []byte) {
|
||||||
|
p := defaultFont
|
||||||
|
t.Cleanup(func() { defaultFont = p })
|
||||||
|
defaultFont = v
|
||||||
|
}
|
43
line.go
43
line.go
@ -30,19 +30,36 @@ type Segment struct {
|
|||||||
Color color.Color `json:"color"`
|
Color color.Color `json:"color"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Line) Render(frame draw.Image, x, y int) error {
|
// Render draws the current contents of Line on [draw.Image].
|
||||||
if l.face == nil {
|
func (l *Line) Render(ctx *Context, frame draw.Image, x, y int) error {
|
||||||
if len(l.Font) == 0 {
|
face := l.face
|
||||||
l.SetFont(defaultFont, l.Options)
|
if face == nil {
|
||||||
|
if len(l.Font) != 0 {
|
||||||
|
// parse & cache Line font
|
||||||
|
if err := l.cacheFont(l.Font); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return l.Render(ctx, frame, x, y)
|
||||||
}
|
}
|
||||||
if f, err := truetype.Parse(l.Font); err != nil {
|
|
||||||
|
// fall back to default font
|
||||||
|
|
||||||
|
if l.Options != nil { // global default: Options is not zero
|
||||||
|
// cache global default without clobbering Line font
|
||||||
|
if err := l.cacheFont(defaultFont); err != nil {
|
||||||
|
return err // unreachable
|
||||||
|
}
|
||||||
|
return l.Render(ctx, frame, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fast path: Font and Options are zero
|
||||||
|
if err := ctx.parseFont(); err != nil {
|
||||||
return err
|
return err
|
||||||
} else {
|
|
||||||
l.face = truetype.NewFace(f, l.Options)
|
|
||||||
}
|
}
|
||||||
|
face = ctx.defaultFont
|
||||||
}
|
}
|
||||||
|
|
||||||
drawer := &font.Drawer{Dst: frame, Face: l.face, Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}}
|
drawer := &font.Drawer{Dst: frame, Face: face, Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}}
|
||||||
for _, seg := range l.Data {
|
for _, seg := range l.Data {
|
||||||
drawer.Src = image.NewUniform(seg.Color)
|
drawer.Src = image.NewUniform(seg.Color)
|
||||||
drawer.DrawString(seg.Value)
|
drawer.DrawString(seg.Value)
|
||||||
@ -50,3 +67,13 @@ func (l *Line) Render(frame draw.Image, x, y int) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cacheFont parses truetype font data v and stores the result in Line.
|
||||||
|
// The cache is invalidated by SetFont.
|
||||||
|
func (l *Line) cacheFont(v []byte) error {
|
||||||
|
f, err := truetype.Parse(v)
|
||||||
|
if err == nil {
|
||||||
|
l.face = truetype.NewFace(f, l.Options)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user