diff --git a/README.md b/README.md index b881291..3c7eeb6 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,15 @@ ## Overview -Package aseprite implements a decoder for Aseprite sprite files (`.ase` and `.aseprite` files). +Package aseprite implements a decoder for [Aseprite sprite files](https://github.com/aseprite/aseprite/blob/main/docs/ase-file-specs.md) (`.ase` and `.aseprite` files). -Layers are flattened, blending modes are applied, and frames are arranged on a single texture atlas. Invisible and reference layers are ignored. Tilesets and external files are not supported. +Layers are flattened, blending modes are applied, and frames are arranged on a single texture atlas. Invisible and reference layers are ignored. + +Limitations: +- Tilemaps are not supported. +- External files are not supported. +- Old aseprite format is not supported. +- Color profiles are ignored. ## Install @@ -23,7 +29,7 @@ Use `image.Decode` to decode an aseprite sprite file to an `image.Image`: img, imgformat, err := image.Decode("test.aseprite") ``` -This works fine when loading single images, but if the sprite contains multiple frames they will be organized as a texture atlas. In that case, type cast the image to a `aseprite.Aseprite` to access the metadata: +This is enough to decode single frame images. Multiple frames are arranged as a texture atlas in a single image. Type cast the image to `aseprite.Aseprite` to access the frame data, as well as other meta data extracted from the sprite file: ```go if imgformat == "aseprite" { @@ -34,6 +40,10 @@ if imgformat == "aseprite" { } ``` +Read the [documentation](https://pkg.go.dev/github.com/askeladdk/aseprite) for more information about what meta data is extracted. + ## License Package aseprite is released under the terms of the ISC license. + +The internal blend package is released by Guillermo Estrada under the terms of the MIT license: http://github.com/phrozen/blend. diff --git a/aseprite.go b/aseprite.go index 15d9f88..55119e3 100644 --- a/aseprite.go +++ b/aseprite.go @@ -16,7 +16,7 @@ import ( ) // LoopDirection enumerates all loop animation directions. -type LoopDirection int +type LoopDirection uint8 const ( Forward LoopDirection = 0 @@ -30,22 +30,17 @@ type Tag struct { // Name is the name of the tag. Can be duplicate. Name string - // Flags is a bitset of flags. - Flags int - - // Repeat specifies how many times to repeat the animation. - Repeat int - // Lo is the first frame in the animation. - Lo int + Lo uint16 // Hi is the last frame in the animation. - Hi int -} + Hi uint16 + + // Repeat specifies how many times to repeat the animation. + Repeat uint16 -// LoopDirection returns the looping direction of the animation. -func (t *Tag) LoopDirection() LoopDirection { - return LoopDirection(t.Flags & 3) + // LoopDirection is the looping direction of the animation. + LoopDirection LoopDirection } // Frame represents a single frame in the sprite. @@ -73,7 +68,7 @@ type Slice struct { // Pivot is the pivot point relative to Bounds. Pivot image.Point - // Name is name of the slice. Can be duplicate. + // Name is the name of the slice. Can be duplicate. Name string // Data is optional user data. @@ -83,7 +78,7 @@ type Slice struct { Color color.Color } -// Aseprite holds the results of a parsed .ase or .aseprite file. +// Aseprite holds the results of a parsed Aseprite image file. type Aseprite struct { // Image contains all frame images in a single image. // Frame bounds specify where the frame images are located. diff --git a/decode.go b/decode.go index fb7cc41..9d885a7 100644 --- a/decode.go +++ b/decode.go @@ -1,14 +1,13 @@ package aseprite import ( - "encoding/binary" "image" "image/color" "io" ) -// Decode reads a Aseprite image from r and returns it as an image.Image. -func Decode(r io.Reader) (image.Image, error) { +// DecodeAseprite decodes an Aseprite image from r. +func DecodeAseprite(r io.Reader) (*Aseprite, error) { var spr Aseprite if err := spr.readFrom(r); err != nil { return nil, err @@ -17,38 +16,41 @@ func Decode(r io.Reader) (image.Image, error) { return &spr, nil } +// Decode decodes an Aseprite image from r and returns it as an image.Image. +func Decode(r io.Reader) (image.Image, error) { + return DecodeAseprite(r) +} + // DecodeConfig returns the color model and dimensions of an Aseprite image // without decoding the entire image. func DecodeConfig(r io.Reader) (image.Config, error) { - var raw [14]byte + var f file - if _, err := io.ReadFull(r, raw[:]); err != nil { + if _, err := f.ReadFrom(r); err != nil { return image.Config{}, err } - if magic := binary.LittleEndian.Uint16(raw[4:]); magic != 0xA5E0 { - return image.Config{}, errInvalidMagic + fw, fh := factorPowerOfTwo(len(f.frames)) + if f.framew > f.frameh { + fw, fh = fh, fw } - nframes := int(binary.LittleEndian.Uint16(raw[6:])) - framew := int(binary.LittleEndian.Uint16(raw[8:])) - frameh := int(binary.LittleEndian.Uint16(raw[10:])) - bpp := binary.LittleEndian.Uint16(raw[12:]) + var colorModel color.Model - colorModel := color.RGBAModel - if bpp == 16 { + switch f.bpp { + case 8: + f.initPalette() + colorModel = f.palette + case 16: colorModel = color.Gray16Model - } - - fw, fh := factorPowerOfTwo(nframes) - if framew > frameh { - fw, fh = fh, fw + default: + colorModel = color.RGBAModel } return image.Config{ ColorModel: colorModel, - Width: framew * fw, - Height: frameh * fh, + Width: f.framew * fw, + Height: f.frameh * fh, }, nil } diff --git a/decode_test.go b/decode_test.go index a203056..539ed61 100644 --- a/decode_test.go +++ b/decode_test.go @@ -6,12 +6,20 @@ import ( "image/png" "os" "testing" + + "github.com/askeladdk/aseprite/internal/require" ) -func assertNoError(t *testing.T, err error) { - if err != nil { - t.Fatal(err) +func equalPalette(a, b color.Palette) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } } + return true } func TestDecode(t *testing.T) { @@ -19,42 +27,51 @@ func TestDecode(t *testing.T) { Name string Filename string Outfile string + Frames int + Tags int }{ { Name: "paletted", Filename: "./testfiles/slime_paletted.aseprite", Outfile: "slime_paletted.png", + Frames: 10, + Tags: 2, }, { Name: "grayscale", Filename: "./testfiles/slime_grayscale.aseprite", Outfile: "slime_grayscale.png", + Frames: 10, + Tags: 2, }, { Name: "blendtest", Filename: "./testfiles/blendtest.aseprite", Outfile: "blendtest.png", + Frames: 1, + Tags: 0, }, } { t.Run(tt.Name, func(t *testing.T) { f, err := os.Open(tt.Filename) - assertNoError(t, err) + require.NoError(t, err) defer f.Close() img, imgformat, err := image.Decode(f) - assertNoError(t, err) + require.NoError(t, err) if imgformat != "aseprite" { t.Fatal(imgformat) } - if _, ok := img.(*Aseprite); !ok { - t.Fatal() - } + aspr, ok := img.(*Aseprite) + require.True(t, ok) + require.True(t, len(aspr.Frames) == tt.Frames, "frames", len(aspr.Frames)) + require.True(t, len(aspr.Tags) == tt.Tags, "tags", len(aspr.Tags)) out, err := os.Create(tt.Outfile) - assertNoError(t, err) - assertNoError(t, png.Encode(out, img)) + require.NoError(t, err) + require.NoError(t, png.Encode(out, img)) }) } } @@ -69,9 +86,15 @@ func TestDecodeConfig(t *testing.T) { Name: "paletted", Filename: "./testfiles/slime_paletted.aseprite", ImageConfig: image.Config{ - ColorModel: color.RGBAModel, - Width: 128, - Height: 256, + ColorModel: &color.Palette{ + 0: color.Alpha16{0}, + 1: color.NRGBA{247, 231, 198, 255}, + 2: color.NRGBA{214, 142, 73, 255}, + 3: color.NRGBA{166, 55, 37, 255}, + 4: color.NRGBA{51, 30, 80, 255}, + }, + Width: 128, + Height: 256, }, }, { @@ -95,18 +118,21 @@ func TestDecodeConfig(t *testing.T) { } { t.Run(tt.Name, func(t *testing.T) { f, err := os.Open(tt.Filename) - assertNoError(t, err) + require.NoError(t, err) defer f.Close() conf, imgformat, err := image.DecodeConfig(f) - assertNoError(t, err) + require.NoError(t, err) + require.True(t, imgformat == "aseprite", "image format", imgformat) - if imgformat != "aseprite" { - t.Fatal(imgformat) - } + require.True(t, conf.Height == tt.ImageConfig.Height, "height") + require.True(t, conf.Width == tt.ImageConfig.Width, "width") - if conf != tt.ImageConfig { - t.Fatal(conf) + if pal, ok := conf.ColorModel.(color.Palette); ok { + imgpal := *tt.ImageConfig.ColorModel.(*color.Palette) + require.True(t, equalPalette(pal, imgpal), "palette") + } else { + require.True(t, conf.ColorModel == tt.ImageConfig.ColorModel, "color model") } }) } diff --git a/internal/blend/blend_test.go b/internal/blend/blend_test.go index e81f086..3b4e30b 100644 --- a/internal/blend/blend_test.go +++ b/internal/blend/blend_test.go @@ -7,13 +7,9 @@ import ( "os" "strings" "testing" -) -func assertNoError(t *testing.T, err error) { - if err != nil { - t.Fatal(err) - } -} + "github.com/askeladdk/aseprite/internal/require" +) func jpgDecode(filename string) (image.Image, error) { f, err := os.Open(filename) @@ -35,10 +31,10 @@ func jpgEncode(filename string, img image.Image) error { func TestBlendModes(t *testing.T) { dst, err := jpgDecode("../../testfiles/dst.jpg") - assertNoError(t, err) + require.NoError(t, err) src, err := jpgDecode("../../testfiles/src.jpg") - assertNoError(t, err) + require.NoError(t, err) for i, name := range []string{ "Normal", @@ -65,7 +61,7 @@ func TestBlendModes(t *testing.T) { img := image.NewRGBA(src.Bounds()) Blend(img, img.Bounds(), src, image.Point{}, dst, image.Point{}, Modes[i]) - assertNoError(t, jpgEncode(fmt.Sprintf("out_%s.jpg", strings.ToLower(name)), img)) + require.NoError(t, jpgEncode(fmt.Sprintf("out_%s.jpg", strings.ToLower(name)), img)) }) } } diff --git a/internal/require/require.go b/internal/require/require.go new file mode 100644 index 0000000..f201c2f --- /dev/null +++ b/internal/require/require.go @@ -0,0 +1,17 @@ +package require + +import "testing" + +func NoError(t *testing.T, err error) { + if err != nil { + t.Helper() + t.Fatal("unexpected error:", err) + } +} + +func True(t *testing.T, test bool, args ...any) { + if !test { + t.Helper() + t.Fatal(args...) + } +} diff --git a/parse.go b/parse.go index 8e79894..afebf45 100644 --- a/parse.go +++ b/parse.go @@ -178,10 +178,10 @@ func (f *file) initCels() error { } func parseTag(t *Tag, raw []byte) []byte { - t.Lo = int(binary.LittleEndian.Uint16(raw)) - t.Hi = int(binary.LittleEndian.Uint16(raw[2:])) - t.Flags = int(raw[4]) - t.Repeat = int(binary.LittleEndian.Uint16(raw[5:])) + t.Lo = binary.LittleEndian.Uint16(raw) + t.Hi = binary.LittleEndian.Uint16(raw[2:]) + t.LoopDirection = LoopDirection(raw[4]) + t.Repeat = binary.LittleEndian.Uint16(raw[5:]) t.Name = parseString(raw[17:]) return raw[19+len(t.Name):] }