Compare commits

..

2 Commits

Author SHA1 Message Date
db7958fcf2 some fixes 2023-10-29 08:58:39 -04:00
d4f5ff8a99 initial commit 2023-10-27 17:13:08 -04:00
3 changed files with 468 additions and 2 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 TheIndustriousRabbit
Copyright (c) 2023 John Bintz
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@ -1,3 +1,7 @@
# png-to-ilbm
Convert PNG images to IFF ILBM images using Ruby.
Convert PNG images to IFF ILBM images using Ruby, preserving palette indexes.
Copyright 2023 John Bintz. Learn more at https://theindustriousrabbit.com/
Licensed under the MIT License.

462
png_to_ilbm_strict_palette.rb Executable file
View File

@ -0,0 +1,462 @@
#!/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 }