Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,88 @@ jobs:
GOARCH: wasm
run: go test ./...

tinygo:
# The size leg. blazon's cost to a caller is the code it drags into their
# binary, and the two arguments for owning the rasteriser, the font, the
# encoder and the digest were reproducibility and size. Reproducibility is
# measured everywhere else in this file; this is where size stops being an
# anecdote from someone's laptop.
#
# TinyGo is the right scale to weigh it on. It has no reflection-heavy
# runtime to hide behind, so an accidental fmt.Errorf on an image type or
# a stray image/draw shows up as hundreds of kilobytes rather than as
# noise.
#
# The leg also renders a mark under a genuinely different compiler and
# compares it byte for byte against gc's. That is a stronger statement
# than the wasm leg makes: LLVM and gc do not have to agree about floats,
# and if they ever stop agreeing, this is where it surfaces.
runs-on: ubuntu-latest
env:
WASMTIME_VERSION: v48.0.1
TINYGO_VERSION: "0.42.0"
# Bytes. A ratchet, not a law: raise it deliberately, in a commit that
# says what the library gained in exchange. The figure has roughly 20%
# of headroom over what the probe currently builds to, so ordinary
# renderer work does not trip it — but not enough that fmt could come
# back unnoticed, which is what the budget is really watching for.
SIZE_BUDGET: 950000
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: stable
cache: true

- name: install tinygo
shell: bash
run: |
deb="tinygo_${TINYGO_VERSION}_amd64.deb"
curl -sSfLO "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/${deb}"
sudo dpkg -i "$deb"

- name: install wasmtime
shell: bash
run: |
name="wasmtime-${WASMTIME_VERSION}-x86_64-linux"
curl -sSfL "https://github.com/bytecodealliance/wasmtime/releases/download/${WASMTIME_VERSION}/${name}.tar.xz" | tar -xJ
echo "$PWD/${name}" >> "$GITHUB_PATH"

- name: the packages that carry the pixels must pass under tinygo
# The root package is left out on purpose: its suite renders whole
# corpora, and TinyGo's wasm heap runs out before it finishes. What is
# checked here is the code that decides bytes — the encoder, the
# digest, the noise, the hash and the canvas.
run: tinygo test -target=wasip1 ./canvas ./internal/...

- name: the probe fits in its budget
shell: bash
run: |
tinygo build -o /tmp/sizeprobe.wasm -target=wasip1 ./internal/sizeprobe
size=$(stat -c%s /tmp/sizeprobe.wasm)
echo "sizeprobe.wasm is $size bytes, budget $SIZE_BUDGET"
echo "sizeprobe.wasm: $size bytes (budget $SIZE_BUDGET)" >> "$GITHUB_STEP_SUMMARY"
if [ "$size" -gt "$SIZE_BUDGET" ]; then
echo "The probe grew past its budget. Either something heavy came"
echo "back into the imports — see TestNoHeavyStandardLibraryImports"
echo "— or the budget needs raising on purpose."
exit 1
fi

- name: the same mark under a different compiler
shell: bash
run: |
go run ./internal/sizeprobe 1.4.2 > /tmp/gc.png
wasmtime run /tmp/sizeprobe.wasm 1.4.2 > /tmp/tinygo.png
if ! cmp /tmp/gc.png /tmp/tinygo.png; then
echo "gc and TinyGo disagree about what version 1.4.2 looks like."
echo "The renderers are float-heavy, so this is not automatically a"
echo "bug — but it is a change in what the library promises, and it"
echo "needs a decision rather than a merge."
exit 1
fi
echo "gc and TinyGo render 1.4.2 to the same bytes"

distinctness:
# The gate. If a change makes patch bumps harder to tell apart, this job is
# what says so — the invariant as a build rule rather than as a claim in
Expand Down
6 changes: 3 additions & 3 deletions bits.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package blazon

import (
"crypto/sha256"
"encoding/binary"
"math"

"github.com/timzifer/blazon/internal/sha256"
)

