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 / no for RAR (reads RAR4 and RAR5; writes ZIP, 7z, TAR)

ffi-libarchive calls the C libarchive library through Ruby FFI, so it reads every format libarchive reads: RAR4 and RAR5 (not password-protected), ZIP, 7z and TAR. The format is detected from the content, so the same code opens CBZ and CB7 files and .cbr files that are really ZIP. 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.

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 (efficient for solid RAR) 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.cbr').first
if cover
  Archive::Reader.open_filename('comic.cbr') 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.cbr')

How to convert CBR to CBZ#

Read each entry and write it to a ZIP archive with libarchive’s writer:

Ruby
def cbr_to_cbz(cbr, cbz)
  Archive.write_open_filename(cbz, Archive::COMPRESSION_NONE, Archive::FORMAT_ZIP) do |zip|
    Archive::Reader.open_filename(cbr) do |reader|
      reader.each_entry_with_data do |entry, data|
        next unless entry.regular?
        data = '' unless data.is_a?(String)
        zip.new_entry do |e|
          e.pathname = entry.pathname.tr('\\', '/')
          e.size = data.bytesize
          e.filetype = Archive::Entry::FILE
          e.perm = 0o644
          zip.write_header(e)
          zip.write_data(data)
        end
      end
    end
  end
end

cbr_to_cbz('comic.cbr', 'comic.cbz')

Tips#