All formats

minitar

LanguageRuby (3.1+)
LicenseRuby License or BSD-2-Clause
Repositorygithub.com/halostatue/minitar
DocumentationREADME
Depends onnothing (pure Ruby)
Read / writeyes / yes (POSIX ustar, with pax and GNU long-name support when reading)

minitar is a pure Ruby TAR library. Minitar::Reader walks the entries of an IO in order and gives each one as a readable stream; Minitar::Writer adds files with add_file_simple. Minitar.pack and Minitar.unpack handle whole directories. There’s no native dependency, so it works on every Ruby platform.

Ruby’s RubyGems also ships Gem::Package::TarReader, but it’s an internal API for .gem files; minitar is the maintained general-purpose choice.

Installation#

Shell
gem install minitar
# or in a Gemfile
bundle add minitar

Basic usage: list pages in natural order#

Ruby
require 'minitar'

IMAGE_EXTS = %w[.jpg .jpeg .png .gif .webp .bmp .avif .jxl].freeze

def page?(name)
  name = name.tr('\\', '/')
  return false if name.include?('__MACOSX/')
  base = File.basename(name)
  !base.start_with?('.') && IMAGE_EXTS.include?(File.extname(base).downcase)  # also skips ._001.jpg
end

def natural_key(name)
  name.downcase.split(/(\d+)/).map { |t| t.match?(/\A\d+\z/) ? [1, t.to_i] : [0, t] }
end

def list_pages(path)
  names = []
  File.open(path, 'rb') do |io|
    Minitar::Reader.each_entry(io) do |entry|
      names << entry.full_name if entry.file? && page?(entry.full_name)
    end
  end
  names.sort_by { |n| natural_key(n) }
end

list_pages(ARGV[0]).each.with_index(1) { |name, i| puts format('%3d %s', i, name) }

How to read pages into memory#

Ruby
def read_pages(path)
  pages = {}
  File.open(path, 'rb') do |io|
    Minitar::Reader.each_entry(io) do |entry|
      pages[entry.full_name] = entry.read if entry.file? && page?(entry.full_name)
    end
  end
  pages.sort_by { |name, _| natural_key(name) }.to_h
end

How to extract the cover#

Ruby
cover = list_pages('comic.cbt').first
if cover
  File.open('comic.cbt', 'rb') do |io|
    Minitar::Reader.each_entry(io) do |entry|
      next unless entry.full_name == cover
      File.binwrite("cover#{File.extname(cover).downcase}", entry.read)
      break
    end
  end
end

How to read ComicInfo.xml#

Ruby
require 'rexml/document'

def comic_info(path)
  File.open(path, 'rb') do |io|
    Minitar::Reader.each_entry(io) do |entry|
      next unless entry.file? && entry.full_name.casecmp?('ComicInfo.xml')
      root = REXML::Document.new(entry.read).root
      front = root.get_elements('Pages/Page').find { |p| p.attributes['Type'].to_s.include?('FrontCover') }
      return {
        series: root.elements['Series']&.text,
        number: root.elements['Number']&.text,
        title: root.elements['Title']&.text,
        cover_index: front&.attributes&.[]('Image')&.to_i  # index in sorted page order
      }
    end
  end
  nil
end

p comic_info('comic.cbt')

How to create a CBT from a folder of images#

add_file_simple with data: writes one file; the writer adds the end-of-archive blocks when the block ends:

Ruby
def make_cbt(dir, out)
  files = Dir.children(dir).select { |f| File.file?(File.join(dir, f)) && page?(f) }
             .sort_by { |f| natural_key(f) }
  File.open(out, 'wb') do |io|
    Minitar::Writer.open(io) do |tar|
      files.each.with_index(1) do |f, i|
        tar.add_file_simple(format('%03d%s', i, File.extname(f).downcase),
                            data: File.binread(File.join(dir, f)), mode: 0o644, mtime: Time.now.to_i)
      end
      info = File.join(dir, 'ComicInfo.xml')
      tar.add_file_simple('ComicInfo.xml', data: File.binread(info), mode: 0o644) if File.exist?(info)
    end
  end
end

make_cbt('Bone 01', 'Bone 01.cbt')

Tips#