png-to-ilbm/png_to_ilbm_strict_palette.rb

463 lines
8.7 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# Read a PNG file and create a palette-locked IFF ILBM file
# This allows for multiple files to be exported by other tools
# with the same palette colors, and for the resulting IFF ILBM
# images to contain those pixels, with those colors, with the
# color indexes the same across all images.
# References:
#
# * http://etwright.org/lwsdk/docs/filefmts/ilbm.html
require 'rmagick'
class PixelColor
attr_reader :r, :g, :b
def initialize(r:, g:, b:)
@r = r
@g = g
@b = b
end
def ==(other)
r == other.r && g == other.g && b == other.b
end
def to_cmap
[
r * 16 + r,
g * 16 + g,
b * 16 + b
]
end
def inspect
"RGB: #{r.to_s(16)},#{g.to_s(16)},#{b.to_s(16)}"
end
end
DESIRED_PALETTE = [
[15,0,15],
[0,0,0],
[15,15,15],
[5,0xd,3],
[0xe, 0xe, 0],
[0xe, 0xd, 0xa],
[3, 8, 0xd],
[4, 2, 2],
[0xd, 0xe, 0xd],
[0xe, 0xa, 0xe],
[8, 1, 2],
[3, 8, 2],
[7, 6, 4],
[5, 5, 5],
[0xf, 0xf, 8],
[0xe, 9, 3]
].map { |p| PixelColor.new(r: p[0], g: p[1], b: p[2]) }
class Bitplane
def initialize(bits:)
@bits = bits
end
def count
@bits.count
end
def [](idx)
@bits[idx]
end
end
# Convert an RMagick image into bitplane data based on
# the provided palette. Raises UnknownPaletteColor if there's a
# color in the image that's not in the palette.
class ImageComponentParser
# @param image [Magick::Image]
def self.parse(image:, desired_palette:)
parser = new(image: image, desired_palette: desired_palette)
parser.parse!
parser
end
UnknownPaletteColor = Class.new(ArgumentError)
def initialize(image:, desired_palette:)
@image = image
@desired_palette = desired_palette
@bitplanes = nil
end
def bitplane_count
Math.sqrt(@desired_palette.length).ceil
end
def bitplanes
return @bitplanes if @bitplanes
bits = []
# get around duplicate objects
bitplane_count.times { |i| bits << [] }
@bytes.each do |byte|
mask = 1
bitplane_count.times do |bit|
bits[bit] << (((byte & mask) == mask) ? 1 : 0)
mask = mask << 1
end
end
@bitplanes = bits.map { |b| Bitplane.new(bits: b) }
end
def parse!
@bytes = []
@image.each_pixel do |px|
pixel_color = PixelColor.new(
# 4 bit amiga pixel colors
r: px.red >> 12,
g: px.green >> 12,
b: px.blue >> 12
)
index = @desired_palette.find_index { |p| p == pixel_color }
unless index
raise UnknownPaletteColor, "Palette color not found! Check export: #{pixel_color}"
end
@bytes << index
end
end
end
module IFF
module WithHeader
def to_bytes
data_bytes = to_data_bytes
data_bytes_count = data_bytes.count
output = self.class.name.split("::").last.unpack("C*") +
[data_bytes_count].pack("N").unpack("C*") +
data_bytes
# pad chunk
output << 0 if data_bytes_count.odd?
output
end
end
# Construct an IFF ILBM image from the provided data.
class ILBMBuilder
def initialize(
width:,
height:,
palette:,
bitplanes:,
row_writer: CompressedRowWriter,
screen_format: CAMG.PalLowRes
)
@width = width
@height = height
@palette = palette
@bitplanes = bitplanes
@row_writer = row_writer
@screen_format = screen_format
end
def row_writer
return UncompressedRowWriter
# here's no reason to use compression if the image is less
# than 24 pixels wide
if @row_writer == CompressedRowWriter && @width < 24
return UncompressedRowWriter
end
@row_writer
end
def build
form = FORM.new
bmhd = BMHD.new
bmhd.width = @width
bmhd.height = @height
bmhd.planes = Math.sqrt(@palette.length).ceil
bmhd.compression = row_writer.bmhd_flag
cmap = CMAP.new(palette: @palette)
body = BODY.new(
bitplanes: @bitplanes,
width: @width,
row_writer: row_writer
)
ilbm = ILBM.new
ilbm.bmhd = bmhd
ilbm.cmap = cmap
ilbm.body = body
ilbm.camg = @screen_format
form << ilbm
form
end
end
# IFF wrapper
class FORM
include WithHeader
def initialize
@chunks = []
end
def <<(chunk)
@chunks << chunk
end
def to_data_bytes
@chunks.flat_map(&:to_bytes)
end
def to_s
to_bytes.pack("C*")
end
end
# Image type header
class ILBM
attr_accessor :bmhd, :cmap, :body, :camg
def to_bytes
"ILBM".unpack("C*") +
bmhd.to_bytes +
cmap.to_bytes +
camg.to_bytes +
# body is always last
body.to_bytes
end
end
# Image properties
class BMHD
include WithHeader
ASPECT_RATIO = 0x2C
attr_accessor :width, :height, :planes, :compression
def to_data_bytes
[
*word_to_bytes(width),
*word_to_bytes(height),
0, 0,
0, 0,
planes,
0,
compression,
0,
0, 0,
ASPECT_RATIO, ASPECT_RATIO,
*word_to_bytes(width),
*word_to_bytes(height),
]
end
private
def word_to_bytes(word)
[ word >> 8, word & 0xff ]
end
end
# Color map
class CMAP
include WithHeader
def initialize(palette:)
@palette = palette
end
def to_data_bytes
@palette.flat_map(&:to_cmap)
end
end
# Amiga-related display properties
class CAMG
include WithHeader
def self.PalLowRes
new(0x00021000)
end
def initialize(setting)
@setting = setting
end
def to_data_bytes
[@setting].pack("N").unpack("C*")
end
end
module AsRowWriter
def initialize
@data = []
end
def <<(byte)
@data << byte
end
# Output pixel data is word padded
def ensure_padded!
@data << 0 if @data.length.odd?
end
end
# Write out uncompressed bitplane data
class UncompressedRowWriter
include AsRowWriter
def self.bmhd_flag
0
end
def to_bytes
ensure_padded!
@data
end
end
# Write out compressed bitplane data using the
# ByteRun1 run encoding algorithm.
class CompressedRowWriter
include AsRowWriter
def self.bmhd_flag
1
end
def to_bytes
ensure_padded!
runs = []
# discover initial runs
# format is [byte, run count]
@data.each do |byte|
if !runs.last
runs << [byte, 1]
elsif runs.last.first == byte
runs.last[1] += 1
else
runs << [byte, 1]
end
end
# categorize and pack literals
# formats are:
# * [:repeat, byte_count]
# * [:literal, byte, ...]
runs = runs.each_with_object([]) do |(byte, count), out|
if count > 1
out << [:repeat, byte, count]
else
# pack literals together
if out.last&.first != :literal
out << [:literal]
end
out.last << byte
end
end
# write out compressed data
runs.each_with_object([]) do |type, obj|
case type.first
when :repeat
# signed int
obj << (129 - type[2]) + 128
obj << type[1]
when :literal
# n-1
obj << type.length - 2
obj.concat(type[1..])
end
end
end
end
# Pixel data
class BODY
include WithHeader
def initialize(bitplanes:, width:, row_writer:)
@bitplanes = bitplanes
@width = width
@row_writer = row_writer
end
START_MASK = 1 << 7
def to_data_bytes
rows = []
row_count = @bitplanes[0].count / @width
row_count.times do |y|
# interleave the bitplanes
pos = y * @width
@bitplanes.each do |bitplane|
val = 0
mask = START_MASK
# the row writer takes care of turning bitplane data into output
current_row_writer = @row_writer.new
@width.times do |x|
val = bitplane[pos + x] * mask | val
mask >>= 1
# end of byte
if mask == 0
current_row_writer << val
val = 0
mask = START_MASK
end
end
unless mask == START_MASK
current_row_writer << val
end
rows.concat(current_row_writer.to_bytes)
end
end
rows
end
end
end
image = Magick::Image.read(ARGV[0]).first
parsed = ImageComponentParser.parse(image: image, desired_palette: DESIRED_PALETTE)
form = IFF::ILBMBuilder.new(
width: image.columns,
height: image.rows,
palette: DESIRED_PALETTE,
bitplanes: parsed.bitplanes,
).build
File.open(ARGV[1], 'wb') { |fh| fh.puts form.to_s }