// BitReader is a deterministic, unlimited bit stream derived from a 32-byte
Expand Down Expand Up @@ -49,7 +49,7 @@ func (b *BitReader) nextByte() byte {
h.Write([]byte("blazon/v1/extend\x00"))
h.Write(b.root[:])
var ctr [8]byte
binary.BigEndian.PutUint64(ctr[:], b.counter)
putUint64(ctr[:], b.counter)
h.Write(ctr[:])
copy(b.block[:], h.Sum(nil))
b.byteIdx = 0
Expand Down
33 changes: 21 additions & 12 deletions blazon.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
package blazon

import (
"bytes"
"fmt"
"errors"
"image"
"image/png"
"sort"
"slices"
"strconv"
"strings"
"sync"

"github.com/timzifer/blazon/canvas"
"github.com/timzifer/blazon/internal/pngenc"
)

// DefaultRenderer is used when Options.Renderer is empty.
Expand All @@ -32,7 +33,7 @@ const DefaultRenderer = "superformula"
// logo in the README is the flowfield mark for this exact version, and the
// release workflow refuses a tag that does not match. Bump it in the same
// commit as the tag.
const Release = "0.1.0"
const Release = "0.2.0"

// LogoRenderer draws the mark blazon uses for itself.
const LogoRenderer = "flowfield"
Expand Down Expand Up @@ -194,7 +195,7 @@ func Renderers() []string {
for n := range registry {
names = append(names, n)
}
sort.Strings(names)
slices.Sort(names)
return names
}

Expand All @@ -203,7 +204,11 @@ func resolve(o Options) (Renderer, error) {
name := o.rendererName()
r, ok := Lookup(name)
if !ok {
return nil, fmt.Errorf("blazon: unknown renderer %q (have %v)", name, Renderers())
// Spelled out rather than formatted: fmt would pull reflection into
// every binary that links this library, which is more than the whole
// renderer set costs.
return nil, errors.New("blazon: unknown renderer " + strconv.Quote(name) +
" (have [" + strings.Join(Renderers(), " ") + "])")
}
return r, nil
}
Expand Down Expand Up @@ -289,12 +294,16 @@ func PNG(version string, o Options) ([]byte, error) {
}

// EncodePNG encodes an image as PNG.
//
// The encoder is the library's own, for the same reason the rasteriser and
// the font are: it is the last stage that decides what bytes a version turns
// into, and importing image/png would hand that decision — and about a fifth
// of a megabyte of binary — to something outside this repository.
//
// The error is retained in the signature because callers already handle one
// and encoding is where a future format could fail; today it is always nil.
func EncodePNG(img image.Image) ([]byte, error) {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
return pngenc.Encode(img), nil
}

// Text renders a version as a block of terminal output.
Expand Down
19 changes: 19 additions & 0 deletions blazon_register_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//go:build !tinygo

package blazon

import "testing"

// TestRegisterRejectsDuplicates is kept away from TinyGo, whose recover()
// does not catch a panic raised inside another function and whose t.Skip does
// not unwind — either one turns this test from a check into a crash. The
// behaviour under test is compiler-independent; only this way of observing it
// is not.
func TestRegisterRejectsDuplicates(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("registering a duplicate name did not panic")
}
}()
Register(truchetRenderer{})
}
9 changes: 0 additions & 9 deletions blazon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,6 @@ func TestRenderersRegistered(t *testing.T) {
}
}

func TestRegisterRejectsDuplicates(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("registering a duplicate name did not panic")
}
}()
Register(truchetRenderer{})
}

