diff --git a/caption.go b/caption.go index 5e34a8a..e5eaf00 100644 --- a/caption.go +++ b/caption.go @@ -2,9 +2,49 @@ package caption import ( + "sync" + + "github.com/golang/freetype/truetype" + "golang.org/x/image/font" "golang.org/x/image/font/gofont/goregular" ) var ( + // package-wide default font 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 +} diff --git a/caption_sample_test.go b/caption_sample_test.go index dd68d48..934b06d 100644 --- a/caption_sample_test.go +++ b/caption_sample_test.go @@ -12,13 +12,17 @@ import ( ) func init() { - beforeCheck = append(beforeCheck, func(t *testing.T, name string, got image.Image) { - t.Run("export", func(t *testing.T) { + beforeCheck = append(beforeCheck, func(t *testing.T, name string, redraw bool, got image.Image) { + suffix := "" + if redraw { + suffix = " redraw" + } + t.Run("export"+suffix, func(t *testing.T) { outPath := strings.TrimSpace(os.Getenv("TEST_RENDER_OUT_PATH")) if len(outPath) == 0 { 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 { t.Fatalf("cannot create: %v", err) } else if err = (&png.Encoder{CompressionLevel: png.NoCompression}).Encode(re, got); err != nil { diff --git a/caption_test.go b/caption_test.go index 7d37a74..ea16924 100644 --- a/caption_test.go +++ b/caption_test.go @@ -11,36 +11,72 @@ import ( "git.gensokyo.uk/yonah/caption" "github.com/golang/freetype/truetype" + "golang.org/x/image/font/gofont/gobolditalic" + "golang.org/x/image/font/gofont/gomono" ) 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) { testCases := []struct { name string line *caption.Line + ctx *caption.Context x, y int want string 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")}, - {"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)}, {"Pink", c(0xf7, 0xa8, 0xb8)}, {"White", color.White}, {"Pink", c(0xf7, 0xa8, 0xb8)}, {"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 for _, tc := range testCases { 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}}) - err := tc.line.Render(got, tc.x, tc.y) + err := tc.line.Render(ctx, got, tc.x, tc.y) if err != nil { if !errors.Is(err, tc.wantErr) { t.Errorf("Render: error = %q, want %q", @@ -50,10 +86,10 @@ func TestRender(t *testing.T) { } 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 if wantDigest, err = hex.DecodeString(tc.want); err != nil { 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) } }) + + 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} } diff --git a/caption_util_test.go b/caption_util_test.go new file mode 100644 index 0000000..c100173 --- /dev/null +++ b/caption_util_test.go @@ -0,0 +1,9 @@ +package caption + +import "testing" + +func OverrideGlobalDefault(t *testing.T, v []byte) { + p := defaultFont + t.Cleanup(func() { defaultFont = p }) + defaultFont = v +} diff --git a/line.go b/line.go index b137fec..e19ec61 100644 --- a/line.go +++ b/line.go @@ -30,19 +30,36 @@ type Segment struct { Color color.Color `json:"color"` } -func (l *Line) Render(frame draw.Image, x, y int) error { - if l.face == nil { - if len(l.Font) == 0 { - l.SetFont(defaultFont, l.Options) +// Render draws the current contents of Line on [draw.Image]. +func (l *Line) Render(ctx *Context, frame draw.Image, x, y int) error { + face := l.face + 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 - } 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 { drawer.Src = image.NewUniform(seg.Color) drawer.DrawString(seg.Value) @@ -50,3 +67,13 @@ func (l *Line) Render(frame draw.Image, x, y int) error { 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 +}