zip_kit 6.3.6 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 687884a060911fe25101e41be05bd32f4b50068c2ac592acec9937e5b0912f47
4
- data.tar.gz: c209fb4839008ca84b956839db24ff65d8ec3ae390c4666aabe34743258409ec
3
+ metadata.gz: 67a468ca21123613319181955fd418268f661dc8beaee1c5eab2054395759478
4
+ data.tar.gz: 33047d74b3e2caaa47ce7ef1868bb5b2dba260e3dc97e116fa0b7fba4c6ec07d
5
5
  SHA512:
6
- metadata.gz: bf6bf944be2ec57fd53950c046f5ed28565c5d97f73edc91fbaf1094319d0c2ad47b0a6c6ae1194e69f7fdab3e85beb479b41755e499497fe7f4ab2423529858
7
- data.tar.gz: 1d54ad55c06b14297057bc594ff45f74a551b453e9cc5d846595abf238289ed0b7b9175177d089db87bceb9601c9322773a44d4601d3a6b32fb30f331f7bbcff
6
+ metadata.gz: 219f3a08a9413835762ee7af7108324e1d2819f3a4a4e4d35c1451b2ad60453f09b8e5783070c879fa644b87199c4e825d40978894489ded72f90ddc99b73a79
7
+ data.tar.gz: 532ca543f7dc346385336ba11e876f14667825d5ef67b6bd1b308ef91c1306591a203adf638894b8a720e1d0abeed0af9ac66ff941dc5b41229261719d47fd21
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## Unreleased
2
2
 
3
+ * Add `Streamer.new(io, ocf: true)` for writing EPUB (OCF) and OpenDocument containers. The first entry must then be a stored `mimetype` file with its sizes in the local header, and `Streamer::OCFViolation` is raised otherwise. That entry is written without the extended timestamp extra field, which those formats forbid - before, they could not be produced with ZipKit at all. File names get checked against the OCF rules as well: valid UTF-8, no forbidden characters, segments of at most 255 bytes not ending with a `.`, and no two names in a directory which only differ by case or Unicode normalization.
4
+ * Add `Streamer#write_mimetype_file` and `SizeEstimator#add_mimetype_entry`
5
+ * Add the `extended_timestamp:` keyword argument to `ZipWriter#write_local_file_header` and `ZipWriter#write_central_directory_file_header`
6
+
3
7
  ## 6.3.6
4
8
 
5
9
  * Fix the end of central directory record counting entries discarded by `rollback!`, which made archives with a rolled back entry unreadable for readers which check the entry count (including `ZipKit::FileReader`).
data/README.md CHANGED
@@ -228,6 +228,25 @@ end
228
228
  Check out the `examples/` directory at the root of the project. This will give you a good idea
229
229
  of various use cases the library supports.
230
230
 
231
+ ### Writing EPUB and OpenDocument files
232
+
233
+ EPUB books (OCF containers) and OpenDocument files are ZIP archives which must start with a `mimetype`
234
+ entry that is stored, has its sizes in the local header and has no extra fields - readers find the media type
235
+ at byte 38 of the file. Pass `ocf: true` to make the Streamer enforce that, and write the entry with `write_mimetype_file`:
236
+
237
+ ```ruby
238
+ ZipKit::Streamer.open(epub_file, ocf: true) do |zip|
239
+ zip.write_mimetype_file("application/epub+zip")
240
+ zip.write_file("META-INF/container.xml") { |sink| sink << container_xml }
241
+ zip.write_file("OEBPS/package.opf") { |sink| sink << package_opf }
242
+ end
243
+ ```
244
+
245
+ If the first entry would make the archive non-conformant, `ZipKit::Streamer::OCFViolation` gets raised
246
+ before anything is written. The same happens for file names those formats do not allow: names which are not
247
+ UTF-8, contain characters like `:`, `?` or `*`, have a segment longer than 255 bytes or ending with a `.`,
248
+ or which only differ from another name in the same directory by case or Unicode normalization.
249
+
231
250
  ### Computing the CRC32 value of a large file
232
251
 
233
252
  `BlockCRC32` computes the CRC32 checksum of an IO in a streaming fashion.
@@ -81,6 +81,16 @@ class ZipKit::SizeEstimator
81
81
  self
82
82
  end
83
83
 
84
+ # Add the `mimetype` entry an EPUB (OCF) or OpenDocument container starts with,
85
+ # see {Streamer#write_mimetype_file}.
86
+ #
87
+ # @param media_type [String] the media type, like "application/epub+zip"
88
+ # @return self
89
+ def add_mimetype_entry(media_type:)
90
+ @streamer.write_mimetype_file(media_type)
91
+ self
92
+ end
93
+
84
94
  # Add an empty directory to the archive.
85
95
  #
86
96
  # @param dirname [String] the name of the directory
@@ -4,7 +4,7 @@
4
4
  # Normally you will not have to use this class directly
5
5
  class ZipKit::Streamer::Entry < Struct.new(:filename, :crc32, :compressed_size,
6
6
  :uncompressed_size, :storage_mode, :mtime,
7
- :use_data_descriptor, :local_header_offset, :bytes_used_for_local_header, :bytes_used_for_data_descriptor, :unix_permissions)
7
+ :use_data_descriptor, :local_header_offset, :bytes_used_for_local_header, :bytes_used_for_data_descriptor, :unix_permissions, :extended_timestamp)
8
8
  def initialize(*)
9
9
  super
10
10
  filename.force_encoding(Encoding::UTF_8)
@@ -85,6 +85,22 @@ require "set"
85
85
  # zip.close
86
86
  #
87
87
  # Calling {Streamer#close} **will not** call `#close` on the underlying IO object.
