Swift の練習を兼ねて PNG ファイルを弄るプログラムを作ってみる。
処理内容は
- PNG ファイルをチャンク単位にして読み込む
- IDAT 以外は変更しない
- IDAT を zlib で展開 (uncompress)
- フィルタ タイプを弄る
- IDAT を zlib で圧縮 (compress)
- PNG ファイルを出力する
で、IDAT 内のフィルタ タイプの処理をテストする。
テスト プログラム(Xcode のコンソールアプリ)
main.swift
import Foundation
import Compression
// ############
var flag_update_image = true
var flag_image_filter = true
var flag_overwrite = false
var flag_verbose = false
// ############
enum PNGError : Error {
case system
case open
case exists
case create
case signature
case unsupported
case data
case io
func errorPrint(_ msg: String) {
switch (self)
{
case .system:
print("内部エラー: \(msg)")
case .open:
print("ファイルが開けません: \(msg)")
case .exists:
print("ファイルが既にあります: \(msg)")
case .create:
print("ファイルが作れません: \(msg)")
case .signature:
print("PNG ではありません: \(msg)")
case .unsupported:
print("非対応の形式です: \(msg)")
case .data:
print("データが破損しています: \(msg)")
case .io:
print("読み込みに失敗しました: \(msg)")
}
}
}
// ############
func UInt32EB(_ byteArray: ArraySlice<UInt8>) -> UInt32 {
var value: UInt32 = 0
for b in byteArray {
value <<= 8
value |= UInt32(b)
}
return value
}
extension Data {
func ToArrayU8() -> [UInt8] {
return [UInt8](unsafeUninitializedCapacity: count)
{ buffer, initializedCount in
copyBytes(to: buffer, from: 0..<count)
initializedCount = count
}
}
}
// ############
extension FileHandle {
func readDataU8(ofLength count: Int) -> [UInt8] {
return readData(ofLength: count).ToArrayU8()
}
func readU32EB() -> UInt32? {
let data = readDataU8(ofLength: 4)
if data.count != 4 {
return nil
}
return UInt32EB(data[0..<4])
}
func writeU32EB(_ value: UInt32) {
write(Data([
UInt8((value >> 24) & 0xff),
UInt8((value >> 16) & 0xff),
UInt8((value >> 8) & 0xff),
UInt8((value >> 0) & 0xff),
]))
}
}
// ############
class CRC32Table {
private static var dictionary: [UInt32: [UInt32]] = [:]
private let table: [UInt32]
subscript(index: Int) -> UInt32 {
return table[index]
}
init(_ polynomial: UInt32) {
if let dictTable = CRC32Table.dictionary[polynomial] {
table = dictTable
return
}
let newTable = [UInt32](unsafeUninitializedCapacity: 256)
{ buffer, initializedCount in
for n in 0..<256 {
var v = UInt32(n)
for _ in 0..<8 {
let f = v & 1
v >>= 1
if f != 0 {
v ^= polynomial
}
}
buffer[n] = v
}
initializedCount = 256
}
CRC32Table.dictionary[polynomial] = newTable
table = newTable
}
}
class CRC32 {
private let table: CRC32Table
private var value_: UInt32 = ~0
var value : UInt32 {
get {
return ~value_
}
}
init(_ polynomial: UInt32 = 0xEDB88320) {
table = CRC32Table(polynomial)
}
func update(_ data: UInt8) {
let index = Int(UInt8(value_ & 0xff) ^ data)
value_ = (value_ >> 8) ^ table[index]
}
func update(_ data: Data) {
update(data.ToArrayU8())
}
func updateEB(_ data: UInt32) {
update(UInt8((data >> 24) & 0xff))
update(UInt8((data >> 16) & 0xff))
update(UInt8((data >> 8) & 0xff))
update(UInt8((data >> 0) & 0xff))
}
func update(_ data: [UInt8]) {
for v in data {
update(v)
}
}
}
// ############
// Compression Module (ZLIB)
func CompressM(_ src: [UInt8]) -> [UInt8]? {
var buffer: [UInt8] = [0x78, 0x5E]
do {
let out = try OutputFilter(.compress, using: .zlib)
{ (data: Data?) -> Void in
buffer.append(contentsOf: data?.ToArrayU8() ?? [])
}
try out.write(src)
try out.finalize()
} catch let error {
print(error.localizedDescription)
return nil
}
return buffer
}
func ZLibUncompress(_ size: UInt32 , _ src: [UInt8]) -> [UInt8]? {
var buffer: [UInt8] = []
do {
let out = try OutputFilter(.decompress, using: .zlib)
{ (data: Data?) -> Void in
buffer.append(contentsOf: data?.ToArrayU8() ?? [])
}
try out.write(src[2...])
try out.finalize()
} catch let error {
print(error.localizedDescription)
return nil
}
return buffer
}
// C Library
func zlibCompress(_ src: [UInt8]) -> [UInt8]? {
var buffer = [UInt8](repeating: 0, count: Int(src.count * 2))
let status = CompressC(&buffer, UInt(buffer.count), src, UInt(src.count), 9)
if status < 0 {
return nil
}
return [UInt8](buffer[0..<status])
}
// https://developer.apple.com/documentation/compression/compression_zlib
// Compression モジュールは圧縮レベル 5 のみだから高圧縮を求めるため C ライブラリを使う.
var ZLibCompress = zlibCompress // C ライブラリ (libz) を使用する.
// ############
class Chunk
{
var length: UInt32 = 0
var idData = Data()
var id = ""
var data: [UInt8] = []
var crc: UInt32 = 0
init() { /*NOP*/ }
init(_ handle: FileHandle) throws {
try read(handle)
}
func read(_ handle: FileHandle) throws {
guard let rLen = handle.readU32EB() else {
throw PNGError.io
}
length = rLen
idData = handle.readData(ofLength: 4)
if idData.count != 4 {
throw PNGError.io
}
guard let cId = String(data: idData, encoding: String.Encoding.utf8) else {
throw PNGError.data
}
id = cId
let rData = handle.readDataU8(ofLength: Int(rLen))
if rData.count != rLen {
throw PNGError.data
}
data = rData
guard let rCrc = handle.readU32EB() else {
throw PNGError.io
}
crc = rCrc
let crc32 = CRC32()
crc32.update(idData)
crc32.update(data)
if crc32.value != rCrc {
throw PNGError.data
}
VerbosePrint(String(format: "\(id): len=%-6d crc=%#010x", length, crc))
}
func write(_ handle: FileHandle) {
handle.writeU32EB(length)
handle.write(idData)
handle.write(Data(data))
let crc32 = CRC32()
crc32.update(idData)
crc32.update(data)
handle.writeU32EB(crc32.value)
VerbosePrint(String(format: "\(id): len=%-6d crc=%#010x", length, crc))
}
}
class IHDRChunk
{
static let colorTypeTable: [UInt8] = [1,0,3,1,2,0,4]
var width: UInt32 = 0
var height: UInt32 = 0
var colorDepth: UInt8 = 0
var colorType: UInt8 = 0
var compression: UInt8 = 0
var filter: UInt8 = 0
var interlace: UInt8 = 0
var bitsPerPixel: UInt8 = 0
var bytesPerPixel : UInt8 = 0
init() { /*NO-OP*/ }
init(_ chunk: Chunk) {
let ihdr = chunk.data
width = UInt32EB(ihdr[0..<4])
height = UInt32EB(ihdr[4..<8])
colorDepth = ihdr[8]
colorType = ihdr[9]
compression = ihdr[10]
filter = ihdr[11]
interlace = ihdr[12]
let numDepth = IHDRChunk.colorTypeTable[Int(colorType)]
bitsPerPixel = colorDepth * numDepth
bytesPerPixel = (bitsPerPixel + 7) >> 3
}
}
struct ADAM7Param {
static let xOffsetTable: [UInt8] = [0, 4, 0, 2, 0, 1, 0, 0]
static let yOffsetTable: [UInt8] = [0, 0, 4, 0, 2, 0, 1, 0]
static let xStepTable : [UInt8] = [8, 8, 4, 4, 2, 2, 1, 1]
static let yStepTable : [UInt8] = [8, 8, 8, 4, 4, 2, 2, 1]
let xOffs: UInt32
let yOffs: UInt32
let xStep: UInt32
let yStep: UInt32
let width: UInt32
let height: UInt32
let rawBytes: UInt32
let stride: UInt32
let imageSize: UInt32
let imageOffset: UInt32
init(_ ihdr: IHDRChunk, _ level:Int, _ offs:UInt32) {
xOffs = UInt32(ADAM7Param.xOffsetTable[level])
yOffs = UInt32(ADAM7Param.yOffsetTable[level])
xStep = UInt32(ADAM7Param.xStepTable[level])
yStep = UInt32(ADAM7Param.yStepTable[level])
//
width = (ihdr.width >= xOffs) ? ((ihdr.width - xOffs + xStep - 1) / xStep) : 0
height = (ihdr.height >= yOffs) ? ((ihdr.height - yOffs + yStep - 1) / yStep) : 0
rawBytes = (width * UInt32(ihdr.bitsPerPixel) + 7) >> 3
stride = (rawBytes != 0) ? (rawBytes + 1) : 0
//
imageSize = stride * height
imageOffset = offs
}
}
class ADAM7 {
let interlace: [ADAM7Param]
let interlaceSize: UInt32
let imageSize: UInt32
var progressive: ADAM7Param {
get {
return interlace[7]
}
}
subscript(index: Int) -> ADAM7Param {
return interlace[index]
}
init(_ ihdr: IHDRChunk) {
interlace = [ADAM7Param](unsafeUninitializedCapacity: 8)
{ buffer, initializedCount in
var offs: UInt32 = 0
for n in 0..<7 {
buffer[n] = ADAM7Param(ihdr, n, offs)
offs += buffer[n].imageSize
}
buffer[7] = ADAM7Param(ihdr, 7, 0)
initializedCount = 8
}
interlaceSize = interlace[6].imageOffset + interlace[6].imageSize
imageSize = (ihdr.interlace != 0) ? interlaceSize: interlace[7].imageSize
}
}
class PNGFile {
static let signature: [UInt8] = [137, 80, 78, 71, 13, 10, 26, 10]
var chunk: [Chunk] = []
var idat_length: [UInt32] = []
var idat_average: UInt32 = 0
var ihdr : IHDRChunk = IHDRChunk()
var image: [UInt8] = []
subscript(id: String) -> Chunk? {
let n = find(id)
if n < 0 {
return nil
}
return chunk[n]
}
init(_ path: String) throws {
try self.read(path)
}
func find(_ id: String) -> Int {
for n in 0..<chunk.count {
if id == chunk[n].id {
return n
}
}
return -1
}
func setImageData(_ image: [UInt8]) {
let n = find("IDAT")
chunk[n].length = UInt32(image.count)
chunk[n].data = image
}
func read(_ handle: FileHandle) throws {
let signature: [UInt8] = handle.readDataU8(ofLength: 8)
if signature != PNGFile.signature {
throw PNGError.signature
}
var rChunk = try Chunk(handle)
ihdr = IHDRChunk(rChunk)
var chunk_id = rChunk.id
if chunk_id != "IHDR" {
throw PNGError.data
}
chunk.append(rChunk)
while chunk_id != "IEND" {
let last_chunk = chunk_id
rChunk = try Chunk(handle)
chunk_id = rChunk.id
switch (chunk_id) {
case "IHDR":
throw PNGError.data
case "IDAT":
idat_length.append(rChunk.length)
if last_chunk == "IDAT" {
let idatIdx = chunk.count - 1
chunk[idatIdx].data.append(contentsOf: rChunk.data)
chunk[idatIdx].length = UInt32(chunk[idatIdx].data.count)
continue
}
if idat_length.count != 1 {
throw PNGError.data
}
default: break
}
chunk.append(rChunk)
}
if ![1,2,4,8,16].contains(ihdr.colorDepth) {
throw PNGError.unsupported
}
if ![0,2,3,4,6].contains(ihdr.colorType) {
throw PNGError.unsupported
}
if ihdr.compression != 0 {
throw PNGError.unsupported
}
if ihdr.filter != 0 {
throw PNGError.unsupported
}
if ihdr.interlace >= 2 {
throw PNGError.unsupported
}
idat_length.removeLast()
idat_average = 0
if idat_length.count != 0 {
let split1 = idat_length.reduce(0, +)
idat_average = split1 / UInt32(idat_length.count)
}
}
func read(_ path: String) throws {
guard let handle = FileHandle(forReadingAtPath: path) else {
throw PNGError.open
}
try! self.read(handle)
handle.closeFile()
}
func write(_ handle: FileHandle) {
handle.write(Data(PNGFile.signature))
for ch in chunk {
if ch.id != "IDAT" {
ch.write(handle)
continue
}
if idat_average == 0 {
ch.write(handle)
continue
}
var idat_spIdx = 0
var idat_offset = 0
var idat_total = ch.data.count
while idat_total > 0 {
var idat_split = Int(idat_average)
if idat_spIdx < idat_length.count {
idat_split = Int(idat_length[idat_spIdx])
idat_spIdx += 1
}
if idat_split > idat_total {
idat_split = idat_total
}
let idat_next = idat_offset + idat_split
let split_data = Data(ch.data[idat_offset..<idat_next])
handle.writeU32EB(UInt32(idat_split))
handle.write(ch.idData)
handle.write(split_data)
let crc32 = CRC32()
crc32.update(ch.idData)
crc32.update(split_data)
let crc = crc32.value
handle.writeU32EB(crc)
idat_offset = idat_next
idat_total -= idat_split
VerbosePrint(String(format: "\(ch.id): len=%-6d crc=%#010x", split_data.count, crc))
}
}
}
func write(_ path: String, _ overwrite: Bool = false) throws {
if FileManager.default.fileExists(atPath: path) {
if !overwrite {
throw PNGError.exists
}
try FileManager.default.removeItem(atPath: path)
}
if !FileManager.default.createFile(atPath: path, contents: nil, attributes: nil) {
throw PNGError.system
}
guard let handle = FileHandle(forWritingAtPath: path) else {
throw PNGError.open
}
self.write(handle)
handle.closeFile()
}
}
// ############
func ImageReconstruct(_ ihdr: IHDRChunk, _ adam7: ADAM7, _ image: inout [UInt8]) throws {
VerbosePrint("Reconstruct:")
for adam in adam7.interlace[(ihdr.filter != 0) ? (0..<7) : (7..<8)] {
VerbosePrint(String(format: " (W:%04d, H:%04d)", adam.width, adam.height))
VerbosePrint(String(format: " (X:%04d, Y:%04d)", adam.xOffs, adam.yOffs))
VerbosePrint(String(format: " (R:%04d, B:%04d)", adam.xStep, adam.yStep))
let pixelBytes = Int(ihdr.bytesPerPixel)
let stride = Int(adam.stride)
let rawBytes = Int(adam.rawBytes)
let lineBytes = pixelBytes + stride - 1
var lineOffs = Int(adam.imageOffset)
var imgOffs = lineOffs + 1
if stride == 0 {
continue
}
var lastLine = [UInt8](repeating: 0, count: pixelBytes + lineBytes)
for y in 0..<adam.height {
var currentLine = [UInt8](repeating: 0, count: pixelBytes)
currentLine.append(contentsOf: image[imgOffs..<(imgOffs+rawBytes)])
switch (image[lineOffs])
{
case 0: // NONE
VerbosePrint(String(format: " Line %4d: NONE", y))
break
case 1: // SUB
VerbosePrint(String(format: " Line %4d: SUB", y))
image[lineOffs] = 0
for x in 0..<rawBytes {
let a = UInt16(currentLine[x])
let p = UInt16(currentLine[x+pixelBytes])
let q = UInt8((p + a) & 0xff)
image[imgOffs+x] = q
currentLine[x+pixelBytes] = q
}
case 2: // UP
VerbosePrint(String(format: " Line %4d: UP", y))
image[lineOffs] = 0
for x in 0..<rawBytes {
let b = UInt16(lastLine[x+pixelBytes])
let p = UInt16(currentLine[x+pixelBytes])
let q = UInt8((p + b) & 0xff)
image[imgOffs+x] = q
currentLine[x+pixelBytes] = q
}
case 3: // AVE
VerbosePrint(String(format: " Line %4d: AVE", y))
image[lineOffs] = 0
for x in 0..<rawBytes {
let a = UInt16(currentLine[x])
let b = UInt16(lastLine[x+pixelBytes])
let p = UInt16(currentLine[x+pixelBytes])
let q = UInt8((p + ((a + b) >> 1)) & 0xff)
image[imgOffs+x] = q
currentLine[x+pixelBytes] = q
}
case 4: // PAETH
VerbosePrint(String(format: " Line %4d: PAETH", y))
image[lineOffs] = 0
for x in 0..<rawBytes {
let a = Int16(currentLine[x])
let b = Int16(lastLine[x+pixelBytes])
let c = Int16(lastLine[x])
let p = Int16(currentLine[x+pixelBytes])
let d = a + b - c
let pa = (d > a) ? (d - a) : (a - d)
var pb = (d > b) ? (d - b) : (b - d)
let pc = (d > c) ? (d - c) : (c - d)
var qa = a
var qb = b
if pb > pc { qb = c; pb = pc }
if pa > pb { qa = qb }
let q = UInt8((p + qa) & 0xff)
image[imgOffs+x] = q
currentLine[x+pixelBytes] = q
}
default:
throw PNGError.data
}
lastLine = currentLine
lineOffs += stride
imgOffs += stride
}
}
}
func ImageFilterHint(_ data: [UInt8]) -> UInt32 {
var hist = [UInt32](repeating: 0, count: 256)
for n in data {
hist[Int(n)] += 1
}
return hist.reduce(0) { ($0 > $1) ? $0 : $1 }
}
let filterName = ["NONE", "SUB", "UP", "AVE", "PAETH"]
func ImageFilter(_ ihdr: IHDRChunk, _ adam7: ADAM7, _ image: inout [UInt8]) {
VerbosePrint("Filter:")
for adam in adam7.interlace[(ihdr.filter != 0) ? (0..<7) : (7..<8)] {
VerbosePrint(String(format: " (W:%04d, H:%04d)", adam.width, adam.height))
VerbosePrint(String(format: " (X:%04d, Y:%04d)", adam.xOffs, adam.yOffs))
VerbosePrint(String(format: " (R:%04d, B:%04d)", adam.xStep, adam.yStep))
let pixelBytes = Int(ihdr.bytesPerPixel)
let stride = Int(adam.stride)
let rawBytes = Int(adam.rawBytes)
let lineBytes = pixelBytes + stride - 1
var lineOffs = Int(adam.imageOffset)
var imgOffs = lineOffs + 1
if stride == 0 {
continue
}
var lastLine = [UInt8](repeating: 0, count: pixelBytes + lineBytes)
for y in 0..<adam.height {
let pixelData = image[imgOffs..<(imgOffs+rawBytes)]
var currentLine = [UInt8](repeating: 0, count: pixelBytes)
currentLine.append(contentsOf: pixelData)
var f0: [UInt8] = [0]
var f1: [UInt8] = [1]
var f2: [UInt8] = [2]
var f3: [UInt8] = [3]
var f4: [UInt8] = [4]
// NONE
f0.append(contentsOf: pixelData)
// SUB
for x in 0..<rawBytes {
let a = Int16(currentLine[x])
let p = Int16(currentLine[x+pixelBytes])
let q = UInt8((p - a) & 0xff)
f1.append(q)
}
// UP
for x in 0..<rawBytes {
let b = Int16(lastLine[x+pixelBytes])
let p = Int16(currentLine[x+pixelBytes])
let q = UInt8((p - b) & 0xff)
f2.append(q)
}
// AVE
for x in 0..<rawBytes {
let a = Int16(currentLine[x])
let b = Int16(lastLine[x+pixelBytes])
let p = Int16(currentLine[x+pixelBytes])
let q = UInt8((p - ((a + b) >> 1)) & 0xff)
f3.append(q)
}
// PAETH
for x in 0..<rawBytes {
let a = Int16(currentLine[x])
let b = Int16(lastLine[x+pixelBytes])
let c = Int16(lastLine[x])
let p = Int16(currentLine[x+pixelBytes])
let d = a + b - c
let pa = (d > a) ? (d - a) : (a - d)
var pb = (d > b) ? (d - b) : (b - d)
let pc = (d > c) ? (d - c) : (c - d)
var qa = a
var qb = b
if pb > pc { qb = c; pb = pc }
if pa > pb { qa = qb }
let q = UInt8((p - qa) & 0xff)
f4.append(q)
}
let filters = [f0,f1,f2,f3,f4]
var hist: UInt32 = 0
var filter: Int = 0
for n in 0..<5 {
let fh = ImageFilterHint(filters[n])
if fh > hist {
hist = fh
filter = n
}
}
VerbosePrint(String(format: " Line %4d: \(filterName[filter])", y))
let filterData = filters[filter]
for n in 0..<stride {
image[lineOffs+n] = filterData[n]
}
lastLine = currentLine
lineOffs += stride
imgOffs += stride
}
}
}
func UpdateImage(_ png: inout PNGFile) throws {
guard let idat = png["IDAT"] else {
throw PNGError.data
}
let ihdr = png.ihdr
let adam7 = ADAM7(ihdr)
guard var image_buffer = ZLibUncompress(adam7.imageSize, idat.data) else {
throw PNGError.data
}
VerbosePrint(String(format: "Uncompress: %d(%d)", image_buffer.count, adam7.imageSize))
if flag_update_image {
try ImageReconstruct(ihdr, adam7, &image_buffer)
if flag_image_filter {
ImageFilter(ihdr, adam7, &image_buffer)
}
}
guard let deflate_buffer = ZLibCompress(image_buffer) else {
throw PNGError.system
}
VerbosePrint(String(format: "Compress: %d -> %d", idat.data.count, deflate_buffer.count))
if flag_update_image {
png.setImageData(deflate_buffer)
}
}
// ############
func VerbosePrint(_ msg: String) {
if flag_verbose {
print(msg)
}
}
// ############
var cmd_args = CommandLine.arguments
var program = cmd_args.removeFirst()
var program_paths = program.split(separator: "/")
var program_name = program_paths.last
func cmd_arg_parse() {
while cmd_args.count > 0 {
var arg = cmd_args[0]
if arg.removeFirst() != "-" {
break
}
cmd_args.removeFirst()
while arg.count > 0 {
switch (arg.removeFirst())
{
case "O":
flag_overwrite = true
case "u":
flag_update_image = false
case "v":
flag_verbose = true
case "f":
flag_image_filter = false
case "M":
ZLibCompress = CompressM
default:
usage()
}
}
}
if cmd_args.count < 2 {
usage()
}
}
cmd_arg_parse()
func usage() -> Never {
print("""
Usage: \(String(describing: program_name)) [オプション] 入力ファイル 出力ファイル
オプション:
-O 既存ファイルへ上書きする
-u IDAT を更新しない(複写になる)
-v 冗長出力モード
IDAT 更新オプション:
-f フィルターなしにする
-M Compression モジュールを使う
""")
exit(1)
}
func main(_ inpFile: String, _ outFile: String) -> Int32 {
var path = inpFile
do {
var png = try PNGFile(inpFile)
try UpdateImage(&png)
path = outFile
try png.write(outFile, flag_overwrite)
} catch let error {
let rError = error as! PNGError
rError.errorPrint(path)
return 2
}
return 0
}
exit(main(cmd_args[0], cmd_args[1]))
zlibwrapper.m
#import <Foundation/Foundation.h>
#import <zlib.h>
long CompressC(void *buffer, unsigned long length, const void *data, unsigned long size, int level)
{
unsigned long bufsz = length;
if (compress2((Bytef*)buffer, &bufsz, (const Bytef*)data, size, level) != 0)
return -1;
return bufsz;
}
project-Bridging-Header.h
long CompressC(void *buffer, unsigned long length, const void *data, unsigned long size, int level);
幾つか試した限りでは、データが小さくなった。uncompress → compress (Lv:9) の処理だけでも小さくなるので、書き出したアプリでの圧縮レベルが最大ではないのだろう。更にフィルタ タイプを弄ると僅かに縮むので、悪い選択処理ではなさそうです。