func TestOptionDefaults(t *testing.T) {
var o Options
if got := o.size(); got != defaultSize {
Expand Down
36 changes: 33 additions & 3 deletions blazontest/blazontest.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"fmt"
"image"
"image/color"
"image/draw"
"io"
"os"
"sort"
Expand Down Expand Up @@ -582,7 +581,7 @@ func ContactSheet(r blazon.Renderer, versions []string, o blazon.Options, cols,
}

sheet := image.NewRGBA(image.Rect(0, 0, cols*cell, rows*(cell+labelH)))
draw.Draw(sheet, sheet.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
fillWhite(sheet)

for i, v := range versions {
img, err := blazon.ImageWith(r, v, o)
Expand All @@ -591,7 +590,7 @@ func ContactSheet(r blazon.Renderer, versions []string, o blazon.Options, cols,
}
cx := (i % cols) * cell
cy := (i / cols) * (cell + labelH)
draw.Draw(sheet, image.Rect(cx, cy, cx+cell, cy+cell), img, image.Point{}, draw.Src)
blit(sheet, img, cx, cy)
scale := labelScaleFor(v, cell)
// Baseline-align the label whatever scale it ended up at, so a row of
// mixed scales still reads as one line.
Expand Down Expand Up @@ -621,3 +620,34 @@ func labelScaleFor(s string, cellW int) int {
func label(dst *image.RGBA, x, y int, s string, scale int) {
canvas.DrawString(dst, x, y, s, color.RGBA{40, 40, 40, 255}, scale)
}

// fillWhite and blit are image/draw's Src operation for the two cases the
// contact sheet needs. They are written out because the library refuses the
// import — see TestNoHeavyStandardLibraryImports — and because a sheet only
// ever paints an opaque background and copies opaque cells onto it.
func fillWhite(dst *image.RGBA) {
for i := range dst.Pix {
dst.Pix[i] = 0xff
}
}

// blit copies src onto dst with its top-left corner at (x, y), clipped to dst.
func blit(dst, src *image.RGBA, x, y int) {
b := src.Bounds()
for row := 0; row < b.Dy(); row++ {
dy := y + row
if dy < 0 || dy >= dst.Bounds().Dy() {
continue
}
w := b.Dx()
if x+w > dst.Bounds().Dx() {
w = dst.Bounds().Dx() - x
}
if w <= 0 {
continue
}
si := src.PixOffset(b.Min.X, b.Min.Y+row)
di := dst.PixOffset(x, dy)
copy(dst.Pix[di:di+w*4], src.Pix[si:si+w*4])
}
}
29 changes: 24 additions & 5 deletions canvas/ansi.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package canvas

import (
"fmt"
"image/color"
"strconv"
"strings"
)

Expand Down Expand Up @@ -114,8 +114,9 @@ func (a *ANSI) String() string {
top := img.RGBAAt(col, row*2)
bottom := img.RGBAAt(col, row*2+1)
if a.trueColor {
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀",
top.R, top.G, top.B, bottom.R, bottom.G, bottom.B)
writeSGR(&b, 38, top.R, top.G, top.B)
writeSGR(&b, 48, bottom.R, bottom.G, bottom.B)
b.WriteRune('▀')
continue
}
b.WriteByte(rampByte(avgLuma(top, bottom)))
Expand All @@ -135,13 +136,31 @@ func (a *ANSI) writeGlyph(b *strings.Builder, g glyphCell) {
}
fr, fg2, fb, _ := g.fg.RGBA()
br, bg2, bb, ba := g.bg.RGBA()
fmt.Fprintf(b, "\x1b[38;2;%d;%d;%dm", fr>>8, fg2>>8, fb>>8)
writeSGR(b, 38, uint8(fr>>8), uint8(fg2>>8), uint8(fb>>8))
if ba > 0 {
fmt.Fprintf(b, "\x1b[48;2;%d;%d;%dm", br>>8, bg2>>8, bb>>8)
writeSGR(b, 48, uint8(br>>8), uint8(bg2>>8), uint8(bb>>8))
}
b.WriteRune(g.r)
}

// writeSGR emits one 24-bit colour escape: 38 sets the foreground, 48 the
// background.
//
// Assembled by hand rather than with fmt, which the library does not import —
// reflection is the heaviest thing a caller's binary could inherit from a
// package that draws pictures, and three integers do not need it.
func writeSGR(b *strings.Builder, ground int, r, g, bl uint8) {
b.WriteString("\x1b[")
b.WriteString(strconv.Itoa(ground))
b.WriteString(";2;")
b.WriteString(strconv.Itoa(int(r)))
b.WriteByte(';')
b.WriteString(strconv.Itoa(int(g)))
b.WriteByte(';')
b.WriteString(strconv.Itoa(int(bl)))
b.WriteByte('m')
}

// avgLuma is the mean relative luminance of the two pixels a cell covers,
// composited over white so an unpainted ground reads as light.
func avgLuma(a, b color.RGBA) float64 {
Expand Down
27 changes: 24 additions & 3 deletions canvas/raster.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package canvas
import (
"image"
"image/color"
"image/draw"
"math"
)

Expand Down Expand Up @@ -70,7 +69,7 @@ func (r *Raster) Background(col color.Color) {
if _, _, _, a := col.RGBA(); a == 0 {
return
}
draw.Draw(r.img, r.img.Bounds(), image.NewUniform(col), image.Point{}, draw.Src)
fillRect(r.img, r.img.Bounds(), col)
}

// Fill implements Canvas.
Expand Down Expand Up @@ -115,6 +114,28 @@ func (r *Raster) rasterise(polys [][]Point, col color.Color) {
r.fill.blit(r.img, col)
}

// fillRect paints a rectangle in one colour, replacing whatever is under it.
//
// This is image/draw's Src over a uniform source, written out because that is
// the only thing the whole package wanted from it, and the import brings a
// general-purpose compositor along for it.
func fillRect(img *image.RGBA, rect image.Rectangle, col color.Color) {
rect = rect.Intersect(img.Bounds())
if rect.Empty() {
return
}
cr, cg, cb, ca := col.RGBA()
// RGBA holds premultiplied 8-bit channels, which is the top byte of each
// of the 16-bit values color.Color reports.
p8 := [4]uint8{uint8(cr >> 8), uint8(cg >> 8), uint8(cb >> 8), uint8(ca >> 8)}
for y := rect.Min.Y; y < rect.Max.Y; y++ {
row := img.Pix[img.PixOffset(rect.Min.X, y) : img.PixOffset(rect.Max.X-1, y)+4]
for x := 0; x+4 <= len(row); x += 4 {
copy(row[x:x+4], p8[:])
}
}
}

// Cell implements Canvas using the built-in bitmap font. It exists so that the
// character renderers still produce an image for the gallery, not because
// anyone should prefer a PNG of a terminal mark over the terminal itself.
Expand All @@ -128,7 +149,7 @@ func (r *Raster) Cell(col, row int, ch rune, fg, bg color.Color) {

if _, _, _, a := bg.RGBA(); a > 0 {
rect := image.Rect(int(x0), int(y0), int(math.Ceil(x0+cw)), int(math.Ceil(y0+chh)))
draw.Draw(r.img, rect.Intersect(r.img.Bounds()), image.NewUniform(bg), image.Point{}, draw.Src)
fillRect(r.img, rect.Intersect(r.img.Bounds()), bg)
}
if ch == ' ' || ch == 0 {
return
Expand Down
Loading