88
+ #
89
+ # ## Writing EPUB and OpenDocument containers
90
+ #
91
+ # EPUB (via its Open Container Format, OCF) and OpenDocument are ZIP files with an extra rule: the
92
+ # first entry must be a file called `mimetype`, stored without compression and with no extra fields
93
+ # in its local header. Readers check this by looking at the raw bytes at fixed offsets: the media type
94
+ # must start at byte 38 of the file. Pass `ocf: true` and write the `mimetype` file first:
95
+ #
96
+ # ZipKit::Streamer.open(epub_file, ocf: true) do |zip|
97
+ # zip.write_mimetype_file("application/epub+zip")
98
+ # zip.write_file("META-INF/container.xml") { |sink| sink << container_xml }
99
+ # ...
100
+ # end
101
+ #
102
+ # With `ocf: true` the Streamer will raise {OCFViolation} if the first entry it is asked to write
103
+ # would make the archive non-conformant, or if a file name is not allowed in such a container.
88
104
  class ZipKit::Streamer
89
105
  autoload :DeflatedWriter, File.dirname(__FILE__) + "/streamer/deflated_writer.rb"
90
106
  autoload :Writable, File.dirname(__FILE__) + "/streamer/writable.rb"
@@ -99,12 +115,28 @@ class ZipKit::Streamer
99
115
  DEFLATED = 8
100
116
 
101
117
  EntryBodySizeMismatch = Class.new(StandardError)
118
+ OCFViolation = Class.new(StandardError)
102
119
  InvalidOutput = Class.new(ArgumentError)
103
120
  Overflow = Class.new(StandardError)
104
121
  UnknownMode = Class.new(StandardError)
105
122
  OffsetOutOfSync = Class.new(StandardError)
106
123
 
107
- private_constant :STORED, :DEFLATED
124
+ # Characters which may not appear in file names inside an OCF container, see
125
+ # https://www.w3.org/TR/epub-33/#sec-container-filenames - a trailing "." is not allowed either
126
+ OCF_FORBIDDEN_CHARACTERS = begin
127
+ ranges = [0x22, 0x2A, 0x3A, 0x3C, 0x3E, 0x3F, 0x5C, 0x7C].map { |c| [c, c] }
128
+ ranges << [0x00, 0x1F] # C0
129
+ ranges << [0x7F, 0x9F] # DEL and C1
130
+ ranges << [0xE000, 0xF8FF] # Private Use Area
131
+ ranges << [0xFDD0, 0xFDEF] # noncharacters
132
+ ranges << [0xFFF0, 0xFFFF] # Specials, includes the U+FFFE and U+FFFF noncharacters
133
+ ranges += (1..14).map { |plane| [plane * 0x10000 + 0xFFFE, plane * 0x10000 + 0xFFFF] } # noncharacters
134
+ ranges << [0xF0000, 0x10FFFF] # Supplementary Private Use Areas
135
+ Regexp.new("[" + ranges.map { |from, to| format("\\u{%X}-\\u{%X}", from, to) }.join + "]").freeze
136
+ end
137
+ OCF_MAX_FILENAME_BYTES = 255
138
+
139
+ private_constant :STORED, :DEFLATED, :OCF_FORBIDDEN_CHARACTERS, :OCF_MAX_FILENAME_BYTES
108
140
 
109
141
  # Creates a new Streamer on top of the given IO-ish object and yields it. Once the given block
110
142
  # returns, the Streamer will have it's `close` method called, which will write out the central
@@ -127,7 +159,14 @@ class ZipKit::Streamer
127
159
  # @param auto_rename_duplicate_filenames[Boolean] whether duplicate filenames, when encountered,
128
160
  # should be suffixed with (1), (2) etc. Default value is `false` - if
129
161
  # dupliate names are used an exception will be raised
130
- def initialize(writable, writer: create_writer, auto_rename_duplicate_filenames: false)
162
+ # @param ocf[Boolean] whether the archive must be an OCF container (EPUB) or an OpenDocument package.
163
+ # When set, the first entry must be a stored `mimetype` file without a data descriptor - use
164
+ # {#write_mimetype_file} for it - and {OCFViolation} gets raised otherwise. That entry will be
165
+ # written without the extended timestamp extra field, which those formats forbid. All file names
166
+ # get checked against the OCF rules too: valid UTF-8, no forbidden characters, no names longer
167
+ # than 255 bytes or ending with a ".", and no two names in a directory which only differ by case
168
+ # or Unicode normalization.
169
+ def initialize(writable, writer: create_writer, auto_rename_duplicate_filenames: false, ocf: false)
131
170
  raise InvalidOutput, "The writable must respond to #<< or #write" unless writable.respond_to?(:<<) || writable.respond_to?(:write)
132
171
 
133
172
  @out = ZipKit::WriteAndTell.new(writable)
@@ -135,6 +174,8 @@ class ZipKit::Streamer
135
174
  @path_set = ZipKit::PathSet.new
136
175
  @writer = writer
137
176
  @dedupe_filenames = auto_rename_duplicate_filenames
177
+ @ocf = ocf
178
+ @ocf_folded_paths = {}
138
179
  end
139
180
 
140
181
  # Writes a part of a zip entry body (actual binary data of the entry) into the output stream.
@@ -211,6 +252,31 @@ class ZipKit::Streamer
211
252
  @out.tell
212
253
  end
213
254
 
255
+ # Writes the `mimetype` file which EPUB (OCF) and OpenDocument containers have to start with.
256
+ # The entry is stored, has no data descriptor and no extra fields, so that the media type
257
+ # ends up at byte 38 of the archive if this is the first entry.
258
+ #
259
+ # @param media_type[String] the media type, like "application/epub+zip"
260
+ # @param modification_time [Time] the modification time of the file in the archive
261
+ # @return [Integer] the offset the output IO is at after writing the entry
262
+ def write_mimetype_file(media_type, modification_time: Time.now.utc)
263
+ unless media_type.ascii_only? && media_type.match?(/\A[[:graph:]]+\z/)
264
+ raise OCFViolation, "The media type must be ASCII without whitespace or padding, but was #{media_type.inspect}"
265
+ end
266
+ media_type = media_type.b
267
+ add_file_and_write_local_header(filename: "mimetype",
268
+ modification_time: modification_time,
269
+ crc32: Zlib.crc32(media_type),
270
+ storage_mode: STORED,
271
+ compressed_size: media_type.bytesize,
272
+ uncompressed_size: media_type.bytesize,
273
+ unix_permissions: nil,
274
+ use_data_descriptor: false,
275
+ extended_timestamp: false)
276
+ @out << media_type
277
+ @out.tell
278
+ end
279
+
214
280
  # Adds an empty directory to the archive with a size of 0 and permissions of 755.
