From 641894997d52fa883ab318a9d4e391734377c333 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 2 Aug 2026 20:39:38 +0200 Subject: [PATCH] =?UTF-8?q?Add=20go-widgets=20=E2=86=92=20PDF=20bridge=20(?= =?UTF-8?q?raster=20+=20vector)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge any github.com/go-widgets/toolkit widget tree onto a PDF page so a UI can be "printed"/exported to PDF. - AddWidget (raster): lays the tree out at the target size, renders it through a go-widgets/painter PixelPainter into an RGBA buffer, and places the result as an image XObject at the target rect. Prints any UI exactly as it draws on screen. - AddWidgetVector (vector): a painter.Painter (+ Clipper) implementation that emits PDF vector operators (fills, strokes, rounded-corner Béziers, 1x1 pixels, clip) instead of pixels. Text drawn through the toolkit's built-in bitmap font reaches the painter's Text primitive and becomes real, selectable Type0 PDF text. Needs a WidgetOptions.Font. - WidgetOptions{Theme, Scale, Font} with sensible defaults (DefaultLight, DefaultWidgetScale=2). Read-only dependency on toolkit + painter; no changes to go-widgets. Tests build a real widget tree (Container + prominent Button + Label), export to PDF and re-open with rsc.io/pdf as an independent oracle, asserting the image XObject dimensions + painted pixels (raster) and the Type0 font + text/fill/stroke/clip operators (vector). Exact 100% statement coverage; go vet clean; builds on the 6 64-bit arches + wasm + darwin + windows. Co-Authored-By: Claude Opus 4.8 --- doc.go | 10 ++ go.mod | 11 +- go.sum | 10 ++ widget.go | 365 +++++++++++++++++++++++++++++++++++++++++++++++++ widget_test.go | 244 +++++++++++++++++++++++++++++++++ 5 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 widget.go create mode 100644 widget_test.go diff --git a/doc.go b/doc.go index 3ab0228..3b1e4de 100644 --- a/doc.go +++ b/doc.go @@ -45,6 +45,16 @@ // /ID, so identical inputs produce byte-identical documents. Set Options.Now to // stamp creation and modification dates. // +// # Widget bridge +// +// AddWidget and AddWidgetVector "print" a github.com/go-widgets/toolkit widget +// tree onto a page. AddWidget lays the tree out at the target size, renders it +// through a go-widgets/painter PixelPainter and places the result as an image +// XObject — any UI, exactly as it draws on screen. AddWidgetVector runs the same +// tree through a painter that emits PDF vector operators instead of pixels, so +// fills and strokes stay crisp and text drawn through the toolkit's built-in +// font becomes real, selectable PDF text (it needs a WidgetOptions.Font). +// // # Font embedding // // go-opentype/opentype supplies every primitive PDF embedding needs: the diff --git a/go.mod b/go.mod index 882570b..d127c90 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,13 @@ go 1.26.4 require github.com/go-opentype/opentype v0.5.0 -require rsc.io/pdf v0.1.1 +require ( + github.com/go-widgets/painter v0.1.3 + github.com/go-widgets/toolkit v0.76.0 + rsc.io/pdf v0.1.1 +) + +require ( + github.com/go-opentype/bidi v0.2.0 // indirect + github.com/go-opentype/shape v0.3.2 // indirect +) diff --git a/go.sum b/go.sum index 73a61a1..bf9d8ad 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,14 @@ +github.com/go-opentype/bidi v0.2.0 h1:ze8chEUw22c98LZug2GByD9cc3Zuq0qARxrSLGj1ioQ= +github.com/go-opentype/bidi v0.2.0/go.mod h1:PaWZ9P93jwQaXsEvAoSxvJ3P70S9f8TuFGDstTvcfmM= +github.com/go-opentype/fonts v0.4.1 h1:f2vVTa0QXiLjW2A5TwyPx0PFgQa9GYlysfgR5C2bbsQ= +github.com/go-opentype/fonts v0.4.1/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g= github.com/go-opentype/opentype v0.5.0 h1:++VoqgbXgYACyTTNzUAivoxW9M3m2gxWMAn6bIY7DXg= github.com/go-opentype/opentype v0.5.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= +github.com/go-opentype/shape v0.3.2 h1:qaaUhg9m6yusm6MCfa07DhZ5Z0ZxOmGwLxHklVeLUUA= +github.com/go-opentype/shape v0.3.2/go.mod h1:oVq3kFGx6kIHKrFXfxqnp8JUYR7k9NzeY+E17LYHhrU= +github.com/go-widgets/painter v0.1.3 h1:50oeDH76vMUlxQ8117wMFf5MGuAJlq5n2JeVS+QlpnM= +github.com/go-widgets/painter v0.1.3/go.mod h1:ccmlkH2UmcXQh6rt9Fu2eDt2RI0B5V0POaGmUKeW0EQ= +github.com/go-widgets/toolkit v0.76.0 h1:A9eJHmTPZpJeQJ+7e22NxLm9Hqa/JiQ4pwBWub0Fq2c= +github.com/go-widgets/toolkit v0.76.0/go.mod h1:AZJWQh4rQrFqwEuPjluV9eGnLqA+XnfrxCcvTs5C+LU= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/widget.go b/widget.go new file mode 100644 index 0000000..2cabae8 --- /dev/null +++ b/widget.go @@ -0,0 +1,365 @@ +// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package pdfkit + +import ( + "errors" + "image" + "math" + + "github.com/go-widgets/painter" + "github.com/go-widgets/toolkit" +) + +// This file bridges a go-widgets/toolkit widget tree onto a PDF page, so any UI +// composed from toolkit widgets can be "printed" to a PDF. Two levels are +// offered: +// +// - AddWidget rasterises the tree through a painter.PixelPainter and places +// the result as an image XObject. It renders every widget faithfully (the +// same pixels a window would show) but the output is a bitmap. +// - AddWidgetVector runs the tree through a painter.Painter that emits PDF +// vector operators (fills, strokes, rounded rects and text-show operators) +// instead of pixels, so text stays selectable and lines stay crisp. It uses +// a caller-supplied embedded Font for text; widgets drawing through the +// toolkit's built-in bitmap font reach the painter's Text primitive, which +// becomes real PDF text. +// +// The toolkit lays a scene out in integer painter units (pixels) with absolute +// child bounds, then every widget's Draw targets a single painter over the whole +// canvas — so one painter, positioned once over the target rectangle, renders +// the entire tree. + +// DefaultWidgetScale is the number of layout pixels per PDF point used when +// WidgetOptions.Scale is unset. A value of 2 lays the tree out at twice the +// point resolution, giving crisper raster output and finer layout rounding. +const DefaultWidgetScale = 2.0 + +// widgetGlyphPx is the layout height, in painter pixels, of the toolkit's +// built-in 5x7 bitmap font (the height widgets lay text out against). The vector +// painter sizes PDF text from it so a run roughly fills the same box. +const widgetGlyphPx = 7.0 + +// errWidgetRectEmpty is returned when the target rectangle scales to a +// zero-or-negative pixel canvas, so there is nothing to lay out or render. +var errWidgetRectEmpty = errors.New("pdfkit: widget target rectangle is empty at the given scale") + +// errWidgetVectorNoFont is returned by AddWidgetVector when no Font is supplied: +// selectable vector text needs an embedded font. +var errWidgetVectorNoFont = errors.New("pdfkit: AddWidgetVector requires WidgetOptions.Font for selectable text") + +// WidgetOptions configures how a widget tree is transferred onto a page. The nil +// *WidgetOptions is valid and selects every default. +type WidgetOptions struct { + // Theme is the toolkit theme the widgets paint with. When nil, + // toolkit.DefaultLight() is used. + Theme *toolkit.Theme + + // Scale is the number of layout pixels per PDF point. Values <= 0 default to + // DefaultWidgetScale. Larger values give a crisper raster (AddWidget) and + // finer layout rounding for both paths. + Scale float64 + + // Font is the embedded font used for selectable text on the vector path + // (AddWidgetVector). It is ignored by the raster AddWidget path. + Font *Font +} + +// resolveWidgetOpts fills in the theme, scale and font defaults for a possibly +// nil *WidgetOptions. +func resolveWidgetOpts(opts *WidgetOptions) (*toolkit.Theme, float64, *Font) { + var theme *toolkit.Theme + var scale float64 + var font *Font + if opts != nil { + theme = opts.Theme + scale = opts.Scale + font = opts.Font + } + if theme == nil { + theme = toolkit.DefaultLight() + } + if scale <= 0 { + scale = DefaultWidgetScale + } + return theme, scale, font +} + +// pixelCanvas resolves the integer pixel dimensions the tree lays out into for +// rect at scale, returning errWidgetRectEmpty when either dimension is not +// positive. +func pixelCanvas(rect Rect, scale float64) (w, h int, err error) { + w = int(math.Round(rect.Width * scale)) + h = int(math.Round(rect.Height * scale)) + if w <= 0 || h <= 0 { + return 0, 0, errWidgetRectEmpty + } + return w, h, nil +} + +// AddWidget lays root out to fill rect (in points), renders the whole widget +// tree to an RGBA raster through a painter.PixelPainter, and places that raster +// as an image XObject on the page at rect. It is the "print any UI to PDF" path: +// every widget renders exactly as it would on screen. opts may be nil. +func (p *Page) AddWidget(root toolkit.Widget, rect Rect, opts *WidgetOptions) error { + theme, scale, _ := resolveWidgetOpts(opts) + w, h, err := pixelCanvas(rect, scale) + if err != nil { + return err + } + root.SetBounds(toolkit.Rect{X: 0, Y: 0, W: w, H: h}) + buf := make([]byte, 4*w*h) + pp := painter.NewPixelPainter(buf, w, h) + root.Draw(pp, theme) + img := &image.RGBA{ + Pix: buf, + Stride: 4 * w, + Rect: image.Rect(0, 0, w, h), + } + p.DrawImage(img, rect) + return nil +} + +// AddWidgetVector lays root out to fill rect (in points) and renders the widget +// tree with PDF vector operators, so text stays selectable and edges stay crisp. +// Text is drawn with opts.Font, which must be non-nil. The whole tree is clipped +// to rect. opts may be nil only if a font is not needed, which is never the case +// for a real tree, so a nil-or-fontless opts returns errWidgetVectorNoFont. +func (p *Page) AddWidgetVector(root toolkit.Widget, rect Rect, opts *WidgetOptions) error { + theme, scale, font := resolveWidgetOpts(opts) + if font == nil { + return errWidgetVectorNoFont + } + w, h, err := pixelCanvas(rect, scale) + if err != nil { + return err + } + root.SetBounds(toolkit.Rect{X: 0, Y: 0, W: w, H: h}) + vp := &vectorPainter{ + page: p, + rx: rect.X, + ry: rect.Y, + rh: rect.Height, + sx: rect.Width / float64(w), + sy: rect.Height / float64(h), + w: w, + h: h, + font: font, + } + p.Save() + p.Rectangle(rect) + p.Clip() + p.EndPath() + root.Draw(vp, theme) + p.Restore() + return nil +} + +// vectorPainter implements painter.Painter (and painter.Clipper) by emitting PDF +// content-stream operators. Painter coordinates are top-left-origin pixels; PDF +// user space is bottom-left-origin points, so every call maps x through fx and y +// through fy (which also flips the axis). +type vectorPainter struct { + page *Page + + rx, ry float64 // target rect lower-left corner, in points + rh float64 // target rect height, in points + sx, sy float64 // points per painter pixel (x and y) + w, h int // canvas size, in painter pixels + font *Font // font used for text-show operators + + clipDepth int // balanced q/Q depth pushed by PushClip +} + +// fx maps a painter x (pixels) to a PDF x (points). +func (v *vectorPainter) fx(x int) float64 { return v.rx + float64(x)*v.sx } + +// fy maps a painter y (pixels, top-down) to a PDF y (points, bottom-up). +func (v *vectorPainter) fy(y int) float64 { return v.ry + v.rh - float64(y)*v.sy } + +// prect maps a painter rectangle (top-left origin) to a PDF rectangle (lower-left +// origin). +func (v *vectorPainter) prect(r painter.Rect) Rect { + return Rect{ + X: v.fx(r.X), + Y: v.fy(r.Y + r.H), + Width: float64(r.W) * v.sx, + Height: float64(r.H) * v.sy, + } +} + +// applyAlpha sets a constant transparency when c is not fully opaque. Opaque +// colours skip the ExtGState so the common case stays clean. +func (v *vectorPainter) applyAlpha(c painter.RGBA) { + if c.A < 0xFF { + a := float64(c.A) / 255 + v.page.SetAlpha(a, a) + } +} + +// FillRect fills r with c. +func (v *vectorPainter) FillRect(r painter.Rect, c painter.RGBA) { + if c.A == 0 || r.W <= 0 || r.H <= 0 { + return + } + v.page.Save() + v.applyAlpha(c) + v.page.SetFillColor(RGB8(c.R, c.G, c.B)) + v.page.Rectangle(v.prect(r)) + v.page.Fill() + v.page.Restore() +} + +// lineWidthPoints converts a painter line-width hint (pixels, minimum 1) to +// points along the x scale. +func (v *vectorPainter) lineWidthPoints(lineW int) float64 { + if lineW < 1 { + lineW = 1 + } + return float64(lineW) * v.sx +} + +// StrokeRect strokes a rectangular border around r. +func (v *vectorPainter) StrokeRect(r painter.Rect, c painter.RGBA, lineW int) { + if c.A == 0 || r.W <= 0 || r.H <= 0 { + return + } + v.page.Save() + v.applyAlpha(c) + v.page.SetStrokeColor(RGB8(c.R, c.G, c.B)) + v.page.SetLineWidth(v.lineWidthPoints(lineW)) + v.page.Rectangle(v.prect(r)) + v.page.Stroke() + v.page.Restore() +} + +// bezierCircle is the control-point offset factor that approximates a quarter +// circle with a cubic Bézier. +const bezierCircle = 0.5522847498307936 + +// roundRectPath emits a rounded-rectangle subpath for the PDF rectangle R with +// horizontal corner radius rx and vertical corner radius ry (both in points). +func (v *vectorPainter) roundRectPath(R Rect, rx, ry float64) { + x0, y0 := R.X, R.Y + x1, y1 := R.X+R.Width, R.Y+R.Height + kx, ky := rx*bezierCircle, ry*bezierCircle + v.page.MoveTo(x0+rx, y0) + v.page.LineTo(x1-rx, y0) + v.page.CurveTo(x1-rx+kx, y0, x1, y0+ry-ky, x1, y0+ry) + v.page.LineTo(x1, y1-ry) + v.page.CurveTo(x1, y1-ry+ky, x1-rx+kx, y1, x1-rx, y1) + v.page.LineTo(x0+rx, y1) + v.page.CurveTo(x0+rx-kx, y1, x0, y1-ry+ky, x0, y1-ry) + v.page.LineTo(x0, y0+ry) + v.page.CurveTo(x0, y0+ry-ky, x0+rx-kx, y0, x0+rx, y0) + v.page.ClosePath() +} + +// cornerRadii resolves the painter pixel radius (clamped to half the smaller +// side, mirroring the pixel painter) into PDF-point radii along each axis. A +// non-positive result means "no rounding". +func (v *vectorPainter) cornerRadii(r painter.Rect, radius int) (rx, ry float64) { + m := r.W + if r.H < m { + m = r.H + } + if radius > m/2 { + radius = m / 2 + } + if radius <= 0 { + return 0, 0 + } + return float64(radius) * v.sx, float64(radius) * v.sy +} + +// FillRoundRect fills r with rounded corners. +func (v *vectorPainter) FillRoundRect(r painter.Rect, radius int, c painter.RGBA) { + if c.A == 0 || r.W <= 0 || r.H <= 0 { + return + } + rx, ry := v.cornerRadii(r, radius) + v.page.Save() + v.applyAlpha(c) + v.page.SetFillColor(RGB8(c.R, c.G, c.B)) + if rx <= 0 { + v.page.Rectangle(v.prect(r)) + } else { + v.roundRectPath(v.prect(r), rx, ry) + } + v.page.Fill() + v.page.Restore() +} + +// StrokeRoundRect strokes a rounded-corner border around r. +func (v *vectorPainter) StrokeRoundRect(r painter.Rect, radius int, c painter.RGBA, lineW int) { + if c.A == 0 || r.W <= 0 || r.H <= 0 { + return + } + rx, ry := v.cornerRadii(r, radius) + v.page.Save() + v.applyAlpha(c) + v.page.SetStrokeColor(RGB8(c.R, c.G, c.B)) + v.page.SetLineWidth(v.lineWidthPoints(lineW)) + if rx <= 0 { + v.page.Rectangle(v.prect(r)) + } else { + v.roundRectPath(v.prect(r), rx, ry) + } + v.page.Stroke() + v.page.Restore() +} + +// PutPixel paints a single painter pixel as a 1x1 filled cell. +func (v *vectorPainter) PutPixel(x, y int, c painter.RGBA) { + if c.A == 0 { + return + } + v.page.Save() + v.applyAlpha(c) + v.page.SetFillColor(RGB8(c.R, c.G, c.B)) + v.page.Rectangle(Rect{X: v.fx(x), Y: v.fy(y + 1), Width: v.sx, Height: v.sy}) + v.page.Fill() + v.page.Restore() +} + +// Text draws s as selectable PDF text. The painter positions text by its +// top-left corner; PDF positions it by the baseline, so the origin drops by the +// glyph box height. The run is sized so it roughly fills that box. +func (v *vectorPainter) Text(x, y int, s string, ink painter.RGBA) { + if ink.A == 0 || s == "" { + return + } + v.page.Save() + v.applyAlpha(ink) + v.page.SetFillColor(RGB8(ink.R, ink.G, ink.B)) + v.page.SetFont(v.font, widgetGlyphPx*v.sy) + baseline := v.fy(y + int(widgetGlyphPx)) + // The font was just set, so Text cannot return the no-font error. + _ = v.page.Text(v.fx(x), baseline, s) + v.page.Restore() +} + +// Size returns the canvas dimensions in painter pixels. +func (v *vectorPainter) Size() (int, int) { return v.w, v.h } + +// PushClip confines subsequent drawing to r (implements painter.Clipper). PDF +// graphics-state nesting carries the intersection with any enclosing clip. +func (v *vectorPainter) PushClip(r painter.Rect) { + v.page.Save() + v.page.Rectangle(v.prect(r)) + v.page.Clip() + v.page.EndPath() + v.clipDepth++ +} + +// PopClip removes the most recent PushClip. It is a no-op when the clip stack is +// empty so an unbalanced call cannot corrupt the graphics-state stack. +func (v *vectorPainter) PopClip() { + if v.clipDepth == 0 { + return + } + v.clipDepth-- + v.page.Restore() +} diff --git a/widget_test.go b/widget_test.go new file mode 100644 index 0000000..aa68f73 --- /dev/null +++ b/widget_test.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package pdfkit + +import ( + "bytes" + "errors" + "testing" + + "github.com/go-widgets/painter" + "github.com/go-widgets/toolkit" + "rsc.io/pdf" +) + +// buildScene returns a small but non-trivial widget tree: a container holding a +// prominent button (fills a rounded rect, strokes a border and draws centred +// text) and a label. It exercises fills, strokes, rounded rects and text on +// both the raster and vector paths. +func buildScene() toolkit.Widget { + root := toolkit.NewContainer(toolkit.NewBoxLayout()) + btn := toolkit.NewButton("Hi", nil) + btn.Style = toolkit.ButtonProminent + root.AddWidget(btn) + root.AddWidget(toolkit.NewLabel("Hi")) + return root +} + +// contentBytes returns page 1's (uncompressed) content stream via the +// independent rsc.io/pdf reader. +func contentBytes(t *testing.T, r *pdf.Reader) []byte { + t.Helper() + return readStream(t, r.Page(1).V.Key("Contents")) +} + +// TestAddWidgetRaster renders a widget tree as an image XObject and verifies via +// rsc.io/pdf that the image is present, correctly sized and actually painted. +func TestAddWidgetRaster(t *testing.T) { + doc := New(Options{}) + p := doc.AddPage(A4) + rect := Rect{X: 72, Y: 500, Width: 200, Height: 120} + if err := p.AddWidget(buildScene(), rect, &WidgetOptions{Scale: 3}); err != nil { + t.Fatalf("AddWidget: %v", err) + } + r := reopen(t, doc) + + im := r.Page(1).V.Key("Resources").Key("XObject").Key("Im0") + if got := im.Key("Subtype").Name(); got != "Image" { + t.Fatalf("XObject Subtype = %q, want Image", got) + } + wantW, wantH := int64(600), int64(360) // 200*3 x 120*3 + if gw, gh := im.Key("Width").Int64(), im.Key("Height").Int64(); gw != wantW || gh != wantH { + t.Fatalf("image dims = %dx%d, want %dx%d", gw, gh, wantW, wantH) + } + // The content stream must reference the image. + if !bytes.Contains(contentBytes(t, r), []byte("/Im0 Do")) { + t.Error("content stream does not draw /Im0") + } + // The decoded RGB samples must contain a painted (non-black) pixel: the + // button fills with a visible colour, proving the tree actually rendered. + pix := readStream(t, im) + painted := false + for _, b := range pix { + if b != 0 { + painted = true + break + } + } + if !painted { + t.Error("raster image is entirely black; widget tree did not render") + } +} + +// TestAddWidgetRasterDefaults drives the nil-options branch (default theme + +// default scale). +func TestAddWidgetRasterDefaults(t *testing.T) { + doc := New(Options{}) + p := doc.AddPage(A4) + if err := p.AddWidget(buildScene(), Rect{X: 10, Y: 10, Width: 100, Height: 50}, nil); err != nil { + t.Fatalf("AddWidget: %v", err) + } + r := reopen(t, doc) + // Default scale is 2, so 100x50 pt -> 200x100 px. + im := r.Page(1).V.Key("Resources").Key("XObject").Key("Im0") + if gw, gh := im.Key("Width").Int64(), im.Key("Height").Int64(); gw != 200 || gh != 100 { + t.Fatalf("default-scale dims = %dx%d, want 200x100", gw, gh) + } +} + +// TestAddWidgetRasterEmptyRect covers the empty-rectangle error branch. +func TestAddWidgetRasterEmptyRect(t *testing.T) { + doc := New(Options{}) + p := doc.AddPage(A4) + err := p.AddWidget(buildScene(), Rect{Width: 0, Height: 100}, &WidgetOptions{Scale: 2}) + if !errors.Is(err, errWidgetRectEmpty) { + t.Fatalf("err = %v, want errWidgetRectEmpty", err) + } +} + +// TestAddWidgetVector renders the tree with PDF vector operators and confirms +// via rsc.io/pdf that a Type0 font resource and selectable text are present +// alongside fill/stroke path operators. +func TestAddWidgetVector(t *testing.T) { + f, err := LoadFont(synthTTF(defaultSynth())) + if err != nil { + t.Fatal(err) + } + doc := New(Options{}) + p := doc.AddPage(A4) + opts := &WidgetOptions{ + Theme: toolkit.DefaultDark(), + Scale: 2, + Font: f, + } + if err := p.AddWidgetVector(buildScene(), Rect{X: 40, Y: 400, Width: 240, Height: 140}, opts); err != nil { + t.Fatalf("AddWidgetVector: %v", err) + } + r := reopen(t, doc) + + // A Type0 font resource proves text became real, selectable PDF text. + if got := firstFontDict(r).Key("Subtype").Name(); got != "Type0" { + t.Fatalf("font Subtype = %q, want Type0", got) + } + content := contentBytes(t, r) + for _, op := range [][]byte{ + []byte("Tj"), // text show + []byte("f\n"), // fill + []byte("S\n"), // stroke + []byte(" c\n"), // rounded-corner Bézier + []byte("W\n"), // clip + } { + if !bytes.Contains(content, op) { + t.Errorf("vector content stream missing operator %q", op) + } + } +} + +// TestAddWidgetVectorNoFont covers the missing-font error branch. +func TestAddWidgetVectorNoFont(t *testing.T) { + doc := New(Options{}) + p := doc.AddPage(A4) + err := p.AddWidgetVector(buildScene(), Rect{Width: 100, Height: 100}, nil) + if !errors.Is(err, errWidgetVectorNoFont) { + t.Fatalf("err = %v, want errWidgetVectorNoFont", err) + } +} + +// TestAddWidgetVectorEmptyRect covers the empty-rectangle error branch on the +// vector path (with a font supplied so it reaches the size check). +func TestAddWidgetVectorEmptyRect(t *testing.T) { + f, err := LoadFont(synthTTF(defaultSynth())) + if err != nil { + t.Fatal(err) + } + doc := New(Options{}) + p := doc.AddPage(A4) + err = p.AddWidgetVector(buildScene(), Rect{Width: 100, Height: 0}, &WidgetOptions{Font: f}) + if !errors.Is(err, errWidgetRectEmpty) { + t.Fatalf("err = %v, want errWidgetRectEmpty", err) + } +} + +// newTestVectorPainter builds a vectorPainter over a fresh page with a 1 pt = +// 1 px mapping, plus the document it belongs to for serialisation. +func newTestVectorPainter(t *testing.T) (*Document, *vectorPainter) { + t.Helper() + f, err := LoadFont(synthTTF(defaultSynth())) + if err != nil { + t.Fatal(err) + } + doc := New(Options{}) + p := doc.AddPage(A4) + vp := &vectorPainter{ + page: p, + rx: 0, ry: 0, rh: 200, + sx: 1, sy: 1, + w: 200, h: 200, + font: f, + } + return doc, vp +} + +// TestVectorPainterPrimitives drives every vectorPainter primitive and branch +// directly, then serialises through rsc.io/pdf to assert the expected operators +// landed in the content stream. +func TestVectorPainterPrimitives(t *testing.T) { + doc, vp := newTestVectorPainter(t) + + red := painter.RGB(200, 20, 20) + semi := painter.RGBA{R: 0, G: 0, B: 200, A: 128} // exercises applyAlpha (ca/CA) + + // Fills and strokes. + vp.FillRect(painter.Rect{X: 5, Y: 5, W: 40, H: 20}, red) + vp.FillRect(painter.Rect{X: 0, Y: 0, W: 10, H: 10}, semi) // alpha branch + vp.StrokeRect(painter.Rect{X: 5, Y: 40, W: 40, H: 20}, red, 0) // lineW<1 clamp + + // Rounded rects: rounded path + degrade-to-square branch. + vp.FillRoundRect(painter.Rect{X: 60, Y: 5, W: 40, H: 30}, 8, red) + vp.FillRoundRect(painter.Rect{X: 60, Y: 45, W: 40, H: 30}, 0, red) // radius<=0 degrade + vp.StrokeRoundRect(painter.Rect{X: 110, Y: 5, W: 40, H: 30}, 6, red, 2) + vp.StrokeRoundRect(painter.Rect{X: 110, Y: 45, W: 40, H: 30}, 0, red, 1) // degrade + // Wide-short rect + oversized radius: exercises cornerRadii's r.H