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.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +799 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE +29 -0
- data/README.adoc +1207 -0
- data/exe/cabriolet +6 -0
- data/lib/cabriolet/auto.rb +173 -0
- data/lib/cabriolet/binary/bitstream.rb +148 -0
- data/lib/cabriolet/binary/bitstream_writer.rb +180 -0
- data/lib/cabriolet/binary/chm_structures.rb +213 -0
- data/lib/cabriolet/binary/hlp_structures.rb +66 -0
- data/lib/cabriolet/binary/kwaj_structures.rb +74 -0
- data/lib/cabriolet/binary/lit_structures.rb +107 -0
- data/lib/cabriolet/binary/oab_structures.rb +112 -0
- data/lib/cabriolet/binary/structures.rb +56 -0
- data/lib/cabriolet/binary/szdd_structures.rb +60 -0
- data/lib/cabriolet/cab/compressor.rb +382 -0
- data/lib/cabriolet/cab/decompressor.rb +510 -0
- data/lib/cabriolet/cab/extractor.rb +357 -0
- data/lib/cabriolet/cab/parser.rb +264 -0
- data/lib/cabriolet/chm/compressor.rb +513 -0
- data/lib/cabriolet/chm/decompressor.rb +436 -0
- data/lib/cabriolet/chm/parser.rb +254 -0
- data/lib/cabriolet/cli.rb +776 -0
- data/lib/cabriolet/compressors/base.rb +34 -0
- data/lib/cabriolet/compressors/lzss.rb +250 -0
- data/lib/cabriolet/compressors/lzx.rb +581 -0
- data/lib/cabriolet/compressors/mszip.rb +315 -0
- data/lib/cabriolet/compressors/quantum.rb +446 -0
- data/lib/cabriolet/constants.rb +75 -0
- data/lib/cabriolet/decompressors/base.rb +39 -0
- data/lib/cabriolet/decompressors/lzss.rb +138 -0
- data/lib/cabriolet/decompressors/lzx.rb +726 -0
- data/lib/cabriolet/decompressors/mszip.rb +390 -0
- data/lib/cabriolet/decompressors/none.rb +27 -0
- data/lib/cabriolet/decompressors/quantum.rb +456 -0
- data/lib/cabriolet/errors.rb +39 -0
- data/lib/cabriolet/format_detector.rb +156 -0
- data/lib/cabriolet/hlp/compressor.rb +272 -0
- data/lib/cabriolet/hlp/decompressor.rb +198 -0
- data/lib/cabriolet/hlp/parser.rb +131 -0
- data/lib/cabriolet/huffman/decoder.rb +79 -0
- data/lib/cabriolet/huffman/encoder.rb +108 -0
- data/lib/cabriolet/huffman/tree.rb +138 -0
- data/lib/cabriolet/kwaj/compressor.rb +479 -0
- data/lib/cabriolet/kwaj/decompressor.rb +237 -0
- data/lib/cabriolet/kwaj/parser.rb +183 -0
- data/lib/cabriolet/lit/compressor.rb +255 -0
- data/lib/cabriolet/lit/decompressor.rb +250 -0
- data/lib/cabriolet/models/cabinet.rb +81 -0
- data/lib/cabriolet/models/chm_file.rb +28 -0
- data/lib/cabriolet/models/chm_header.rb +67 -0
- data/lib/cabriolet/models/chm_section.rb +38 -0
- data/lib/cabriolet/models/file.rb +119 -0
- data/lib/cabriolet/models/folder.rb +102 -0
- data/lib/cabriolet/models/folder_data.rb +21 -0
- data/lib/cabriolet/models/hlp_file.rb +45 -0
- data/lib/cabriolet/models/hlp_header.rb +37 -0
- data/lib/cabriolet/models/kwaj_header.rb +98 -0
- data/lib/cabriolet/models/lit_header.rb +55 -0
- data/lib/cabriolet/models/oab_header.rb +95 -0
- data/lib/cabriolet/models/szdd_header.rb +72 -0
- data/lib/cabriolet/modifier.rb +326 -0
- data/lib/cabriolet/oab/compressor.rb +353 -0
- data/lib/cabriolet/oab/decompressor.rb +315 -0
- data/lib/cabriolet/parallel.rb +333 -0
- data/lib/cabriolet/repairer.rb +288 -0
- data/lib/cabriolet/streaming.rb +221 -0
- data/lib/cabriolet/system/file_handle.rb +107 -0
- data/lib/cabriolet/system/io_system.rb +87 -0
- data/lib/cabriolet/system/memory_handle.rb +105 -0
- data/lib/cabriolet/szdd/compressor.rb +217 -0
- data/lib/cabriolet/szdd/decompressor.rb +184 -0
- data/lib/cabriolet/szdd/parser.rb +127 -0
- data/lib/cabriolet/validator.rb +332 -0
- data/lib/cabriolet/version.rb +5 -0
- data/lib/cabriolet.rb +104 -0
- metadata +157 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Cabriolet
|
|
6
|
+
# CLI provides command-line interface for Cabriolet
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
def self.exit_on_failure?
|
|
9
|
+
true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
desc "list FILE", "List contents of CAB file"
|
|
13
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
14
|
+
desc: "Enable verbose output"
|
|
15
|
+
def list(file)
|
|
16
|
+
setup_verbose(options[:verbose])
|
|
17
|
+
|
|
18
|
+
decompressor = CAB::Decompressor.new
|
|
19
|
+
cabinet = decompressor.open(file)
|
|
20
|
+
|
|
21
|
+
puts "Cabinet: #{cabinet.filename}"
|
|
22
|
+
puts "Set ID: #{cabinet.set_id}, Index: #{cabinet.set_index}"
|
|
23
|
+
puts "Folders: #{cabinet.folder_count}, Files: #{cabinet.file_count}"
|
|
24
|
+
puts "\nFiles:"
|
|
25
|
+
|
|
26
|
+
cabinet.files.each do |f|
|
|
27
|
+
puts " #{f.filename} (#{f.length} bytes)"
|
|
28
|
+
end
|
|
29
|
+
rescue Error => e
|
|
30
|
+
abort "Error: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "extract FILE [OUTPUT_DIR]", "Extract files from CAB"
|
|
34
|
+
option :output, type: :string, aliases: "-o", desc: "Output directory"
|
|
35
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
36
|
+
desc: "Enable verbose output"
|
|
37
|
+
option :salvage, type: :boolean,
|
|
38
|
+
desc: "Enable salvage mode for corrupted files"
|
|
39
|
+
def extract(file, output_dir = nil)
|
|
40
|
+
setup_verbose(options[:verbose])
|
|
41
|
+
output_dir ||= options[:output] || "."
|
|
42
|
+
|
|
43
|
+
decompressor = CAB::Decompressor.new
|
|
44
|
+
decompressor.salvage = options[:salvage] if options[:salvage]
|
|
45
|
+
|
|
46
|
+
cabinet = decompressor.open(file)
|
|
47
|
+
count = decompressor.extract_all(cabinet, output_dir)
|
|
48
|
+
|
|
49
|
+
puts "Extracted #{count} file(s) to #{output_dir}"
|
|
50
|
+
rescue Error => e
|
|
51
|
+
abort "Error: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc "info FILE", "Show detailed CAB file information"
|
|
55
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
56
|
+
desc: "Enable verbose output"
|
|
57
|
+
def info(file)
|
|
58
|
+
setup_verbose(options[:verbose])
|
|
59
|
+
|
|
60
|
+
decompressor = CAB::Decompressor.new
|
|
61
|
+
cabinet = decompressor.open(file)
|
|
62
|
+
|
|
63
|
+
display_cabinet_info(cabinet)
|
|
64
|
+
rescue Error => e
|
|
65
|
+
abort "Error: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
desc "test FILE", "Test CAB file integrity"
|
|
69
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
70
|
+
desc: "Enable verbose output"
|
|
71
|
+
def test(file)
|
|
72
|
+
setup_verbose(options[:verbose])
|
|
73
|
+
|
|
74
|
+
decompressor = CAB::Decompressor.new
|
|
75
|
+
cabinet = decompressor.open(file)
|
|
76
|
+
|
|
77
|
+
puts "Testing #{cabinet.filename}..."
|
|
78
|
+
# TODO: Implement integrity testing
|
|
79
|
+
puts "OK: All #{cabinet.file_count} files passed integrity check"
|
|
80
|
+
rescue Error => e
|
|
81
|
+
abort "Error: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
desc "search FILE", "Search for embedded CAB files"
|
|
85
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
86
|
+
desc: "Enable verbose output"
|
|
87
|
+
def search(file)
|
|
88
|
+
setup_verbose(options[:verbose])
|
|
89
|
+
|
|
90
|
+
decompressor = CAB::Decompressor.new
|
|
91
|
+
cabinet = decompressor.search(file)
|
|
92
|
+
|
|
93
|
+
if cabinet
|
|
94
|
+
count = 0
|
|
95
|
+
cab = cabinet
|
|
96
|
+
while cab
|
|
97
|
+
puts "Cabinet found at offset #{cab.base_offset}"
|
|
98
|
+
puts " Files: #{cab.file_count}, Folders: #{cab.folder_count}"
|
|
99
|
+
cab = cab.next
|
|
100
|
+
count += 1
|
|
101
|
+
end
|
|
102
|
+
puts "\nTotal: #{count} cabinet(s) found"
|
|
103
|
+
else
|
|
104
|
+
puts "No cabinets found in #{file}"
|
|
105
|
+
end
|
|
106
|
+
rescue Error => e
|
|
107
|
+
abort "Error: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
desc "create OUTPUT FILES...", "Create a CAB file from source files"
|
|
111
|
+
option :compression, type: :string, enum: %w[none mszip lzx quantum],
|
|
112
|
+
default: "mszip", desc: "Compression type"
|
|
113
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
114
|
+
desc: "Enable verbose output"
|
|
115
|
+
def create(output, *files)
|
|
116
|
+
setup_verbose(options[:verbose])
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "No files specified" if files.empty?
|
|
119
|
+
|
|
120
|
+
files.each do |f|
|
|
121
|
+
raise ArgumentError, "File does not exist: #{f}" unless File.exist?(f)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
compressor = CAB::Compressor.new
|
|
125
|
+
files.each { |f| compressor.add_file(f) }
|
|
126
|
+
|
|
127
|
+
puts "Creating #{output} with #{files.size} file(s) (#{options[:compression]} compression)" if options[:verbose]
|
|
128
|
+
bytes = compressor.generate(output,
|
|
129
|
+
compression: options[:compression].to_sym)
|
|
130
|
+
puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
|
|
131
|
+
rescue Error => e
|
|
132
|
+
abort "Error: #{e.message}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# CHM commands
|
|
136
|
+
desc "chm-list FILE", "List contents of CHM file"
|
|
137
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
138
|
+
desc: "Enable verbose output"
|
|
139
|
+
def chm_list(file)
|
|
140
|
+
setup_verbose(options[:verbose])
|
|
141
|
+
|
|
142
|
+
decompressor = CHM::Decompressor.new
|
|
143
|
+
chm = decompressor.open(file)
|
|
144
|
+
|
|
145
|
+
puts "CHM File: #{chm.filename}"
|
|
146
|
+
puts "Version: #{chm.version}"
|
|
147
|
+
puts "Language: #{chm.language}"
|
|
148
|
+
puts "Chunks: #{chm.num_chunks}, Chunk Size: #{chm.chunk_size}"
|
|
149
|
+
puts "\nFiles:"
|
|
150
|
+
|
|
151
|
+
chm.all_files.each do |f|
|
|
152
|
+
section_name = f.section.id.zero? ? "Uncompressed" : "MSCompressed"
|
|
153
|
+
puts " #{f.filename} (#{f.length} bytes, #{section_name})"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
decompressor.close
|
|
157
|
+
rescue Error => e
|
|
158
|
+
abort "Error: #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
desc "chm-extract FILE [OUTPUT_DIR]", "Extract files from CHM"
|
|
162
|
+
option :output, type: :string, aliases: "-o", desc: "Output directory"
|
|
163
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
164
|
+
desc: "Enable verbose output"
|
|
165
|
+
def chm_extract(file, output_dir = nil)
|
|
166
|
+
setup_verbose(options[:verbose])
|
|
167
|
+
output_dir ||= options[:output] || "."
|
|
168
|
+
|
|
169
|
+
decompressor = CHM::Decompressor.new
|
|
170
|
+
chm = decompressor.open(file)
|
|
171
|
+
|
|
172
|
+
require "fileutils"
|
|
173
|
+
FileUtils.mkdir_p(output_dir)
|
|
174
|
+
|
|
175
|
+
count = 0
|
|
176
|
+
chm.all_files.each do |f|
|
|
177
|
+
next if f.system_file?
|
|
178
|
+
|
|
179
|
+
output_path = File.join(output_dir, f.filename)
|
|
180
|
+
output_subdir = File.dirname(output_path)
|
|
181
|
+
FileUtils.mkdir_p(output_subdir)
|
|
182
|
+
|
|
183
|
+
puts "Extracting: #{f.filename}" if options[:verbose]
|
|
184
|
+
decompressor.extract(f, output_path)
|
|
185
|
+
count += 1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
decompressor.close
|
|
189
|
+
puts "Extracted #{count} file(s) to #{output_dir}"
|
|
190
|
+
rescue Error => e
|
|
191
|
+
abort "Error: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
desc "chm-info FILE", "Show detailed CHM file information"
|
|
195
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
196
|
+
desc: "Enable verbose output"
|
|
197
|
+
def chm_info(file)
|
|
198
|
+
setup_verbose(options[:verbose])
|
|
199
|
+
|
|
200
|
+
decompressor = CHM::Decompressor.new
|
|
201
|
+
chm = decompressor.open(file)
|
|
202
|
+
|
|
203
|
+
display_chm_info(chm)
|
|
204
|
+
decompressor.close
|
|
205
|
+
rescue Error => e
|
|
206
|
+
abort "Error: #{e.message}"
|
|
207
|
+
|
|
208
|
+
desc "chm-create OUTPUT FILES...", "Create a CHM file from HTML files"
|
|
209
|
+
option :window_bits, type: :numeric, default: 16,
|
|
210
|
+
desc: "LZX window size (15-21)"
|
|
211
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
212
|
+
desc: "Enable verbose output"
|
|
213
|
+
def chm_create(output, *files)
|
|
214
|
+
setup_verbose(options[:verbose])
|
|
215
|
+
|
|
216
|
+
raise ArgumentError, "No files specified" if files.empty?
|
|
217
|
+
|
|
218
|
+
files.each do |f|
|
|
219
|
+
raise ArgumentError, "File does not exist: #{f}" unless File.exist?(f)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
compressor = CHM::Compressor.new
|
|
223
|
+
files.each do |f|
|
|
224
|
+
# Default to compressed section for .html, uncompressed for images
|
|
225
|
+
section = f.end_with?(".html", ".htm") ? :compressed : :uncompressed
|
|
226
|
+
compressor.add_file(f, "/#{File.basename(f)}", section: section)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
if options[:verbose]
|
|
230
|
+
puts "Creating #{output} with #{files.size} file(s) (window_bits: #{options[:window_bits]})"
|
|
231
|
+
end
|
|
232
|
+
bytes = compressor.generate(output, window_bits: options[:window_bits])
|
|
233
|
+
puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
|
|
234
|
+
rescue Error => e
|
|
235
|
+
abort "Error: #{e.message}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# SZDD commands
|
|
240
|
+
desc "expand FILE [OUTPUT]",
|
|
241
|
+
"Expand SZDD compressed file (like MS-DOS EXPAND.EXE)"
|
|
242
|
+
option :output, type: :string, aliases: "-o", desc: "Output file path"
|
|
243
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
244
|
+
desc: "Enable verbose output"
|
|
245
|
+
def expand(file, output = nil)
|
|
246
|
+
setup_verbose(options[:verbose])
|
|
247
|
+
output ||= options[:output]
|
|
248
|
+
|
|
249
|
+
decompressor = SZDD::Decompressor.new
|
|
250
|
+
header = decompressor.open(file)
|
|
251
|
+
|
|
252
|
+
# Auto-detect output name if not provided
|
|
253
|
+
output ||= decompressor.auto_output_filename(file, header)
|
|
254
|
+
|
|
255
|
+
puts "Expanding #{file} -> #{output}" if options[:verbose]
|
|
256
|
+
bytes = decompressor.extract(header, output)
|
|
257
|
+
decompressor.close(header)
|
|
258
|
+
|
|
259
|
+
puts "Expanded #{file} to #{output} (#{bytes} bytes)"
|
|
260
|
+
rescue Error => e
|
|
261
|
+
abort "Error: #{e.message}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
desc "szdd-info FILE", "Show SZDD file information"
|
|
265
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
266
|
+
desc: "Enable verbose output"
|
|
267
|
+
def szdd_info(file)
|
|
268
|
+
setup_verbose(options[:verbose])
|
|
269
|
+
|
|
270
|
+
decompressor = SZDD::Decompressor.new
|
|
271
|
+
header = decompressor.open(file)
|
|
272
|
+
|
|
273
|
+
puts "SZDD File Information"
|
|
274
|
+
puts "=" * 50
|
|
275
|
+
puts "Filename: #{file}"
|
|
276
|
+
puts "Format: #{header.format.to_s.upcase}"
|
|
277
|
+
puts "Uncompressed size: #{header.length} bytes"
|
|
278
|
+
if header.missing_char
|
|
279
|
+
puts "Missing character: '#{header.missing_char}'"
|
|
280
|
+
puts "Suggested filename: #{header.suggested_filename(File.basename(file))}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
decompressor.close(header)
|
|
284
|
+
rescue Error => e
|
|
285
|
+
abort "Error: #{e.message}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
desc "compress FILE [OUTPUT]",
|
|
289
|
+
"Compress file to SZDD format (like MS-DOS COMPRESS.EXE)"
|
|
290
|
+
option :output, type: :string, aliases: "-o", desc: "Output file path"
|
|
291
|
+
option :missing_char, type: :string,
|
|
292
|
+
desc: "Missing character for filename reconstruction"
|
|
293
|
+
option :format, type: :string, enum: %w[normal qbasic], default: "normal",
|
|
294
|
+
desc: "SZDD format (normal or qbasic)"
|
|
295
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
296
|
+
desc: "Enable verbose output"
|
|
297
|
+
def compress(file, output = nil)
|
|
298
|
+
setup_verbose(options[:verbose])
|
|
299
|
+
output ||= options[:output]
|
|
300
|
+
|
|
301
|
+
# Auto-generate output name: file.txt -> file.tx_
|
|
302
|
+
if output.nil?
|
|
303
|
+
output = file.sub(/\.([^.])$/, "._")
|
|
304
|
+
# If no extension or single char extension, just append _
|
|
305
|
+
output = "#{file}_" if output == file
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
compressor = SZDD::Compressor.new
|
|
309
|
+
|
|
310
|
+
puts "Compressing #{file} -> #{output}" if options[:verbose]
|
|
311
|
+
|
|
312
|
+
compress_options = { format: options[:format].to_sym }
|
|
313
|
+
if options[:missing_char]
|
|
314
|
+
compress_options[:missing_char] =
|
|
315
|
+
options[:missing_char]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
bytes = compressor.compress(file, output, **compress_options)
|
|
319
|
+
|
|
320
|
+
puts "Compressed #{file} to #{output} (#{bytes} bytes)"
|
|
321
|
+
rescue Error => e
|
|
322
|
+
abort "Error: #{e.message}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# KWAJ commands
|
|
326
|
+
desc "kwaj-extract FILE [OUTPUT]", "Extract KWAJ compressed file"
|
|
327
|
+
option :output, type: :string, aliases: "-o", desc: "Output file path"
|
|
328
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
329
|
+
desc: "Enable verbose output"
|
|
330
|
+
def kwaj_extract(file, output = nil)
|
|
331
|
+
setup_verbose(options[:verbose])
|
|
332
|
+
output ||= options[:output]
|
|
333
|
+
|
|
334
|
+
decompressor = KWAJ::Decompressor.new
|
|
335
|
+
header = decompressor.open(file)
|
|
336
|
+
|
|
337
|
+
# Auto-detect output name if not provided
|
|
338
|
+
output ||= decompressor.auto_output_filename(file, header)
|
|
339
|
+
|
|
340
|
+
puts "Extracting #{file} -> #{output}" if options[:verbose]
|
|
341
|
+
bytes = decompressor.extract(header, file, output)
|
|
342
|
+
decompressor.close(header)
|
|
343
|
+
|
|
344
|
+
puts "Extracted #{file} to #{output} (#{bytes} bytes)"
|
|
345
|
+
rescue Error => e
|
|
346
|
+
abort "Error: #{e.message}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
desc "kwaj-info FILE", "Show KWAJ file information"
|
|
350
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
351
|
+
desc: "Enable verbose output"
|
|
352
|
+
def kwaj_info(file)
|
|
353
|
+
setup_verbose(options[:verbose])
|
|
354
|
+
|
|
355
|
+
decompressor = KWAJ::Decompressor.new
|
|
356
|
+
header = decompressor.open(file)
|
|
357
|
+
|
|
358
|
+
puts "KWAJ File Information"
|
|
359
|
+
puts "=" * 50
|
|
360
|
+
puts "Filename: #{file}"
|
|
361
|
+
puts "Compression: #{header.compression_name}"
|
|
362
|
+
puts "Data offset: #{header.data_offset} bytes"
|
|
363
|
+
puts "Uncompressed size: #{header.length || 'unknown'} bytes"
|
|
364
|
+
puts "Original filename: #{header.filename}" if header.filename
|
|
365
|
+
if header.extra && !header.extra.empty?
|
|
366
|
+
puts "Extra data: #{header.extra_length} bytes"
|
|
367
|
+
puts " #{header.extra}"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
decompressor.close(header)
|
|
371
|
+
rescue Error => e
|
|
372
|
+
abort "Error: #{e.message}"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
desc "kwaj-compress FILE [OUTPUT]", "Compress file to KWAJ format"
|
|
376
|
+
option :output, type: :string, aliases: "-o", desc: "Output file path"
|
|
377
|
+
option :compression, type: :string, enum: %w[none xor szdd mszip],
|
|
378
|
+
default: "szdd", desc: "Compression method"
|
|
379
|
+
option :include_length, type: :boolean,
|
|
380
|
+
desc: "Include uncompressed length in header"
|
|
381
|
+
option :filename, type: :string,
|
|
382
|
+
desc: "Original filename to embed in header"
|
|
383
|
+
option :extra_data, type: :string, desc: "Extra data to include in header"
|
|
384
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
385
|
+
desc: "Enable verbose output"
|
|
386
|
+
def kwaj_compress(file, output = nil)
|
|
387
|
+
setup_verbose(options[:verbose])
|
|
388
|
+
output ||= options[:output] || "#{file}.kwj"
|
|
389
|
+
|
|
390
|
+
compressor = KWAJ::Compressor.new
|
|
391
|
+
|
|
392
|
+
puts "Compressing #{file} -> #{output} (#{options[:compression]} compression)" if options[:verbose]
|
|
393
|
+
|
|
394
|
+
compress_options = { compression: options[:compression].to_sym }
|
|
395
|
+
if options[:include_length]
|
|
396
|
+
compress_options[:include_length] =
|
|
397
|
+
options[:include_length]
|
|
398
|
+
end
|
|
399
|
+
compress_options[:filename] = options[:filename] if options[:filename]
|
|
400
|
+
if options[:extra_data]
|
|
401
|
+
compress_options[:extra_data] =
|
|
402
|
+
options[:extra_data]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
bytes = compressor.compress(file, output, **compress_options)
|
|
406
|
+
|
|
407
|
+
puts "Compressed #{file} to #{output} (#{bytes} bytes, #{options[:compression]} compression)"
|
|
408
|
+
rescue Error => e
|
|
409
|
+
abort "Error: #{e.message}"
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# HLP commands
|
|
413
|
+
desc "hlp-extract FILE [OUTPUT_DIR]", "Extract HLP file"
|
|
414
|
+
option :output, type: :string, aliases: "-o", desc: "Output directory"
|
|
415
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
416
|
+
desc: "Enable verbose output"
|
|
417
|
+
def hlp_extract(file, output_dir = nil)
|
|
418
|
+
setup_verbose(options[:verbose])
|
|
419
|
+
output_dir ||= options[:output] || "."
|
|
420
|
+
|
|
421
|
+
decompressor = HLP::Decompressor.new
|
|
422
|
+
header = decompressor.open(file)
|
|
423
|
+
|
|
424
|
+
require "fileutils"
|
|
425
|
+
FileUtils.mkdir_p(output_dir)
|
|
426
|
+
|
|
427
|
+
puts "Extracting #{header.files.size} files from #{file}" if options[:verbose]
|
|
428
|
+
count = decompressor.extract_all(header, output_dir)
|
|
429
|
+
|
|
430
|
+
decompressor.close(header)
|
|
431
|
+
puts "Extracted #{count} file(s) to #{output_dir}"
|
|
432
|
+
rescue Error => e
|
|
433
|
+
abort "Error: #{e.message}"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
desc "hlp-create OUTPUT FILES...", "Create HLP file"
|
|
437
|
+
option :compress, type: :boolean, default: true,
|
|
438
|
+
desc: "Compress files (LZSS MODE_MSHELP)"
|
|
439
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
440
|
+
desc: "Enable verbose output"
|
|
441
|
+
def hlp_create(output, *files)
|
|
442
|
+
setup_verbose(options[:verbose])
|
|
443
|
+
|
|
444
|
+
raise ArgumentError, "No files specified" if files.empty?
|
|
445
|
+
|
|
446
|
+
files.each do |f|
|
|
447
|
+
raise ArgumentError, "File does not exist: #{f}" unless File.exist?(f)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
compressor = HLP::Compressor.new
|
|
451
|
+
files.each do |f|
|
|
452
|
+
compressor.add_file(f, File.basename(f), compress: options[:compress])
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
puts "Creating #{output} with #{files.size} file(s)" if options[:verbose]
|
|
456
|
+
bytes = compressor.generate(output)
|
|
457
|
+
puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
|
|
458
|
+
rescue Error => e
|
|
459
|
+
abort "Error: #{e.message}"
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
desc "hlp-info FILE", "Show HLP file information"
|
|
463
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
464
|
+
desc: "Enable verbose output"
|
|
465
|
+
def hlp_info(file)
|
|
466
|
+
setup_verbose(options[:verbose])
|
|
467
|
+
|
|
468
|
+
decompressor = HLP::Decompressor.new
|
|
469
|
+
header = decompressor.open(file)
|
|
470
|
+
|
|
471
|
+
puts "HLP File Information"
|
|
472
|
+
puts "=" * 50
|
|
473
|
+
puts "Filename: #{file}"
|
|
474
|
+
puts "Version: #{header.version}"
|
|
475
|
+
puts "Files: #{header.files.size}"
|
|
476
|
+
puts ""
|
|
477
|
+
puts "Files:"
|
|
478
|
+
header.files.each do |f|
|
|
479
|
+
compression = f.compressed? ? "LZSS" : "none"
|
|
480
|
+
puts " #{f.filename}"
|
|
481
|
+
puts " Uncompressed: #{f.length} bytes"
|
|
482
|
+
puts " Compressed: #{f.compressed_length} bytes (#{compression})"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
decompressor.close(header)
|
|
486
|
+
rescue Error => e
|
|
487
|
+
abort "Error: #{e.message}"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# LIT commands
|
|
491
|
+
desc "lit-extract FILE [OUTPUT_DIR]", "Extract LIT eBook file"
|
|
492
|
+
option :output, type: :string, aliases: "-o", desc: "Output directory"
|
|
493
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
494
|
+
desc: "Enable verbose output"
|
|
495
|
+
def lit_extract(file, output_dir = nil)
|
|
496
|
+
setup_verbose(options[:verbose])
|
|
497
|
+
output_dir ||= options[:output] || "."
|
|
498
|
+
|
|
499
|
+
decompressor = LIT::Decompressor.new
|
|
500
|
+
header = decompressor.open(file)
|
|
501
|
+
|
|
502
|
+
abort "Error: LIT file is DRM-encrypted. Decryption not yet supported." if header.encrypted?
|
|
503
|
+
|
|
504
|
+
require "fileutils"
|
|
505
|
+
FileUtils.mkdir_p(output_dir)
|
|
506
|
+
|
|
507
|
+
puts "Extracting #{header.files.size} files from #{file}" if options[:verbose]
|
|
508
|
+
count = decompressor.extract_all(header, output_dir)
|
|
509
|
+
|
|
510
|
+
decompressor.close(header)
|
|
511
|
+
puts "Extracted #{count} file(s) to #{output_dir}"
|
|
512
|
+
rescue Error => e
|
|
513
|
+
abort "Error: #{e.message}"
|
|
514
|
+
rescue NotImplementedError => e
|
|
515
|
+
abort "Error: #{e.message}"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
desc "lit-create OUTPUT FILES...", "Create LIT eBook file"
|
|
519
|
+
option :compress, type: :boolean, default: true,
|
|
520
|
+
desc: "Compress files with LZX"
|
|
521
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
522
|
+
desc: "Enable verbose output"
|
|
523
|
+
def lit_create(output, *files)
|
|
524
|
+
setup_verbose(options[:verbose])
|
|
525
|
+
|
|
526
|
+
raise ArgumentError, "No files specified" if files.empty?
|
|
527
|
+
|
|
528
|
+
files.each do |f|
|
|
529
|
+
raise ArgumentError, "File does not exist: #{f}" unless File.exist?(f)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
compressor = LIT::Compressor.new
|
|
533
|
+
files.each do |f|
|
|
534
|
+
compressor.add_file(f, File.basename(f), compress: options[:compress])
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
puts "Creating #{output} with #{files.size} file(s)" if options[:verbose]
|
|
538
|
+
bytes = compressor.generate(output)
|
|
539
|
+
puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
|
|
540
|
+
rescue Error => e
|
|
541
|
+
abort "Error: #{e.message}"
|
|
542
|
+
rescue NotImplementedError => e
|
|
543
|
+
abort "Error: #{e.message}"
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
desc "lit-info FILE", "Show LIT file information"
|
|
547
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
548
|
+
desc: "Enable verbose output"
|
|
549
|
+
def lit_info(file)
|
|
550
|
+
setup_verbose(options[:verbose])
|
|
551
|
+
|
|
552
|
+
decompressor = LIT::Decompressor.new
|
|
553
|
+
header = decompressor.open(file)
|
|
554
|
+
|
|
555
|
+
puts "LIT File Information"
|
|
556
|
+
puts "=" * 50
|
|
557
|
+
puts "Filename: #{file}"
|
|
558
|
+
puts "Version: #{header.version}"
|
|
559
|
+
puts "Encrypted: #{header.encrypted? ? 'Yes (DES)' : 'No'}"
|
|
560
|
+
puts "Files: #{header.files.size}"
|
|
561
|
+
puts ""
|
|
562
|
+
puts "Files:"
|
|
563
|
+
header.files.each do |f|
|
|
564
|
+
compression = f.compressed? ? "LZX" : "none"
|
|
565
|
+
encryption = f.encrypted? ? " [encrypted]" : ""
|
|
566
|
+
puts " #{f.filename}"
|
|
567
|
+
puts " Size: #{f.length} bytes"
|
|
568
|
+
puts " Compression: #{compression}#{encryption}"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
decompressor.close(header)
|
|
572
|
+
rescue Error => e
|
|
573
|
+
abort "Error: #{e.message}"
|
|
574
|
+
rescue NotImplementedError => e
|
|
575
|
+
abort "Error: #{e.message}"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# OAB commands
|
|
579
|
+
desc "oab-extract INPUT OUTPUT", "Extract OAB (Outlook Address Book) file"
|
|
580
|
+
option :base, type: :string, desc: "Base file for incremental patch"
|
|
581
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
582
|
+
desc: "Enable verbose output"
|
|
583
|
+
def oab_extract(input, output)
|
|
584
|
+
setup_verbose(options[:verbose])
|
|
585
|
+
|
|
586
|
+
decompressor = OAB::Decompressor.new
|
|
587
|
+
|
|
588
|
+
if options[:base]
|
|
589
|
+
puts "Applying patch: #{input} + #{options[:base]} -> #{output}" if options[:verbose]
|
|
590
|
+
bytes = decompressor.decompress_incremental(input, options[:base],
|
|
591
|
+
output)
|
|
592
|
+
puts "Applied patch: #{input} + #{options[:base]} -> #{output} (#{bytes} bytes)"
|
|
593
|
+
else
|
|
594
|
+
puts "Extracting: #{input} -> #{output}" if options[:verbose]
|
|
595
|
+
bytes = decompressor.decompress(input, output)
|
|
596
|
+
puts "Extracted #{input} -> #{output} (#{bytes} bytes)"
|
|
597
|
+
end
|
|
598
|
+
rescue Error => e
|
|
599
|
+
abort "Error: #{e.message}"
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
desc "oab-create INPUT OUTPUT", "Create compressed OAB file"
|
|
603
|
+
option :base, type: :string, desc: "Base file for incremental patch"
|
|
604
|
+
option :block_size, type: :numeric, default: 32_768,
|
|
605
|
+
desc: "Block size (default: 32KB)"
|
|
606
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
607
|
+
desc: "Enable verbose output"
|
|
608
|
+
def oab_create(input, output)
|
|
609
|
+
setup_verbose(options[:verbose])
|
|
610
|
+
|
|
611
|
+
compressor = OAB::Compressor.new
|
|
612
|
+
|
|
613
|
+
if options[:base]
|
|
614
|
+
puts "Creating patch: #{input} (base: #{options[:base]}) -> #{output}" if options[:verbose]
|
|
615
|
+
bytes = compressor.compress_incremental(input, options[:base], output,
|
|
616
|
+
block_size: options[:block_size])
|
|
617
|
+
puts "Created patch: #{output} (#{bytes} bytes)"
|
|
618
|
+
else
|
|
619
|
+
puts "Compressing: #{input} -> #{output}" if options[:verbose]
|
|
620
|
+
bytes = compressor.compress(input, output,
|
|
621
|
+
block_size: options[:block_size])
|
|
622
|
+
puts "Created #{output} (#{bytes} bytes)"
|
|
623
|
+
end
|
|
624
|
+
rescue Error => e
|
|
625
|
+
abort "Error: #{e.message}"
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
desc "oab-info FILE", "Show OAB file information"
|
|
629
|
+
option :verbose, type: :boolean, aliases: "-v",
|
|
630
|
+
desc: "Enable verbose output"
|
|
631
|
+
def oab_info(file)
|
|
632
|
+
setup_verbose(options[:verbose])
|
|
633
|
+
|
|
634
|
+
# Read and parse header
|
|
635
|
+
io_system = System::IOSystem.new
|
|
636
|
+
handle = io_system.open(file, Constants::MODE_READ)
|
|
637
|
+
|
|
638
|
+
begin
|
|
639
|
+
header_data = io_system.read(handle, 28) # Read full patch header size
|
|
640
|
+
io_system.close(handle)
|
|
641
|
+
|
|
642
|
+
# Try to parse as full header first
|
|
643
|
+
if header_data.length >= 16
|
|
644
|
+
full_header = Binary::OABStructures::FullHeader.read(header_data[0,
|
|
645
|
+
16])
|
|
646
|
+
|
|
647
|
+
if full_header.valid?
|
|
648
|
+
puts "OAB File Information (Full)"
|
|
649
|
+
puts "=" * 50
|
|
650
|
+
puts "Filename: #{file}"
|
|
651
|
+
puts "Version: #{full_header.version_hi}.#{full_header.version_lo}"
|
|
652
|
+
puts "Block size: #{full_header.block_max} bytes"
|
|
653
|
+
puts "Target size: #{full_header.target_size} bytes"
|
|
654
|
+
elsif header_data.length >= 28
|
|
655
|
+
# Try as patch header
|
|
656
|
+
patch_header = Binary::OABStructures::PatchHeader.read(header_data)
|
|
657
|
+
|
|
658
|
+
if patch_header.valid?
|
|
659
|
+
puts "OAB File Information (Patch)"
|
|
660
|
+
puts "=" * 50
|
|
661
|
+
puts "Filename: #{file}"
|
|
662
|
+
puts "Version: #{patch_header.version_hi}.#{patch_header.version_lo}"
|
|
663
|
+
puts "Block size: #{patch_header.block_max} bytes"
|
|
664
|
+
puts "Source size: #{patch_header.source_size} bytes"
|
|
665
|
+
puts "Target size: #{patch_header.target_size} bytes"
|
|
666
|
+
puts "Source CRC: 0x#{patch_header.source_crc.to_s(16)}"
|
|
667
|
+
puts "Target CRC: 0x#{patch_header.target_crc.to_s(16)}"
|
|
668
|
+
else
|
|
669
|
+
abort "Error: Not a valid OAB file"
|
|
670
|
+
end
|
|
671
|
+
else
|
|
672
|
+
abort "Error: Not a valid OAB file"
|
|
673
|
+
end
|
|
674
|
+
else
|
|
675
|
+
abort "Error: File too small to be OAB"
|
|
676
|
+
end
|
|
677
|
+
rescue StandardError => e
|
|
678
|
+
io_system.close(handle) if handle
|
|
679
|
+
abort "Error: #{e.message}"
|
|
680
|
+
end
|
|
681
|
+
rescue Error => e
|
|
682
|
+
abort "Error: #{e.message}"
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
desc "version", "Show version information"
|
|
686
|
+
def version
|
|
687
|
+
puts "Cabriolet version #{Cabriolet::VERSION}"
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
private
|
|
691
|
+
|
|
692
|
+
def setup_verbose(verbose)
|
|
693
|
+
Cabriolet.verbose = verbose
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def display_cabinet_info(cabinet)
|
|
697
|
+
puts "Cabinet Information"
|
|
698
|
+
puts "=" * 50
|
|
699
|
+
puts "Filename: #{cabinet.filename}"
|
|
700
|
+
puts "Set ID: #{cabinet.set_id}"
|
|
701
|
+
puts "Set Index: #{cabinet.set_index}"
|
|
702
|
+
puts "Size: #{cabinet.length} bytes"
|
|
703
|
+
puts "Folders: #{cabinet.folder_count}"
|
|
704
|
+
puts "Files: #{cabinet.file_count}"
|
|
705
|
+
puts ""
|
|
706
|
+
|
|
707
|
+
puts "Folders:"
|
|
708
|
+
cabinet.folders.each_with_index do |folder, idx|
|
|
709
|
+
puts " [#{idx}] #{folder.compression_name} (#{folder.num_blocks} blocks)"
|
|
710
|
+
end
|
|
711
|
+
puts ""
|
|
712
|
+
|
|
713
|
+
puts "Files:"
|
|
714
|
+
cabinet.files.each do |f|
|
|
715
|
+
puts " #{f.filename}"
|
|
716
|
+
puts " Size: #{f.length} bytes"
|
|
717
|
+
puts " Modified: #{f.modification_time}" if f.modification_time
|
|
718
|
+
puts " Attributes: #{file_attributes(f)}"
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def file_attributes(file)
|
|
723
|
+
attrs = []
|
|
724
|
+
attrs << "readonly" if file.readonly?
|
|
725
|
+
attrs << "hidden" if file.hidden?
|
|
726
|
+
attrs << "system" if file.system?
|
|
727
|
+
attrs << "archive" if file.archived?
|
|
728
|
+
attrs << "executable" if file.executable?
|
|
729
|
+
attrs.empty? ? "none" : attrs.join(", ")
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def display_chm_info(chm)
|
|
733
|
+
puts "CHM File Information"
|
|
734
|
+
puts "=" * 50
|
|
735
|
+
puts "Filename: #{chm.filename}"
|
|
736
|
+
puts "Version: #{chm.version}"
|
|
737
|
+
puts "Language ID: #{chm.language}"
|
|
738
|
+
puts "Timestamp: #{chm.timestamp}"
|
|
739
|
+
puts "Size: #{chm.length} bytes"
|
|
740
|
+
puts ""
|
|
741
|
+
puts "Directory:"
|
|
742
|
+
puts " Offset: #{chm.dir_offset}"
|
|
743
|
+
puts " Chunks: #{chm.num_chunks}"
|
|
744
|
+
puts " Chunk Size: #{chm.chunk_size}"
|
|
745
|
+
puts " First PMGL: #{chm.first_pmgl}"
|
|
746
|
+
puts " Last PMGL: #{chm.last_pmgl}"
|
|
747
|
+
puts ""
|
|
748
|
+
puts "Sections:"
|
|
749
|
+
puts " Section 0 (Uncompressed): offset #{chm.sec0.offset}"
|
|
750
|
+
puts " Section 1 (MSCompressed): LZX compression"
|
|
751
|
+
puts ""
|
|
752
|
+
|
|
753
|
+
regular_files = chm.all_files
|
|
754
|
+
system_files = chm.all_sysfiles
|
|
755
|
+
|
|
756
|
+
puts "Files: #{regular_files.length} regular, #{system_files.length} system"
|
|
757
|
+
puts ""
|
|
758
|
+
puts "Regular Files:"
|
|
759
|
+
regular_files.each do |f|
|
|
760
|
+
section_name = f.section.id.zero? ? "Sec0" : "Sec1"
|
|
761
|
+
puts " #{f.filename}"
|
|
762
|
+
puts " Size: #{f.length} bytes (#{section_name})"
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
return unless system_files.any?
|
|
766
|
+
|
|
767
|
+
puts ""
|
|
768
|
+
puts "System Files:"
|
|
769
|
+
system_files.each do |f|
|
|
770
|
+
section_name = f.section.id.zero? ? "Sec0" : "Sec1"
|
|
771
|
+
puts " #{f.filename}"
|
|
772
|
+
puts " Size: #{f.length} bytes (#{section_name})"
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
end
|