minitar
| Language | Ruby (3.1+) |
|---|---|
| License | Ruby License or BSD-2-Clause |
| Repository | github.com/halostatue/minitar |
| Documentation | README |
| Depends on | nothing (pure Ruby) |
| Read / write | yes / 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#
- minitar is strict about header fields. It reads tar files from GNU tar, bsdtar, Python and .NET, but rejects some files from less common writers (for example numeric fields padded with spaces) with
Minitar::InvalidTarStream. Fall back to ffi-libarchive for those. Minitar.unpack(io, 'dest')extracts everything and raisesSecureRelativePathErrorfor entries with... Pass an open IO, not a file name: minitar opens names withKernel.open, which runs commands for names starting with|.- For gzip-compressed tar, wrap the IO:
Minitar::Reader.each_entry(Zlib::GzipReader.new(io)). A proper CBT is not compressed.