diff --git a/go.mod b/go.mod index f93a343..a4930bd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/echosoar/imgpro -go 1.15 +go 1.25.0 + +require golang.org/x/image v0.23.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8ec2c9e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= diff --git a/processor/apng.go b/processor/apng.go new file mode 100644 index 0000000..6f357f0 --- /dev/null +++ b/processor/apng.go @@ -0,0 +1,108 @@ +package processor + +import ( + "encoding/binary" + "hash/crc32" +) + +// apngFrame holds the parsed metadata and compressed image data for a single APNG frame. +type apngFrame struct { + width int + height int + xOffset int + yOffset int + // idatData is the raw compressed pixel data (equivalent to PNG IDAT content). + idatData []byte +} + +// parseAPNGFrames parses an APNG file and returns each animation frame together +// with the raw IHDR chunk bytes and any ancillary chunks (PLTE, tRNS, etc.) +// required to reconstruct valid per-frame PNG images. +func parseAPNGFrames(fileBytes []byte) (frames []apngFrame, ihdrChunk []byte, ancillaryChunks []byte) { + offset := 8 // skip PNG signature + var current *apngFrame + firstFctlSeen := false + + for offset+12 <= len(fileBytes) { + chunkLen := int(binary.BigEndian.Uint32(fileBytes[offset : offset+4])) + if offset+12+chunkLen > len(fileBytes) { + break + } + chunkType := string(fileBytes[offset+4 : offset+8]) + chunkData := fileBytes[offset+8 : offset+8+chunkLen] + rawChunk := fileBytes[offset : offset+12+chunkLen] + + switch chunkType { + case "IHDR": + ihdrChunk = rawChunk + case "PLTE", "tRNS", "gAMA", "cHRM", "sRGB", "iCCP", "bKGD", "sBIT": + ancillaryChunks = append(ancillaryChunks, rawChunk...) + case "fcTL": + if current != nil { + frames = append(frames, *current) + } + // fcTL layout: seq(4) + width(4) + height(4) + x_offset(4) + y_offset(4) + ... + f := apngFrame{ + width: int(binary.BigEndian.Uint32(chunkData[4:8])), + height: int(binary.BigEndian.Uint32(chunkData[8:12])), + xOffset: int(binary.BigEndian.Uint32(chunkData[12:16])), + yOffset: int(binary.BigEndian.Uint32(chunkData[16:20])), + } + current = &f + firstFctlSeen = true + case "IDAT": + // When the first fcTL precedes the first IDAT, those IDAT chunks + // belong to the first animation frame. + if firstFctlSeen && current != nil { + current.idatData = append(current.idatData, chunkData...) + } + case "fdAT": + // fdAT layout: seq(4) + compressed_image_data + if current != nil && len(chunkData) > 4 { + current.idatData = append(current.idatData, chunkData[4:]...) + } + case "IEND": + if current != nil { + frames = append(frames, *current) + current = nil + } + } + + offset += 12 + chunkLen + } + return +} + +// buildAPNGFramePNG reconstructs a minimal, self-contained PNG file for the +// given frame so that it can be decoded with image.Decode. +func buildAPNGFramePNG(frame apngFrame, ihdrChunk []byte, ancillaryChunks []byte) []byte { + pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + + // Build a new IHDR using the frame's own width/height but the original + // bit-depth, colour-type, compression, filter and interlace values. + originalIHDRData := ihdrChunk[8 : len(ihdrChunk)-4] // strip length, type and CRC + newIHDRData := make([]byte, 13) + binary.BigEndian.PutUint32(newIHDRData[0:4], uint32(frame.width)) + binary.BigEndian.PutUint32(newIHDRData[4:8], uint32(frame.height)) + copy(newIHDRData[8:13], originalIHDRData[8:13]) + + var buf []byte + buf = append(buf, pngSig...) + buf = append(buf, buildPNGChunk("IHDR", newIHDRData)...) + buf = append(buf, ancillaryChunks...) + buf = append(buf, buildPNGChunk("IDAT", frame.idatData)...) + buf = append(buf, buildPNGChunk("IEND", nil)...) + return buf +} + +// buildPNGChunk creates a complete PNG chunk (length + type + data + CRC). +func buildPNGChunk(chunkType string, data []byte) []byte { + buf := make([]byte, 4+4+len(data)+4) + binary.BigEndian.PutUint32(buf[0:4], uint32(len(data))) + copy(buf[4:8], chunkType) + copy(buf[8:], data) + c := crc32.NewIEEE() + c.Write(buf[4 : 8+len(data)]) + binary.BigEndian.PutUint32(buf[8+len(data):], c.Sum32()) + return buf +} diff --git a/processor/rgba.go b/processor/rgba.go index 3919512..d90ad51 100644 --- a/processor/rgba.go +++ b/processor/rgba.go @@ -7,6 +7,8 @@ import ( "image/gif" img "github.com/echosoar/imgpro/core" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/webp" ) // RGBAProcessor bin size processor @@ -52,7 +54,7 @@ func rgbaRunner(core *img.Core) map[string]img.Value { frame := 0 frameRGBAs := []img.Value{} - if imgType == "png" || imgType == "jpg" { + if imgType == "png" || imgType == "jpg" || imgType == "webp" || imgType == "bmp" { frame = 1 frameRGBAs = make([]img.Value, 1) originalImage, _, err := image.Decode(bytes.NewReader(core.FileBinary)) @@ -86,6 +88,26 @@ func rgbaRunner(core *img.Core) map[string]img.Value { Rgba: rgbaFrame, } } + } else if imgType == "apng" { + apngFrames, ihdrChunk, ancillaryChunks := parseAPNGFrames(core.FileBinary) + frame = len(apngFrames) + frameRGBAs = make([]img.Value, frame) + for frameIndex, apngF := range apngFrames { + framePNG := buildAPNGFramePNG(apngF, ihdrChunk, ancillaryChunks) + frameImg, _, err := image.Decode(bytes.NewReader(framePNG)) + if err != nil { + continue + } + rgbaFrame := make([]img.RGBA, height*width) + bounds := image.Rect(apngF.xOffset, apngF.yOffset, apngF.xOffset+apngF.width, apngF.yOffset+apngF.height) + rgba := image.NewRGBA(bounds) + draw.Draw(rgba, bounds, frameImg, frameImg.Bounds().Min, draw.Src) + readImageRGBA(width, height, &bounds, &rgbaFrame, rgba) + frameRGBAs[frameIndex] = img.Value{ + Type: img.ValueTypeRGBA, + Rgba: rgbaFrame, + } + } } return map[string]img.Value{ diff --git a/processor/type.go b/processor/type.go index 6e0d401..ef36bdb 100644 --- a/processor/type.go +++ b/processor/type.go @@ -2,6 +2,7 @@ package processor import ( "bytes" + "encoding/binary" img "github.com/echosoar/imgpro/core" ) @@ -24,7 +25,11 @@ func typeRunner(core *img.Core) map[string]img.Value { } else if bytes.HasPrefix(fileBytes, []byte("GIF87a")) || bytes.HasPrefix(fileBytes, []byte("GIF89a")) { imgType = "gif" } else if bytes.HasPrefix(fileBytes, []byte("\x89PNG\x0D\x0A\x1A\x0A")) { - imgType = "png" + if isPNGAnimated(fileBytes) { + imgType = "apng" + } else { + imgType = "png" + } } else if bytes.HasPrefix(fileBytes, []byte("\xFF\xD8\xFF")) { imgType = "jpg" } else if len(fileBytes) >= 14 { @@ -50,3 +55,21 @@ func typeRunner(core *img.Core) map[string]img.Value { }, } } + +// isPNGAnimated returns true if the PNG data contains an acTL chunk, identifying it as APNG. +func isPNGAnimated(fileBytes []byte) bool { + offset := 8 // skip PNG signature + for offset+12 <= len(fileBytes) { + chunkLen := int(binary.BigEndian.Uint32(fileBytes[offset : offset+4])) + chunkType := string(fileBytes[offset+4 : offset+8]) + if chunkType == "acTL" { + return true + } + // Stop searching after the first IDAT or IEND chunk + if chunkType == "IDAT" || chunkType == "IEND" { + return false + } + offset += 12 + chunkLen + } + return false +} diff --git a/processor/wh.go b/processor/wh.go index 13c038e..3551ea2 100644 --- a/processor/wh.go +++ b/processor/wh.go @@ -29,7 +29,7 @@ func whRunner(core *img.Core) map[string]img.Value { height := 0 fileBytes := core.FileBinary - if imgType == "png" || imgType == "jpg" || imgType == "gif" { + if imgType == "png" || imgType == "apng" || imgType == "jpg" || imgType == "gif" { image, _, err := image.DecodeConfig(bytes.NewReader(core.FileBinary)) if err != nil { panic(err) diff --git a/test/imgs/cool.apng b/test/imgs/cool.apng new file mode 100644 index 0000000..90d7b99 Binary files /dev/null and b/test/imgs/cool.apng differ diff --git a/test/imgs/go_24.bmp b/test/imgs/go_24.bmp new file mode 100644 index 0000000..e52075c Binary files /dev/null and b/test/imgs/go_24.bmp differ diff --git a/test/rgba_test.go b/test/rgba_test.go index d7e2355..a7cde83 100644 --- a/test/rgba_test.go +++ b/test/rgba_test.go @@ -44,3 +44,63 @@ func TestRGBAGif(t *testing.T) { t.Fatal("rgba gif frames error", result["frame"].Int) } } + +func TestRGBABmp(t *testing.T) { + result := Run("./imgs/go_24.bmp", []string{"rgba"}) + if len(result["rgba"].Frames[0].Rgba) != 1402640 { + t.Fatal("rgba bmp error") + } + if result["rgba"].Frames[0].Rgba[600000].R != 106 { + t.Fatal("rgba bmp r error") + } + if result["rgba"].Frames[0].Rgba[600000].G != 215 { + t.Fatal("rgba bmp g error") + } + if result["rgba"].Frames[0].Rgba[600000].B != 229 { + t.Fatal("rgba bmp b error") + } +} + +func TestRGBAWebp(t *testing.T) { + result := Run("./imgs/go_32.webp", []string{"rgba"}) + if len(result["rgba"].Frames[0].Rgba) != 1402640 { + t.Fatal("rgba webp error") + } + if result["rgba"].Frames[0].Rgba[600000].R != 107 { + t.Fatal("rgba webp r error") + } + if result["rgba"].Frames[0].Rgba[600000].G != 201 { + t.Fatal("rgba webp g error") + } + if result["rgba"].Frames[0].Rgba[600000].B != 213 { + t.Fatal("rgba webp b error") + } +} + +func TestRGBAApng(t *testing.T) { + result := Run("./imgs/cool.apng", []string{"rgba", "frame"}) + if result["frame"].Int != 3 { + t.Fatal("rgba apng frames error", result["frame"].Int) + } + if len(result["rgba"].Frames[0].Rgba) != 16 { + t.Fatal("rgba apng frame size error") + } + // Frame 0 is solid red + if result["rgba"].Frames[0].Rgba[0].R != 255 { + t.Fatal("rgba apng frame0 r error") + } + if result["rgba"].Frames[0].Rgba[0].G != 0 { + t.Fatal("rgba apng frame0 g error") + } + if result["rgba"].Frames[0].Rgba[0].B != 0 { + t.Fatal("rgba apng frame0 b error") + } + // Frame 1 is solid green + if result["rgba"].Frames[1].Rgba[0].G != 255 { + t.Fatal("rgba apng frame1 g error") + } + // Frame 2 is solid blue + if result["rgba"].Frames[2].Rgba[0].B != 255 { + t.Fatal("rgba apng frame2 b error") + } +} diff --git a/test/type_test.go b/test/type_test.go index 83fe2af..dc74782 100644 --- a/test/type_test.go +++ b/test/type_test.go @@ -46,3 +46,10 @@ func TestTypeWebp88(t *testing.T) { t.Fatal("type error") } } + +func TestTypeApng(t *testing.T) { + result := Run("./imgs/cool.apng", []string{"type"}) + if result["type"].String != "apng" { + t.Fatal("type error", result["type"].String) + } +} diff --git a/test/wh_test.go b/test/wh_test.go index 8d3bcd9..e571ce6 100644 --- a/test/wh_test.go +++ b/test/wh_test.go @@ -55,6 +55,15 @@ func TestWHWebp32(t *testing.T) { t.Fatal("webp height error") } } +func TestWHApng(t *testing.T) { + result := Run("./imgs/cool.apng", []string{"width", "height"}) + if result["width"].Int != 4 { + t.Fatal("apng width error") + } + if result["height"].Int != 4 { + t.Fatal("apng height error") + } +} func TestWHWebp88(t *testing.T) { result := Run("./imgs/cool_88.webp", []string{"width", "height"}) if result["width"].Int != 300 {