215
281
  #
216
282
  # @param dirname [String] the name of the directory in the archive
@@ -425,7 +491,8 @@ class ZipKit::Streamer
425
491
  mtime: entry.mtime,
426
492
  crc32: entry.crc32,
427
493
  filename: entry.filename,
428
- unix_permissions: entry.unix_permissions)
494
+ unix_permissions: entry.unix_permissions,
495
+ extended_timestamp: entry.extended_timestamp)
429
496
  end
430
497
 
431
498
  # Record the central directory size, for the EOCDR
@@ -440,6 +507,7 @@ class ZipKit::Streamer
440
507
  # Clear the files so that GC will not have to trace all the way to here to deallocate them
441
508
  @files.clear
442
509
  @path_set.clear
510
+ @ocf_folded_paths.clear
443
511
 
444
512
  # and return the final offset
445
513
  @out.tell
@@ -509,8 +577,11 @@ class ZipKit::Streamer
509
577
 
510
578
  # Recreate the path set from remaining entries (PathSet does not support cheap deletes yet)
511
579
  @path_set.clear
580
+ @ocf_folded_paths.clear
512
581
  @files.each do |e|
513
- @path_set.add_directory_or_file_path(e.filename) unless e.filler?
582
+ next if e.filler?
583
+ @path_set.add_directory_or_file_path(e.filename)
584
+ register_ocf_folded_path(e.filename) if @ocf
514
585
  end
515
586
 
516
587
  # Create filler for the truncated or unusable local file entry that did get written into the output
@@ -572,7 +643,8 @@ class ZipKit::Streamer
572
643
  compressed_size:,
573
644
  uncompressed_size:,
574
645
  use_data_descriptor:,
575
- unix_permissions:
646
+ unix_permissions:,
647
+ extended_timestamp: true
576
648
  )
577
649
  # Set state needed for proper rollback later. If write_local_file_header
578
650
  # does manage to write _some_ bytes, but fails later (we write in tiny bits sometimes)
@@ -585,10 +657,21 @@ class ZipKit::Streamer
585
657
  raise UnknownMode, "Unknown compression mode #{storage_mode}" unless [STORED, DEFLATED].include?(storage_mode)
586
658
  raise Overflow, "Filename is too long" if filename.bytesize > 0xFFFF
587
659
 
660
+ # Going by the offset and not by the entries, since a rolled back `mimetype` leaves a filler behind
661
+ if @ocf && @out.tell.zero?
662
+ verify_ocf_mimetype_entry!(filename, storage_mode, use_data_descriptor)
663
+ extended_timestamp = false
664
+ elsif @ocf && (@files.empty? || @files.first.filler?)
665
+ raise OCFViolation, "The \"mimetype\" entry of an OCF container must start at offset 0, " \
666
+ "but #{@out.tell} bytes have already been output"
667
+ end
668
+
588
669
  # If we need to massage filenames to enforce uniqueness,
589
670
  # do so before we check for file/directory conflicts
590
671
  filename = ZipKit::UniquifyFilename.call(filename, @path_set) if @dedupe_filenames
591
672
 
673
+ verify_ocf_filename!(filename) if @ocf
674
+
592
675
  # Make sure there is no file/directory clobbering (conflicts), or - if deduping is disabled -
593
676
  # no duplicate filenames/paths
594
677
  if filename.end_with?("/")
@@ -596,6 +679,7 @@ class ZipKit::Streamer
596
679
  else
597
680
  @path_set.add_file_path(filename)
598
681
  end
682
+ register_ocf_folded_path(filename) if @ocf
599
683
 
600
684
  if use_data_descriptor
601
685
  crc32 = 0
@@ -615,7 +699,8 @@ class ZipKit::Streamer
615
699
  _local_file_header_offset = local_header_starts_at,
616
700
  _bytes_used_for_local_header = 0,
617
701
  _bytes_used_for_data_descriptor = 0,
618
- unix_permissions)
702
+ unix_permissions,
703
+ extended_timestamp)
619
704
 
620
705
  @writer.write_local_file_header(io: @out,
621
706
  gp_flags: e.gp_flags,
@@ -624,7 +709,8 @@ class ZipKit::Streamer
624
709
  uncompressed_size: e.uncompressed_size,
625
710
  mtime: e.mtime,
626
711
  filename: e.filename,
627
- storage_mode: e.storage_mode)
712
+ storage_mode: e.storage_mode,
713
+ extended_timestamp: e.extended_timestamp)
628
714
 
629
715
  e.bytes_used_for_local_header = @out.tell - e.local_header_offset
630
716
 
@@ -632,6 +718,62 @@ class ZipKit::Streamer
632
718
  @remove_last_file_at_rollback = true
633
719
  end
634
720
 
