commit 734c4e7324bb6849a27b658004495c18f62ba90d Author: Yonah Date: Sun Jul 13 00:18:21 2025 +0900 caption: basic line rendering diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47c5249 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.pkg + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env +.idea +.vscode \ No newline at end of file diff --git a/caption.go b/caption.go new file mode 100644 index 0000000..5e34a8a --- /dev/null +++ b/caption.go @@ -0,0 +1,10 @@ +// Package caption provides a caption rendering interface that prioritises ease of use. +package caption + +import ( + "golang.org/x/image/font/gofont/goregular" +) + +var ( + defaultFont = goregular.TTF +) diff --git a/caption_sample_test.go b/caption_sample_test.go new file mode 100644 index 0000000..dd68d48 --- /dev/null +++ b/caption_sample_test.go @@ -0,0 +1,30 @@ +//go:build export_samples + +package caption_test + +import ( + "image" + "image/png" + "os" + "path" + "strings" + "testing" +) + +func init() { + beforeCheck = append(beforeCheck, func(t *testing.T, name string, got image.Image) { + t.Run("export", 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") + 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 { + t.Fatalf("cannot encode to file: %v", err) + } + t.Logf("test case exported to %s", outPath) + }) + }) +} diff --git a/caption_test.go b/caption_test.go new file mode 100644 index 0000000..7d37a74 --- /dev/null +++ b/caption_test.go @@ -0,0 +1,75 @@ +package caption_test + +import ( + "crypto/sha1" + "encoding/hex" + "errors" + "image" + "image/color" + "image/png" + "testing" + + "git.gensokyo.uk/yonah/caption" + "github.com/golang/freetype/truetype" +) + +var ( + beforeCheck []func(t *testing.T, name string, got image.Image) +) + +func TestRender(t *testing.T) { + testCases := []struct { + name string + line *caption.Line + x, y int + want string + wantErr error + }{ + {"bad font", &caption.Line{Font: []byte{0xfd}}, + 0, 0, "", truetype.FormatError("TTF data is too short")}, + {"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}, + } + + var digest []byte + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := image.NewRGBA64(image.Rectangle{Max: image.Point{X: 1 << 10, Y: 1 << 9}}) + err := tc.line.Render(got, tc.x, tc.y) + if err != nil { + if !errors.Is(err, tc.wantErr) { + t.Errorf("Render: error = %q, want %q", + err, tc.wantErr) + } + return + } + + for _, f := range beforeCheck { + f(t, tc.name, got) + } + + t.Run("hash", func(t *testing.T) { + var wantDigest []byte + if wantDigest, err = hex.DecodeString(tc.want); err != nil { + t.Errorf("cannot parse expected hash: %v", err) + } + + h := sha1.New() + if err = (&png.Encoder{CompressionLevel: png.NoCompression}).Encode(h, got); err != nil { + t.Fatalf("cannot encode png for hashing: %v", err) + } + digest := h.Sum(digest[:0]) + if string(digest) != string(wantDigest) { + t.Errorf("Render: %x, want %x", digest, wantDigest) + } + }) + }) + } +} + +func c(r, g, b uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: 0xff} } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..001135f --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.gensokyo.uk/yonah/caption + +go 1.24.4 + +require ( + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 + golang.org/x/image v0.29.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3eec594 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= +golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= diff --git a/line.go b/line.go new file mode 100644 index 0000000..b137fec --- /dev/null +++ b/line.go @@ -0,0 +1,52 @@ +package caption + +import ( + "image" + "image/color" + + "github.com/golang/freetype/truetype" + "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" +) + +type Line struct { + // collection of all text segments + Data []Segment `json:"data"` + // applies to all segments; avoid direct assignment and use SetFont instead + Font []byte `json:"font"` + // applies to all segments; avoid direct assignment and use SetFont instead + Options *truetype.Options `json:"options"` + + // contains reference to parsed font, populated once per instance + face font.Face +} + +// SetFont clears line font cache and sets the new font. +func (l *Line) SetFont(v []byte, opts *truetype.Options) { l.face = nil; l.Font = v; l.Options = opts } + +type Segment struct { + Value string `json:"value"` + 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) + } + if f, err := truetype.Parse(l.Font); err != nil { + return err + } else { + l.face = truetype.NewFace(f, l.Options) + } + } + + drawer := &font.Drawer{Dst: frame, Face: l.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) + } + + return nil +}