diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77cb106..ec706ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/bits.go b/bits.go index e396a6d..b73b833 100644 --- a/bits.go +++ b/bits.go @@ -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 @@ -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 diff --git a/blazon.go b/blazon.go index c3efbed..69bc157 100644 --- a/blazon.go +++ b/blazon.go @@ -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. @@ -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" @@ -194,7 +195,7 @@ func Renderers() []string { for n := range registry { names = append(names, n) } - sort.Strings(names) + slices.Sort(names) return names } @@ -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 } @@ -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. diff --git a/blazon_register_test.go b/blazon_register_test.go new file mode 100644 index 0000000..219d5bc --- /dev/null +++ b/blazon_register_test.go @@ -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{}) +} diff --git a/blazon_test.go b/blazon_test.go index b89c6f8..63e572a 100644 --- a/blazon_test.go +++ b/blazon_test.go @@ -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 { diff --git a/blazontest/blazontest.go b/blazontest/blazontest.go index 2399275..f8d7c66 100644 --- a/blazontest/blazontest.go +++ b/blazontest/blazontest.go @@ -15,7 +15,6 @@ import ( "fmt" "image" "image/color" - "image/draw" "io" "os" "sort" @@ -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) @@ -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. @@ -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]) + } +} diff --git a/canvas/ansi.go b/canvas/ansi.go index 7286ba3..2b30bf2 100644 --- a/canvas/ansi.go +++ b/canvas/ansi.go @@ -1,8 +1,8 @@ package canvas import ( - "fmt" "image/color" + "strconv" "strings" ) @@ -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))) @@ -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 { diff --git a/canvas/raster.go b/canvas/raster.go index 9aab769..9ebc9ba 100644 --- a/canvas/raster.go +++ b/canvas/raster.go @@ -3,7 +3,6 @@ package canvas import ( "image" "image/color" - "image/draw" "math" ) @@ -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. @@ -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. @@ -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 diff --git a/canvas/svg.go b/canvas/svg.go index f5091e5..14c4eac 100644 --- a/canvas/svg.go +++ b/canvas/svg.go @@ -1,7 +1,6 @@ package canvas import ( - "fmt" "image/color" "strconv" "strings" @@ -45,8 +44,8 @@ func (s *SVG) Background(col color.Color) { if _, _, _, a := col.RGBA(); a == 0 { return } - fmt.Fprintf(&s.body, " \n", - num(s.w), num(s.h), hex(col)) + s.write(" \n") } // Fill implements Canvas. @@ -56,9 +55,9 @@ func (s *SVG) Fill(col color.Color) { if d == "" { return } - fmt.Fprintf(&s.body, " \n") } @@ -70,16 +69,28 @@ func (s *SVG) Stroke(col color.Color, width float64) { if d == "" || width <= 0 { return } - fmt.Fprintf(&s.body, - " \n") } +// write appends the pieces of one element to the document. +// +// The markup is assembled from parts rather than from a format string because +// fmt would be the single heaviest import in the library: it pulls in +// reflection, which on a small target is a larger download than everything +// this package does put together. Every value here is already a string by the +// time it arrives, so the format string was never doing any work. +func (s *SVG) write(parts ...string) { + for _, p := range parts { + s.body.WriteString(p) + } +} + func (s *SVG) pathData() string { var b strings.Builder for _, sub := range s.path.subs { @@ -115,16 +126,17 @@ func (s *SVG) Cell(col, row int, r rune, fg, bg color.Color) { cw, ch := s.w/float64(s.cols), s.h/float64(s.rows) x, y := float64(col)*cw, float64(row)*ch if _, _, _, a := bg.RGBA(); a > 0 { - fmt.Fprintf(&s.body, " \n", - num(x), num(y), num(cw), num(ch), hex(bg)) + s.write(" \n") } if r == ' ' || r == 0 { return } - fmt.Fprintf(&s.body, - " %s\n", - num(x+cw/2), num(y+ch/2), num(ch*0.8), hex(fg), escapeXML(string(r))) + s.write(" ", escapeXML(string(r)), "\n") } // Bytes renders the finished document. @@ -161,7 +173,13 @@ func num(v float64) string { func hex(c color.Color) string { r, g, b, _ := c.RGBA() - return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8)) + out := []byte("#000000") + const digits = "0123456789abcdef" + for i, v := range [3]uint8{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)} { + out[1+i*2] = digits[v>>4] + out[2+i*2] = digits[v&0xf] + } + return string(out) } // opacity returns the fill-opacity attribute value for partly transparent diff --git a/deps_test.go b/deps_test.go index 7100e60..5089cad 100644 --- a/deps_test.go +++ b/deps_test.go @@ -1,7 +1,12 @@ package blazon_test import ( + "go/parser" + "go/token" + "io/fs" "os" + "path/filepath" + "strconv" "strings" "testing" ) @@ -42,3 +47,68 @@ func TestNoDependencies(t *testing.T) { } } } + +// TestNoHeavyStandardLibraryImports keeps the binary small on purpose. +// +// A program whose whole job is to draw one small mark should not carry a +// general-purpose compositor, a general-purpose compressor, the FIPS-140 +// module, or reflection. Each of these was measured and replaced by code in +// this repository that produces the same bytes; together they were two thirds +// of what the library added to a caller's binary. +// +// The rule is here because they come back by accident and nothing else would +// notice. A single fmt.Errorf brings reflection with it, and it is the kind of +// line nobody reviews twice. +func TestNoHeavyStandardLibraryImports(t *testing.T) { + banned := map[string]string{ + "image/png": "use internal/pngenc", + "image/draw": "fill the pixels directly; see canvas.fillRect", + "compress/zlib": "use internal/pngenc", + "compress/flate": "use internal/pngenc", + "crypto/sha256": "use internal/sha256", + "hash/crc32": "use internal/pngenc", + "fmt": "assemble the string; every value here is already one", + "reflect": "nothing in a drawing library needs to inspect a type", + "encoding/binary": "shift the bytes; encoding/binary imports reflect", + "sort": "use slices; sort imports reflect", + } + + fset := token.NewFileSet() + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + switch { + case err != nil: + return err + case d.IsDir(): + // The rule covers what a caller links: the library and the + // packages it draws with. blazontest is exempt because its + // callers already link testing, which brings fmt and reflection + // anyway, and cmd/blazon because flag does the same. The probe + // directory is scratch and testdata is not code. + switch d.Name() { + case "testdata", "probe", ".git", "blazontest", "cmd": + return fs.SkipDir + } + return nil + case !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go"): + return nil + } + + f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imp := range f.Imports { + p, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return err + } + if why, ok := banned[p]; ok { + t.Errorf("%s imports %s: %s", path, p, why) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/docs/gallery/bishop-family.png b/docs/gallery/bishop-family.png index 875d4f8..241e2da 100644 Binary files a/docs/gallery/bishop-family.png and b/docs/gallery/bishop-family.png differ diff --git a/docs/gallery/bishop-sheet.png b/docs/gallery/bishop-sheet.png index c612ce4..b3b3b27 100644 Binary files a/docs/gallery/bishop-sheet.png and b/docs/gallery/bishop-sheet.png differ diff --git a/docs/gallery/cistercian-family.png b/docs/gallery/cistercian-family.png index e993979..66d49c6 100644 Binary files a/docs/gallery/cistercian-family.png and b/docs/gallery/cistercian-family.png differ diff --git a/docs/gallery/cistercian-sheet.png b/docs/gallery/cistercian-sheet.png index 9088838..efc4b05 100644 Binary files a/docs/gallery/cistercian-sheet.png and b/docs/gallery/cistercian-sheet.png differ diff --git a/docs/gallery/dial-family.png b/docs/gallery/dial-family.png index d92ff16..b756a4d 100644 Binary files a/docs/gallery/dial-family.png and b/docs/gallery/dial-family.png differ diff --git a/docs/gallery/dial-sheet.png b/docs/gallery/dial-sheet.png index 57b1078..c1b372b 100644 Binary files a/docs/gallery/dial-sheet.png and b/docs/gallery/dial-sheet.png differ diff --git a/docs/gallery/flowfield-family.png b/docs/gallery/flowfield-family.png index c45e3b3..42a9aa0 100644 Binary files a/docs/gallery/flowfield-family.png and b/docs/gallery/flowfield-family.png differ diff --git a/docs/gallery/flowfield-sheet.png b/docs/gallery/flowfield-sheet.png index c45fa0a..0fac63d 100644 Binary files a/docs/gallery/flowfield-sheet.png and b/docs/gallery/flowfield-sheet.png differ diff --git a/docs/gallery/orbit-family.png b/docs/gallery/orbit-family.png index b9eb0fc..2c134ed 100644 Binary files a/docs/gallery/orbit-family.png and b/docs/gallery/orbit-family.png differ diff --git a/docs/gallery/orbit-sheet.png b/docs/gallery/orbit-sheet.png index 78ee8e9..2733be0 100644 Binary files a/docs/gallery/orbit-sheet.png and b/docs/gallery/orbit-sheet.png differ diff --git a/docs/gallery/polar-family.png b/docs/gallery/polar-family.png index fbd2f65..2129fab 100644 Binary files a/docs/gallery/polar-family.png and b/docs/gallery/polar-family.png differ diff --git a/docs/gallery/polar-sheet.png b/docs/gallery/polar-sheet.png index eb33158..dbd7a48 100644 Binary files a/docs/gallery/polar-sheet.png and b/docs/gallery/polar-sheet.png differ diff --git a/docs/gallery/superformula-family.png b/docs/gallery/superformula-family.png index 7143449..972f565 100644 Binary files a/docs/gallery/superformula-family.png and b/docs/gallery/superformula-family.png differ diff --git a/docs/gallery/superformula-sheet.png b/docs/gallery/superformula-sheet.png index 0f6a3d2..3d72cb0 100644 Binary files a/docs/gallery/superformula-sheet.png and b/docs/gallery/superformula-sheet.png differ diff --git a/docs/gallery/truchet-family.png b/docs/gallery/truchet-family.png index 2eee0ea..0ee023d 100644 Binary files a/docs/gallery/truchet-family.png and b/docs/gallery/truchet-family.png differ diff --git a/docs/gallery/truchet-sheet.png b/docs/gallery/truchet-sheet.png index 1e9160d..4098672 100644 Binary files a/docs/gallery/truchet-sheet.png and b/docs/gallery/truchet-sheet.png differ diff --git a/docs/logo.svg b/docs/logo.svg index 8e0c80a..b0e83d7 100644 --- a/docs/logo.svg +++ b/docs/logo.svg @@ -1,3 +1,3 @@ - + diff --git a/entropy.go b/entropy.go index 35460d3..ad420b7 100644 --- a/entropy.go +++ b/entropy.go @@ -1,9 +1,6 @@ package blazon -import ( - "crypto/sha256" - "encoding/binary" -) +import "github.com/timzifer/blazon/internal/sha256" // Policy decides how much of a version's identity is inherited from its // neighbours. It is an API-level choice, not a design detail: it determines @@ -67,10 +64,19 @@ func NewSeed(v Version) *Seed { func num(u uint64) []byte { var b [8]byte - binary.BigEndian.PutUint64(b[:], u) + putUint64(b[:], u) return b[:] } +// putUint64 writes a big-endian counter. encoding/binary would do it, but it +// imports reflect, which is more weight than every renderer in this package +// put together. +func putUint64(b []byte, v uint64) { + for i := 0; i < 8; i++ { + b[i] = byte(v >> (56 - 8*i)) + } +} + // hashFields hashes a domain tag followed by length-prefixed fields. The // length prefix is what keeps "1.23.4" and "12.3.4" apart; naive concatenation // would collide them. @@ -81,7 +87,7 @@ func hashFields(domain string, fields ...[]byte) [32]byte { h.Write([]byte{0}) var n [8]byte for _, f := range fields { - binary.BigEndian.PutUint64(n[:], uint64(len(f))) + putUint64(n[:], uint64(len(f))) h.Write(n[:]) h.Write(f) } diff --git a/internal/noise/simplex.go b/internal/noise/simplex.go index 86a118b..6b68212 100644 --- a/internal/noise/simplex.go +++ b/internal/noise/simplex.go @@ -8,8 +8,7 @@ package noise import ( - "crypto/sha256" - "encoding/binary" + "github.com/timzifer/blazon/internal/sha256" "math" ) @@ -45,6 +44,14 @@ func New(seed [32]byte) *Simplex { return &s } +// putUint64 writes a big-endian block counter, in place of encoding/binary, +// which imports reflect. +func putUint64(b []byte, v uint64) { + for i := 0; i < 8; i++ { + b[i] = byte(v >> (56 - 8*i)) + } +} + // expand derives n bytes from a seed by hashing it with a block counter. func expand(seed [32]byte, n int) []byte { out := make([]byte, 0, n+sha256.Size) @@ -54,7 +61,7 @@ func expand(seed [32]byte, n int) []byte { h.Write([]byte("blazon/v1/noise")) h.Write([]byte{0}) h.Write(seed[:]) - binary.BigEndian.PutUint64(ctr[:], block) + putUint64(ctr[:], block) h.Write(ctr[:]) out = h.Sum(out) } diff --git a/internal/pngenc/crc.go b/internal/pngenc/crc.go new file mode 100644 index 0000000..3e9aaf4 --- /dev/null +++ b/internal/pngenc/crc.go @@ -0,0 +1,30 @@ +package pngenc + +// crc32 is the CRC-32 every PNG chunk carries. It is here rather than from +// hash/crc32 for the same reason the compressor is: four chunks' worth of +// checksum is not worth an import that drags a hardware-accelerated table +// builder into a binary that only ever draws one small picture. + +var crcTable [256]uint32 + +func init() { + for i := range crcTable { + c := uint32(i) + for k := 0; k < 8; k++ { + if c&1 != 0 { + c = 0xedb88320 ^ (c >> 1) + } else { + c >>= 1 + } + } + crcTable[i] = c + } +} + +func crc32(data []byte) uint32 { + c := ^uint32(0) + for _, b := range data { + c = crcTable[byte(c)^b] ^ (c >> 8) + } + return ^c +} diff --git a/internal/pngenc/deflate.go b/internal/pngenc/deflate.go new file mode 100644 index 0000000..cc474b3 --- /dev/null +++ b/internal/pngenc/deflate.go @@ -0,0 +1,238 @@ +package pngenc + +// A deflate compressor, written here so the library keeps no dependency on +// compress/flate. The output is a fixed-Huffman stream: it compresses the +// large flat areas and repeated scanlines a mark produces well enough, and it +// costs a fraction of the code a dynamic-Huffman encoder would. +// +// Determinism matters more here than the last percent of ratio. The match +// search is fully specified below — window, chain limit, lazy rule — so the +// same pixels always produce the same file, on every architecture and every +// Go release. + +const ( + windowSize = 1 << 15 // the deflate window, 32 KiB + minMatch = 3 + maxMatch = 258 + hashBits = 15 + hashSize = 1 << hashBits + maxChain = 128 // positions examined per match; caps the worst case + noPos = -1 +) + +// Tables from RFC 1951, section 3.2.5. +var ( + lengthBase = [...]int{3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, + 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258} + lengthExtra = [...]uint{0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, + 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0} + distBase = [...]int{1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, + 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577} + distExtra = [...]uint{0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, + 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13} +) + +// bitWriter emits deflate's little-endian bit order: bits fill each byte from +// the least significant end, while Huffman codes are written most significant +// bit first. +type bitWriter struct { + out []byte + bits uint32 + nbits uint +} + +func (w *bitWriter) writeBits(v uint32, n uint) { + w.bits |= v << w.nbits + w.nbits += n + for w.nbits >= 8 { + w.out = append(w.out, byte(w.bits)) + w.bits >>= 8 + w.nbits -= 8 + } +} + +// writeCode emits a Huffman code, whose bits travel in the opposite order to +// the value bits above. +func (w *bitWriter) writeCode(code uint32, n uint) { + var rev uint32 + for i := uint(0); i < n; i++ { + rev = rev<<1 | (code>>i)&1 + } + w.writeBits(rev, n) +} + +func (w *bitWriter) flush() { + for w.nbits > 0 { + w.out = append(w.out, byte(w.bits)) + w.bits >>= 8 + if w.nbits < 8 { + w.nbits = 0 + } else { + w.nbits -= 8 + } + } +} + +// writeSymbol emits a literal/length symbol from the fixed alphabet. +func (w *bitWriter) writeSymbol(sym int) { + switch { + case sym < 144: + w.writeCode(0x30+uint32(sym), 8) + case sym < 256: + w.writeCode(0x190+uint32(sym)-144, 9) + case sym < 280: + w.writeCode(uint32(sym)-256, 7) + default: + w.writeCode(0xC0+uint32(sym)-280, 8) + } +} + +func (w *bitWriter) writeMatch(length, dist int) { + lc := len(lengthBase) - 1 + for lc > 0 && lengthBase[lc] > length { + lc-- + } + w.writeSymbol(257 + lc) + if n := lengthExtra[lc]; n > 0 { + w.writeBits(uint32(length-lengthBase[lc]), n) + } + + dc := len(distBase) - 1 + for dc > 0 && distBase[dc] > dist { + dc-- + } + // Distance codes are five fixed bits, most significant first. + w.writeCode(uint32(dc), 5) + if n := distExtra[dc]; n > 0 { + w.writeBits(uint32(dist-distBase[dc]), n) + } +} + +// deflate compresses src into a single fixed-Huffman block. +func deflate(src []byte) []byte { + w := &bitWriter{out: make([]byte, 0, len(src)/2+64)} + w.writeBits(1, 1) // final block + w.writeBits(1, 2) // fixed Huffman + + head := make([]int32, hashSize) + for i := range head { + head[i] = noPos + } + prev := make([]int32, len(src)) + + pos := 0 + for pos < len(src) { + length, dist := 0, 0 + if pos+minMatch <= len(src) { + length, dist = findMatch(src, prev, head, pos, 0) + } + if length >= minMatch { + // Lazy matching: a longer match starting one byte later is worth + // the extra literal, and this is where most of the ratio beyond a + // plain greedy parse comes from. + if pos+1+minMatch <= len(src) { + if l2, d2 := findMatch(src, prev, head, pos+1, length); l2 > length { + w.writeSymbol(int(src[pos])) + insert(src, prev, head, pos) + pos++ + length, dist = l2, d2 + } + } + w.writeMatch(length, dist) + for i := 0; i < length; i++ { + insert(src, prev, head, pos+i) + } + pos += length + continue + } + w.writeSymbol(int(src[pos])) + insert(src, prev, head, pos) + pos++ + } + + w.writeSymbol(256) // end of block + w.flush() + return w.out +} + +func hash4(src []byte, pos int) uint32 { + // Three bytes are the shortest match deflate can encode, so they are what + // the chain is keyed on. + h := uint32(src[pos])<<16 | uint32(src[pos+1])<<8 | uint32(src[pos+2]) + return (h * 0x9E3779B1) >> (32 - hashBits) +} + +func insert(src []byte, prev []int32, head []int32, pos int) { + if pos+minMatch > len(src) { + return + } + h := hash4(src, pos) + prev[pos] = head[h] + head[h] = int32(pos) +} + +// findMatch returns the longest match for src[pos:], or a length below +// minMatch when there is none longer than best. +func findMatch(src []byte, prev []int32, head []int32, pos, best int) (int, int) { + if pos+minMatch > len(src) { + return 0, 0 + } + limit := pos - windowSize + if limit < 0 { + limit = 0 + } + maxLen := len(src) - pos + if maxLen > maxMatch { + maxLen = maxMatch + } + bestLen, bestDist := best, 0 + cand := head[hash4(src, pos)] + for chain := 0; chain < maxChain && cand >= 0 && int(cand) >= limit; chain++ { + i := int(cand) + cand = prev[i] + if bestLen >= maxLen { + break + } + // The byte past the current best is the cheapest way to reject a + // candidate that cannot improve on it. + if bestLen > 0 && src[i+bestLen] != src[pos+bestLen] { + continue + } + n := 0 + for n < maxLen && src[i+n] == src[pos+n] { + n++ + } + if n > bestLen { + bestLen, bestDist = n, pos-i + } + } + if bestLen < minMatch || bestDist == 0 { + return 0, 0 + } + return bestLen, bestDist +} + +// zlib wraps a deflate stream in the container PNG's IDAT expects. +func zlib(src []byte) []byte { + out := make([]byte, 0, len(src)/2+64) + out = append(out, 0x78, 0x01) // 32 KiB window, deflate + out = append(out, deflate(src)...) + a := adler32(src) + return append(out, byte(a>>24), byte(a>>16), byte(a>>8), byte(a)) +} + +func adler32(data []byte) uint32 { + const mod = 65521 + s1, s2 := uint32(1), uint32(0) + for _, b := range data { + s1 += uint32(b) + if s1 >= mod { + s1 -= mod + } + s2 += s1 + if s2 >= mod { + s2 -= mod + } + } + return s2<<16 | s1 +} diff --git a/internal/pngenc/png.go b/internal/pngenc/png.go new file mode 100644 index 0000000..b716546 --- /dev/null +++ b/internal/pngenc/png.go @@ -0,0 +1,204 @@ +// Package pngenc encodes images as PNG without image/png. +// +// The library owns its rasteriser and its font so that a mark can only change +// when this repository decides it changes; the encoder is the last step of +// that same pipeline. Owning it keeps the file format on the same footing — +// and keeps the whole library inside the standard library's smallest corner, +// which is what makes a binary that draws marks cost kilobytes rather than +// megabytes. +package pngenc + +import ( + "image" + "image/color" +) + +// Encode returns the image as a PNG file. +// +// Fully opaque images are written as 8-bit truecolour and the rest as +// truecolour with alpha, which is the one size decision worth making here: +// most marks are opaque, and the alpha channel would otherwise be a quarter +// of the data for nothing. +func Encode(img image.Image) []byte { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= 0 || h <= 0 { + // A zero-sized image is still a valid PNG; the alternative is an + // error return that no caller could do anything with. + w, h = 0, 0 + } + + bpp := 4 + colorType := byte(6) // truecolour with alpha + if opaque(img) { + bpp = 3 + colorType = 2 // truecolour + } + + raw := scanlines(img, b, w, h, bpp) + + out := make([]byte, 0, len(raw)/2+256) + out = append(out, 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n') + + ihdr := make([]byte, 0, 13) + ihdr = appendU32(ihdr, uint32(w)) + ihdr = appendU32(ihdr, uint32(h)) + ihdr = append(ihdr, 8, colorType, 0, 0, 0) // depth, colour, deflate, adaptive filtering, no interlace + out = appendChunk(out, "IHDR", ihdr) + out = appendChunk(out, "IDAT", zlib(raw)) + out = appendChunk(out, "IEND", nil) + return out +} + +// scanlines lays the pixels out as PNG wants them: one filter byte in front +// of every row, and the filter chosen per row. +func scanlines(img image.Image, b image.Rectangle, w, h, bpp int) []byte { + stride := w * bpp + raw := make([]byte, 0, (stride+1)*h) + cur := make([]byte, stride) + prev := make([]byte, stride) + filtered := make([]byte, stride) + + rgba, _ := img.(*image.RGBA) + for y := 0; y < h; y++ { + if rgba != nil { + readRGBA(rgba, b, y, bpp, cur) + } else { + readGeneric(img, b, y, bpp, cur) + } + ft := filterRow(cur, prev, bpp, filtered) + raw = append(raw, ft) + raw = append(raw, filtered...) + prev, cur = cur, prev + } + return raw +} + +// readRGBA is the fast path. image.RGBA holds premultiplied alpha, which PNG +// does not, so the colours are divided back out. +func readRGBA(img *image.RGBA, b image.Rectangle, y, bpp int, dst []byte) { + row := img.Pix[img.PixOffset(b.Min.X, b.Min.Y+y):] + for x := 0; x < len(dst)/bpp; x++ { + r, g, bl, a := row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] + if a != 0 && a != 0xff { + // The division is carried out at 16 bits and truncated back + // down, which is what color.NRGBAModel does; doing it at 8 bits + // would round differently and lose a round trip through any + // other decoder. + r = byte(uint32(r) * 0xffff / uint32(a) >> 8) + g = byte(uint32(g) * 0xffff / uint32(a) >> 8) + bl = byte(uint32(bl) * 0xffff / uint32(a) >> 8) + } + dst[x*bpp], dst[x*bpp+1], dst[x*bpp+2] = r, g, bl + if bpp == 4 { + dst[x*bpp+3] = a + } + } +} + +func readGeneric(img image.Image, b image.Rectangle, y, bpp int, dst []byte) { + for x := 0; x < len(dst)/bpp; x++ { + c := color.NRGBAModel.Convert(img.At(b.Min.X+x, b.Min.Y+y)).(color.NRGBA) + dst[x*bpp], dst[x*bpp+1], dst[x*bpp+2] = c.R, c.G, c.B + if bpp == 4 { + dst[x*bpp+3] = c.A + } + } +} + +func opaque(img image.Image) bool { + if o, ok := img.(interface{ Opaque() bool }); ok { + return o.Opaque() + } + b := img.Bounds() + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if _, _, _, a := img.At(x, y).RGBA(); a != 0xffff { + return false + } + } + } + return true +} + +// filterRow picks the row filter and writes the filtered bytes into dst, +// returning the filter type. The choice is the minimum sum of absolute +// signed differences, the heuristic PNG's own specification suggests: it is +// cheap, and on marks — flat areas and long runs of identical rows — it picks +// Up almost everywhere, which is exactly what compresses. +func filterRow(cur, prev []byte, bpp int, dst []byte) byte { + best := byte(0) + bestScore := -1 + var cand []byte = make([]byte, len(cur)) + for ft := byte(0); ft <= 4; ft++ { + applyFilter(ft, cur, prev, bpp, cand) + score := 0 + for _, v := range cand { + if int8(v) < 0 { + score += 256 - int(v) + } else { + score += int(v) + } + } + if bestScore < 0 || score < bestScore { + bestScore, best = score, ft + copy(dst, cand) + } + } + return best +} + +func applyFilter(ft byte, cur, prev []byte, bpp int, dst []byte) { + for i := range cur { + var a, b, c byte + if i >= bpp { + a = cur[i-bpp] + c = prev[i-bpp] + } + b = prev[i] + switch ft { + case 0: + dst[i] = cur[i] + case 1: + dst[i] = cur[i] - a + case 2: + dst[i] = cur[i] - b + case 3: + dst[i] = cur[i] - byte((int(a)+int(b))/2) + case 4: + dst[i] = cur[i] - paeth(a, b, c) + } + } +} + +func paeth(a, b, c byte) byte { + p := int(a) + int(b) - int(c) + pa, pb, pc := abs(p-int(a)), abs(p-int(b)), abs(p-int(c)) + switch { + case pa <= pb && pa <= pc: + return a + case pb <= pc: + return b + default: + return c + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +func appendU32(dst []byte, v uint32) []byte { + return append(dst, byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) +} + +func appendChunk(dst []byte, kind string, data []byte) []byte { + dst = appendU32(dst, uint32(len(data))) + start := len(dst) + dst = append(dst, kind...) + dst = append(dst, data...) + return appendU32(dst, crc32(dst[start:])) +} diff --git a/internal/pngenc/png_test.go b/internal/pngenc/png_test.go new file mode 100644 index 0000000..e9f4e41 --- /dev/null +++ b/internal/pngenc/png_test.go @@ -0,0 +1,170 @@ +package pngenc + +import ( + "bytes" + "image" + "image/color" + stdpng "image/png" + "math/rand" + "testing" +) + +// TestRoundTripsThroughTheStandardDecoder is the whole contract of this +// package: whatever the encoder emits, the reference implementation must read +// back as the pixels that went in. It is checked over several kinds of image +// because the parts most likely to be wrong — the filter heuristic and the +// match search — behave differently on flat, noisy and transparent input. +func TestRoundTripsThroughTheStandardDecoder(t *testing.T) { + for _, tc := range []struct { + name string + img *image.RGBA + }{ + {"flat", flat(64, 64, color.RGBA{0x20, 0x30, 0x40, 0xff})}, + {"noise", noise(64, 64, 1, 0xff)}, + {"translucent", noise(64, 64, 2, 0)}, + {"one pixel", flat(1, 1, color.RGBA{0xff, 0, 0, 0xff})}, + {"tall and thin", noise(1, 300, 3, 0xff)}, + {"wide and short", noise(300, 1, 4, 0xff)}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := stdpng.Decode(bytes.NewReader(Encode(tc.img))) + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.Bounds() != tc.img.Bounds() { + t.Fatalf("bounds = %v, want %v", got.Bounds(), tc.img.Bounds()) + } + for y := 0; y < tc.img.Bounds().Dy(); y++ { + for x := 0; x < tc.img.Bounds().Dx(); x++ { + want := color.NRGBAModel.Convert(tc.img.At(x, y)) + if have := color.NRGBAModel.Convert(got.At(x, y)); have != want { + t.Fatalf("pixel (%d,%d) = %v, want %v", x, y, have, want) + } + } + } + }) + } +} + +// TestOpaqueImagesDropTheAlphaChannel guards the one format choice the +// encoder makes on the caller's behalf. +func TestOpaqueImagesDropTheAlphaChannel(t *testing.T) { + if got := colorType(Encode(noise(32, 32, 5, 0xff))); got != 2 { + t.Errorf("opaque image encoded as colour type %d, want 2", got) + } + if got := colorType(Encode(noise(32, 32, 5, 0x80))); got != 6 { + t.Errorf("translucent image encoded as colour type %d, want 6", got) + } +} + +// TestEncodingIsDeterministic matters more here than anywhere else in the +// package: the gallery is committed and diffed, so an encoder that varied +// would show up as a spurious image change on every regeneration. +func TestEncodingIsDeterministic(t *testing.T) { + img := noise(48, 48, 6, 0xff) + if a, b := Encode(img), Encode(img); !bytes.Equal(a, b) { + t.Error("two encodings of the same image differ") + } +} + +// TestCompressesBetterThanStoring keeps the compressor honest. It is not a +// race against image/png — the fixed-Huffman stream will lose that — but a +// file no smaller than its pixels would mean the match search is not working +// at all. +func TestCompressesBetterThanStoring(t *testing.T) { + img := flat(128, 128, color.RGBA{0x11, 0x22, 0x33, 0xff}) + if got, raw := len(Encode(img)), 128*128*3; got > raw/10 { + t.Errorf("flat 128x128 image encodes to %d bytes, want well under %d", got, raw/10) + } + + img = noise(128, 128, 7, 0xff) + std := new(bytes.Buffer) + if err := stdpng.Encode(std, img); err != nil { + t.Fatal(err) + } + // Noise is incompressible, so this is really a check that the fixed + // Huffman block does not inflate the data the way a stored block would. + if got, want := len(Encode(img)), std.Len()*12/10; got > want { + t.Errorf("noise encodes to %d bytes, more than 1.2x image/png's %d", got, std.Len()) + } +} + +// TestEncodesAnyImage covers the generic path, which the library itself does +// not take but an exported encoder invites. +func TestEncodesAnyImage(t *testing.T) { + src := image.NewGray(image.Rect(0, 0, 16, 16)) + for i := range src.Pix { + src.Pix[i] = byte(i) + } + got, err := stdpng.Decode(bytes.NewReader(Encode(src))) + if err != nil { + t.Fatalf("decode: %v", err) + } + for y := 0; y < 16; y++ { + for x := 0; x < 16; x++ { + want := color.NRGBAModel.Convert(src.At(x, y)) + if have := color.NRGBAModel.Convert(got.At(x, y)); have != want { + t.Fatalf("pixel (%d,%d) = %v, want %v", x, y, have, want) + } + } + } +} + +// TestNonZeroOriginIsEncodedFromItsBounds catches the classic sub-image bug, +// where the pixels are read from the origin instead of the bounds. +func TestNonZeroOriginIsEncodedFromItsBounds(t *testing.T) { + full := noise(32, 32, 8, 0xff) + sub := full.SubImage(image.Rect(8, 8, 24, 24)).(*image.RGBA) + got, err := stdpng.Decode(bytes.NewReader(Encode(sub))) + if err != nil { + t.Fatalf("decode: %v", err) + } + if b := got.Bounds(); b.Dx() != 16 || b.Dy() != 16 { + t.Fatalf("bounds = %v, want 16x16", b) + } + for y := 0; y < 16; y++ { + for x := 0; x < 16; x++ { + want := color.NRGBAModel.Convert(full.At(8+x, 8+y)) + if have := color.NRGBAModel.Convert(got.At(x, y)); have != want { + t.Fatalf("pixel (%d,%d) = %v, want %v", x, y, have, want) + } + } + } +} + +func colorType(data []byte) byte { + // Signature, length and type of the first chunk, then width and height. + return data[8+8+8+1] +} + +func flat(w, h int, c color.RGBA) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetRGBA(x, y, c) + } + } + return img +} + +// noise fills an image with random premultiplied pixels. An alpha of 0xff +// makes it opaque; anything else randomises alpha from that floor upwards. +func noise(w, h int, seed int64, alpha byte) *image.RGBA { + rng := rand.New(rand.NewSource(seed)) + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + a := alpha + if a != 0xff { + a = byte(int(alpha) + rng.Intn(256-int(alpha))) + } + img.SetRGBA(x, y, color.RGBA{ + byte(rng.Intn(int(a) + 1)), + byte(rng.Intn(int(a) + 1)), + byte(rng.Intn(int(a) + 1)), + a, + }) + } + } + return img +} diff --git a/internal/sha256/sha256.go b/internal/sha256/sha256.go new file mode 100644 index 0000000..065152a --- /dev/null +++ b/internal/sha256/sha256.go @@ -0,0 +1,174 @@ +// Package sha256 implements SHA-256 (FIPS 180-4). +// +// It exists for size, not for secrecy. Nothing here is a secret — the input +// is a version string and the output is a picture — but crypto/sha256 now +// arrives with the FIPS-140 module's self-tests, entropy sources and runtime +// checks behind it, which is about a sixth of a megabyte in a binary whose +// only job is to draw a mark. The algorithm itself is a fixed standard, so +// owning it costs nothing in drift: the digests are the same digests, and a +// test checks that against crypto/sha256 for every input the library uses. +package sha256 + +// Size is the length of a SHA-256 digest in bytes. +const Size = 32 + +// BlockSize is the algorithm's block size in bytes. +const BlockSize = 64 + +var initial = [8]uint32{ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +} + +var k = [64]uint32{ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +} + +// Digest is an in-progress SHA-256 computation. Its method set is the part of +// hash.Hash the library uses, so callers read the same as they did against +// crypto/sha256. +type Digest struct { + h [8]uint32 + buf [BlockSize]byte + n int // bytes buffered + len uint64 // bytes consumed in total +} + +// New returns a Digest ready to hash. +func New() *Digest { + d := new(Digest) + d.Reset() + return d +} + +// Reset returns the Digest to its initial state. +func (d *Digest) Reset() { + d.h = initial + d.n = 0 + d.len = 0 +} + +// Size returns the digest length in bytes. +func (d *Digest) Size() int { return Size } + +// BlockSize returns the algorithm's block size in bytes. +func (d *Digest) BlockSize() int { return BlockSize } + +// Write adds more data to the running digest. It never returns an error. +func (d *Digest) Write(p []byte) (int, error) { + written := len(p) + d.len += uint64(written) + if d.n > 0 { + n := copy(d.buf[d.n:], p) + d.n += n + p = p[n:] + if d.n < BlockSize { + // Still short of a block, so everything is buffered and there is + // nothing to compress yet. + return written, nil + } + d.block(d.buf[:]) + d.n = 0 + } + for len(p) >= BlockSize { + d.block(p[:BlockSize]) + p = p[BlockSize:] + } + d.n = copy(d.buf[:], p) + return written, nil +} + +// Sum appends the digest of everything written so far to b, leaving the +// Digest itself usable for further writes. +func (d *Digest) Sum(b []byte) []byte { + // The padding is applied to a copy so that Sum stays non-destructive, + // which is what hash.Hash promises and what the callers here rely on. + c := *d + + // A one bit, then zeroes, then the length in bits — enough zeroes that + // the length lands in the final eight bytes of a block. + var pad [BlockSize * 2]byte + pad[0] = 0x80 + padLen := BlockSize - int((c.len+9)%BlockSize) + if padLen == BlockSize { + padLen = 0 + } + tail := pad[:1+padLen+8] + putUint64(tail[1+padLen:], c.len*8) + c.Write(tail) + + var out [Size]byte + for i, v := range c.h { + putUint32(out[i*4:], v) + } + return append(b, out[:]...) +} + +// Sum256 returns the digest of data. +func Sum256(data []byte) [Size]byte { + d := New() + d.Write(data) + var out [Size]byte + copy(out[:], d.Sum(nil)) + return out +} + +func (d *Digest) block(p []byte) { + var w [64]uint32 + for i := 0; i < 16; i++ { + w[i] = uint32be(p[i*4:]) + } + for i := 16; i < 64; i++ { + s0 := rotr(w[i-15], 7) ^ rotr(w[i-15], 18) ^ (w[i-15] >> 3) + s1 := rotr(w[i-2], 17) ^ rotr(w[i-2], 19) ^ (w[i-2] >> 10) + w[i] = w[i-16] + s0 + w[i-7] + s1 + } + + a, b, c, dd := d.h[0], d.h[1], d.h[2], d.h[3] + e, f, g, h := d.h[4], d.h[5], d.h[6], d.h[7] + for i := 0; i < 64; i++ { + s1 := rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25) + ch := (e & f) ^ (^e & g) + t1 := h + s1 + ch + k[i] + w[i] + s0 := rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22) + maj := (a & b) ^ (a & c) ^ (b & c) + t2 := s0 + maj + + h, g, f, e = g, f, e, dd+t1 + dd, c, b, a = c, b, a, t1+t2 + } + d.h[0] += a + d.h[1] += b + d.h[2] += c + d.h[3] += dd + d.h[4] += e + d.h[5] += f + d.h[6] += g + d.h[7] += h +} + +func rotr(v uint32, n uint) uint32 { return v>>n | v<<(32-n) } + +// The three big-endian conversions the algorithm needs. encoding/binary would +// do them, but it imports reflect, and reflect is the largest thing this +// library could add to a caller's binary. + +func uint32be(b []byte) uint32 { + return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) +} + +func putUint32(b []byte, v uint32) { + b[0], b[1], b[2], b[3] = byte(v>>24), byte(v>>16), byte(v>>8), byte(v) +} + +func putUint64(b []byte, v uint64) { + putUint32(b, uint32(v>>32)) + putUint32(b[4:], uint32(v)) +} diff --git a/internal/sha256/sha256_test.go b/internal/sha256/sha256_test.go new file mode 100644 index 0000000..425742b --- /dev/null +++ b/internal/sha256/sha256_test.go @@ -0,0 +1,90 @@ +package sha256 + +import ( + "bytes" + std "crypto/sha256" + "math/rand" + "testing" +) + +// TestAgreesWithTheStandardLibrary is the only test this package really +// needs: SHA-256 has one right answer, and the reference implementation is +// two lines away. Lengths around the block boundary are enumerated because +// padding is where a hand-written implementation goes wrong. +func TestAgreesWithTheStandardLibrary(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + data := make([]byte, 300) + rng.Read(data) + for n := 0; n <= len(data); n++ { + want := std.Sum256(data[:n]) + if got := Sum256(data[:n]); got != want { + t.Fatalf("Sum256 of %d bytes = %x, want %x", n, got, want) + } + } +} + +// TestWritesInAnyChunkingAgree covers the buffering, which the callers +// exercise by hashing several pieces into one digest. +func TestWritesInAnyChunkingAgree(t *testing.T) { + rng := rand.New(rand.NewSource(2)) + data := make([]byte, 1000) + rng.Read(data) + want := std.Sum256(data) + + for _, chunk := range []int{1, 7, 63, 64, 65, 128, 999} { + d := New() + for i := 0; i < len(data); i += chunk { + end := i + chunk + if end > len(data) { + end = len(data) + } + if _, err := d.Write(data[i:end]); err != nil { + t.Fatal(err) + } + } + if got := d.Sum(nil); !bytes.Equal(got, want[:]) { + t.Errorf("chunked by %d = %x, want %x", chunk, got, want) + } + } +} + +// TestSumDoesNotEndTheDigest guards the property the noise permutation +// depends on: it hashes a counter, takes a digest, and keeps writing. +func TestSumDoesNotEndTheDigest(t *testing.T) { + d := New() + d.Write([]byte("one")) + first := d.Sum(nil) + d.Write([]byte("two")) + second := d.Sum(nil) + + if want := std.Sum256([]byte("one")); !bytes.Equal(first, want[:]) { + t.Errorf("first sum = %x, want %x", first, want) + } + if want := std.Sum256([]byte("onetwo")); !bytes.Equal(second, want[:]) { + t.Errorf("second sum = %x, want %x", second, want) + } +} + +// TestSumAppends checks the hash.Hash convention, since callers pass a +// non-empty prefix. +func TestSumAppends(t *testing.T) { + d := New() + d.Write([]byte("data")) + got := d.Sum([]byte("prefix")) + want := std.Sum256([]byte("data")) + if string(got[:6]) != "prefix" || !bytes.Equal(got[6:], want[:]) { + t.Errorf("Sum with prefix = %x", got) + } +} + +// TestResetRestartsTheDigest keeps Reset honest for callers that reuse one. +func TestResetRestartsTheDigest(t *testing.T) { + d := New() + d.Write([]byte("throwaway")) + d.Reset() + d.Write([]byte("real")) + want := std.Sum256([]byte("real")) + if got := d.Sum(nil); !bytes.Equal(got, want[:]) { + t.Errorf("after Reset = %x, want %x", got, want) + } +} diff --git a/internal/sizeprobe/main.go b/internal/sizeprobe/main.go new file mode 100644 index 0000000..213cb2a --- /dev/null +++ b/internal/sizeprobe/main.go @@ -0,0 +1,31 @@ +// Command sizeprobe renders one mark and writes it to standard output. +// +// It exists to be weighed, not to be run. A library's cost to its callers is +// the code it drags into their binary, and the only honest way to measure +// that is to build the smallest possible program that uses it. Everything +// here beyond the blazon call — one os.Stdout.Write, no flags, no fmt — is +// deliberately absent so that what the scale reads is this library. +// +// The size budget in the tinygo CI job is measured on this program. PNG is +// the format probed because it is the widest path through the library: +// entropy, rasteriser, and encoder. +package main + +import ( + "os" + + "github.com/timzifer/blazon" +) + +func main() { + version := "1.4.2" + if len(os.Args) > 1 { + version = os.Args[1] + } + data, err := blazon.PNG(version, blazon.Options{Size: 256}) + if err != nil { + os.Stderr.WriteString(err.Error() + "\n") + os.Exit(1) + } + os.Stdout.Write(data) +} diff --git a/version.go b/version.go index 54fe5e6..6c2d9a9 100644 --- a/version.go +++ b/version.go @@ -2,7 +2,6 @@ package blazon import ( "errors" - "fmt" "strconv" "strings" ) @@ -25,6 +24,21 @@ var ( ErrSyntax = errors.New("blazon: invalid semantic version") ) +// syntaxError reports a malformed version and wraps ErrSyntax, so that +// errors.Is keeps working while the package stays clear of fmt: reflection is +// the single heaviest thing this library could put in a caller's binary, and +// nothing here formats anything but strings. +type syntaxError struct { + orig string + detail string +} + +func (e *syntaxError) Error() string { + return ErrSyntax.Error() + " " + strconv.Quote(e.orig) + ": " + e.detail +} + +func (e *syntaxError) Unwrap() error { return ErrSyntax } + // ParseVersion parses a semantic version string. A single leading "v" is // tolerated because Go module tags carry one. Missing minor and patch // components default to zero, so "1" and "1.2" are accepted as "1.0.0" and @@ -45,26 +59,26 @@ func ParseVersion(s string) (Version, error) { v.Build = s[i+1:] s = s[:i] if err := checkDotIdents(v.Build, true); err != nil { - return Version{}, fmt.Errorf("%w %q: build metadata: %v", ErrSyntax, orig, err) + return Version{}, &syntaxError{orig: orig, detail: "build metadata: " + err.Error()} } } if i := strings.IndexByte(s, '-'); i >= 0 { v.Prerelease = s[i+1:] s = s[:i] if err := checkDotIdents(v.Prerelease, false); err != nil { - return Version{}, fmt.Errorf("%w %q: prerelease: %v", ErrSyntax, orig, err) + return Version{}, &syntaxError{orig: orig, detail: "prerelease: " + err.Error()} } } parts := strings.Split(s, ".") if len(parts) > 3 { - return Version{}, fmt.Errorf("%w %q: too many numeric components", ErrSyntax, orig) + return Version{}, &syntaxError{orig: orig, detail: "too many numeric components"} } dst := [...]*uint64{&v.Major, &v.Minor, &v.Patch} for i, p := range parts { n, err := parseNumeric(p) if err != nil { - return Version{}, fmt.Errorf("%w %q: %v", ErrSyntax, orig, err) + return Version{}, &syntaxError{orig: orig, detail: err.Error()} } *dst[i] = n } @@ -79,15 +93,15 @@ func parseNumeric(p string) (uint64, error) { } for i := 0; i < len(p); i++ { if p[i] < '0' || p[i] > '9' { - return 0, fmt.Errorf("non-digit %q in numeric component", p[i]) + return 0, errors.New("non-digit " + strconv.QuoteRune(rune(p[i])) + " in numeric component") } } if len(p) > 1 && p[0] == '0' { - return 0, fmt.Errorf("leading zero in numeric component %q", p) + return 0, errors.New("leading zero in numeric component " + strconv.Quote(p)) } n, err := strconv.ParseUint(p, 10, 64) if err != nil { - return 0, fmt.Errorf("numeric component %q out of range", p) + return 0, errors.New("numeric component " + strconv.Quote(p) + " out of range") } return n, nil } @@ -111,11 +125,12 @@ func checkDotIdents(s string, isBuild bool) error { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '-': numeric = false default: - return fmt.Errorf("invalid character %q in identifier %q", c, id) + return errors.New("invalid character " + strconv.QuoteRune(rune(c)) + + " in identifier " + strconv.Quote(id)) } } if !isBuild && numeric && len(id) > 1 && id[0] == '0' { - return fmt.Errorf("leading zero in numeric identifier %q", id) + return errors.New("leading zero in numeric identifier " + strconv.Quote(id)) } } return nil