721
+ def verify_ocf_mimetype_entry!(filename, storage_mode, use_data_descriptor)
722
+ problem = if filename != "mimetype"
723
+ "it is #{filename.inspect}"
724
+ elsif storage_mode != STORED
725
+ "it is compressed"
726
+ elsif use_data_descriptor
727
+ "it uses a data descriptor (sizes are not known upfront)"
728
+ end
729
+ return unless problem
730
+
731
+ message = "The first entry of an OCF container must be a stored \"mimetype\" file without a data descriptor, " \
732
+ "but #{problem}. Use `write_mimetype_file` to write it."
733
+ raise OCFViolation, message
734
+ end
735
+
736
+ def verify_ocf_filename!(filename)
737
+ path = filename.dup.force_encoding(Encoding::UTF_8)
738
+ raise OCFViolation, "File names in an OCF container must be UTF-8, but #{filename.inspect} is not" unless path.valid_encoding?
739
+
740
+ path.chomp("/").split("/", -1).each do |name|
741
+ problem = if name.empty?
742
+ "has an empty path segment"
743
+ elsif name.bytesize > OCF_MAX_FILENAME_BYTES
744
+ "has a segment longer than #{OCF_MAX_FILENAME_BYTES} bytes"
745
+ elsif name.end_with?(".")
746
+ "has a segment ending with a \".\""
747
+ elsif (char = name[OCF_FORBIDDEN_CHARACTERS])
748
+ "contains the forbidden character U+%04X" % char.ord
749
+ end
750
+ raise OCFViolation, "The file name #{path.inspect} is not allowed in an OCF container: it #{problem}" if problem
751
+ end
752
+
753
+ each_ocf_folded_prefix(path) do |prefix, folded|
754
+ existing = @ocf_folded_paths[folded]
755
+ if existing && existing != prefix
756
+ raise OCFViolation, "The file name #{path.inspect} is not allowed in an OCF container: " \
757
+ "#{prefix.inspect} only differs from #{existing.inspect} by case or Unicode normalization"
758
+ end
759
+ end
760
+ end
761
+
762
+ def register_ocf_folded_path(filename)
763
+ each_ocf_folded_prefix(filename.dup.force_encoding(Encoding::UTF_8)) do |prefix, folded|
764
+ @ocf_folded_paths[folded] ||= prefix
765
+ end
766
+ end
767
+
768
+ # Directories have to be unique too, so "OEBPS/a.xhtml" and "oebps/b.xhtml" clash on "OEBPS"
769
+ def each_ocf_folded_prefix(path)
770
+ segments = path.chomp("/").split("/")
771
+ segments.each_index do |i|
772
+ prefix = segments[0..i].join("/")
773
+ yield(prefix, prefix.unicode_normalize(:nfc).downcase(:fold))
774
+ end
775
+ end
776
+
635
777
  def remove_backslash(filename)
636
778
  filename.tr("\\", "_")
637
779
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZipKit
4
- VERSION = "6.3.6"
4
+ VERSION = "6.4.0"
5
5
  end
@@ -72,8 +72,13 @@ class ZipKit::ZipWriter
72
72
  # @param mtime[Time] the modification time to be recorded in the ZIP
73
73
  # @param gp_flags[Integer] bit-packed general purpose flags
74
74
  # @param storage_mode[Integer] 8 for deflated, 0 for stored...
75
+ # @param extended_timestamp[Boolean] whether to add the extended timestamp ("UT") extra field. The DOS date
76
+ # and time get written regardless, but they carry local time with 2-second precision - which is why the
77
+ # extra field is there by default. Some formats built on top of ZIP forbid extra fields on certain entries
78
+ # though: EPUB (OCF) and OpenDocument require their `mimetype` entry to have none. Normally `Streamer`
79
+ # decides this for you, see the `ocf:` option of {Streamer#initialize}
75
80
  # @return [void]
76
- def write_local_file_header(io:, filename:, compressed_size:, uncompressed_size:, crc32:, gp_flags:, mtime:, storage_mode:)
81
+ def write_local_file_header(io:, filename:, compressed_size:, uncompressed_size:, crc32:, gp_flags:, mtime:, storage_mode:, extended_timestamp: true)
77
82
  requires_zip64 = compressed_size > FOUR_BYTE_MAX_UINT || uncompressed_size > FOUR_BYTE_MAX_UINT
78
83
 
79
84
  # local file header signature 4 bytes (0x04034b50)
@@ -121,7 +126,7 @@ class ZipKit::ZipWriter
121
126
  if requires_zip64
122
127
  extra_fields << zip_64_extra_for_local_file_header(compressed_size: compressed_size, uncompressed_size: uncompressed_size)
123
128
  end
124
- extra_fields << timestamp_extra_for_local_file_header(mtime)
129
+ extra_fields << timestamp_extra_for_local_file_header(mtime) if extended_timestamp
125
130
 
126
131
  # extra field length 2 bytes
127
132
  io << [extra_fields.size].pack(C_UINT2)
@@ -144,6 +149,8 @@ class ZipKit::ZipWriter
144
149
  # @param mtime[Time] the modification time to be recorded in the ZIP
145
150
  # @param gp_flags[Integer] bit-packed general purpose flags
146
151
  # @param unix_permissions[Integer] the permissions for the file, or nil for the default to be used
152
+ # @param extended_timestamp[Boolean] whether to add the extended timestamp ("UT") extra field,
153
+ # see {#write_local_file_header}
147
154
  # @return [void]
148
155
  def write_central_directory_file_header(io:,
149
156
  local_file_header_location:,
@@ -154,7 +161,8 @@ class ZipKit::ZipWriter
154
161
  mtime:,
155
162
  crc32:,
156
163
  filename:,
157
- unix_permissions: nil)
164
+ unix_permissions: nil,
165
+ extended_timestamp: true)
158
166
  # At this point if the header begins somewhere beyound 0xFFFFFFFF we _have_ to record the offset
159
167
  # of the local file header as a zip64 extra field, so we give up, give in, you loose, love will always win...
160
168
  add_zip64 = (local_file_header_location > FOUR_BYTE_MAX_UINT) ||
@@ -203,7 +211,7 @@ class ZipKit::ZipWriter
203
211
  compressed_size: compressed_size,
204
212
  uncompressed_size: uncompressed_size)
205
213
  end
206
- extra_fields << timestamp_extra_for_central_directory_entry(mtime)
214
+ extra_fields << timestamp_extra_for_central_directory_entry(mtime) if extended_timestamp
207
215
 
