line: optional bounds check

This has significant overhead so is made optional.
This commit is contained in:
Yonah 2025-07-13 03:04:05 +09:00
parent 26651ce252
commit 6a2822f2fb
Signed by: yonah
SSH Key Fingerprint: SHA256:vnQvK8+XXH9Tbni2AV1a/8qdVK/zPcXw52GM0ruQvwA
3 changed files with 78 additions and 8 deletions

20
line.go
View File

@ -3,6 +3,7 @@ package caption
import (
"image"
"image/color"
"syscall"
"github.com/golang/freetype/truetype"
"golang.org/x/image/draw"
@ -20,11 +21,16 @@ type Line struct {
// contains reference to parsed font, populated once per instance
face font.Face
// whether to fail if pixels are drawn out-of-bounds
bounds bool
}
// 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 }
// SetBoundsCheck changes whether the frame's bounding box is enforced.
func (l *Line) SetBoundsCheck(v bool) { l.bounds = v }
type Segment struct {
Value string `json:"value"`
Color color.Color `json:"color"`
@ -59,12 +65,26 @@ func (l *Line) Render(ctx *Context, frame draw.Image, x, y int) error {
face = ctx.defaultFont
}
outOfBounds := false
bounds := frame.Bounds()
boundsFixed := fixed.R(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)
drawer := &font.Drawer{Dst: frame, Src: new(image.Uniform), Face: face, Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}}
for _, seg := range l.Data {
drawer.Src.(*image.Uniform).C = seg.Color
// optional bounds check to reduce overhead
if l.bounds && !outOfBounds {
b, _ := drawer.BoundString(seg.Value)
outOfBounds = !b.In(boundsFixed)
}
drawer.DrawString(seg.Value)
}
if outOfBounds {
return syscall.EDOM
}
return nil
}

View File

@ -7,6 +7,8 @@ import (
"image"
"image/color"
"image/png"
"runtime"
"syscall"
"testing"
"git.gensokyo.uk/yonah/caption"
@ -33,6 +35,16 @@ func TestRender(t *testing.T) {
{"bad fast path font", &caption.Line{}, caption.NewContext([]byte{0xfd}, nil),
0, 0, "", truetype.FormatError("TTF data is too short")},
{"out of bounds top left", &caption.Line{
Data: []caption.Segment{{"Beispieltext", color.Black}},
}, nil, 0, 0, "", syscall.EDOM},
{"out of bounds right", &caption.Line{
Data: []caption.Segment{{"Beispieltext", color.Black}},
}, nil, 1<<10 - 0x3e, 1 << 8, "", syscall.EDOM},
{"right edge", &caption.Line{
Data: []caption.Segment{{"Beispieltext", color.Black}},
}, nil, 1<<10 - 0x3f, 1 << 8, "8037459938bcaa6c427412be2ec1e8e77f8ff0e4", nil},
{"custom line options", &caption.Line{
Data: []caption.Segment{{"Beispieltext", color.Black}},
Options: &truetype.Options{Size: 24},
@ -74,12 +86,14 @@ func TestRender(t *testing.T) {
ctx = sharedCtx
}
tc.line.SetBoundsCheck(true)
redraw := ""
redraw:
got := image.NewRGBA64(image.Rectangle{Max: image.Point{X: 1 << 10, Y: 1 << 9}})
err := tc.line.Render(ctx, got, tc.x, tc.y)
for _, f := range beforeCheck {
f(t, tc.name, redraw == "", got)
f(t, tc.name, redraw != "", got)
}
if !errors.Is(err, tc.wantErr) {
@ -113,20 +127,56 @@ func TestRender(t *testing.T) {
})
}
scratchpad := image.NewRGBA64(image.Rectangle{Max: image.Point{X: 1 << 10, Y: 1 << 9}})
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,
)
Render(new(caption.Context), scratchpad, 0, 1<<8)
if !errors.Is(err, wantErr) {
t.Errorf("Render: error = %q, want %q",
err, wantErr)
t.Errorf("Render: error = %q, want %q", err, wantErr)
}
})
t.Run("bounds check skip", func(t *testing.T) {
err := (&caption.Line{Data: []caption.Segment{{"Beispieltext", color.Black}}}).
Render(new(caption.Context), scratchpad, 0, 0)
if err != nil {
t.Errorf("Render: error = %q", err)
}
})
}
func c(r, g, b uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: 0xff} }
type stubImage struct {
image.Uniform
}
func (s *stubImage) Set(x, y int, c color.Color) {
runtime.KeepAlive(x)
runtime.KeepAlive(y)
runtime.KeepAlive(c)
}
func (s *stubImage) Bounds() image.Rectangle {
return image.Rectangle{Max: image.Point{X: 1 << 10, Y: 1 << 9}}
}
func benchmarkRender(b *testing.B, bounds bool) {
stub := new(stubImage)
stub.C = color.Black
ctx := new(caption.Context)
line := &caption.Line{Data: []caption.Segment{{"Beispieltext", color.Black}}}
line.SetBoundsCheck(bounds)
for i := 0; i < b.N; i++ {
err := line.Render(ctx, stub, 0, 1<<8)
if err != nil {
b.Fatalf("Render: error = %q", err)
}
}
}
func BenchmarkRender(b *testing.B) { benchmarkRender(b, false) }
func BenchmarkRenderBounds(b *testing.B) { benchmarkRender(b, true) }