diff --git a/pdf/images.go b/pdf/images.go index 1805816..efa4ae7 100644 --- a/pdf/images.go +++ b/pdf/images.go @@ -41,6 +41,7 @@ const ( const ( pngColorTypeGray = 0 pngColorTypeRGB = 2 + pngColorTypePalette = 3 pngColorTypeGrayAlpha = 4 pngColorTypeRGBA = 6 ) @@ -211,18 +212,32 @@ func pngInfo(data []byte) (imageInfo, error) { if interlaceMethod != 0 { return imageInfo{}, errUnsupportedPNG } - if bitDepth != 8 { - return imageInfo{}, errUnsupportedPNG - } components := 0 switch colorType { case pngColorTypeGray: + if bitDepth != 1 && bitDepth != 2 && bitDepth != 4 && bitDepth != 8 { + return imageInfo{}, errUnsupportedPNG + } components = imageComponentsGray case pngColorTypeRGB: + if bitDepth != 8 { + return imageInfo{}, errUnsupportedPNG + } components = imageComponentsRGB + case pngColorTypePalette: + if bitDepth != 1 && bitDepth != 2 && bitDepth != 4 && bitDepth != 8 { + return imageInfo{}, errUnsupportedPNG + } + components = imageComponentsRGBA case pngColorTypeGrayAlpha: + if bitDepth != 8 { + return imageInfo{}, errUnsupportedPNG + } components = imageComponentsGrayAlpha case pngColorTypeRGBA: + if bitDepth != 8 { + return imageInfo{}, errUnsupportedPNG + } components = imageComponentsRGBA default: return imageInfo{}, errUnsupportedPNG @@ -231,7 +246,7 @@ func pngInfo(data []byte) (imageInfo, error) { width: width, height: height, components: components, - bitsPerComponent: bitDepth, + bitsPerComponent: 8, }, nil } @@ -347,6 +362,12 @@ func decodePNG(data []byte) (decodedImage, error) { } if allOpaque { result.alphaData = nil + switch result.info.components { + case imageComponentsGrayAlpha: + result.info.components = imageComponentsGray + case imageComponentsRGBA: + result.info.components = imageComponentsRGB + } } return result, nil } diff --git a/pdf/images_test.go b/pdf/images_test.go index f4de1a9..bf445df 100644 --- a/pdf/images_test.go +++ b/pdf/images_test.go @@ -5,7 +5,9 @@ package pdf import ( "bytes" + "compress/zlib" "encoding/binary" + "hash/crc32" "image" "image/color" "image/jpeg" @@ -208,6 +210,78 @@ func mutatePNGInterlace(t *testing.T, data []byte, interlace byte) []byte { return mutated } +func pngHeaderFields(t *testing.T, data []byte) (bitDepth, colorType byte) { + t.Helper() + if !isPNG(data) || len(data) < 29 { + t.Fatalf("expected PNG header, got %d bytes", len(data)) + } + return data[24], data[25] +} + +func writePNGChunk(t *testing.T, buf *bytes.Buffer, chunkType string, data []byte) { + t.Helper() + if len(chunkType) != 4 { + t.Fatalf("PNG chunk type %q must be 4 bytes", chunkType) + } + if err := binary.Write(buf, binary.BigEndian, uint32(len(data))); err != nil { + t.Fatal(err) + } + buf.WriteString(chunkType) + buf.Write(data) + crc := crc32.NewIEEE() + crc.Write([]byte(chunkType)) + crc.Write(data) + if err := binary.Write(buf, binary.BigEndian, crc.Sum32()); err != nil { + t.Fatal(err) + } +} + +func testOneBitGrayPNG(t *testing.T) []byte { + t.Helper() + var compressed bytes.Buffer + zw := zlib.NewWriter(&compressed) + zw.Write([]byte{0x00, 0x40}) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + var ihdr bytes.Buffer + if err := binary.Write(&ihdr, binary.BigEndian, uint32(2)); err != nil { + t.Fatal(err) + } + if err := binary.Write(&ihdr, binary.BigEndian, uint32(1)); err != nil { + t.Fatal(err) + } + ihdr.Write([]byte{1, pngColorTypeGray, 0, 0, 0}) + + var pngData bytes.Buffer + pngData.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) + writePNGChunk(t, &pngData, "IHDR", ihdr.Bytes()) + writePNGChunk(t, &pngData, "IDAT", compressed.Bytes()) + writePNGChunk(t, &pngData, "IEND", nil) + return pngData.Bytes() +} + +func testPalettedPNG(t *testing.T, paletteSize int, transparent bool) []byte { + t.Helper() + palette := make(color.Palette, paletteSize) + for i := range palette { + palette[i] = color.NRGBA{ + R: uint8(i), + G: uint8(0x80 + i/2), + B: uint8(0xF0 - i/2), + A: 0xFF, + } + } + if transparent { + palette[1] = color.NRGBA{R: 0x22, G: 0x44, B: 0x66, A: 0x80} + } + img := image.NewPaletted(image.Rect(0, 0, 2, 1), palette) + img.SetColorIndex(0, 0, 0) + img.SetColorIndex(1, 0, 1) + return mustEncodePNG(t, img) +} + func TestIsJPEG(t *testing.T) { if !isJPEG(mustReadTestImage(t)) { t.Fatal("expected fixture to be detected as JPEG") @@ -276,6 +350,56 @@ func TestPNGInfo(t *testing.T) { } } +func TestPNGInfo_PalettedBitDepthsExpandToRGB(t *testing.T) { + tests := []struct { + name string + paletteSize int + wantDepth byte + }{ + {name: "2Bit", paletteSize: 4, wantDepth: 2}, + {name: "4Bit", paletteSize: 16, wantDepth: 4}, + {name: "8Bit", paletteSize: 256, wantDepth: 8}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := testPalettedPNG(t, tt.paletteSize, false) + bitDepth, colorType := pngHeaderFields(t, data) + if bitDepth != tt.wantDepth || colorType != pngColorTypePalette { + t.Fatalf("encoded PNG = colorType %d bitDepth %d, want palette/%d", colorType, bitDepth, tt.wantDepth) + } + info, err := pngInfo(data) + if err != nil { + t.Fatal(err) + } + if info.components != imageComponentsRGBA { + t.Fatalf("components = %d, want alpha-capable palette expansion", info.components) + } + if info.bitsPerComponent != 8 { + t.Fatalf("bitsPerComponent = %d, want expanded 8-bit samples", info.bitsPerComponent) + } + }) + } +} + +func TestPNGInfo_OneBitGrayExpandsToEightBitSamples(t *testing.T) { + data := testOneBitGrayPNG(t) + bitDepth, colorType := pngHeaderFields(t, data) + if bitDepth != 1 || colorType != pngColorTypeGray { + t.Fatalf("encoded PNG = colorType %d bitDepth %d, want 1-bit gray", colorType, bitDepth) + } + + info, err := pngInfo(data) + if err != nil { + t.Fatal(err) + } + if info.components != imageComponentsGray { + t.Fatalf("components = %d, want gray", info.components) + } + if info.bitsPerComponent != 8 { + t.Fatalf("bitsPerComponent = %d, want expanded 8-bit samples", info.bitsPerComponent) + } +} + func TestPNGInfo_UnsupportedInterlace(t *testing.T) { img := image.NewRGBA(image.Rect(0, 0, 1, 1)) data := mutatePNGInterlace(t, mustEncodePNG(t, img), 1) @@ -349,6 +473,60 @@ func TestDecodePNG_Gray(t *testing.T) { } } +func TestDecodePNG_PalettedOpaque(t *testing.T) { + data := testPalettedPNG(t, 4, false) + + decoded, err := decodePNG(data) + if err != nil { + t.Fatal(err) + } + if decoded.info.components != imageComponentsRGB { + t.Fatalf("components = %d, want opaque palette expanded to RGB", decoded.info.components) + } + if !bytes.Equal(decoded.data, []byte{0x00, 0x80, 0xF0, 0x01, 0x80, 0xF0}) { + t.Fatalf("unexpected RGB data %v", decoded.data) + } + if len(decoded.alphaData) != 0 { + t.Fatalf("expected opaque palette to omit alpha mask, got %d alpha bytes", len(decoded.alphaData)) + } +} + +func TestDecodePNG_PalettedTRNS(t *testing.T) { + data := testPalettedPNG(t, 4, true) + + decoded, err := decodePNG(data) + if err != nil { + t.Fatal(err) + } + if decoded.info.components != imageComponentsRGBA { + t.Fatalf("components = %d, want palette with tRNS expanded to RGBA", decoded.info.components) + } + if !bytes.Equal(decoded.data, []byte{0x00, 0x80, 0xF0, 0x22, 0x44, 0x66}) { + t.Fatalf("unexpected RGB data %v", decoded.data) + } + if !bytes.Equal(decoded.alphaData, []byte{0xFF, 0x80}) { + t.Fatalf("unexpected alpha mask %v", decoded.alphaData) + } +} + +func TestDecodePNG_OneBitGray(t *testing.T) { + data := testOneBitGrayPNG(t) + + decoded, err := decodePNG(data) + if err != nil { + t.Fatal(err) + } + if decoded.info.components != imageComponentsGray { + t.Fatalf("components = %d, want gray", decoded.info.components) + } + if !bytes.Equal(decoded.data, []byte{0x00, 0xFF}) { + t.Fatalf("unexpected gray data %v", decoded.data) + } + if len(decoded.alphaData) != 0 { + t.Fatalf("expected no alpha data, got %d bytes", len(decoded.alphaData)) + } +} + func TestPageWriter_PrintImage_PNG_DefaultSize(t *testing.T) { img := image.NewRGBA(image.Rect(0, 0, 3, 2)) data := mustEncodePNG(t, img)