208
216
  # extra field length 2 bytes
209
217
  io << [extra_fields.size].pack(C_UINT2)
data/rbi/zip_kit.rbi CHANGED
@@ -186,15 +186,44 @@ module ZipKit
186
186
  # zip.close
187
187
  #
188
188
  # Calling {Streamer#close} **will not** call `#close` on the underlying IO object.
189
+ #
190
+ # ## Writing EPUB and OpenDocument containers
191
+ #
192
+ # EPUB (via its Open Container Format, OCF) and OpenDocument are ZIP files with an extra rule: the
193
+ # first entry must be a file called `mimetype`, stored without compression and with no extra fields
194
+ # in its local header. Readers check this by looking at the raw bytes at fixed offsets: the media type
195
+ # must start at byte 38 of the file. Pass `ocf: true` and write the `mimetype` file first:
196
+ #
197
+ # ZipKit::Streamer.open(epub_file, ocf: true) do |zip|
198
+ # zip.write_mimetype_file("application/epub+zip")
199
+ # zip.write_file("META-INF/container.xml") { |sink| sink << container_xml }
200
+ # ...
201
+ # end
202
+ #
203
+ # With `ocf: true` the Streamer will raise {OCFViolation} if the first entry it is asked to write
204
+ # would make the archive non-conformant, or if a file name is not allowed in such a container.
189
205
  class Streamer
190
206
  include ZipKit::WriteShovel
191
207
  STORED = T.let(0, T.untyped)
192
208
  DEFLATED = T.let(8, T.untyped)
193
209
  EntryBodySizeMismatch = T.let(Class.new(StandardError), T.untyped)
210
+ OCFViolation = T.let(Class.new(StandardError), T.untyped)
194
211
  InvalidOutput = T.let(Class.new(ArgumentError), T.untyped)
195
212
  Overflow = T.let(Class.new(StandardError), T.untyped)
196
213
  UnknownMode = T.let(Class.new(StandardError), T.untyped)
197
214
  OffsetOutOfSync = T.let(Class.new(StandardError), T.untyped)
215
+ OCF_FORBIDDEN_CHARACTERS = T.let(begin
216
+ ranges = [0x22, 0x2A, 0x3A, 0x3C, 0x3E, 0x3F, 0x5C, 0x7C].map { |c| [c, c] }
217
+ ranges << [0x00, 0x1F] # C0
218
+ ranges << [0x7F, 0x9F] # DEL and C1
219
+ ranges << [0xE000, 0xF8FF] # Private Use Area
220
+ ranges << [0xFDD0, 0xFDEF] # noncharacters
221
+ ranges << [0xFFF0, 0xFFFF] # Specials, includes the U+FFFE and U+FFFF noncharacters
222
+ ranges += (1..14).map { |plane| [plane * 0x10000 + 0xFFFE, plane * 0x10000 + 0xFFFF] } # noncharacters
223
+ ranges << [0xF0000, 0x10FFFF] # Supplementary Private Use Areas
224
+ Regexp.new("[" + ranges.map { |from, to| format("\\u{%X}-\\u{%X}", from, to) }.join + "]").freeze
225
+ end, T.untyped)
226
+ OCF_MAX_FILENAME_BYTES = T.let(255, T.untyped)
198
227
 
199
228
  # sord omit - no YARD return type given, using untyped
200
229
  # Creates a new Streamer on top of the given IO-ish object and yields it. Once the given block
@@ -215,8 +244,17 @@ module ZipKit
215
244
  # _@param_ `writer` — the object to be used as the writer. Defaults to an instance of ZipKit::ZipWriter, normally you won't need to override it
216
245
  #
217
246
  # _@param_ `auto_rename_duplicate_filenames` — whether duplicate filenames, when encountered, should be suffixed with (1), (2) etc. Default value is `false` - if dupliate names are used an exception will be raised
218
- sig { params(writable: T.untyped, writer: ZipKit::ZipWriter, auto_rename_duplicate_filenames: T::Boolean).void }
219
- def initialize(writable, writer: create_writer, auto_rename_duplicate_filenames: false); end
247
+ #
248
+ # _@param_ `ocf` — whether the archive must be an OCF container (EPUB) or an OpenDocument package. When set, the first entry must be a stored `mimetype` file without a data descriptor - use {#write_mimetype_file} for it - and {OCFViolation} gets raised otherwise. That entry will be written without the extended timestamp extra field, which those formats forbid. All file names get checked against the OCF rules too: valid UTF-8, no forbidden characters, no names longer than 255 bytes or ending with a ".", and no two names in a directory which only differ by case or Unicode normalization.
249
+ sig do
250
+ params(
251
+ writable: T.untyped,
252
+ writer: ZipKit::ZipWriter,
253
+ auto_rename_duplicate_filenames: T::Boolean,
254
+ ocf: T::Boolean
255
+ ).void
256
+ end
257
+ def initialize(writable, writer: create_writer, auto_rename_duplicate_filenames: false, ocf: false); end
220
258
 
221
259
  # Writes a part of a zip entry body (actual binary data of the entry) into the output stream.
222
260
  #
@@ -303,6 +341,18 @@ module ZipKit
303
341
  end
304
342
  def add_stored_entry(filename:, modification_time: Time.now.utc, size: 0, crc32: 0, unix_permissions: nil, use_data_descriptor: false); end
305
343
 
344
+ # Writes the `mimetype` file which EPUB (OCF) and OpenDocument containers have to start with.
345
+ # The entry is stored, has no data descriptor and no extra fields, so that the media type
346
+ # ends up at byte 38 of the archive if this is the first entry.
347
+ #
348
+ # _@param_ `media_type` — the media type, like "application/epub+zip"
349
+ #
350
+ # _@param_ `modification_time` — the modification time of the file in the archive
351
+ #
352
+ # _@return_ — the offset the output IO is at after writing the entry
353
+ sig { params(media_type: String, modification_time: Time).returns(Integer) }
354
+ def write_mimetype_file(media_type, modification_time: Time.now.utc); end
355
+
306
356
  # Adds an empty directory to the archive with a size of 0 and permissions of 755.
