Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
module github.com/echosoar/imgpro

go 1.15
go 1.25.0

require golang.org/x/image v0.23.0
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
108 changes: 108 additions & 0 deletions processor/apng.go
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 23 additions & 1 deletion processor/rgba.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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{
Expand Down
25 changes: 24 additions & 1 deletion processor/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package processor

import (
"bytes"
"encoding/binary"

img "github.com/echosoar/imgpro/core"
)
Expand All @@ -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 {
Expand All @@ -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
}
2 changes: 1 addition & 1 deletion processor/wh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Binary file added test/imgs/cool.apng
Binary file not shown.
Binary file added test/imgs/go_24.bmp
Binary file not shown.
60 changes: 60 additions & 0 deletions test/rgba_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
7 changes: 7 additions & 0 deletions test/type_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
9 changes: 9 additions & 0 deletions test/wh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down