ffi-libarchive
| Language | Ruby |
|---|---|
| License | Apache-2.0 (binding); libarchive is BSD-2-Clause |
| Repository | github.com/chef/ffi-libarchive |
| Documentation | README |
| Depends on | the libarchive shared library, the ffi gem |
| Read / write | yes / 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:
sudo apt install libarchive13 # Debian / Ubuntu
brew install libarchive # macOS
gem install ffi-libarchive
Basic usage: list pages in natural order#
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:
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#
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#
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:
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#
- On Windows, ffi needs
archive.dllonPATH(for example from MSYS2:pacman -S mingw-w64-ucrt-x86_64-libarchive). reader.format_name(after the first entry) says what the archive really is:RAR,RAR5,ZIP 2.0 (deflation).- For CBZ only, rubyzip is pure Ruby and needs no native library.