From 6a2822f2fbd563843611ffdcf4b945093891a389 Mon Sep 17 00:00:00 2001 From: Yonah Date: Sun, 13 Jul 2025 03:04:05 +0900 Subject: [PATCH] line: optional bounds check This has significant overhead so is made optional. --- line.go | 20 ++++++ caption_sample_test.go => line_sample_test.go | 0 caption_test.go => line_test.go | 66 ++++++++++++++++--- 3 files changed, 78 insertions(+), 8 deletions(-) rename caption_sample_test.go => line_sample_test.go (100%) rename caption_test.go => line_test.go (67%) diff --git a/line.go b/line.go index fb9230b..70d848a 100644 --- a/line.go +++ b/line.go @@ -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 } diff --git a/caption_sample_test.go b/line_sample_test.go similarity index 100% rename from caption_sample_test.go rename to line_sample_test.go diff --git a/caption_test.go b/line_test.go similarity index 67% rename from caption_test.go rename to line_test.go index 8387185..b63f2b7 100644 --- a/caption_test.go +++ b/line_test.go @@ -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) }