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 (7z; 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: 7z, ZIP, TAR, and RAR (read only). The format is detected from the content, so the same code opens CBZ, CBR and CB7 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. 7z archives are often solid, so reading entries in archive order, as libarchive does, is the efficient way through them.

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.cb7').first
if cover
  Archive::Reader.open_filename('comic.cb7') 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.cb7')

How to create a CB7 from a folder of images#

Ruby
def make_cb7(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_7ZIP) 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_cb7('My Comic', 'My Comic.cb7')

libarchive’s 7z writer compresses with LZMA1 by default (when built with liblzma). ffi-libarchive doesn’t expose archive_write_set_options, so you can’t switch to store from Ruby; that only costs CPU time for JPEG/PNG pages.

Tips#