cabriolet 0.1.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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +799 -0
  3. data/CHANGELOG.md +44 -0
  4. data/LICENSE +29 -0
  5. data/README.adoc +1207 -0
  6. data/exe/cabriolet +6 -0
  7. data/lib/cabriolet/auto.rb +173 -0
  8. data/lib/cabriolet/binary/bitstream.rb +148 -0
  9. data/lib/cabriolet/binary/bitstream_writer.rb +180 -0
  10. data/lib/cabriolet/binary/chm_structures.rb +213 -0
  11. data/lib/cabriolet/binary/hlp_structures.rb +66 -0
  12. data/lib/cabriolet/binary/kwaj_structures.rb +74 -0
  13. data/lib/cabriolet/binary/lit_structures.rb +107 -0
  14. data/lib/cabriolet/binary/oab_structures.rb +112 -0
  15. data/lib/cabriolet/binary/structures.rb +56 -0
  16. data/lib/cabriolet/binary/szdd_structures.rb +60 -0
  17. data/lib/cabriolet/cab/compressor.rb +382 -0
  18. data/lib/cabriolet/cab/decompressor.rb +510 -0
  19. data/lib/cabriolet/cab/extractor.rb +357 -0
  20. data/lib/cabriolet/cab/parser.rb +264 -0
  21. data/lib/cabriolet/chm/compressor.rb +513 -0
  22. data/lib/cabriolet/chm/decompressor.rb +436 -0
  23. data/lib/cabriolet/chm/parser.rb +254 -0
  24. data/lib/cabriolet/cli.rb +776 -0
  25. data/lib/cabriolet/compressors/base.rb +34 -0
  26. data/lib/cabriolet/compressors/lzss.rb +250 -0
  27. data/lib/cabriolet/compressors/lzx.rb +581 -0
  28. data/lib/cabriolet/compressors/mszip.rb +315 -0
  29. data/lib/cabriolet/compressors/quantum.rb +446 -0
  30. data/lib/cabriolet/constants.rb +75 -0
  31. data/lib/cabriolet/decompressors/base.rb +39 -0
  32. data/lib/cabriolet/decompressors/lzss.rb +138 -0
  33. data/lib/cabriolet/decompressors/lzx.rb +726 -0
  34. data/lib/cabriolet/decompressors/mszip.rb +390 -0
  35. data/lib/cabriolet/decompressors/none.rb +27 -0
  36. data/lib/cabriolet/decompressors/quantum.rb +456 -0
  37. data/lib/cabriolet/errors.rb +39 -0
  38. data/lib/cabriolet/format_detector.rb +156 -0
  39. data/lib/cabriolet/hlp/compressor.rb +272 -0
  40. data/lib/cabriolet/hlp/decompressor.rb +198 -0
  41. data/lib/cabriolet/hlp/parser.rb +131 -0
  42. data/lib/cabriolet/huffman/decoder.rb +79 -0
  43. data/lib/cabriolet/huffman/encoder.rb +108 -0
  44. data/lib/cabriolet/huffman/tree.rb +138 -0
  45. data/lib/cabriolet/kwaj/compressor.rb +479 -0
  46. data/lib/cabriolet/kwaj/decompressor.rb +237 -0
  47. data/lib/cabriolet/kwaj/parser.rb +183 -0
  48. data/lib/cabriolet/lit/compressor.rb +255 -0
  49. data/lib/cabriolet/lit/decompressor.rb +250 -0
  50. data/lib/cabriolet/models/cabinet.rb +81 -0
  51. data/lib/cabriolet/models/chm_file.rb +28 -0
  52. data/lib/cabriolet/models/chm_header.rb +67 -0
  53. data/lib/cabriolet/models/chm_section.rb +38 -0
  54. data/lib/cabriolet/models/file.rb +119 -0
  55. data/lib/cabriolet/models/folder.rb +102 -0
  56. data/lib/cabriolet/models/folder_data.rb +21 -0
  57. data/lib/cabriolet/models/hlp_file.rb +45 -0
  58. data/lib/cabriolet/models/hlp_header.rb +37 -0
  59. data/lib/cabriolet/models/kwaj_header.rb +98 -0
  60. data/lib/cabriolet/models/lit_header.rb +55 -0
  61. data/lib/cabriolet/models/oab_header.rb +95 -0
  62. data/lib/cabriolet/models/szdd_header.rb +72 -0
  63. data/lib/cabriolet/modifier.rb +326 -0
  64. data/lib/cabriolet/oab/compressor.rb +353 -0
  65. data/lib/cabriolet/oab/decompressor.rb +315 -0
  66. data/lib/cabriolet/parallel.rb +333 -0
  67. data/lib/cabriolet/repairer.rb +288 -0
  68. data/lib/cabriolet/streaming.rb +221 -0
  69. data/lib/cabriolet/system/file_handle.rb +107 -0
  70. data/lib/cabriolet/system/io_system.rb +87 -0
  71. data/lib/cabriolet/system/memory_handle.rb +105 -0
  72. data/lib/cabriolet/szdd/compressor.rb +217 -0
  73. data/lib/cabriolet/szdd/decompressor.rb +184 -0
  74. data/lib/cabriolet/szdd/parser.rb +127 -0
  75. data/lib/cabriolet/validator.rb +332 -0
  76. data/lib/cabriolet/version.rb +5 -0
  77. data/lib/cabriolet.rb +104 -0
  78. metadata +157 -0
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ module KWAJ
5
+ # Parser reads and parses KWAJ file headers
6
+ #
7
+ # KWAJ files support multiple compression methods and have variable-length
8
+ # headers with optional fields determined by flag bits.
9
+ class Parser
10
+ attr_reader :io_system
11
+
12
+ # Initialize a new parser
13
+ #
14
+ # @param io_system [System::IOSystem] I/O system for reading
15
+ def initialize(io_system)
16
+ @io_system = io_system
17
+ end
18
+
19
+ # Parse a KWAJ file and return header information
20
+ #
21
+ # @param filename [String] Path to the KWAJ file
22
+ # @return [Models::KWAJHeader] Parsed header
23
+ # @raise [Errors::ParseError] if the file is not a valid KWAJ
24
+ def parse(filename)
25
+ handle = @io_system.open(filename, Constants::MODE_READ)
26
+ header = parse_handle(handle)
27
+ @io_system.close(handle)
28
+ header
29
+ end
30
+
31
+ # Parse KWAJ header from an already-open handle
32
+ #
33
+ # @param handle [System::FileHandle, System::MemoryHandle] Open handle
34
+ # @return [Models::KWAJHeader] Parsed header
35
+ # @raise [Errors::ParseError] if not a valid KWAJ
36
+ def parse_handle(handle)
37
+ # Read base header (14 bytes)
38
+ base_data = @io_system.read(handle, 14)
39
+ raise ParseError, "Cannot read KWAJ header" if base_data.bytesize < 14
40
+
41
+ # Parse base header
42
+ base = Binary::KWAJStructures::BaseHeader.read(base_data)
43
+
44
+ # Verify signature
45
+ unless Binary::KWAJStructures.valid_signature?(
46
+ base.signature1, base.signature2
47
+ )
48
+ raise ParseError, "Invalid KWAJ signature"
49
+ end
50
+
51
+ # Create header model
52
+ header = Models::KWAJHeader.new
53
+ header.comp_type = base.comp_method
54
+ header.data_offset = base.data_offset
55
+ header.headers = base.flags
56
+
57
+ # Parse optional headers based on flags
58
+ parse_optional_headers(handle, header)
59
+
60
+ header
61
+ end
62
+
63
+ private
64
+
65
+ # Parse optional headers based on flag bits
66
+ #
67
+ # @param handle [System::FileHandle, System::MemoryHandle] Open handle
68
+ # @param header [Models::KWAJHeader] Header to populate
69
+ # @return [void]
70
+ # @raise [Errors::ParseError] if header parsing fails
71
+ def parse_optional_headers(handle, header)
72
+ # Optional length field (4 bytes)
73
+ if header.has_length?
74
+ data = @io_system.read(handle, 4)
75
+ raise ParseError, "Cannot read length field" if data.bytesize < 4
76
+
77
+ header.length = data.unpack1("V") # Little-endian uint32
78
+ end
79
+
80
+ # Optional unknown field 1 (2 bytes)
81
+ if header.headers.anybits?(Constants::KWAJ_HDR_HASUNKNOWN1)
82
+ data = @io_system.read(handle, 2)
83
+ raise ParseError, "Cannot read unknown1 field" if data.bytesize < 2
84
+ # We read it but don't store it
85
+ end
86
+
87
+ # Optional unknown field 2 (variable length)
88
+ if header.headers.anybits?(Constants::KWAJ_HDR_HASUNKNOWN2)
89
+ data = @io_system.read(handle, 2)
90
+ raise ParseError, "Cannot read unknown2 length" if data.bytesize < 2
91
+
92
+ length = data.unpack1("v") # Little-endian uint16
93
+
94
+ # Skip the unknown data
95
+ if length.positive?
96
+ skip_data = @io_system.read(handle, length)
97
+ if skip_data.bytesize < length
98
+ raise ParseError,
99
+ "Cannot read unknown2 data"
100
+ end
101
+ end
102
+ end
103
+
104
+ # Optional filename and extension
105
+ if header.has_filename? || header.has_file_extension?
106
+ parse_filename(handle,
107
+ header)
108
+ end
109
+
110
+ # Optional extra text (variable length)
111
+ return unless header.has_extra_text?
112
+
113
+ data = @io_system.read(handle, 2)
114
+ raise ParseError, "Cannot read extra text length" if
115
+ data.bytesize < 2
116
+
117
+ length = data.unpack1("v") # Little-endian uint16
118
+
119
+ return unless length.positive?
120
+
121
+ extra_data = @io_system.read(handle, length)
122
+ if extra_data.bytesize < length
123
+ raise ParseError,
124
+ "Cannot read extra text data"
125
+ end
126
+
127
+ header.extra = extra_data
128
+ header.extra_length = length
129
+ end
130
+
131
+ # Parse filename and extension fields
132
+ #
133
+ # @param handle [System::FileHandle, System::MemoryHandle] Open handle
134
+ # @param header [Models::KWAJHeader] Header to populate
135
+ # @return [void]
136
+ # @raise [Errors::ParseError] if filename parsing fails
137
+ def parse_filename(handle, header)
138
+ filename_parts = []
139
+
140
+ # Read filename (up to 9 bytes, null-terminated)
141
+ if header.has_filename?
142
+ name_data = @io_system.read(handle, 9)
143
+ raise ParseError, "Cannot read filename" if name_data.empty?
144
+
145
+ # Find null terminator or end of data
146
+ null_pos = name_data.index("\x00")
147
+ raise ParseError, "Filename not null-terminated" unless null_pos
148
+
149
+ filename_parts << name_data[0...null_pos]
150
+ # Seek back to position after null terminator
151
+ bytes_to_skip = null_pos + 1 - name_data.bytesize
152
+ @io_system.seek(handle, bytes_to_skip, Constants::SEEK_CUR) if
153
+ bytes_to_skip != 0
154
+
155
+ # No null terminator in 9 bytes is an error
156
+
157
+ end
158
+
159
+ # Read extension (up to 4 bytes, null-terminated)
160
+ if header.has_file_extension?
161
+ ext_data = @io_system.read(handle, 4)
162
+ raise ParseError, "Cannot read file extension" if ext_data.empty?
163
+
164
+ # Find null terminator or end of data
165
+ null_pos = ext_data.index("\x00")
166
+ raise ParseError, "File extension not null-terminated" unless null_pos
167
+
168
+ extension = ext_data[0...null_pos]
169
+ filename_parts << ".#{extension}" unless extension.empty?
170
+ # Seek back to position after null terminator
171
+ bytes_to_skip = null_pos + 1 - ext_data.bytesize
172
+ @io_system.seek(handle, bytes_to_skip, Constants::SEEK_CUR) if
173
+ bytes_to_skip != 0
174
+
175
+ # No null terminator in 4 bytes is an error
176
+
177
+ end
178
+
179
+ header.filename = filename_parts.join unless filename_parts.empty?
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ module LIT
5
+ # Compressor creates LIT eBook files
6
+ #
7
+ # LIT files are Microsoft Reader eBook files that use LZX compression.
8
+ # The compressor allows adding multiple files to create a LIT archive.
9
+ #
10
+ # NOTE: This implementation creates non-encrypted LIT files only.
11
+ # DES encryption (DRM protection) is not implemented.
12
+ class Compressor
13
+ attr_reader :io_system
14
+ attr_accessor :files
15
+
16
+ # Initialize a new LIT compressor
17
+ #
18
+ # @param io_system [System::IOSystem, nil] Custom I/O system or nil for
19
+ # default
20
+ def initialize(io_system = nil)
21
+ @io_system = io_system || System::IOSystem.new
22
+ @files = []
23
+ end
24
+
25
+ # Add a file to the LIT archive
26
+ #
27
+ # @param source_path [String] Path to the source file
28
+ # @param lit_path [String] Path within the LIT archive
29
+ # @param options [Hash] Options for the file
30
+ # @option options [Boolean] :compress Whether to compress the file
31
+ # (default: true)
32
+ # @return [void]
33
+ def add_file(source_path, lit_path, **options)
34
+ compress = options.fetch(:compress, true)
35
+
36
+ @files << {
37
+ source: source_path,
38
+ lit_path: lit_path,
39
+ compress: compress,
40
+ }
41
+ end
42
+
43
+ # Generate the LIT archive
44
+ #
45
+ # @param output_file [String] Path to output LIT file
46
+ # @param options [Hash] Generation options
47
+ # @option options [Integer] :version LIT format version (default: 1)
48
+ # @option options [Boolean] :encrypt Whether to encrypt (not supported,
49
+ # raises error)
50
+ # @return [Integer] Bytes written to output file
51
+ # @raise [Errors::CompressionError] if generation fails
52
+ # @raise [NotImplementedError] if encryption is requested
53
+ def generate(output_file, **options)
54
+ version = options.fetch(:version, 1)
55
+ encrypt = options.fetch(:encrypt, false)
56
+
57
+ if encrypt
58
+ raise NotImplementedError,
59
+ "DES encryption is not implemented. " \
60
+ "LIT files will be created without encryption."
61
+ end
62
+
63
+ raise ArgumentError, "No files added to archive" if @files.empty?
64
+
65
+ output_handle = @io_system.open(output_file, Constants::MODE_WRITE)
66
+
67
+ begin
68
+ # Prepare file data
69
+ file_data = prepare_files
70
+
71
+ # Write header
72
+ header_bytes = write_header(
73
+ output_handle,
74
+ version,
75
+ file_data.size,
76
+ )
77
+
78
+ # Write file entries
79
+ entries_bytes = write_file_entries(output_handle, file_data)
80
+
81
+ # Write file contents
82
+ content_bytes = write_file_contents(
83
+ output_handle,
84
+ file_data,
85
+ )
86
+
87
+ header_bytes + entries_bytes + content_bytes
88
+ ensure
89
+ @io_system.close(output_handle) if output_handle
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ # Prepare file data for archiving
96
+ #
97
+ # @return [Array<Hash>] Array of file information hashes
98
+ def prepare_files
99
+ @files.map do |file_info|
100
+ source = file_info[:source]
101
+ lit_path = file_info[:lit_path]
102
+ compress = file_info[:compress]
103
+
104
+ # Read source file
105
+ handle = @io_system.open(source, Constants::MODE_READ)
106
+ begin
107
+ size = @io_system.seek(handle, 0, Constants::SEEK_END)
108
+ @io_system.seek(handle, 0, Constants::SEEK_START)
109
+ data = @io_system.read(handle, size)
110
+ ensure
111
+ @io_system.close(handle)
112
+ end
113
+
114
+ {
115
+ lit_path: lit_path,
116
+ data: data,
117
+ uncompressed_size: data.bytesize,
118
+ compress: compress,
119
+ }
120
+ end
121
+ end
122
+
123
+ # Write LIT header
124
+ #
125
+ # @param output_handle [System::FileHandle] Output file handle
126
+ # @param version [Integer] LIT format version
127
+ # @param file_count [Integer] Number of files
128
+ # @return [Integer] Number of bytes written
129
+ def write_header(output_handle, version, file_count)
130
+ header = Binary::LITStructures::LITHeader.new
131
+ header.signature = Binary::LITStructures::SIGNATURE
132
+ header.version = version
133
+ header.flags = 0 # Not encrypted
134
+ header.file_count = file_count
135
+ header.header_size = 24 # Size of the header structure
136
+
137
+ header_data = header.to_binary_s
138
+ written = @io_system.write(output_handle, header_data)
139
+
140
+ unless written == header_data.bytesize
141
+ raise Errors::CompressionError,
142
+ "Failed to write LIT header"
143
+ end
144
+
145
+ written
146
+ end
147
+
148
+ # Write file entries directory
149
+ #
150
+ # @param output_handle [System::FileHandle] Output file handle
151
+ # @param file_data [Array<Hash>] Array of file information
152
+ # @return [Integer] Number of bytes written
153
+ def write_file_entries(output_handle, file_data)
154
+ total_bytes = 0
155
+ current_offset = calculate_header_size(file_data)
156
+
157
+ file_data.each do |file_info|
158
+ # Compress or store data
159
+ if file_info[:compress]
160
+ compressed = compress_data(file_info[:data])
161
+ compressed_size = compressed.bytesize
162
+ flags = Binary::LITStructures::FileFlags::COMPRESSED
163
+ else
164
+ compressed = file_info[:data]
165
+ compressed_size = compressed.bytesize
166
+ flags = 0
167
+ end
168
+
169
+ # Store compressed data for later writing
170
+ file_info[:compressed_data] = compressed
171
+ file_info[:compressed_size] = compressed_size
172
+ file_info[:offset] = current_offset
173
+
174
+ # Write file entry
175
+ entry = Binary::LITStructures::LITFileEntry.new
176
+ entry.filename_length = file_info[:lit_path].bytesize
177
+ entry.filename = file_info[:lit_path]
178
+ entry.offset = current_offset
179
+ entry.compressed_size = compressed_size
180
+ entry.uncompressed_size = file_info[:uncompressed_size]
181
+ entry.flags = flags
182
+
183
+ entry_data = entry.to_binary_s
184
+ written = @io_system.write(output_handle, entry_data)
185
+ total_bytes += written
186
+
187
+ current_offset += compressed_size
188
+ end
189
+
190
+ total_bytes
191
+ end
192
+
193
+ # Write file contents
194
+ #
195
+ # @param output_handle [System::FileHandle] Output file handle
196
+ # @param file_data [Array<Hash>] Array of file information with
197
+ # compressed data
198
+ # @return [Integer] Number of bytes written
199
+ def write_file_contents(output_handle, file_data)
200
+ total_bytes = 0
201
+
202
+ file_data.each do |file_info|
203
+ written = @io_system.write(
204
+ output_handle,
205
+ file_info[:compressed_data],
206
+ )
207
+ total_bytes += written
208
+ end
209
+
210
+ total_bytes
211
+ end
212
+
213
+ # Calculate total header size (header + all file entries)
214
+ #
215
+ # @param file_data [Array<Hash>] Array of file information
216
+ # @return [Integer] Total header size in bytes
217
+ def calculate_header_size(file_data)
218
+ # Header: 24 bytes
219
+ header_size = 24
220
+
221
+ # File entries: variable size
222
+ file_data.each do |file_info|
223
+ # 4 bytes filename length + filename + 28 bytes metadata
224
+ header_size += 4 + file_info[:lit_path].bytesize + 28
225
+ end
226
+
227
+ header_size
228
+ end
229
+
230
+ # Compress data using LZX
231
+ #
232
+ # @param data [String] Data to compress
233
+ # @return [String] Compressed data
234
+ def compress_data(data)
235
+ input_handle = System::MemoryHandle.new(data)
236
+ output_handle = System::MemoryHandle.new("", Constants::MODE_WRITE)
237
+
238
+ begin
239
+ compressor = Compressors::LZX.new(
240
+ @io_system,
241
+ input_handle,
242
+ output_handle,
243
+ 32_768,
244
+ )
245
+
246
+ compressor.compress
247
+
248
+ output_handle.data
249
+
250
+ # Memory handles don't need closing but maintain consistency
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ module LIT
5
+ # Decompressor is the main interface for LIT file operations
6
+ #
7
+ # LIT files are Microsoft Reader eBook files that use LZX compression.
8
+ #
9
+ # NOTE: This implementation handles non-encrypted LIT files only.
10
+ # DES-encrypted (DRM-protected) LIT files are not supported.
11
+ # For encrypted files, use Microsoft Reader or convert to another format
12
+ # first.
13
+ class Decompressor
14
+ attr_reader :io_system
15
+ attr_accessor :buffer_size
16
+
17
+ # Input buffer size for decompression
18
+ DEFAULT_BUFFER_SIZE = 32_768
19
+
20
+ # Initialize a new LIT decompressor
21
+ #
22
+ # @param io_system [System::IOSystem, nil] Custom I/O system or nil for
23
+ # default
24
+ def initialize(io_system = nil)
25
+ @io_system = io_system || System::IOSystem.new
26
+ @buffer_size = DEFAULT_BUFFER_SIZE
27
+ end
28
+
29
+ # Open and parse a LIT file
30
+ #
31
+ # @param filename [String] Path to the LIT file
32
+ # @return [Models::LITHeader] Parsed header with file list
33
+ # @raise [Errors::ParseError] if the file is not a valid LIT
34
+ # @raise [NotImplementedError] if the file is DES-encrypted
35
+ def open(filename)
36
+ header = parse_header(filename)
37
+ header.filename = filename
38
+
39
+ # Check for encryption
40
+ if header.encrypted?
41
+ raise NotImplementedError,
42
+ "DES-encrypted LIT files not yet supported. " \
43
+ "Use Microsoft Reader or another tool to decrypt first."
44
+ end
45
+
46
+ header
47
+ end
48
+
49
+ # Close a LIT file (no-op for compatibility)
50
+ #
51
+ # @param _header [Models::LITHeader] Header to close
52
+ # @return [void]
53
+ def close(_header)
54
+ # No resources to free in the header itself
55
+ # File handles are managed separately during extraction
56
+ nil
57
+ end
58
+
59
+ # Extract a file from LIT archive
60
+ #
61
+ # @param header [Models::LITHeader] LIT header from open()
62
+ # @param file [Models::LITFile] File entry to extract
63
+ # @param output_path [String] Where to write the decompressed file
64
+ # @return [Integer] Number of bytes written
65
+ # @raise [Errors::DecompressionError] if decompression fails
66
+ # @raise [NotImplementedError] if the file is encrypted
67
+ def extract(header, file, output_path)
68
+ raise ArgumentError, "Header must not be nil" unless header
69
+ raise ArgumentError, "File must not be nil" unless file
70
+ raise ArgumentError, "Output path must not be nil" unless output_path
71
+
72
+ if file.encrypted?
73
+ raise NotImplementedError,
74
+ "DES-encrypted files not yet supported. " \
75
+ "Use Microsoft Reader or another tool to decrypt first."
76
+ end
77
+
78
+ input_handle = @io_system.open(header.filename, Constants::MODE_READ)
79
+ output_handle = @io_system.open(output_path, Constants::MODE_WRITE)
80
+
81
+ begin
82
+ # Seek to file data
83
+ @io_system.seek(input_handle, file.offset, Constants::SEEK_START)
84
+
85
+ bytes_written = if file.compressed?
86
+ # Decompress using LZX
87
+ decompress_lzx(
88
+ input_handle, output_handle, file.length
89
+ )
90
+ else
91
+ # Direct copy
92
+ copy_data(
93
+ input_handle, output_handle, file.length
94
+ )
95
+ end
96
+
97
+ bytes_written
98
+ ensure
99
+ @io_system.close(input_handle) if input_handle
100
+ @io_system.close(output_handle) if output_handle
101
+ end
102
+ end
103
+
104
+ # Extract all files from LIT archive
105
+ #
106
+ # @param header [Models::LITHeader] LIT header from open()
107
+ # @param output_dir [String] Directory to extract files to
108
+ # @return [Integer] Number of files extracted
109
+ # @raise [Errors::DecompressionError] if extraction fails
110
+ def extract_all(header, output_dir)
111
+ raise ArgumentError, "Header must not be nil" unless header
112
+ raise ArgumentError, "Output dir must not be nil" unless output_dir
113
+
114
+ # Create output directory if it doesn't exist
115
+ ::FileUtils.mkdir_p(output_dir)
116
+
117
+ extracted = 0
118
+ header.files.each do |file|
119
+ output_path = ::File.join(output_dir, file.filename)
120
+
121
+ # Create subdirectories if needed
122
+ file_dir = ::File.dirname(output_path)
123
+ ::FileUtils.mkdir_p(file_dir) unless ::File.directory?(file_dir)
124
+
125
+ extract(header, file, output_path)
126
+ extracted += 1
127
+ end
128
+
129
+ extracted
130
+ end
131
+
132
+ private
133
+
134
+ # Parse LIT file header
135
+ #
136
+ # @param filename [String] Path to LIT file
137
+ # @return [Models::LITHeader] Parsed header
138
+ # @raise [Errors::ParseError] if file is not valid LIT
139
+ def parse_header(filename)
140
+ handle = @io_system.open(filename, Constants::MODE_READ)
141
+
142
+ begin
143
+ # Read and verify signature
144
+ signature = @io_system.read(handle, 8)
145
+ unless signature.start_with?(Binary::LITStructures::SIGNATURE[0..3])
146
+ raise Errors::ParseError,
147
+ "Not a valid LIT file: invalid signature"
148
+ end
149
+
150
+ # Seek back to start
151
+ @io_system.seek(handle, 0, Constants::SEEK_START)
152
+
153
+ # Read header structure
154
+ header_data = @io_system.read(handle, 24)
155
+ lit_header = Binary::LITStructures::LITHeader.read(header_data)
156
+
157
+ # Create header model
158
+ header = Models::LITHeader.new
159
+ header.version = lit_header.version
160
+ header.encrypted = lit_header.flags.anybits?(0x01)
161
+
162
+ # Parse file entries
163
+ header.files = parse_file_entries(
164
+ handle, lit_header.file_count
165
+ )
166
+
167
+ header
168
+ ensure
169
+ @io_system.close(handle) if handle
170
+ end
171
+ end
172
+
173
+ # Parse file entries from LIT archive
174
+ #
175
+ # @param handle [System::FileHandle] File handle positioned at file
176
+ # entries
177
+ # @param file_count [Integer] Number of files to parse
178
+ # @return [Array<Models::LITFile>] List of file entries
179
+ def parse_file_entries(handle, file_count)
180
+ files = []
181
+
182
+ file_count.times do
183
+ # Read filename length
184
+ len_data = @io_system.read(handle, 4)
185
+ filename_length = len_data.unpack1("V")
186
+
187
+ # Read filename
188
+ filename = @io_system.read(handle, filename_length)
189
+
190
+ # Read file metadata
191
+ metadata = @io_system.read(handle, 28)
192
+ offset, _, uncompressed_size, flags =
193
+ metadata.unpack("QQQV")
194
+
195
+ # Create file entry
196
+ file = Models::LITFile.new
197
+ file.filename = filename
198
+ file.offset = offset
199
+ file.length = uncompressed_size
200
+ file.compressed = flags.anybits?(Binary::LITStructures::FileFlags::COMPRESSED)
201
+ file.encrypted = flags.anybits?(Binary::LITStructures::FileFlags::ENCRYPTED)
202
+
203
+ files << file
204
+ end
205
+
206
+ files
207
+ end
208
+
209
+ # Decompress data using LZX
210
+ #
211
+ # @param input_handle [System::FileHandle] Input handle
212
+ # @param output_handle [System::FileHandle] Output handle
213
+ # @param expected_size [Integer] Expected output size
214
+ # @return [Integer] Number of bytes written
215
+ def decompress_lzx(input_handle, output_handle, expected_size)
216
+ decompressor = Decompressors::LZX.new(
217
+ @io_system,
218
+ input_handle,
219
+ output_handle,
220
+ @buffer_size,
221
+ )
222
+
223
+ decompressor.decompress(expected_size)
224
+ end
225
+
226
+ # Copy data directly without decompression
227
+ #
228
+ # @param input_handle [System::FileHandle] Input handle
229
+ # @param output_handle [System::FileHandle] Output handle
230
+ # @param size [Integer] Number of bytes to copy
231
+ # @return [Integer] Number of bytes written
232
+ def copy_data(input_handle, output_handle, size)
233
+ bytes_written = 0
234
+ remaining = size
235
+
236
+ while remaining.positive?
237
+ chunk_size = [remaining, @buffer_size].min
238
+ data = @io_system.read(input_handle, chunk_size)
239
+ break if data.empty?
240
+
241
+ written = @io_system.write(output_handle, data)
242
+ bytes_written += written
243
+ remaining -= written
244
+ end
245
+
246
+ bytes_written
247
+ end
248
+ end
249
+ end
250
+ end