rubyzip
| Language | Ruby |
|---|---|
| License | BSD-2-Clause |
| Repository | github.com/rubyzip/rubyzip |
| Documentation | rubydoc.info/gems/rubyzip |
| Depends on | nothing (pure Ruby, uses zlib from the standard library) |
| Read / write | yes / 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#
gem install rubyzip
# or in a Gemfile
bundle add rubyzip
Basic usage: list pages in natural order#
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:
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#
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:
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:
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#
- Names from old archives that don’t set the UTF-8 flag come back as binary strings. Set
Zip.force_entry_names_encoding = 'UTF-8'(or the right code page) at startup. - Check
entry.sizebefore reading untrusted files: rubyzip raisesZip::EntrySizeErrorwhen the real size exceeds the declared size ifZip.validate_entry_sizesis on (the default in 3.x).