307
357
  #
308
358
  # _@param_ `dirname` — the name of the directory in the archive
@@ -554,6 +604,7 @@ module ZipKit
554
604
  # sord omit - no YARD type given for "uncompressed_size:", using untyped
555
605
  # sord omit - no YARD type given for "use_data_descriptor:", using untyped
556
606
  # sord omit - no YARD type given for "unix_permissions:", using untyped
607
+ # sord omit - no YARD type given for "extended_timestamp:", using untyped
557
608
  # sord omit - no YARD return type given, using untyped
558
609
  sig do
559
610
  params(
@@ -564,10 +615,34 @@ module ZipKit
564
615
  compressed_size: T.untyped,
565
616
  uncompressed_size: T.untyped,
566
617
  use_data_descriptor: T.untyped,
567
- unix_permissions: T.untyped
618
+ unix_permissions: T.untyped,
619
+ extended_timestamp: T.untyped
568
620
  ).returns(T.untyped)
569
621
  end
570
- def add_file_and_write_local_header(filename:, modification_time:, crc32:, storage_mode:, compressed_size:, uncompressed_size:, use_data_descriptor:, unix_permissions:); end
622
+ def add_file_and_write_local_header(filename:, modification_time:, crc32:, storage_mode:, compressed_size:, uncompressed_size:, use_data_descriptor:, unix_permissions:, extended_timestamp: true); end
623
+
624
+ # sord omit - no YARD type given for "filename", using untyped
625
+ # sord omit - no YARD type given for "storage_mode", using untyped
626
+ # sord omit - no YARD type given for "use_data_descriptor", using untyped
627
+ # sord omit - no YARD return type given, using untyped
628
+ sig { params(filename: T.untyped, storage_mode: T.untyped, use_data_descriptor: T.untyped).returns(T.untyped) }
629
+ def verify_ocf_mimetype_entry!(filename, storage_mode, use_data_descriptor); end
630
+
631
+ # sord omit - no YARD type given for "filename", using untyped
632
+ # sord omit - no YARD return type given, using untyped
633
+ sig { params(filename: T.untyped).returns(T.untyped) }
634
+ def verify_ocf_filename!(filename); end
635
+
636
+ # sord omit - no YARD type given for "filename", using untyped
637
+ # sord omit - no YARD return type given, using untyped
638
+ sig { params(filename: T.untyped).returns(T.untyped) }
639
+ def register_ocf_folded_path(filename); end
640
+
641
+ # sord omit - no YARD type given for "path", using untyped
642
+ # sord omit - no YARD return type given, using untyped
643
+ # Directories have to be unique too, so "OEBPS/a.xhtml" and "oebps/b.xhtml" clash on "OEBPS"
644
+ sig { params(path: T.untyped).returns(T.untyped) }
645
+ def each_ocf_folded_prefix(path); end
571
646
 
572
647
  # sord omit - no YARD type given for "filename", using untyped
573
648
  # sord omit - no YARD return type given, using untyped
@@ -648,6 +723,10 @@ module ZipKit
648
723
  # Returns the value of attribute unix_permissions
649
724
  sig { returns(Object) }
650
725
  attr_accessor :unix_permissions
726
+
727
+ # Returns the value of attribute extended_timestamp
728
+ sig { returns(Object) }
729
+ attr_accessor :extended_timestamp
651
730
  end
652
731
 
653
732
  # Is used internally by Streamer to keep track of entries in the archive during writing.
@@ -979,6 +1058,8 @@ end, T.untyped)
979
1058
  # _@param_ `gp_flags` — bit-packed general purpose flags
980
1059
  #
981
1060
  # _@param_ `storage_mode` — 8 for deflated, 0 for stored...
1061
+ #
1062
+ # _@param_ `extended_timestamp` — whether to add the extended timestamp ("UT") extra field. The DOS date and time get written regardless, but they carry local time with 2-second precision - which is why the extra field is there by default. Some formats built on top of ZIP forbid extra fields on certain entries though: EPUB (OCF) and OpenDocument require their `mimetype` entry to have none. Normally `Streamer` decides this for you, see the `ocf:` option of {Streamer#initialize}
982
1063
  sig do
983
1064
  params(
984
1065
  io: T.untyped,
@@ -988,10 +1069,11 @@ end, T.untyped)
988
1069
  crc32: Integer,
989
1070
  gp_flags: Integer,
990
1071
  mtime: Time,
991
- storage_mode: Integer
1072
+ storage_mode: Integer,
1073
+ extended_timestamp: T::Boolean
992
1074
  ).void
993
1075
  end
994
- def write_local_file_header(io:, filename:, compressed_size:, uncompressed_size:, crc32:, gp_flags:, mtime:, storage_mode:); end
1076
+ def write_local_file_header(io:, filename:, compressed_size:, uncompressed_size:, crc32:, gp_flags:, mtime:, storage_mode:, extended_timestamp: true); end
995
1077
 
996
1078
  # sord duck - #<< looks like a duck type, replacing with untyped
997
1079
  # sord omit - no YARD type given for "local_file_header_location:", using untyped
@@ -1014,6 +1096,8 @@ end, T.untyped)
1014
1096
  # _@param_ `gp_flags` — bit-packed general purpose flags
1015
1097
  #
1016
1098
  # _@param_ `unix_permissions` — the permissions for the file, or nil for the default to be used
1099
+ #
1100
+ # _@param_ `extended_timestamp` — whether to add the extended timestamp ("UT") extra field, see {#write_local_file_header}
1017
1101
  sig do
1018
1102
  params(
1019
1103
  io: T.untyped,
@@ -1025,10 +1109,11 @@ end, T.untyped)
1025
1109
  mtime: Time,
