shiny/iconvg: add UpgradeToFileFormatVersion1

The iconvg Go implementation started at golang.org/x/exp/shiny/iconvg in
2016, speaking a file format retroactively named FFV0 (File Format
Version 0). The github.com/google/iconvg/src/go/* packages were created
more recently in 2021 and will speak a revised format, FFV1.

The long term plan is for the github.com/... packages to *only* speak
FFV1. The golang.org/... package (this package) will continue to speak
FFV0 directly and will additionally delegate decoding FFV1 to the
github.com/... packages. FFV0 will be considered a deprecated experiment
(it was marked EXPERIMENTAL ever since its inception). See
https://github.com/google/iconvg/issues/4

This commit, which decodes FFV0 and encodes FFV1, lives in the
golang.org/... packages, since it involves FFV0. Testing it (beyond the
basic consistency checks in upgrade_test.go) lives in the github.com/...
packages, since that involves decoding FFV1. For example, some outputs
of the UpgradeToFileFormatVersion1 function (added by this commit) were
checked in as https://github.com/google/iconvg/commit/c98b08c

Change-Id: Ib5861ae97928cf31faf207b915568532e2624f09
Reviewed-on: https://go-review.googlesource.com/c/exp/+/332989
Trust: Nigel Tao <nigeltao@golang.org>
Reviewed-by: Andrew Gerrand <adg@golang.org>
diff --git a/shiny/iconvg/buffer.go b/shiny/iconvg/buffer.go
index 44daf54..d5249b5 100644
--- a/shiny/iconvg/buffer.go
+++ b/shiny/iconvg/buffer.go
@@ -39,6 +39,30 @@
 	return 0, 0
 }
 
+// decodeNaturalFFV1 is like decodeNatural but for File Format Version 1. See
+// https://github.com/google/iconvg/issues/33
+func (b buffer) decodeNaturalFFV1() (u uint32, n int) {
+	if len(b) < 1 {
+		return 0, 0
+	}
+	x := b[0]
+	if x&0x01 != 0 {
+		return uint32(x) >> 1, 1
+	}
+	if x&0x02 != 0 {
+		if len(b) >= 2 {
+			y := uint16(b[0]) | uint16(b[1])<<8
+			return uint32(y) >> 2, 2
+		}
+		return 0, 0
+	}
+	if len(b) >= 4 {
+		y := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
+		return y >> 2, 4
+	}
+	return 0, 0
+}
+
 func (b buffer) decodeReal() (f float32, n int) {
 	switch u, n := b.decodeNatural(); n {
 	case 0:
@@ -143,6 +167,23 @@
 	*b = append(*b, uint8(u), uint8(u>>8), uint8(u>>16), uint8(u>>24))
 }
 
+// encodeNaturalFFV1 is like encodeNatural but for File Format Version 1. See
+// https://github.com/google/iconvg/issues/33
+func (b *buffer) encodeNaturalFFV1(u uint32) {
+	if u < 1<<7 {
+		u = (u << 1) | 0x01
+		*b = append(*b, uint8(u))
+		return
+	}
+	if u < 1<<14 {
+		u = (u << 2) | 0x02
+		*b = append(*b, uint8(u), uint8(u>>8))
+		return
+	}
+	u = (u << 2)
+	*b = append(*b, uint8(u), uint8(u>>8), uint8(u>>16), uint8(u>>24))
+}
+
 func (b *buffer) encodeReal(f float32) int {
 	if u := uint32(f); float32(u) == f && u < 1<<14 {
 		if u < 1<<7 {
@@ -174,6 +215,24 @@
 	*b = append(*b, uint8(u), uint8(u>>8), uint8(u>>16), uint8(u>>24))
 }
 
+// encode4ByteRealFFV1 is like encode4ByteReal but for File Format Version 1.
+// See https://github.com/google/iconvg/issues/33
+func (b *buffer) encode4ByteRealFFV1(f float32) {
+	u := math.Float32bits(f)
+
+	// Round the fractional bits (the low 23 bits) to the nearest multiple of
+	// 4, being careful not to overflow into the upper bits.
+	v := u & 0x007fffff
+	if v < 0x007ffffe {
+		v += 2
+	}
+	u = (u & 0xff800000) | v
+
+	// A 4 byte encoding has the low two bits unset.
+	u &= 0xfffffffc
+	*b = append(*b, uint8(u), uint8(u>>8), uint8(u>>16), uint8(u>>24))
+}
+
 func (b *buffer) encodeCoordinate(f float32) int {
 	if i := int32(f); -64 <= i && i < +64 && float32(i) == f {
 		u := uint32(i + 64)
@@ -191,6 +250,31 @@
 	return 4
 }
 
+// encodeCoordinateFFV1 is like encodeCoordinate but for File Format Version 1.
+// See https://github.com/google/iconvg/issues/33
+func (b *buffer) encodeCoordinateFFV1(f float32) int {
+	if i := int32(f); -64 <= i && i < +64 && float32(i) == f {
+		u := uint32(i + 64)
+		u = (u << 1) | 0x01
+		*b = append(*b, uint8(u))
+		return 1
+	}
+	if i := int32(f * 64); -128*64 <= i && i < +128*64 && float32(i) == f*64 {
+		u := uint32(i + 128*64)
+		u = (u << 2) | 0x02
+		*b = append(*b, uint8(u), uint8(u>>8))
+		return 2
+	}
+	b.encode4ByteRealFFV1(f)
+	return 4
+}
+
+func (b *buffer) encodeCoordinatePairFFV1(f [2]float32) int {
+	n0 := b.encodeCoordinateFFV1(f[0])
+	n1 := b.encodeCoordinateFFV1(f[1])
+	return n0 + n1
+}
+
 func (b *buffer) encodeAngle(f float32) int {
 	// Normalize f to the range [0, 1).
 	g := float64(f)
diff --git a/shiny/iconvg/decode.go b/shiny/iconvg/decode.go
index b5bb381..137e173 100644
--- a/shiny/iconvg/decode.go
+++ b/shiny/iconvg/decode.go
@@ -23,6 +23,7 @@
 	errUnsupportedDrawingOpcode        = errors.New("iconvg: unsupported drawing opcode")
 	errUnsupportedMetadataIdentifier   = errors.New("iconvg: unsupported metadata identifier")
 	errUnsupportedStylingOpcode        = errors.New("iconvg: unsupported styling opcode")
+	errUnsupportedUpgrade              = errors.New("iconvg: unsupported upgrade")
 )
 
 var midDescriptions = [...]string{
@@ -100,6 +101,8 @@
 
 func decode(dst Destination, p printer, m *Metadata, metadataOnly bool, src buffer, opts *DecodeOptions) (err error) {
 	if !bytes.HasPrefix(src, magicBytes) {
+		// TODO: detect FFV 1 (File Format Version 1), as opposed to the FFV 0
+		// that this package implements, and delegate to a FFV 1 decoder.
 		return errInvalidMagicIdentifier
 	}
 	if p != nil {
@@ -659,6 +662,20 @@
 	return src, nil
 }
 
+func decodeCoordinatePairs(coords [][2]float32, p printer, src buffer) (src1 buffer, err error) {
+	for i := range coords {
+		coords[i][0], src, err = decodeNumber(p, src, buffer.decodeCoordinate)
+		if err != nil {
+			return nil, err
+		}
+		coords[i][1], src, err = decodeNumber(p, src, buffer.decodeCoordinate)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return src, nil
+}
+
 func decodeAngle(p printer, src buffer) (float32, buffer, error) {
 	x, n := src.decodeZeroToOne()
 	if n == 0 {
diff --git a/shiny/iconvg/encode.go b/shiny/iconvg/encode.go
index 47e6a58..3cf6360 100644
--- a/shiny/iconvg/encode.go
+++ b/shiny/iconvg/encode.go
@@ -446,8 +446,8 @@
 	}
 	e.highResolutionCoordinates = e.HighResolutionCoordinates
 	e.buf = append(e.buf, uint8(0xc0+adj))
-	e.buf.encodeCoordinate(e.quantize(x))
-	e.buf.encodeCoordinate(e.quantize(y))
+	e.buf.encodeCoordinate(quantize(x, e.highResolutionCoordinates))
+	e.buf.encodeCoordinate(quantize(y, e.highResolutionCoordinates))
 	e.mode = modeDrawing
 }
 
@@ -543,17 +543,17 @@
 			switch e.drawOp {
 			default:
 				for j := m * int(op.nArgs); j > 0; j-- {
-					e.buf.encodeCoordinate(e.quantize(e.drawArgs[i]))
+					e.buf.encodeCoordinate(quantize(e.drawArgs[i], e.highResolutionCoordinates))
 					i++
 				}
 			case 'A', 'a':
 				for j := m; j > 0; j-- {
-					e.buf.encodeCoordinate(e.quantize(e.drawArgs[i+0]))
-					e.buf.encodeCoordinate(e.quantize(e.drawArgs[i+1]))
+					e.buf.encodeCoordinate(quantize(e.drawArgs[i+0], e.highResolutionCoordinates))
+					e.buf.encodeCoordinate(quantize(e.drawArgs[i+1], e.highResolutionCoordinates))
 					e.buf.encodeAngle(e.drawArgs[i+2])
 					e.buf.encodeNatural(uint32(e.drawArgs[i+3]))
-					e.buf.encodeCoordinate(e.quantize(e.drawArgs[i+4]))
-					e.buf.encodeCoordinate(e.quantize(e.drawArgs[i+5]))
+					e.buf.encodeCoordinate(quantize(e.drawArgs[i+4], e.highResolutionCoordinates))
+					e.buf.encodeCoordinate(quantize(e.drawArgs[i+5], e.highResolutionCoordinates))
 					i += 6
 				}
 			}
@@ -566,8 +566,8 @@
 	e.drawArgs = e.drawArgs[:0]
 }
 
-func (e *Encoder) quantize(coord float32) float32 {
-	if !e.highResolutionCoordinates && (-128 <= coord && coord < 128) {
+func quantize(coord float32, highResolutionCoordinates bool) float32 {
+	if !highResolutionCoordinates && (-128 <= coord && coord < 128) {
 		x := math.Floor(float64(coord*64 + 0.5))
 		return float32(x) / 64
 	}
diff --git a/shiny/iconvg/iconvg.go b/shiny/iconvg/iconvg.go
index 753d49e..99eaac2 100644
--- a/shiny/iconvg/iconvg.go
+++ b/shiny/iconvg/iconvg.go
@@ -25,8 +25,13 @@
 }
 
 const (
+	// File Format Version 0.
 	midViewBox          = 0
 	midSuggestedPalette = 1
+
+	// File Format Version 1.
+	ffv1MIDViewBox          = 8
+	ffv1MIDSuggestedPalette = 16
 )
 
 var gradientShapeNames = [2]string{
diff --git a/shiny/iconvg/upgrade.go b/shiny/iconvg/upgrade.go
new file mode 100644
index 0000000..6057391
--- /dev/null
+++ b/shiny/iconvg/upgrade.go
@@ -0,0 +1,1097 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package iconvg
+
+import (
+	"bytes"
+	"errors"
+	"image/color"
+	"math"
+)
+
+// UpgradeToFileFormatVersion1Options are the options to the
+// UpgradeToFileFormatVersion1 function.
+type UpgradeToFileFormatVersion1Options struct {
+	// ArcsExpandWithHighResolutionCoordinates is like the
+	// Encoder.HighResolutionCoordinates field. It controls whether to favor
+	// file size (false) or precision (true) when replacing File Format Version
+	// 0's arcs with cubic Bézier curves.
+	ArcsExpandWithHighResolutionCoordinates bool
+}
+
+// UpgradeToFileFormatVersion1 upgrades IconVG data from the 2016 experimental
+// "File Format Version 0" to the 2021 "File Format Version 1".
+//
+// This package (golang.org/x/exp/shiny/iconvg) holds a decoder for FFV0,
+// including this function to convert from FFV0 to FFV1. Different packages
+// (github.com/google/iconvg/src/go/*) decode FFV1.
+//
+// Amongst some new features and other clean-ups, FFV1 sets up the capability
+// for animated vector graphics, therefore removing some FFV0 features (such as
+// arc segments) that can be hard to animate smoothly. The IconvG FFV1 format
+// and its design decisions are discussed at
+// https://github.com/google/iconvg/issues/4#issuecomment-874105547
+func UpgradeToFileFormatVersion1(v0 []byte, opts *UpgradeToFileFormatVersion1Options) (v1 []byte, retErr error) {
+	u := &upgrader{}
+	if opts != nil {
+		u.opts = *opts
+	}
+	for i := range u.creg {
+		u.creg[i] = upgradeColor{
+			typ:          ColorTypePaletteIndex,
+			paletteIndex: uint8(i),
+		}
+	}
+
+	if !bytes.HasPrefix(v0, magicBytes) {
+		return nil, errInvalidMagicIdentifier
+	}
+	v1 = append(v1, "\x8AIVG"...)
+	v0 = v0[4:]
+
+	v1, v0, retErr = u.upgradeMetadata(v1, v0)
+	if retErr != nil {
+		return nil, retErr
+	}
+
+	v1, _, retErr = u.upgradeBytecode(v1, v0)
+	if retErr != nil {
+		return nil, retErr
+	}
+
+	return v1, nil
+}
+
+const (
+	upgradeVerbMoveTo = 0
+	upgradeVerbLineTo = 1
+	upgradeVerbQuadTo = 2
+	upgradeVerbCubeTo = 3
+)
+
+type upgrader struct {
+	opts UpgradeToFileFormatVersion1Options
+
+	// These fields hold the current path's geometry.
+	verbs []uint8
+	args  [][2]float32
+
+	// These fields track most of the FFV0 virtual machine register state. The
+	// FFV1 register model is different enough that we don't just translate
+	// each FFV0 register-related opcode individually.
+	creg [64]upgradeColor
+	nreg [64]float32
+	csel uint32
+	nsel uint32
+	fill uint32
+
+	// These fields track the most recent color written to FFV1 register
+	// REGS[SEL+7] (and SEL is kept at 56). As a file size optimization, we
+	// don't have to emit the first half of "Set REGS[SEL+7] = etc; Use
+	// REGS[SEL+7]" if the register already holds the "etc" value.
+	regsSel7    color.RGBA
+	hasRegsSel7 bool
+
+	// calculatingJumpLOD is whether the upgrader.upgradeBytecode method is
+	// being called recursively. FFV0 sets a Level-Of-Detail filter that
+	// applies implicitly until the next SetLOD opcode (if any). FFV1 instead
+	// explicitly gives the number of opcodes to skip if outside the LOD range.
+	calculatingJumpLOD bool
+}
+
+func (u *upgrader) upgradeMetadata(v1 buffer, v0 buffer) (newV1 buffer, newV0 buffer, retErr error) {
+	nMetadataChunks, n := v0.decodeNatural()
+	if n == 0 {
+		return nil, nil, errInvalidNumberOfMetadataChunks
+	}
+	v1.encodeNaturalFFV1(nMetadataChunks)
+	v0 = v0[n:]
+
+	for ; nMetadataChunks > 0; nMetadataChunks-- {
+		length, n := v0.decodeNatural()
+		if n == 0 {
+			return nil, nil, errInvalidMetadataChunkLength
+		}
+		v0 = v0[n:]
+		if uint64(length) > uint64(len(v0)) {
+			return nil, nil, errInvalidMetadataChunkLength
+		}
+		upgrade, err := u.upgradeMetadataChunk(v0[:length])
+		if err != nil {
+			return nil, nil, err
+		}
+		v1.encodeNaturalFFV1(uint32(len(upgrade)))
+		v1 = append(v1, upgrade...)
+		v0 = v0[length:]
+	}
+	return v1, v0, nil
+}
+
+func (u *upgrader) upgradeMetadataChunk(v0 buffer) (v1 buffer, retErr error) {
+	mid, n := v0.decodeNatural()
+	if n == 0 {
+		return nil, errInvalidMetadataIdentifier
+	}
+	switch mid {
+	case midViewBox:
+		mid = ffv1MIDViewBox
+	case midSuggestedPalette:
+		mid = ffv1MIDSuggestedPalette
+	default:
+		return nil, errInvalidMetadataIdentifier
+	}
+	v1.encodeNaturalFFV1(mid)
+	v0 = v0[n:]
+
+	switch mid {
+	case ffv1MIDViewBox:
+		for i := 0; i < 4; i++ {
+			x, n := v0.decodeNatural()
+			if n == 0 {
+				return nil, errInvalidViewBox
+			}
+			v1.encodeNaturalFFV1(x)
+			v0 = v0[n:]
+		}
+		if len(v0) != 0 {
+			return nil, errInvalidViewBox
+		}
+
+	case ffv1MIDSuggestedPalette:
+		if len(v0) == 0 {
+			return nil, errInvalidSuggestedPalette
+		}
+		numColors := 1 + int(v0[0]&0x3f)
+		colorLength := 1 + int(v0[0]>>6)
+		v1 = append(v1, uint8(numColors-1))
+		v0 = v0[1:]
+		for i := 0; i < numColors; i++ {
+			c, n := Color{}, 0
+			switch colorLength {
+			case 1:
+				c, n = v0.decodeColor1()
+			case 2:
+				c, n = v0.decodeColor2()
+			case 3:
+				c, n = v0.decodeColor3Direct()
+			case 4:
+				c, n = v0.decodeColor4()
+			}
+			if n == 0 {
+				return nil, errInvalidSuggestedPalette
+			} else if (c.typ == ColorTypeRGBA) && validAlphaPremulColor(c.data) {
+				v1 = append(v1, c.data.R, c.data.G, c.data.B, c.data.A)
+			} else {
+				v1 = append(v1, 0x00, 0x00, 0x00, 0xff)
+			}
+			v0 = v0[n:]
+		}
+		if len(v0) != 0 {
+			return nil, errInvalidSuggestedPalette
+		}
+	}
+	return v1, nil
+}
+
+func (u *upgrader) upgradeBytecode(v1 buffer, v0 buffer) (newV1 buffer, newV0 buffer, retErr error) {
+	uf := upgradeFunc(upgradeStyling)
+	for len(v0) > 0 {
+		uf, v1, v0, retErr = uf(u, v1, v0)
+		if retErr != nil {
+			if retErr == errCalculatingJumpLOD {
+				return v1, v0, nil
+			}
+			return nil, nil, retErr
+		}
+	}
+	return v1, v0, nil
+}
+
+var errCalculatingJumpLOD = errors.New("iconvg: calculating JumpLOD")
+
+type upgradeFunc func(*upgrader, buffer, buffer) (upgradeFunc, buffer, buffer, error)
+
+func upgradeStyling(u *upgrader, v1 buffer, v0 buffer) (uf upgradeFunc, newV1 buffer, newV0 buffer, retErr error) {
+	for len(v0) > 0 {
+		switch opcode := v0[0]; {
+		case opcode < 0x80: // "Set CSEL/NSEL"
+			if opcode < 0x40 {
+				u.csel = uint32(opcode & 63)
+			} else {
+				u.nsel = uint32(opcode & 63)
+			}
+			v0 = v0[1:]
+
+		case opcode < 0xa8: // "Set CREG[etc] to an etc color"
+			adj := uint32(opcode & 7)
+			if adj == 7 {
+				adj = 0
+			}
+			index := (u.csel - adj) & 63
+
+			v0 = v0[1:]
+			c, n := Color{}, 0
+			switch (opcode - 0x80) >> 3 {
+			case 0:
+				c, n = v0.decodeColor1()
+			case 1:
+				c, n = v0.decodeColor2()
+			case 2:
+				c, n = v0.decodeColor3Direct()
+			case 3:
+				c, n = v0.decodeColor4()
+			case 4:
+				c, n = v0.decodeColor3Indirect()
+			}
+			if n == 0 {
+				return nil, nil, nil, errInvalidColor
+			}
+			u.creg[index], retErr = u.resolve(c, false)
+			if retErr != nil {
+				return nil, nil, nil, retErr
+			}
+			v0 = v0[n:]
+
+			if (opcode & 7) == 7 {
+				u.csel = (u.csel + 1) & 63
+			}
+
+		case opcode < 0xc0: // "Set NREG[etc] to a real number"
+			adj := uint32(opcode & 7)
+			if adj == 7 {
+				adj = 0
+			}
+			index := (u.nsel - adj) & 63
+
+			v0 = v0[1:]
+			f, n := float32(0), 0
+			switch (opcode - 0x80) >> 3 {
+			case 5:
+				f, n = v0.decodeReal()
+			case 6:
+				f, n = v0.decodeCoordinate()
+			case 7:
+				f, n = v0.decodeZeroToOne()
+			}
+			if n == 0 {
+				return nil, nil, nil, errInvalidNumber
+			}
+			u.nreg[index] = f
+			v0 = v0[n:]
+
+			if (opcode & 7) == 7 {
+				u.nsel = (u.nsel + 1) & 63
+			}
+
+		case opcode < 0xc7: // Start path.
+			adj := uint32(opcode & 7)
+			u.fill = (u.csel - adj) & 63
+			v1 = append(v1, 0x35) // FFV1 MoveTo.
+			v0 = v0[1:]
+			return upgradeDrawing, v1, v0, nil
+
+		case opcode == 0xc7: // "Set LOD"
+			if u.calculatingJumpLOD {
+				u.calculatingJumpLOD = false
+				return nil, v1, v0, errCalculatingJumpLOD
+			}
+
+			v0 = v0[1:]
+			lod := [2]float32{}
+			for i := range lod {
+				f, n := v0.decodeReal()
+				if n == 0 {
+					return nil, nil, nil, errInvalidNumber
+				}
+				lod[i] = f
+				v0 = v0[n:]
+			}
+			if (lod[0] == 0) && math.IsInf(float64(lod[1]), +1) {
+				break
+			}
+
+			u.calculatingJumpLOD = true
+			ifTrue := []byte(nil)
+			if ifTrue, v0, retErr = u.upgradeBytecode(nil, v0); retErr != nil {
+				return nil, nil, nil, retErr
+			}
+			nInstructions := countFFV1Instructions(ifTrue)
+			if nInstructions >= (1 << 30) {
+				return nil, nil, nil, errUnsupportedUpgrade
+			}
+			v1 = append(v1, 0x3a) // FFV1 JumpLOD.
+			v1.encodeNaturalFFV1(uint32(nInstructions))
+			v1.encodeCoordinateFFV1(lod[0])
+			v1.encodeCoordinateFFV1(lod[1])
+			v1 = append(v1, ifTrue...)
+
+		default:
+			return nil, nil, nil, errUnsupportedStylingOpcode
+		}
+	}
+	return upgradeStyling, v1, v0, nil
+}
+
+func upgradeDrawing(u *upgrader, v1 buffer, v0 buffer) (uf upgradeFunc, newV1 buffer, newV0 buffer, retErr error) {
+	u.verbs = u.verbs[:0]
+	u.args = u.args[:0]
+
+	coords := [3][2]float32{}
+	pen := [2]float32{}
+	prevSmoothType := smoothTypeNone
+	prevSmoothPoint := [2]float32{}
+
+	// Handle the implicit M after a "Start path" styling op.
+	v0, retErr = decodeCoordinates(pen[:2], nil, v0)
+	if retErr != nil {
+		return nil, nil, nil, retErr
+	}
+	u.verbs = append(u.verbs, upgradeVerbMoveTo)
+	u.args = append(u.args, pen)
+	startingPoint := pen
+
+	for len(v0) > 0 {
+		switch opcode := v0[0]; {
+		case opcode < 0xc0: // LineTo, QuadTo, CubeTo.
+			nCoordPairs, nReps, relative, smoothType := 0, 1+int(opcode&0x0f), false, smoothTypeNone
+			switch opcode >> 4 {
+			case 0x00, 0x01: // "L (absolute lineTo)"
+				nCoordPairs = 1
+				nReps = 1 + int(opcode&0x1f)
+			case 0x02, 0x03: // "l (relative lineTo)"
+				nCoordPairs = 1
+				nReps = 1 + int(opcode&0x1f)
+				relative = true
+			case 0x04: // "T (absolute smooth quadTo)"
+				nCoordPairs = 1
+				smoothType = smoothTypeQuad
+			case 0x05: // "t (relative smooth quadTo)"
+				nCoordPairs = 1
+				relative = true
+				smoothType = smoothTypeQuad
+			case 0x06: // "Q (absolute quadTo)"
+				nCoordPairs = 2
+			case 0x07: // "q (relative quadTo)"
+				nCoordPairs = 2
+				relative = true
+			case 0x08: // "S (absolute smooth cubeTo)"
+				nCoordPairs = 2
+				smoothType = smoothTypeCube
+			case 0x09: // "s (relative smooth cubeTo)"
+				nCoordPairs = 2
+				relative = true
+				smoothType = smoothTypeCube
+			case 0x0a: // "C (absolute cubeTo)"
+				nCoordPairs = 3
+			case 0x0b: // "c (relative cubeTo)"
+				nCoordPairs = 3
+				relative = true
+			}
+			v0 = v0[1:]
+
+			for i := 0; i < nReps; i++ {
+				smoothIndex := 0
+				if smoothType != smoothTypeNone {
+					smoothIndex = 1
+					if smoothType != prevSmoothType {
+						coords[0][0] = pen[0]
+						coords[0][1] = pen[1]
+					} else {
+						coords[0][0] = (2 * pen[0]) - prevSmoothPoint[0]
+						coords[0][1] = (2 * pen[1]) - prevSmoothPoint[1]
+					}
+				}
+				allCoords := coords[:smoothIndex+nCoordPairs]
+				explicitCoords := allCoords[smoothIndex:]
+
+				v0, retErr = decodeCoordinatePairs(explicitCoords, nil, v0)
+				if retErr != nil {
+					return nil, nil, nil, retErr
+				}
+				if relative {
+					for c := range explicitCoords {
+						explicitCoords[c][0] += pen[0]
+						explicitCoords[c][1] += pen[1]
+					}
+				}
+
+				u.verbs = append(u.verbs, uint8(len(allCoords)))
+				u.args = append(u.args, allCoords...)
+
+				pen = allCoords[len(allCoords)-1]
+				if len(allCoords) == 2 {
+					prevSmoothPoint = allCoords[0]
+					prevSmoothType = smoothTypeQuad
+				} else if len(allCoords) == 3 {
+					prevSmoothPoint = allCoords[1]
+					prevSmoothType = smoothTypeCube
+				} else {
+					prevSmoothType = smoothTypeNone
+				}
+			}
+
+		case opcode < 0xe0: // ArcTo.
+			v1, v0, retErr = u.upgradeArcs(&pen, v1, v0)
+			if retErr != nil {
+				return nil, nil, nil, retErr
+			}
+			prevSmoothType = smoothTypeNone
+
+		default: // Other drawing opcodes.
+			v0 = v0[1:]
+			switch opcode {
+			case 0xe1: // "z (closePath); end path"
+				goto endPath
+
+			case 0xe2, 0xe3: // "z (closePath); M (absolute/relative moveTo)"
+				v0, retErr = decodeCoordinatePairs(coords[:1], nil, v0)
+				if retErr != nil {
+					return nil, nil, nil, retErr
+				}
+				if opcode == 0xe2 {
+					pen[0] = coords[0][0]
+					pen[1] = coords[0][1]
+				} else {
+					pen[0] += coords[0][0]
+					pen[1] += coords[0][1]
+				}
+				u.verbs = append(u.verbs, upgradeVerbMoveTo)
+				u.args = append(u.args, pen)
+
+			default:
+				tmp := [1]float32{}
+				v0, retErr = decodeCoordinates(tmp[:1], nil, v0)
+				if retErr != nil {
+					return nil, nil, nil, retErr
+				}
+				switch opcode {
+				case 0xe6: // "H (absolute horizontal lineTo)"
+					pen[0] = tmp[0]
+				case 0xe7: // "h (relative horizontal lineTo)"
+					pen[0] += tmp[0]
+				case 0xe8: // "V (absolute vertical lineTo)"
+					pen[1] = tmp[0]
+				case 0xe9: // "v (relative vertical lineTo)"
+					pen[1] += tmp[0]
+				default:
+					return nil, nil, nil, errUnsupportedDrawingOpcode
+				}
+				u.verbs = append(u.verbs, upgradeVerbLineTo)
+				u.args = append(u.args, pen)
+			}
+			prevSmoothType = smoothTypeNone
+		}
+	}
+
+endPath:
+	v1, retErr = u.finishDrawing(v1, startingPoint)
+	return upgradeStyling, v1, v0, retErr
+}
+
+func (u *upgrader) finishDrawing(v1 buffer, startingPoint [2]float32) (newV1 buffer, retErr error) {
+	v1.encodeCoordinatePairFFV1(u.args[0])
+
+	for i, j := 1, 1; i < len(u.verbs); {
+		curr := u.args[j-1]
+		runLength := u.computeRunLength(u.verbs[i:])
+		verb := u.verbs[i]
+
+		if verb == upgradeVerbMoveTo {
+			v1 = append(v1, 0x35) // FFV1 MoveTo.
+			v1.encodeCoordinatePairFFV1(u.args[j])
+			i += 1
+			j += 1
+			continue
+		}
+
+		switch verb {
+		case upgradeVerbLineTo:
+			if ((runLength == 3) && ((j + 3) == len(u.args)) && u.looksLikeParallelogram3(&curr, u.args[j:], &startingPoint)) ||
+				((runLength == 4) && u.looksLikeParallelogram4(&curr, u.args[j:j+4])) {
+				v1 = append(v1, 0x34) // FFV1 Parallelogram.
+				v1.encodeCoordinatePairFFV1(u.args[j+0])
+				v1.encodeCoordinatePairFFV1(u.args[j+1])
+				i += 4
+				j += 4 * 1
+				continue
+			}
+		case upgradeVerbCubeTo:
+			if (runLength == 4) && u.looksLikeEllipse(&curr, u.args[j:j+(4*3)]) {
+				v1 = append(v1, 0x33) // FFV1 Ellipse (4 quarters).
+				v1.encodeCoordinatePairFFV1(u.args[j+2])
+				v1.encodeCoordinatePairFFV1(u.args[j+5])
+				i += 4
+				j += 4 * 3
+				continue
+			}
+		}
+
+		opcodeBase := 0x10 * (verb - 1) // FFV1 LineTo / QuadTo / CubeTo.
+		if runLength < 16 {
+			v1 = append(v1, opcodeBase|uint8(runLength))
+		} else {
+			v1 = append(v1, opcodeBase)
+			v1.encodeNaturalFFV1(uint32(runLength) - 16)
+		}
+		args := u.args[j : j+(runLength*int(verb))]
+		for _, arg := range args {
+			v1.encodeCoordinatePairFFV1(arg)
+		}
+		i += runLength
+		j += len(args)
+	}
+
+	return u.emitFill(v1)
+}
+
+func (u *upgrader) emitFill(v1 buffer) (newV1 buffer, retErr error) {
+	switch c := u.creg[u.fill]; c.typ {
+	case ColorTypeRGBA:
+		if validAlphaPremulColor(c.rgba) {
+			if !u.hasRegsSel7 || (u.regsSel7 != c.rgba) {
+				u.hasRegsSel7, u.regsSel7 = true, c.rgba
+				v1 = append(v1, 0x57, // FFV1 Set REGS[SEL+7].hi32.
+					c.rgba.R, c.rgba.G, c.rgba.B, c.rgba.A)
+			}
+			v1 = append(v1, 0x87) // FFV1 Fill (flat color) with REGS[SEL+7].
+
+		} else if (c.rgba.A == 0) && (c.rgba.B&0x80 != 0) {
+			nStops := int(c.rgba.R & 63)
+			cBase := int(c.rgba.G & 63)
+			nBase := int(c.rgba.B & 63)
+			if nStops < 2 {
+				return nil, errInvalidColor
+			} else if nStops > 17 {
+				return nil, errUnsupportedUpgrade
+			}
+
+			v1 = append(v1, 0x70|uint8(nStops-2)) // FFV1 SEL -= N; Set REGS[SEL+1 .. SEL+1+N].
+			for i := 0; i < nStops; i++ {
+				if stopOffset := u.nreg[(nBase+i)&63]; stopOffset <= 0 {
+					v1 = append(v1, 0x00, 0x00, 0x00, 0x00)
+				} else if stopOffset < 1 {
+					u := uint32(stopOffset * 0x10000)
+					v1 = append(v1, uint8(u>>0), uint8(u>>8), uint8(u>>16), uint8(u>>24))
+				} else {
+					v1 = append(v1, 0x00, 0x00, 0x01, 0x00)
+				}
+
+				if stopColor := u.creg[(cBase+i)&63]; stopColor.typ != ColorTypeRGBA {
+					return nil, errUnsupportedUpgrade
+				} else {
+					v1 = append(v1,
+						stopColor.rgba.R,
+						stopColor.rgba.G,
+						stopColor.rgba.B,
+						stopColor.rgba.A,
+					)
+				}
+			}
+
+			nMatrixElements := 0
+			if c.rgba.B&0x40 == 0 {
+				v1 = append(v1, 0x91, // FFV1 Fill (linear gradient) with REGS[SEL+1 .. SEL+1+N].
+					(c.rgba.G&0xc0)|uint8(nStops-2))
+				nMatrixElements = 3
+			} else {
+				v1 = append(v1, 0xa1, // FFV1 Fill (radial gradient) with REGS[SEL+1 .. SEL+1+N].
+					(c.rgba.G&0xc0)|uint8(nStops-2))
+				nMatrixElements = 6
+			}
+			for i := 0; i < nMatrixElements; i++ {
+				u := math.Float32bits(u.nreg[(nBase+i-6)&63])
+				v1 = append(v1, uint8(u>>0), uint8(u>>8), uint8(u>>16), uint8(u>>24))
+			}
+
+			v1 = append(v1, 0x36, // FFV1 SEL += N.
+				uint8(nStops))
+		} else {
+			return nil, errInvalidColor
+		}
+
+	case ColorTypePaletteIndex:
+		if c.paletteIndex < 7 {
+			v1 = append(v1, 0x88+c.paletteIndex) // FFV1 Fill (flat color) with REGS[SEL+8+N].
+		} else {
+			v1 = append(v1, 0x56, // FFV1 Set REGS[SEL+6].hi32.
+				0x80|c.paletteIndex, 0, 0, 0,
+				0x86) // FFV1 Fill (flat color) with REGS[SEL+6].
+		}
+
+	case ColorTypeBlend:
+		if c.color0.typ == ColorTypeRGBA {
+			v1 = append(v1, 0x53, // FFV1 Set REGS[SEL+3].hi32.
+				c.color0.rgba.R, c.color0.rgba.G, c.color0.rgba.B, c.color0.rgba.A)
+		}
+		if c.color1.typ == ColorTypeRGBA {
+			v1 = append(v1, 0x54, // FFV1 Set REGS[SEL+4].hi32.
+				c.color1.rgba.R, c.color1.rgba.G, c.color1.rgba.B, c.color1.rgba.A)
+		}
+		v1 = append(v1, 0x55, // FFV1 Set REGS[SEL+5].hi32.
+			c.blend)
+		if c.color0.typ == ColorTypeRGBA {
+			v1 = append(v1, 0xfe)
+		} else {
+			v1 = append(v1, 0x80|c.color0.paletteIndex)
+		}
+		if c.color1.typ == ColorTypeRGBA {
+			v1 = append(v1, 0xff)
+		} else {
+			v1 = append(v1, 0x80|c.color1.paletteIndex)
+		}
+		v1 = append(v1, 0, 0x85) // FFV1 Fill (flat color) with REGS[SEL+5].
+	}
+
+	return v1, nil
+}
+
+func (u *upgrader) computeRunLength(verbs []uint8) int {
+	firstVerb := verbs[0]
+	if firstVerb == 0 {
+		return 1
+	}
+	n := 1
+	for ; (n < len(verbs)) && (verbs[n] == firstVerb); n++ {
+	}
+	return n
+}
+
+// looksLikeParallelogram3 is like looksLikeParallelogram4 but the final point
+// (implied by the ClosePath op) is separate from the middle 3 args.
+func (u *upgrader) looksLikeParallelogram3(curr *[2]float32, args [][2]float32, final *[2]float32) bool {
+	if len(args) != 3 {
+		panic("unreachable")
+	}
+	return (*curr == *final) &&
+		(curr[0] == (args[0][0] - args[1][0] + args[2][0])) &&
+		(curr[1] == (args[0][1] - args[1][1] + args[2][1]))
+}
+
+// looksLikeParallelogram4 returns whether the 5 coordinate pairs (A, B, C, D,
+// E) form a parallelogram:
+//
+// E=A           B
+//    o---------o
+//     \         \
+//      \         \
+//       \         \
+//        o---------o
+//       D           C
+//
+// Specifically, it checks that (A == E) and ((A - B) == (D - C)). That last
+// equation can be rearranged as (A == (B - C + D)).
+//
+// The motivation is that, if looksLikeParallelogram4 is true, then the 5 input
+// coordinate pairs can then be compressed to 3: A, B and C. Or, if the current
+// point A is implied by context then 4 input pairs can be compressed to 2.
+func (u *upgrader) looksLikeParallelogram4(curr *[2]float32, args [][2]float32) bool {
+	if len(args) != 4 {
+		panic("unreachable")
+	}
+	return (*curr == args[3]) &&
+		(curr[0] == (args[0][0] - args[1][0] + args[2][0])) &&
+		(curr[1] == (args[0][1] - args[1][1] + args[2][1]))
+}
+
+// looksLikeEllipse returns whether the 13 coordinate pairs (A, A+, B-, B, B+,
+// C- C, C+, D-, D, D+, A-, E) form a cubic Bézier approximation to an ellipse.
+// Let A± denote the two tangent vectors (A+ - A) and (A - A-) and likewise for
+// B±, C± and D±.
+//
+//     A+     B-
+// E=A  o    o   B
+// A- o---------o   B+
+//  o  \         \ o
+//      \    X    \
+//     o \         \  o
+//    D+  o---------o  C-
+//       D   o    o  C
+//          D-     C+
+//
+// See https://nigeltao.github.io/blog/2021/three-points-define-ellipse.html
+// for a better version of that ASCII art.
+//
+// Specifically, it checks that (A, B, C, D, E), also known as (*curr, args[2],
+// args[5], args[8] and args[11]), forms a parallelogram. If so, let X be the
+// parallelogram center and define two axis vectors: r = B-X and s = C-X.
+//
+// These axes define the parallelogram's or ellipse's shape but they are not
+// necessarily orthogonal and hence not necessarily the ellipse's major
+// (longest) and minor (shortest) axes. If s is a 90 degree rotation of r then
+// the parallelogram is a square and the ellipse is a circle.
+//
+// This function further checks that the A±, B± C± and D± tangents are
+// approximately equal to +λ×r, +λ×s, -λ×r and -λ×s, where λ = ((math.Sqrt2 -
+// 1) × 4 / 3) comes from the cubic Bézier approximation to a quarter-circle.
+//
+// The motivation is that, if looksLikeEllipse is true, then the 13 input
+// coordinate pairs can then be compressed to 3: A, B and C. Or, if the current
+// point A is implied by context then 12 input pairs can be compressed to 2.
+func (u *upgrader) looksLikeEllipse(curr *[2]float32, args [][2]float32) bool {
+	if len(args) != 12 {
+		panic("unreachable")
+	}
+	if (*curr != args[11]) ||
+		(curr[0] != (args[2][0] - args[5][0] + args[8][0])) ||
+		(curr[1] != (args[2][1] - args[5][1] + args[8][1])) {
+		return false
+	}
+	center := [2]float32{
+		(args[2][0] + args[8][0]) / 2,
+		(args[2][1] + args[8][1]) / 2,
+	}
+
+	// 0.5522847498307933984022516322796 ≈ ((math.Sqrt2 - 1) × 4 / 3), the
+	// tangent lengths (as a fraction of the radius) for a commonly used cubic
+	// Bézier approximation to a circle. Multiplying that by 0.98 and 1.02
+	// checks that we're within 2% of that fraction.
+	//
+	// This also covers the slightly different 0.551784777779014 constant,
+	// recommended by https://pomax.github.io/bezierinfo/#circles_cubic
+	const λMin = 0.98 * 0.5522847498307933984022516322796
+	const λMax = 1.02 * 0.5522847498307933984022516322796
+
+	// Check the first axis.
+	r := [2]float32{
+		args[2][0] - center[0],
+		args[2][1] - center[1],
+	}
+	rMin := [2]float32{r[0] * λMin, r[1] * λMin}
+	rMax := [2]float32{r[0] * λMax, r[1] * λMax}
+	if rMin[0] > rMax[0] {
+		rMin[0], rMax[0] = rMax[0], rMin[0]
+	}
+	if rMin[1] > rMax[1] {
+		rMin[1], rMax[1] = rMax[1], rMin[1]
+	}
+	if !within(args[0][0]-curr[0], args[0][1]-curr[1], rMin, rMax) ||
+		!within(args[4][0]-args[5][0], args[4][1]-args[5][1], rMin, rMax) ||
+		!within(args[5][0]-args[6][0], args[5][1]-args[6][1], rMin, rMax) ||
+		!within(args[11][0]-args[10][0], args[11][1]-args[10][1], rMin, rMax) {
+		return false
+	}
+
+	// Check the second axis.
+	s := [2]float32{
+		args[5][0] - center[0],
+		args[5][1] - center[1],
+	}
+	sMin := [2]float32{s[0] * λMin, s[1] * λMin}
+	sMax := [2]float32{s[0] * λMax, s[1] * λMax}
+	if sMin[0] > sMax[0] {
+		sMin[0], sMax[0] = sMax[0], sMin[0]
+	}
+	if sMin[1] > sMax[1] {
+		sMin[1], sMax[1] = sMax[1], sMin[1]
+	}
+	if !within(args[2][0]-args[1][0], args[2][1]-args[1][1], sMin, sMax) ||
+		!within(args[3][0]-args[2][0], args[3][1]-args[2][1], sMin, sMax) ||
+		!within(args[7][0]-args[8][0], args[7][1]-args[8][1], sMin, sMax) ||
+		!within(args[8][0]-args[9][0], args[8][1]-args[9][1], sMin, sMax) {
+		return false
+	}
+
+	return true
+}
+
+func within(v0 float32, v1 float32, min [2]float32, max [2]float32) bool {
+	return (min[0] <= v0) && (v0 <= max[0]) && (min[1] <= v1) && (v1 <= max[1])
+}
+
+func (u *upgrader) upgradeArcs(pen *[2]float32, v1 buffer, v0 buffer) (newV1 buffer, newV0 buffer, retErr error) {
+	coords := [6]float32{}
+	largeArc, sweep := false, false
+	opcode := v0[0]
+	v0 = v0[1:]
+	nReps := 1 + int(opcode&0x0f)
+	for i := 0; i < nReps; i++ {
+		v0, retErr = decodeCoordinates(coords[:2], nil, v0)
+		if retErr != nil {
+			return nil, nil, retErr
+		}
+		coords[2], v0, retErr = decodeAngle(nil, v0)
+		if retErr != nil {
+			return nil, nil, retErr
+		}
+		largeArc, sweep, v0, retErr = decodeArcToFlags(nil, v0)
+		if retErr != nil {
+			return nil, nil, retErr
+		}
+		v0, retErr = decodeCoordinates(coords[4:6], nil, v0)
+		if retErr != nil {
+			return nil, nil, retErr
+		}
+		if (opcode >> 4) == 0x0d {
+			coords[4] += pen[0]
+			coords[5] += pen[1]
+		}
+		u.upgradeArc(pen, coords[0], coords[1], coords[2], largeArc, sweep, coords[4], coords[5])
+		pen[0] = coords[4]
+		pen[1] = coords[5]
+	}
+	return v1, v0, nil
+}
+
+func (u *upgrader) upgradeArc(pen *[2]float32, rx, ry, xAxisRotation float32, largeArc, sweep bool, finalX, finalY float32) {
+	// We follow the "Conversion from endpoint to center parameterization"
+	// algorithm as per
+	// https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter
+
+	// There seems to be a bug in the spec's "implementation notes".
+	//
+	// Actual implementations, such as
+	//	- https://git.gnome.org/browse/librsvg/tree/rsvg-path.c
+	//	- http://svn.apache.org/repos/asf/xmlgraphics/batik/branches/svg11/sources/org/apache/batik/ext/awt/geom/ExtendedGeneralPath.java
+	//	- https://java.net/projects/svgsalamander/sources/svn/content/trunk/svg-core/src/main/java/com/kitfox/svg/pathcmd/Arc.java
+	//	- https://github.com/millermedeiros/SVGParser/blob/master/com/millermedeiros/geom/SVGArc.as
+	// do something slightly different (marked with a †).
+
+	// (†) The Abs isn't part of the spec. Neither is checking that Rx and Ry
+	// are non-zero (and non-NaN).
+	Rx := math.Abs(float64(rx))
+	Ry := math.Abs(float64(ry))
+	if !(Rx > 0 && Ry > 0) {
+		u.verbs = append(u.verbs, upgradeVerbLineTo)
+		u.args = append(u.args, [2]float32{finalX, finalY})
+		return
+	}
+
+	x1 := float64(pen[0])
+	y1 := float64(pen[1])
+	x2 := float64(finalX)
+	y2 := float64(finalY)
+
+	phi := 2 * math.Pi * float64(xAxisRotation)
+
+	// Step 1: Compute (x1′, y1′)
+	halfDx := (x1 - x2) / 2
+	halfDy := (y1 - y2) / 2
+	cosPhi := math.Cos(phi)
+	sinPhi := math.Sin(phi)
+	x1Prime := +cosPhi*halfDx + sinPhi*halfDy
+	y1Prime := -sinPhi*halfDx + cosPhi*halfDy
+
+	// Step 2: Compute (cx′, cy′)
+	rxSq := Rx * Rx
+	rySq := Ry * Ry
+	x1PrimeSq := x1Prime * x1Prime
+	y1PrimeSq := y1Prime * y1Prime
+
+	// (†) Check that the radii are large enough.
+	radiiCheck := x1PrimeSq/rxSq + y1PrimeSq/rySq
+	if radiiCheck > 1 {
+		c := math.Sqrt(radiiCheck)
+		Rx *= c
+		Ry *= c
+		rxSq = Rx * Rx
+		rySq = Ry * Ry
+	}
+
+	denom := rxSq*y1PrimeSq + rySq*x1PrimeSq
+	step2 := 0.0
+	if a := rxSq*rySq/denom - 1; a > 0 {
+		step2 = math.Sqrt(a)
+	}
+	if largeArc == sweep {
+		step2 = -step2
+	}
+	cxPrime := +step2 * Rx * y1Prime / Ry
+	cyPrime := -step2 * Ry * x1Prime / Rx
+
+	// Step 3: Compute (cx, cy) from (cx′, cy′)
+	cx := +cosPhi*cxPrime - sinPhi*cyPrime + (x1+x2)/2
+	cy := +sinPhi*cxPrime + cosPhi*cyPrime + (y1+y2)/2
+
+	// Step 4: Compute θ1 and Δθ
+	ax := (+x1Prime - cxPrime) / Rx
+	ay := (+y1Prime - cyPrime) / Ry
+	bx := (-x1Prime - cxPrime) / Rx
+	by := (-y1Prime - cyPrime) / Ry
+	theta1 := angle(1, 0, ax, ay)
+	deltaTheta := angle(ax, ay, bx, by)
+	if sweep {
+		if deltaTheta < 0 {
+			deltaTheta += 2 * math.Pi
+		}
+	} else {
+		if deltaTheta > 0 {
+			deltaTheta -= 2 * math.Pi
+		}
+	}
+
+	// This ends the
+	// https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter
+	// algorithm. What follows below is specific to this implementation.
+
+	// We approximate an arc by one or more cubic Bézier curves.
+	n := int(math.Ceil(math.Abs(deltaTheta) / (math.Pi/2 + 0.001)))
+	for i := 0; i < n; i++ {
+		u.arcSegmentTo(cx, cy,
+			theta1+deltaTheta*float64(i+0)/float64(n),
+			theta1+deltaTheta*float64(i+1)/float64(n),
+			Rx, Ry, cosPhi, sinPhi,
+		)
+	}
+}
+
+// arcSegmentTo approximates an arc by a cubic Bézier curve. The mathematical
+// formulae for the control points are the same as that used by librsvg.
+func (u *upgrader) arcSegmentTo(cx, cy, theta1, theta2, rx, ry, cosPhi, sinPhi float64) {
+	halfDeltaTheta := (theta2 - theta1) * 0.5
+	q := math.Sin(halfDeltaTheta * 0.5)
+	t := (8 * q * q) / (3 * math.Sin(halfDeltaTheta))
+	cos1 := math.Cos(theta1)
+	sin1 := math.Sin(theta1)
+	cos2 := math.Cos(theta2)
+	sin2 := math.Sin(theta2)
+	x1 := rx * (+cos1 - t*sin1)
+	y1 := ry * (+sin1 + t*cos1)
+	x2 := rx * (+cos2 + t*sin2)
+	y2 := ry * (+sin2 - t*cos2)
+	x3 := rx * (+cos2)
+	y3 := ry * (+sin2)
+	highResolutionCoordinates := u.opts.ArcsExpandWithHighResolutionCoordinates
+	u.verbs = append(u.verbs, upgradeVerbCubeTo)
+	u.args = append(u.args,
+		[2]float32{
+			quantize(float32(cx+cosPhi*x1-sinPhi*y1), highResolutionCoordinates),
+			quantize(float32(cy+sinPhi*x1+cosPhi*y1), highResolutionCoordinates),
+		},
+		[2]float32{
+			quantize(float32(cx+cosPhi*x2-sinPhi*y2), highResolutionCoordinates),
+			quantize(float32(cy+sinPhi*x2+cosPhi*y2), highResolutionCoordinates),
+		},
+		[2]float32{
+			quantize(float32(cx+cosPhi*x3-sinPhi*y3), highResolutionCoordinates),
+			quantize(float32(cy+sinPhi*x3+cosPhi*y3), highResolutionCoordinates),
+		},
+	)
+}
+
+func countFFV1Instructions(src buffer) (ret uint64) {
+	for len(src) > 0 {
+		ret++
+		opcode := src[0]
+		src = src[1:]
+
+		switch {
+		case opcode < 0x40:
+			switch {
+			case opcode < 0x30:
+				nReps := uint32(opcode & 15)
+				if nReps == 0 {
+					n := 0
+					nReps, n = src.decodeNaturalFFV1()
+					src = src[n:]
+					nReps += 16
+				}
+				nCoords := 2 * (1 + int(opcode>>4))
+				for ; nReps > 0; nReps-- {
+					for i := 0; i < nCoords; i++ {
+						_, n := src.decodeNaturalFFV1()
+						src = src[n:]
+					}
+				}
+			case opcode < 0x35:
+				for i := 0; i < 4; i++ {
+					_, n := src.decodeNaturalFFV1()
+					src = src[n:]
+				}
+			case opcode == 0x35:
+				for i := 0; i < 2; i++ {
+					_, n := src.decodeNaturalFFV1()
+					src = src[n:]
+				}
+			case opcode == 0x36:
+				src = src[1:]
+			case opcode == 0x37:
+				// No-op.
+			default:
+				// upgradeBytecode (with calculatingJumpLOD set) will not emit
+				// jump or call instructions.
+				panic("unexpected FFV1 instruction")
+			}
+
+		case opcode < 0x80:
+			switch (opcode >> 4) & 3 {
+			case 0, 1:
+				src = src[4:]
+			case 2:
+				src = src[8:]
+			default:
+				src = src[8*(2+int(opcode&15)):]
+			}
+
+		case opcode < 0xc0:
+			switch (opcode >> 4) & 3 {
+			case 0:
+				// No-op.
+			case 1:
+				src = src[13:]
+			case 2:
+				src = src[25:]
+			default:
+				// upgradeBytecode (with calculatingJumpLOD set) will not emit
+				// reserved instructions.
+				panic("unexpected FFV1 instruction")
+			}
+
+		default:
+			// upgradeBytecode (with calculatingJumpLOD set) will not emit
+			// reserved instructions.
+			panic("unexpected FFV1 instruction")
+		}
+	}
+	return ret
+}
+
+type upgradeColor struct {
+	typ          ColorType
+	paletteIndex uint8
+	blend        uint8
+	rgba         color.RGBA
+	color0       *upgradeColor
+	color1       *upgradeColor
+}
+
+func (u *upgrader) resolve(c Color, denyBlend bool) (upgradeColor, error) {
+	switch c.typ {
+	case ColorTypeRGBA:
+		return upgradeColor{
+			typ:  ColorTypeRGBA,
+			rgba: c.data,
+		}, nil
+	case ColorTypePaletteIndex:
+		return upgradeColor{
+			typ:          ColorTypePaletteIndex,
+			paletteIndex: c.paletteIndex(),
+		}, nil
+	case ColorTypeCReg:
+		upgrade := u.creg[c.cReg()]
+		if denyBlend && (upgrade.typ == ColorTypeBlend) {
+			return upgradeColor{}, errUnsupportedUpgrade
+		}
+		return upgrade, nil
+	}
+
+	if denyBlend {
+		return upgradeColor{}, errUnsupportedUpgrade
+	}
+	t, c0, c1 := c.blend()
+	color0, err := u.resolve(decodeColor1(c0), true)
+	if err != nil {
+		return upgradeColor{}, err
+	}
+	color1, err := u.resolve(decodeColor1(c1), true)
+	if err != nil {
+		return upgradeColor{}, err
+	}
+	return upgradeColor{
+		typ:    ColorTypeBlend,
+		blend:  t,
+		color0: &color0,
+		color1: &color1,
+	}, nil
+}
diff --git a/shiny/iconvg/upgrade_test.go b/shiny/iconvg/upgrade_test.go
new file mode 100644
index 0000000..00a1968
--- /dev/null
+++ b/shiny/iconvg/upgrade_test.go
@@ -0,0 +1,47 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package iconvg
+
+import (
+	"io/ioutil"
+	"path/filepath"
+	"testing"
+)
+
+func TestUpgradeToFileFormatVersion1(t *testing.T) {
+	for _, tc := range testdataTestCases {
+		original, err := ioutil.ReadFile(filepath.FromSlash(tc.filename) + ".ivg")
+		if err != nil {
+			t.Errorf("%s: ReadFile: %v", tc.filename, err)
+			continue
+		}
+
+		upgraded, err := UpgradeToFileFormatVersion1(original, nil)
+		if err != nil {
+			t.Errorf("%s: Upgrade: %v", tc.filename, err)
+			continue
+		}
+
+		// For most of the testdataTestCases, we just check (above) that
+		// calling UpgradeToFileFormatVersion1 returns a nil error. As a
+		// further basic consistency check, we hard-code the expected results
+		// for upgrading the "action-info.lores" icon.
+		//
+		// These 36 bytes (and its disassembly via the cmd/iconvg-disassemble
+		// tool) is also a file in the test/data directory of the
+		// github.com/google/iconvg repository (the repository that is
+		// generally responsible for "File Format Version 1").
+		if tc.filename == "testdata/action-info.lores" {
+			const want = "" +
+				"\x8A\x49\x56\x47\x03\x0B\x11\x51\x51\xB1\xB1\x35\x81\x59\x33\x59" +
+				"\x81\x81\xA9\x35\x85\x95\x34\x7D\x95\x7D\x7D\x35\x85\x75\x34\x7D" +
+				"\x75\x7D\x6D\x88"
+			if got := string(upgraded); got != want {
+				t.Errorf("%s: Upgrade: got:\n% 02x\nwant:\n% 02x", tc.filename, got, want)
+				continue
+			}
+		}
+	}
+}