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 + } + } + } +}