2023-10-27 21:13:08 +00:00
|
|
|
#!/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
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def initialize(r:, g:, b:)
|
|
|
|
@r = r
|
|
|
|
@g = g
|
|
|
|
@b = b
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def ==(other)
|
|
|
|
r == other.r && g == other.g && b == other.b
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def to_cmap
|
|
|
|
[
|
|
|
|
r * 16 + r,
|
|
|
|
g * 16 + g,
|
|
|
|
b * 16 + b
|
|
|
|
]
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
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
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def count
|
|
|
|
@bits.count
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
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
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
UnknownPaletteColor = Class.new(ArgumentError)
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def initialize(image:, desired_palette:)
|
|
|
|
@image = image
|
|
|
|
@desired_palette = desired_palette
|
|
|
|
@bitplanes = nil
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def bitplane_count
|
|
|
|
Math.sqrt(@desired_palette.length).ceil
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def bitplanes
|
|
|
|
return @bitplanes if @bitplanes
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
bits = []
|
|
|
|
# get around duplicate objects
|
|
|
|
bitplane_count.times { |i| bits << [] }
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
@bytes.each do |byte|
|
|
|
|
mask = 1
|
|
|
|
bitplane_count.times do |bit|
|
|
|
|
bits[bit] << (((byte & mask) == mask) ? 1 : 0)
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
mask = mask << 1
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
@bitplanes = bits.map { |b| Bitplane.new(bits: b) }
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
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
|
|
|
|
)
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
index = @desired_palette.find_index { |p| p == pixel_color }
|
|
|
|
unless index
|
|
|
|
raise UnknownPaletteColor, "Palette color not found! Check export: #{pixel_color}"
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
@bytes << index
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
module IFF
|
|
|
|
module WithHeader
|
|
|
|
def to_bytes
|
|
|
|
data_bytes = to_data_bytes
|
|
|
|
data_bytes_count = data_bytes.count
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
output = self.class.name.split("::").last.unpack("C*") +
|
2023-10-29 12:58:39 +00:00
|
|
|
[data_bytes_count].pack("N").unpack("C*") +
|
|
|
|
data_bytes
|
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# pad chunk
|
|
|
|
output << 0 if data_bytes_count.odd?
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
output
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# Construct an IFF ILBM image from the provided data.
|
2023-10-29 12:58:39 +00:00
|
|
|
class ILBMBuilder
|
2023-10-27 21:13:08 +00:00
|
|
|
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
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def row_writer
|
|
|
|
return UncompressedRowWriter
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# here's no reason to use compression if the image is less
|
|
|
|
# than 24 pixels wide
|
|
|
|
if @row_writer == CompressedRowWriter && @width < 24
|
|
|
|
return UncompressedRowWriter
|
2023-10-29 12:58:39 +00:00
|
|
|
end
|
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
@row_writer
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def build
|
|
|
|
form = FORM.new
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
bmhd = BMHD.new
|
|
|
|
bmhd.width = @width
|
|
|
|
bmhd.height = @height
|
|
|
|
bmhd.planes = Math.sqrt(@palette.length).ceil
|
|
|
|
bmhd.compression = row_writer.bmhd_flag
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
cmap = CMAP.new(palette: @palette)
|
|
|
|
|
|
|
|
body = BODY.new(
|
|
|
|
bitplanes: @bitplanes,
|
|
|
|
width: @width,
|
|
|
|
row_writer: row_writer
|
|
|
|
)
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
ilbm = ILBM.new
|
|
|
|
ilbm.bmhd = bmhd
|
|
|
|
ilbm.cmap = cmap
|
|
|
|
ilbm.body = body
|
|
|
|
ilbm.camg = @screen_format
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
form << ilbm
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
form
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# IFF wrapper
|
|
|
|
class FORM
|
|
|
|
include WithHeader
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def initialize
|
|
|
|
@chunks = []
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def <<(chunk)
|
|
|
|
@chunks << chunk
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def to_data_bytes
|
|
|
|
@chunks.flat_map(&:to_bytes)
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def to_s
|
|
|
|
to_bytes.pack("C*")
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# Image type header
|
|
|
|
class ILBM
|
|
|
|
attr_accessor :bmhd, :cmap, :body, :camg
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def to_bytes
|
|
|
|
"ILBM".unpack("C*") +
|
2023-10-29 12:58:39 +00:00
|
|
|
bmhd.to_bytes +
|
|
|
|
cmap.to_bytes +
|
|
|
|
camg.to_bytes +
|
|
|
|
# body is always last
|
|
|
|
body.to_bytes
|
2023-10-27 21:13:08 +00:00
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# Image properties
|
|
|
|
class BMHD
|
|
|
|
include WithHeader
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
ASPECT_RATIO = 0x2C
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
attr_accessor :width, :height, :planes, :compression
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
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
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
private
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def word_to_bytes(word)
|
|
|
|
[ word >> 8, word & 0xff ]
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# Color map
|
|
|
|
class CMAP
|
|
|
|
include WithHeader
|
|
|
|
|
|
|
|
def initialize(palette:)
|
|
|
|
@palette = palette
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def to_data_bytes
|
|
|
|
@palette.flat_map(&:to_cmap)
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# Amiga-related display properties
|
|
|
|
class CAMG
|
|
|
|
include WithHeader
|
|
|
|
|
|
|
|
def self.PalLowRes
|
|
|
|
new(0x00021000)
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def initialize(setting)
|
|
|
|
@setting = setting
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def to_data_bytes
|
|
|
|
[@setting].pack("N").unpack("C*")
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
module AsRowWriter
|
|
|
|
def initialize
|
|
|
|
@data = []
|
|
|
|
end
|
|
|
|
|
|
|
|
def <<(byte)
|
|
|
|
@data << byte
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# Output pixel data is word padded
|
|
|
|
def ensure_padded!
|
|
|
|
@data << 0 if @data.length.odd?
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# Write out uncompressed bitplane data
|
|
|
|
class UncompressedRowWriter
|
|
|
|
include AsRowWriter
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def self.bmhd_flag
|
|
|
|
0
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def to_bytes
|
|
|
|
ensure_padded!
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
@data
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# Write out compressed bitplane data using the
|
|
|
|
# ByteRun1 run encoding algorithm.
|
|
|
|
class CompressedRowWriter
|
|
|
|
include AsRowWriter
|
|
|
|
|
|
|
|
def self.bmhd_flag
|
|
|
|
1
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def to_bytes
|
|
|
|
ensure_padded!
|
|
|
|
|
|
|
|
runs = []
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# 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
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
out.last << byte
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# 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
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# Pixel data
|
|
|
|
class BODY
|
|
|
|
include WithHeader
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def initialize(bitplanes:, width:, row_writer:)
|
|
|
|
@bitplanes = bitplanes
|
|
|
|
@width = width
|
|
|
|
@row_writer = row_writer
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
START_MASK = 1 << 7
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
def to_data_bytes
|
|
|
|
rows = []
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
row_count = @bitplanes[0].count / @width
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
row_count.times do |y|
|
|
|
|
# interleave the bitplanes
|
|
|
|
pos = y * @width
|
2023-10-29 12:58:39 +00:00
|
|
|
|
|
|
|
@bitplanes.each do |bitplane|
|
2023-10-27 21:13:08 +00:00
|
|
|
val = 0
|
|
|
|
mask = START_MASK
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# the row writer takes care of turning bitplane data into output
|
|
|
|
current_row_writer = @row_writer.new
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
@width.times do |x|
|
|
|
|
val = bitplane[pos + x] * mask | val
|
|
|
|
mask >>= 1
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
# end of byte
|
|
|
|
if mask == 0
|
|
|
|
current_row_writer << val
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
val = 0
|
|
|
|
mask = START_MASK
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
unless mask == START_MASK
|
|
|
|
current_row_writer << val
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
rows.concat(current_row_writer.to_bytes)
|
|
|
|
end
|
|
|
|
end
|
2023-10-29 12:58:39 +00:00
|
|
|
|
2023-10-27 21:13:08 +00:00
|
|
|
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 }
|