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 / 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:
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 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.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#
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#
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#
- 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.- To convert a CBT to CBZ, write with
Archive::FORMAT_ZIPin the loop above, reading entries witheach_entry_with_data.