All formats

ffi-libarchive

LanguageRuby
LicenseApache-2.0 (binding); libarchive is BSD-2-Clause
Repositorygithub.com/chef/ffi-libarchive
DocumentationREADME
Depends onthe libarchive shared library, the ffi gem
Read / writeyes / yes (TAR; also reads RAR and ZIP, writes ZIP)

ffi-libarchive calls the C libarchive library through Ruby FFI, so it reads and writes every format libarchive does: TAR, ZIP, 7z, and RAR (read only). The format is detected from the content, so the same code opens CBZ, CBR and CBT files. Chef maintains it for its own packaging tools; releases are infrequent but the API is small and stable.

Like libarchive, it reads entries sequentially: each_entry_with_data yields each entry with its contents in archive order. Collect what you need in one pass, then sort. TAR has no index; libarchive walks the headers and skips data you don’t read, which is fast for an uncompressed CBT.

Installation#

Install libarchive, then the gem:

Shell
sudo apt install libarchive13     # Debian / Ubuntu
brew install libarchive           # macOS
gem install ffi-libarchive

Basic usage: list pages in natural order#

Ruby
require 'ffi-libarchive'

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)
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 = []
  Archive::Reader.open_filename(path) do |reader|
    reader.each_entry do |entry|
      names << entry.pathname if entry.regular? && page?(entry.pathname)
    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 all pages into memory#

Read pages in archive order and sort afterwards:

Ruby
def read_pages(path)
  pages = {}
  Archive::Reader.open_filename(path) do |reader|
    reader.each_entry_with_data do |entry, data|
      pages[entry.pathname] = data if entry.regular? && page?(entry.pathname)
    end
  end
  pages.sort_by { |name, _| natural_key(name) }.to_h
end

read_data returns the number of bytes (0) instead of a string for empty entries; each_entry_with_data passes that value through, so check data.is_a?(String) if your archives may contain empty files.

How to extract the cover#

Ruby
cover = list_pages('comic.cbt').first
if cover
  Archive::Reader.open_filename('comic.cbt') do |reader|
    reader.each_entry do |entry|
      next unless entry.pathname == cover
      File.binwrite("cover#{File.extname(cover).downcase}", reader.read_data)
      break
    end
  end
end

How to read ComicInfo.xml#

Ruby
require 'rexml/document'

def comic_info(path)
  Archive::Reader.open_filename(path) do |reader|
    reader.each_entry do |entry|
      next unless entry.pathname.casecmp?('ComicInfo.xml')
      root = REXML::Document.new(reader.read_data).root
      return {
        'Series' => root.elements['Series']&.text,
        'Number' => root.elements['Number']&.text,
        'Title' => root.elements['Title']&.text,
        'Manga' => root.elements['Manga']&.text,
        # Image is the 0-based index into the sorted page list
        'Cover' => root.get_elements('Pages/Page').find { |p| p.attributes['Type'].to_s.include?('FrontCover') }&.attributes&.[]('Image')
      }
    end
  end
  nil
end

p comic_info('comic.cbt')

How to create a CBT from a folder of images#

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) }
  names = files.each.with_index(1).map { |f, i| [f, format('%03d%s', i, File.extname(f).downcase)] }
  names << ['ComicInfo.xml', 'ComicInfo.xml'] if File.exist?(File.join(dir, 'ComicInfo.xml'))

  Archive.write_open_filename(out, Archive::COMPRESSION_NONE, Archive::FORMAT_TAR_PAX_RESTRICTED) do |ar|
    names.each do |src, name|
      data = File.binread(File.join(dir, src))
      ar.new_entry do |e|
        e.pathname = name
        e.size = data.bytesize
        e.filetype = Archive::Entry::FILE
        e.perm = 0o644
        ar.write_header(e)
        ar.write_data(data)
      end
    end
  end
end

make_cbt('My Comic', 'My Comic.cbt')

COMPRESSION_NONE keeps the CBT a plain tar, which is what comic readers expect.

Tips#