diff --git a/LICENSE b/LICENSE index f385185..8e3676e 100644 --- a/LICENSE +++ b/LICENSE @@ -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: diff --git a/README.md b/README.md index 31e8b74..7eddf6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # png-to-ilbm -Convert PNG images to IFF ILBM images using Ruby. \ No newline at end of file +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. diff --git a/png_to_ilbm_strict_palette.rb b/png_to_ilbm_strict_palette.rb index 6347df2..152f9f2 100755 --- a/png_to_ilbm_strict_palette.rb +++ b/png_to_ilbm_strict_palette.rb @@ -10,24 +10,21 @@ # # * http://etwright.org/lwsdk/docs/filefmts/ilbm.html -# * [ ] AMOS and ADPro fail on an image that's less than 8 pixels wide? -# * DPaint can load it, other apps get weird - 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, @@ -35,7 +32,7 @@ class PixelColor b * 16 + b ] end - + def inspect "RGB: #{r.to_s(16)},#{g.to_s(16)},#{b.to_s(16)}" end @@ -64,11 +61,11 @@ class Bitplane def initialize(bits:) @bits = bits end - + def count @bits.count end - + def [](idx) @bits[idx] end @@ -84,38 +81,38 @@ class ImageComponentParser 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| @@ -125,12 +122,12 @@ class ImageComponentParser 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 @@ -141,20 +138,20 @@ module IFF 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 - + [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 + class ILBMBuilder def initialize( width:, height:, @@ -170,28 +167,28 @@ module IFF @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 - + 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( @@ -199,62 +196,62 @@ module IFF 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 + 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), @@ -271,14 +268,14 @@ module IFF *word_to_bytes(height), ] end - + private - + def word_to_bytes(word) [ word >> 8, word & 0xff ] end end - + # Color map class CMAP include WithHeader @@ -286,12 +283,12 @@ module IFF 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 @@ -299,16 +296,16 @@ module IFF 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 = [] @@ -317,28 +314,28 @@ module IFF 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 @@ -347,12 +344,12 @@ module IFF def self.bmhd_flag 1 end - + def to_bytes ensure_padded! runs = [] - + # discover initial runs # format is [byte, run count] @data.each do |byte| @@ -377,11 +374,11 @@ module IFF 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 @@ -397,56 +394,56 @@ module IFF 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| + + @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