All formats

rubyzip

LanguageRuby
LicenseBSD-2-Clause
Repositorygithub.com/rubyzip/rubyzip
Documentationrubydoc.info/gems/rubyzip
Depends onnothing (pure Ruby, uses zlib from the standard library)
Read / writeyes / yes (ZIP, ZIP64, AES and ZipCrypto decryption)

rubyzip reads and writes ZIP archives in pure Ruby. Zip::File.open reads the central directory; each Zip::Entry can be read on its own with get_input_stream, so a comic viewer or a thumbnail job reads only the pages it needs. add_stored writes entries without compression, which is what you want for image pages.

Version 3.0 changed some APIs: Zip::File.open(path, create: true) replaces the positional create flag, and Ruby 3.0+ is required.

Installation#

Shell
gem install rubyzip
# or in a Gemfile
bundle add rubyzip

Basic usage: list pages in natural order#

Ruby
require 'zip'

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

# "Page 10.jpg" -> ["page ", 10, ".jpg"]; digit runs compare as numbers
def natural_key(name)
  name.downcase.split(/(\d+)/).map { |t| t.match?(/\A\d+\z/) ? [1, t.to_i] : [0, t] }
end

def pages(zip)
  zip.entries.select { |e| e.file? && page?(e.name) }.sort_by { |e| natural_key(e.name) }
end

Zip::File.open(ARGV[0]) do |zip|
  pages(zip).each.with_index(1) do |entry, i|
    puts format('%3d %s (%d bytes)', i, entry.name, entry.size)
  end
end

natural_key wraps each part in a [type, value] pair so numbers and strings never get compared with each other.

How to extract the cover#

The cover is the first page in natural order:

Ruby
Zip::File.open('comic.cbz') do |zip|
  cover = pages(zip).first
  if cover
    File.binwrite("cover#{File.extname(cover.name).downcase}", cover.get_input_stream.read)
  end
end

How to read ComicInfo.xml#

Ruby
require 'rexml/document'

def comic_info(zip)
  entry = zip.entries.find { |e| e.name.casecmp?('ComicInfo.xml') }
  return nil unless entry

  doc = REXML::Document.new(entry.get_input_stream.read)
  root = doc.root
  info = root.elements.to_a.reject { |e| e.name == 'Pages' }.to_h { |e| [e.name, e.text.to_s] }
  info['Pages'] = root.get_elements('Pages/Page').map(&:attributes).map { |a| a.to_h { |k, v| [k, v.to_s] } }
  info
end

Zip::File.open('comic.cbz') do |zip|
  info = comic_info(zip) or next
  puts "#{info['Series']} ##{info['Number']}: #{info['Title']}"
  puts "right to left: #{info['Manga'] == 'YesAndRightToLeft'}"
  # Image is the 0-based index into the sorted page list
  front = info['Pages'].find { |p| p['Type'].to_s.include?('FrontCover') }
  puts "cover: #{pages(zip)[front['Image'].to_i].name}" if front
end

REXML is a bundled gem since Ruby 3.0; add gem 'rexml' to your Gemfile if Bundler complains. Nokogiri is faster for large libraries.

How to create a CBZ from a folder of images#

add_stored stores pages without recompressing them:

Ruby
require 'zip'

def make_cbz(dir, out)
  files = Dir.children(dir).select { |f| File.file?(File.join(dir, f)) && page?(f) }
             .sort_by { |f| natural_key(f) }
  Zip::File.open(out, create: true) do |zip|
    files.each.with_index(1) do |f, i|
      zip.add_stored(format('%03d%s', i, File.extname(f).downcase), File.join(dir, f))
    end
    info = File.join(dir, 'ComicInfo.xml')
    zip.add('ComicInfo.xml', info) if File.exist?(info)
  end
end

make_cbz('Batman 001', 'Batman 001.cbz')

How to add or replace ComicInfo.xml#

rubyzip rewrites the archive when the block ends:

Ruby
Zip::File.open('comic.cbz') do |zip|
  old = zip.entries.find { |e| e.name.casecmp?('ComicInfo.xml') }
  zip.remove(old) if old
  zip.get_output_stream('ComicInfo.xml') { |os| os.write(File.read('ComicInfo.xml')) }
end

Tips#