1026
1110
  crc32: Integer,
1027
1111
  filename: String,
1028
- unix_permissions: T.nilable(Integer)
1112
+ unix_permissions: T.nilable(Integer),
1113
+ extended_timestamp: T::Boolean
1029
1114
  ).void
1030
1115
  end
1031
- def write_central_directory_file_header(io:, local_file_header_location:, gp_flags:, storage_mode:, compressed_size:, uncompressed_size:, mtime:, crc32:, filename:, unix_permissions: nil); end
1116
+ def write_central_directory_file_header(io:, local_file_header_location:, gp_flags:, storage_mode:, compressed_size:, uncompressed_size:, mtime:, crc32:, filename:, unix_permissions: nil, extended_timestamp: true); end
1032
1117
 
1033
1118
  # sord duck - #<< looks like a duck type, replacing with untyped
1034
1119
  # Writes the data descriptor following the file data for a file whose local file header
@@ -2030,6 +2115,15 @@ end, T.untyped)
2030
2115
  end
2031
2116
  def add_deflated_entry(filename:, uncompressed_size:, compressed_size:, use_data_descriptor: false); end
2032
2117
 
2118
+ # Add the `mimetype` entry an EPUB (OCF) or OpenDocument container starts with,
2119
+ # see {Streamer#write_mimetype_file}.
2120
+ #
2121
+ # _@param_ `media_type` — the media type, like "application/epub+zip"
2122
+ #
2123
+ # _@return_ — self
2124
+ sig { params(media_type: String).returns(T.untyped) }
2125
+ def add_mimetype_entry(media_type:); end
2126
+
2033
2127
  # Add an empty directory to the archive.
2034
2128
  #
2035
2129
  # _@param_ `dirname` — the name of the directory
data/rbi/zip_kit.rbs CHANGED
@@ -177,15 +177,34 @@ module ZipKit
177
177
  # zip.close
178
178
  #
179
179
  # Calling {Streamer#close} **will not** call `#close` on the underlying IO object.
180
+ #
181
+ # ## Writing EPUB and OpenDocument containers
182
+ #
183
+ # EPUB (via its Open Container Format, OCF) and OpenDocument are ZIP files with an extra rule: the
184
+ # first entry must be a file called `mimetype`, stored without compression and with no extra fields
185
+ # in its local header. Readers check this by looking at the raw bytes at fixed offsets: the media type
186
+ # must start at byte 38 of the file. Pass `ocf: true` and write the `mimetype` file first:
187
+ #
188
+ # ZipKit::Streamer.open(epub_file, ocf: true) do |zip|
189
+ # zip.write_mimetype_file("application/epub+zip")
190
+ # zip.write_file("META-INF/container.xml") { |sink| sink << container_xml }
191
+ # ...
192
+ # end
193
+ #
194
+ # With `ocf: true` the Streamer will raise {OCFViolation} if the first entry it is asked to write
195
+ # would make the archive non-conformant, or if a file name is not allowed in such a container.
180
196
  class Streamer
181
197
  include ZipKit::WriteShovel
182
198
  STORED: untyped
183
199
  DEFLATED: untyped
184
200
  EntryBodySizeMismatch: untyped
201
+ OCFViolation: untyped
185
202
  InvalidOutput: untyped
186
203
  Overflow: untyped
187
204
  UnknownMode: untyped
188
205
  OffsetOutOfSync: untyped
206
+ OCF_FORBIDDEN_CHARACTERS: untyped
207
+ OCF_MAX_FILENAME_BYTES: untyped
189
208
 
190
209
  # sord omit - no YARD return type given, using untyped
191
210
  # Creates a new Streamer on top of the given IO-ish object and yields it. Once the given block
@@ -205,7 +224,14 @@ module ZipKit
205
224
  # _@param_ `writer` — the object to be used as the writer. Defaults to an instance of ZipKit::ZipWriter, normally you won't need to override it
206
225
  #
207
226
  # _@param_ `auto_rename_duplicate_filenames` — whether duplicate filenames, when encountered, should be suffixed with (1), (2) etc. Default value is `false` - if dupliate names are used an exception will be raised
208
- def initialize: (untyped writable, ?writer: ZipKit::ZipWriter, ?auto_rename_duplicate_filenames: bool) -> void
227
+ #
228
+ # _@param_ `ocf` — whether the archive must be an OCF container (EPUB) or an OpenDocument package. When set, the first entry must be a stored `mimetype` file without a data descriptor - use {#write_mimetype_file} for it - and {OCFViolation} gets raised otherwise. That entry will be written without the extended timestamp extra field, which those formats forbid. All file names get checked against the OCF rules too: valid UTF-8, no forbidden characters, no names longer than 255 bytes or ending with a ".", and no two names in a directory which only differ by case or Unicode normalization.
229
+ def initialize: (
230
+ untyped writable,
231
+ ?writer: ZipKit::ZipWriter,
232
+ ?auto_rename_duplicate_filenames: bool,
233
+ ?ocf: bool
234
+ ) -> void
209
235
 
210
236
  # Writes a part of a zip entry body (actual binary data of the entry) into the output stream.
211
237
  #
@@ -284,6 +310,17 @@ module ZipKit
284
310
  ?use_data_descriptor: bool
285
311
  ) -> Integer
286
312
 
313
+ # Writes the `mimetype` file which EPUB (OCF) and OpenDocument containers have to start with.
314
+ # The entry is stored, has no data descriptor and no extra fields, so that the media type
315
+ # ends up at byte 38 of the archive if this is the first entry.
316
+ #
317
+ # _@param_ `media_type` — the media type, like "application/epub+zip"
318
+ #
319
+ # _@param_ `modification_time` — the modification time of the file in the archive
320
+ #
321
+ # _@return_ — the offset the output IO is at after writing the entry
322
+ def write_mimetype_file: (String media_type, ?modification_time: Time) -> Integer
323
+
287
324
  # Adds an empty directory to the archive with a size of 0 and permissions of 755.
