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,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