288
325
  #
289
326
  # _@param_ `dirname` — the name of the directory in the archive
@@ -504,6 +541,7 @@ module ZipKit
504
541
  # sord omit - no YARD type given for "uncompressed_size:", using untyped
505
542
  # sord omit - no YARD type given for "use_data_descriptor:", using untyped
506
543
  # sord omit - no YARD type given for "unix_permissions:", using untyped
544
+ # sord omit - no YARD type given for "extended_timestamp:", using untyped
507
545
  # sord omit - no YARD return type given, using untyped
508
546
  def add_file_and_write_local_header: (
509
547
  filename: untyped,
@@ -513,9 +551,29 @@ module ZipKit
513
551
  compressed_size: untyped,
514
552
  uncompressed_size: untyped,
515
553
  use_data_descriptor: untyped,
516
- unix_permissions: untyped
554
+ unix_permissions: untyped,
555
+ ?extended_timestamp: untyped
517
556
  ) -> untyped
518
557
 
558
+ # sord omit - no YARD type given for "filename", using untyped
559
+ # sord omit - no YARD type given for "storage_mode", using untyped
560
+ # sord omit - no YARD type given for "use_data_descriptor", using untyped
561
+ # sord omit - no YARD return type given, using untyped
562
+ def verify_ocf_mimetype_entry!: (untyped filename, untyped storage_mode, untyped use_data_descriptor) -> untyped
563
+
564
+ # sord omit - no YARD type given for "filename", using untyped
565
+ # sord omit - no YARD return type given, using untyped
566
+ def verify_ocf_filename!: (untyped filename) -> untyped
567
+
568
+ # sord omit - no YARD type given for "filename", using untyped
569
+ # sord omit - no YARD return type given, using untyped
570
+ def register_ocf_folded_path: (untyped filename) -> untyped
571
+
572
+ # sord omit - no YARD type given for "path", using untyped
573
+ # sord omit - no YARD return type given, using untyped
574
+ # Directories have to be unique too, so "OEBPS/a.xhtml" and "oebps/b.xhtml" clash on "OEBPS"
575
+ def each_ocf_folded_prefix: (untyped path) -> untyped
576
+
519
577
  # sord omit - no YARD type given for "filename", using untyped
520
578
  # sord omit - no YARD return type given, using untyped
521
579
  def remove_backslash: (untyped filename) -> untyped
@@ -578,6 +636,9 @@ module ZipKit
578
636
 
579
637
  # Returns the value of attribute unix_permissions
580
638
  attr_accessor unix_permissions: Object
639
+
640
+ # Returns the value of attribute extended_timestamp
641
+ attr_accessor extended_timestamp: Object
581
642
  end
582
643
 
583
644
  # Is used internally by Streamer to keep track of entries in the archive during writing.
@@ -873,6 +934,8 @@ module ZipKit
873
934
  # _@param_ `gp_flags` — bit-packed general purpose flags
874
935
  #
875
936
  # _@param_ `storage_mode` — 8 for deflated, 0 for stored...
937
+ #
938
+ # _@param_ `extended_timestamp` — whether to add the extended timestamp ("UT") extra field. The DOS date and time get written regardless, but they carry local time with 2-second precision - which is why the extra field is there by default. Some formats built on top of ZIP forbid extra fields on certain entries though: EPUB (OCF) and OpenDocument require their `mimetype` entry to have none. Normally `Streamer` decides this for you, see the `ocf:` option of {Streamer#initialize}
876
939
  def write_local_file_header: (
877
940
  io: untyped,
878
941
  filename: String,
@@ -881,7 +944,8 @@ module ZipKit
881
944
  crc32: Integer,
882
945
  gp_flags: Integer,
883
946
  mtime: Time,
884
- storage_mode: Integer
947
+ storage_mode: Integer,
948
+ ?extended_timestamp: bool
885
949
  ) -> void
886
950
 
887
951
  # sord duck - #<< looks like a duck type, replacing with untyped
@@ -905,6 +969,8 @@ module ZipKit
905
969
  # _@param_ `gp_flags` — bit-packed general purpose flags
906
970
  #
907
971
  # _@param_ `unix_permissions` — the permissions for the file, or nil for the default to be used
972
+ #
973
+ # _@param_ `extended_timestamp` — whether to add the extended timestamp ("UT") extra field, see {#write_local_file_header}
908
974
  def write_central_directory_file_header: (
909
975
  io: untyped,
910
976
  local_file_header_location: untyped,
@@ -915,7 +981,8 @@ module ZipKit
915
981
  mtime: Time,
916
982
  crc32: Integer,
917
983
  filename: String,
918
- ?unix_permissions: Integer?
984
+ ?unix_permissions: Integer?,
985
+ ?extended_timestamp: bool
919
986
  ) -> void
920
987
 
921
988
  # sord duck - #<< looks like a duck type, replacing with untyped
@@ -1780,6 +1847,14 @@ module ZipKit
1780
1847
  ?use_data_descriptor: bool
1781
1848
  ) -> untyped
1782
1849
 
1850
+ # Add the `mimetype` entry an EPUB (OCF) or OpenDocument container starts with,
1851
+ # see {Streamer#write_mimetype_file}.
1852
+ #
1853
+ # _@param_ `media_type` — the media type, like "application/epub+zip"
1854
+ #
1855
+ # _@return_ — self
1856
+ def add_mimetype_entry: (media_type: String) -> untyped
1857
+
1783
1858
  # Add an empty directory to the archive.
1784
1859
  #
1785
1860
  # _@param_ `dirname` — the name of the directory
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zip_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.3.6
4
+ version: 6.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
@@ -11,7 +11,7 @@ authors:
11
11
  - Felix Bünemann
12
12
  bindir: exe
13
13
  cert_chain: []
14
- date: 2026-10-02 00:00:00.000000000 Z
14
+ date: 2026-10-08 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: bundler