cabriolet 0.1.2 → 0.2.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +700 -38
  3. data/lib/cabriolet/algorithm_factory.rb +250 -0
  4. data/lib/cabriolet/base_compressor.rb +206 -0
  5. data/lib/cabriolet/binary/bitstream.rb +154 -14
  6. data/lib/cabriolet/binary/bitstream_writer.rb +129 -17
  7. data/lib/cabriolet/binary/chm_structures.rb +2 -2
  8. data/lib/cabriolet/binary/hlp_structures.rb +258 -37
  9. data/lib/cabriolet/binary/lit_structures.rb +231 -65
  10. data/lib/cabriolet/binary/oab_structures.rb +17 -1
  11. data/lib/cabriolet/cab/command_handler.rb +226 -0
  12. data/lib/cabriolet/cab/compressor.rb +35 -43
  13. data/lib/cabriolet/cab/decompressor.rb +14 -19
  14. data/lib/cabriolet/cab/extractor.rb +140 -31
  15. data/lib/cabriolet/chm/command_handler.rb +227 -0
  16. data/lib/cabriolet/chm/compressor.rb +7 -3
  17. data/lib/cabriolet/chm/decompressor.rb +39 -21
  18. data/lib/cabriolet/chm/parser.rb +5 -2
  19. data/lib/cabriolet/cli/base_command_handler.rb +127 -0
  20. data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
  21. data/lib/cabriolet/cli/command_registry.rb +83 -0
  22. data/lib/cabriolet/cli.rb +356 -607
  23. data/lib/cabriolet/compressors/base.rb +1 -1
  24. data/lib/cabriolet/compressors/lzx.rb +241 -54
  25. data/lib/cabriolet/compressors/mszip.rb +35 -3
  26. data/lib/cabriolet/compressors/quantum.rb +34 -45
  27. data/lib/cabriolet/decompressors/base.rb +1 -1
  28. data/lib/cabriolet/decompressors/lzss.rb +13 -3
  29. data/lib/cabriolet/decompressors/lzx.rb +70 -33
  30. data/lib/cabriolet/decompressors/mszip.rb +126 -39
  31. data/lib/cabriolet/decompressors/quantum.rb +3 -2
  32. data/lib/cabriolet/errors.rb +3 -0
  33. data/lib/cabriolet/file_entry.rb +156 -0
  34. data/lib/cabriolet/file_manager.rb +144 -0
  35. data/lib/cabriolet/hlp/command_handler.rb +282 -0
  36. data/lib/cabriolet/hlp/compressor.rb +28 -238
  37. data/lib/cabriolet/hlp/decompressor.rb +107 -147
  38. data/lib/cabriolet/hlp/parser.rb +52 -101
  39. data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
  40. data/lib/cabriolet/hlp/quickhelp/compressor.rb +626 -0
  41. data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
  42. data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
  43. data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
  44. data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
  45. data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
  46. data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
  47. data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
  48. data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
  49. data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
  50. data/lib/cabriolet/huffman/tree.rb +85 -1
  51. data/lib/cabriolet/kwaj/command_handler.rb +213 -0
  52. data/lib/cabriolet/kwaj/compressor.rb +7 -3
  53. data/lib/cabriolet/kwaj/decompressor.rb +18 -12
  54. data/lib/cabriolet/lit/command_handler.rb +221 -0
  55. data/lib/cabriolet/lit/compressor.rb +633 -38
  56. data/lib/cabriolet/lit/decompressor.rb +518 -152
  57. data/lib/cabriolet/lit/parser.rb +670 -0
  58. data/lib/cabriolet/models/hlp_file.rb +130 -29
  59. data/lib/cabriolet/models/hlp_header.rb +105 -17
  60. data/lib/cabriolet/models/lit_header.rb +212 -25
  61. data/lib/cabriolet/models/szdd_header.rb +10 -2
  62. data/lib/cabriolet/models/winhelp_header.rb +127 -0
  63. data/lib/cabriolet/oab/command_handler.rb +257 -0
  64. data/lib/cabriolet/oab/compressor.rb +17 -8
  65. data/lib/cabriolet/oab/decompressor.rb +41 -10
  66. data/lib/cabriolet/offset_calculator.rb +81 -0
  67. data/lib/cabriolet/plugin.rb +233 -0
  68. data/lib/cabriolet/plugin_manager.rb +453 -0
  69. data/lib/cabriolet/plugin_validator.rb +422 -0
  70. data/lib/cabriolet/system/io_system.rb +3 -0
  71. data/lib/cabriolet/system/memory_handle.rb +17 -4
  72. data/lib/cabriolet/szdd/command_handler.rb +217 -0
  73. data/lib/cabriolet/szdd/compressor.rb +15 -11
  74. data/lib/cabriolet/szdd/decompressor.rb +18 -9
  75. data/lib/cabriolet/version.rb +1 -1
  76. data/lib/cabriolet.rb +67 -17
  77. metadata +33 -2
@@ -1,271 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "quickhelp/compressor"
4
+ require_relative "winhelp/compressor"
5
+
3
6
  module Cabriolet
4
7
  module HLP
5
- # Compressor creates HLP (Windows Help) compressed archives
6
- #
7
- # HLP files contain an internal file system where files can be compressed
8
- # using LZSS MODE_MSHELP compression. The compressor builds the archive
9
- # structure and compresses files as needed.
8
+ # Main compressor for HLP files
10
9
  #
11
- # NOTE: This implementation is based on the knowledge that HLP files use
12
- # LZSS compression with MODE_MSHELP, but cannot be fully validated due to
13
- # lack of test fixtures and incomplete libmspack implementation.
10
+ # Creates HLP files in either QuickHelp or Windows Help format.
11
+ # By default, uses QuickHelp format for compatibility.
14
12
  class Compressor
15
13
  attr_reader :io_system
16
14
 
17
- # Default buffer size for I/O operations
18
- DEFAULT_BUFFER_SIZE = 2048
19
-
20
- # Initialize a new HLP compressor
15
+ # Initialize compressor
21
16
  #
22
- # @param io_system [System::IOSystem, nil] Custom I/O system or nil for
23
- # default
17
+ # @param io_system [System::IOSystem, nil] Custom I/O system or nil for default
24
18
  def initialize(io_system = nil)
25
19
  @io_system = io_system || System::IOSystem.new
26
- @files = []
20
+ @quickhelp = QuickHelp::Compressor.new(@io_system)
27
21
  end
28
22
 
29
- # Add a file to the HLP archive
23
+ # Add a file to the archive
30
24
  #
31
25
  # @param source_path [String] Path to source file
32
- # @param hlp_path [String] Path within HLP archive
33
- # @param compress [Boolean] Whether to compress the file
26
+ # @param hlp_path [String] Path within archive
27
+ # @param compress [Boolean] Whether to compress
34
28
  # @return [void]
35
29
  def add_file(source_path, hlp_path, compress: true)
36
- @files << {
37
- source: source_path,
38
- hlp_path: hlp_path,
39
- compress: compress,
40
- }
30
+ @quickhelp.add_file(source_path, hlp_path, compress: compress)
41
31
  end
42
32
 
43
- # Add data from memory to the HLP archive
33
+ # Add data from memory
44
34
  #
45
35
  # @param data [String] Data to add
46
- # @param hlp_path [String] Path within HLP archive
47
- # @param compress [Boolean] Whether to compress the data
36
+ # @param hlp_path [String] Path within archive
37
+ # @param compress [Boolean] Whether to compress
48
38
  # @return [void]
49
39
  def add_data(data, hlp_path, compress: true)
50
- @files << {
51
- data: data,
52
- hlp_path: hlp_path,
53
- compress: compress,
54
- }
40
+ @quickhelp.add_data(data, hlp_path, compress: compress)
55
41
  end
56
42
 
57
- # Generate HLP archive
43
+ # Generate HLP archive (QuickHelp format by default)
58
44
  #
59
- # @param output_file [String] Path to output HLP file
60
- # @param options [Hash] Compression options
61
- # @option options [Integer] :version HLP format version (default: 1)
62
- # @return [Integer] Bytes written to output file
63
- # @raise [Errors::CompressionError] if compression fails
45
+ # @param output_file [String] Output file path
46
+ # @param options [Hash] Format options
47
+ # @return [Integer] Bytes written
64
48
  def generate(output_file, **options)
65
- version = options.fetch(:version, 1)
66
-
67
- output_handle = @io_system.open(output_file, Constants::MODE_WRITE)
68
-
69
- begin
70
- # Compress all files and collect metadata
71
- compressed_files = compress_all_files
72
-
73
- # Calculate directory size first
74
- directory_size = calculate_directory_size(compressed_files)
75
-
76
- # Calculate offsets
77
- header_size = 18 # Header structure size
78
- directory_offset = header_size
79
- data_offset = header_size + directory_size
80
-
81
- # Assign file offsets
82
- current_offset = data_offset
83
- compressed_files.each do |file_info|
84
- file_info[:offset] = current_offset
85
- current_offset += file_info[:compressed_data].bytesize
86
- end
87
-
88
- # Write header
89
- header_bytes = write_header(
90
- output_handle,
91
- version,
92
- compressed_files.size,
93
- directory_offset,
94
- )
95
-
96
- # Write directory
97
- directory_bytes = write_directory(output_handle, compressed_files)
98
-
99
- # Write file data
100
- data_bytes = write_file_data(output_handle, compressed_files)
101
-
102
- header_bytes + directory_bytes + data_bytes
103
- ensure
104
- @io_system.close(output_handle) if output_handle
105
- end
106
- end
107
-
108
- private
109
-
110
- # Compress all files and collect metadata
111
- #
112
- # @return [Array<Hash>] Array of file information hashes
113
- def compress_all_files
114
- @files.map do |file_spec|
115
- compress_file_spec(file_spec)
116
- end
117
- end
118
-
119
- # Compress a single file specification
120
- #
121
- # @param file_spec [Hash] File specification
122
- # @return [Hash] File information with compressed data
123
- def compress_file_spec(file_spec)
124
- # Get source data
125
- data = file_spec[:data] || read_file_data(file_spec[:source])
126
-
127
- # Compress if requested
128
- compressed_data = if file_spec[:compress]
129
- compress_data_lzss(data)
130
- else
131
- data
132
- end
133
-
134
- {
135
- hlp_path: file_spec[:hlp_path],
136
- uncompressed_size: data.bytesize,
137
- compressed_data: compressed_data,
138
- compressed: file_spec[:compress],
139
- }
140
- end
141
-
142
- # Read file data from disk
143
- #
144
- # @param filename [String] Path to file
145
- # @return [String] File contents
146
- def read_file_data(filename)
147
- handle = @io_system.open(filename, Constants::MODE_READ)
148
- begin
149
- data = +""
150
- loop do
151
- chunk = @io_system.read(handle, DEFAULT_BUFFER_SIZE)
152
- break if chunk.empty?
153
-
154
- data << chunk
155
- end
156
- data
157
- ensure
158
- @io_system.close(handle)
159
- end
160
- end
161
-
162
- # Compress data using LZSS MODE_MSHELP
163
- #
164
- # @param data [String] Data to compress
165
- # @return [String] Compressed data
166
- def compress_data_lzss(data)
167
- input_handle = System::MemoryHandle.new(data)
168
- output_handle = System::MemoryHandle.new("", Constants::MODE_WRITE)
169
-
170
- compressor = Compressors::LZSS.new(
171
- @io_system,
172
- input_handle,
173
- output_handle,
174
- DEFAULT_BUFFER_SIZE,
175
- Compressors::LZSS::MODE_MSHELP,
176
- )
177
-
178
- compressor.compress
179
- output_handle.data
180
- end
181
-
182
- # Calculate directory size
183
- #
184
- # @param compressed_files [Array<Hash>] Compressed file information
185
- # @return [Integer] Directory size in bytes
186
- def calculate_directory_size(compressed_files)
187
- size = 0
188
- compressed_files.each do |file_info|
189
- # 4 bytes for filename length
190
- # N bytes for filename
191
- # 4 + 4 + 4 + 1 = 13 bytes for file metadata
192
- size += 4 + file_info[:hlp_path].bytesize + 13
193
- end
194
- size
195
- end
196
-
197
- # Write HLP header
198
- #
199
- # @param output_handle [System::FileHandle] Output file handle
200
- # @param version [Integer] Format version
201
- # @param file_count [Integer] Number of files
202
- # @param directory_offset [Integer] Offset to directory
203
- # @return [Integer] Number of bytes written
204
- def write_header(output_handle, version, file_count, directory_offset)
205
- header = Binary::HLPStructures::Header.new
206
- header.signature = Binary::HLPStructures::SIGNATURE
207
- header.version = version
208
- header.file_count = file_count
209
- header.directory_offset = directory_offset
210
-
211
- header_data = header.to_binary_s
212
- written = @io_system.write(output_handle, header_data)
213
-
214
- unless written == header_data.bytesize
215
- raise Errors::CompressionError,
216
- "Failed to write HLP header"
217
- end
218
-
219
- written
220
- end
221
-
222
- # Write file directory
223
- #
224
- # @param output_handle [System::FileHandle] Output file handle
225
- # @param compressed_files [Array<Hash>] Compressed file information
226
- # @return [Integer] Number of bytes written
227
- def write_directory(output_handle, compressed_files)
228
- bytes_written = 0
229
-
230
- compressed_files.each do |file_info|
231
- # Write filename length
232
- filename = file_info[:hlp_path].b
233
- length_data = [filename.bytesize].pack("V")
234
- bytes_written += @io_system.write(output_handle, length_data)
235
-
236
- # Write filename
237
- bytes_written += @io_system.write(output_handle, filename)
238
-
239
- # Write file metadata
240
- metadata = [
241
- file_info[:offset],
242
- file_info[:uncompressed_size],
243
- file_info[:compressed_data].bytesize,
244
- file_info[:compressed] ? 1 : 0,
245
- ].pack("V3C")
246
- bytes_written += @io_system.write(output_handle, metadata)
247
- end
248
-
249
- bytes_written
49
+ @quickhelp.generate(output_file, **options)
250
50
  end
251
51
 
252
- # Write file data
52
+ # Create a Windows Help format HLP file
253
53
  #
254
- # @param output_handle [System::FileHandle] Output file handle
255
- # @param compressed_files [Array<Hash>] Compressed file information
256
- # @return [Integer] Number of bytes written
257
- def write_file_data(output_handle, compressed_files)
258
- bytes_written = 0
259
-
260
- compressed_files.each do |file_info|
261
- written = @io_system.write(
262
- output_handle,
263
- file_info[:compressed_data],
264
- )
265
- bytes_written += written
266
- end
267
-
268
- bytes_written
54
+ # @param output_file [String] Output file path
55
+ # @param options [Hash] Format options
56
+ # @return [WinHelp::Compressor] Compressor for building WinHelp file
57
+ def self.create_winhelp(io_system = nil)
58
+ WinHelp::Compressor.new(io_system)
269
59
  end
270
60
  end
271
61
  end
@@ -1,197 +1,157 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "quickhelp/decompressor"
4
+ require_relative "winhelp/decompressor"
5
+
3
6
  module Cabriolet
4
7
  module HLP
5
- # Decompressor is the main interface for HLP file operations
6
- #
7
- # HLP files use LZSS compression with MODE_MSHELP and contain an internal
8
- # file system. Files are decompressed using the Decompressors::LZSS class.
8
+ # Main decompressor for HLP files
9
9
  #
10
- # NOTE: This implementation is based on the knowledge that HLP files use
11
- # LZSS compression with MODE_MSHELP, but cannot be fully validated due to
12
- # lack of test fixtures and incomplete libmspack implementation.
10
+ # Detects the HLP format variant and delegates to the appropriate decompressor:
11
+ # - QuickHelp (DOS format)
12
+ # - Windows Help (WinHelp 3.x/4.x format)
13
13
  class Decompressor
14
14
  attr_reader :io_system, :parser
15
- attr_accessor :buffer_size
16
15
 
17
- # Input buffer size for decompression
18
- DEFAULT_BUFFER_SIZE = 2048
19
-
20
- # Initialize a new HLP decompressor
16
+ # Initialize decompressor
21
17
  #
22
- # @param io_system [System::IOSystem, nil] Custom I/O system or nil for
23
- # default
18
+ # @param io_system [System::IOSystem, nil] Custom I/O system or nil for default
24
19
  def initialize(io_system = nil)
25
20
  @io_system = io_system || System::IOSystem.new
26
21
  @parser = Parser.new(@io_system)
27
- @buffer_size = DEFAULT_BUFFER_SIZE
22
+ @delegate = nil
23
+ @current_format = nil
28
24
  end
29
25
 
30
- # Open and parse an HLP file
26
+ # Open and parse HLP file
31
27
  #
32
- # @param filename [String] Path to the HLP file
33
- # @return [Models::HLPHeader] Parsed header with file list
34
- # @raise [Errors::ParseError] if the file is not a valid HLP
28
+ # @param filename [String] Path to HLP file
29
+ # @return [Models::HLPHeader, Models::WinHelpHeader] Parsed header
35
30
  def open(filename)
36
- header = @parser.parse(filename)
37
- header.filename = filename
38
- header
31
+ @current_format = detect_format(filename)
32
+
33
+ case @current_format
34
+ when :quickhelp
35
+ @delegate = QuickHelp::Decompressor.new(@io_system)
36
+ @delegate.open(filename)
37
+ when :winhelp
38
+ @delegate = WinHelp::Decompressor.new(filename, @io_system)
39
+ @delegate.parse
40
+ else
41
+ raise Cabriolet::ParseError, "Unknown HLP format"
42
+ end
39
43
  end
40
44
 
41
- # Close an HLP file (no-op for compatibility)
45
+ # Close HLP file
42
46
  #
43
- # @param _header [Models::HLPHeader] Header to close
44
- # @return [void]
45
- def close(_header)
46
- # No resources to free in the header itself
47
- # File handles are managed separately during extraction
47
+ # @param header [Models::HLPHeader, Models::WinHelpHeader] Header to close
48
+ # @return [nil]
49
+ def close(header)
50
+ @delegate&.close(header) if @delegate.respond_to?(:close)
48
51
  nil
49
52
  end
50
53
 
51
- # Extract a file from HLP archive
54
+ # Extract a file
52
55
  #
53
- # @param header [Models::HLPHeader] HLP header from open()
54
- # @param hlp_file [Models::HLPFile] File to extract from archive
55
- # @param output_path [String] Where to write the extracted file
56
- # @return [Integer] Number of bytes written
57
- # @raise [Errors::DecompressionError] if extraction fails
56
+ # @param header [Models::HLPHeader, Models::WinHelpHeader] Parsed header
57
+ # @param hlp_file [Models::HLPFile] File to extract
58
+ # @param output_path [String] Output file path
59
+ # @return [Integer] Bytes written
58
60
  def extract_file(header, hlp_file, output_path)
59
- raise ArgumentError, "Header must not be nil" unless header
60
- raise ArgumentError, "HLP file must not be nil" unless hlp_file
61
- raise ArgumentError, "Output path must not be nil" unless output_path
62
-
63
- input_handle = @io_system.open(header.filename, Constants::MODE_READ)
64
- output_handle = @io_system.open(output_path, Constants::MODE_WRITE)
65
-
66
- begin
67
- # Seek to file data
68
- @io_system.seek(input_handle, hlp_file.offset,
69
- Constants::SEEK_START)
70
-
71
- bytes_written = if hlp_file.compressed?
72
- decompress_file(input_handle, output_handle,
73
- hlp_file)
74
- else
75
- copy_file(input_handle, output_handle, hlp_file)
76
- end
77
-
78
- # Verify size if expected
79
- if bytes_written != hlp_file.length && Cabriolet.verbose
80
- warn "[Cabriolet] WARNING: extracted #{bytes_written} bytes, " \
81
- "expected #{hlp_file.length} bytes"
82
- end
83
-
84
- bytes_written
85
- ensure
86
- @io_system.close(input_handle) if input_handle
87
- @io_system.close(output_handle) if output_handle
61
+ raise ArgumentError, "Header must not be nil" if header.nil?
62
+ raise ArgumentError, "HLP file must not be nil" if hlp_file.nil?
63
+ raise ArgumentError, "Output path must not be nil" if output_path.nil?
64
+
65
+ case @current_format
66
+ when :quickhelp
67
+ @delegate.extract_file(header, hlp_file, output_path)
68
+ when :winhelp
69
+ # WinHelp uses different extraction model
70
+ raise NotImplementedError,
71
+ "WinHelp file extraction not yet implemented via this API"
88
72
  end
89
73
  end
90
74
 
91
- # Extract a file to memory
75
+ # Extract file to memory
92
76
  #
93
- # @param header [Models::HLPHeader] HLP header from open()
77
+ # @param header [Models::HLPHeader, Models::WinHelpHeader] Parsed header
94
78
  # @param hlp_file [Models::HLPFile] File to extract
95
- # @return [String] Extracted data
96
- # @raise [Errors::DecompressionError] if extraction fails
79
+ # @return [String] File contents
97
80
  def extract_file_to_memory(header, hlp_file)
98
- raise ArgumentError, "Header must not be nil" unless header
99
- raise ArgumentError, "HLP file must not be nil" unless hlp_file
100
-
101
- input_handle = @io_system.open(header.filename, Constants::MODE_READ)
102
- output_handle = System::MemoryHandle.new("", Constants::MODE_WRITE)
103
-
104
- begin
105
- # Seek to file data
106
- @io_system.seek(input_handle, hlp_file.offset,
107
- Constants::SEEK_START)
108
-
109
- if hlp_file.compressed?
110
- decompress_file(input_handle, output_handle, hlp_file)
111
- else
112
- copy_file(input_handle, output_handle, hlp_file)
113
- end
114
-
115
- output_handle.data
116
- ensure
117
- @io_system.close(input_handle) if input_handle
81
+ raise ArgumentError, "Header must not be nil" if header.nil?
82
+ raise ArgumentError, "HLP file must not be nil" if hlp_file.nil?
83
+
84
+ case @current_format
85
+ when :quickhelp
86
+ @delegate.extract_file_to_memory(header, hlp_file)
87
+ when :winhelp
88
+ raise NotImplementedError,
89
+ "WinHelp memory extraction not yet implemented via this API"
118
90
  end
119
91
  end
120
92
 
121
- # Extract all files from HLP archive
93
+ # Extract all files
122
94
  #
123
- # @param header [Models::HLPHeader] HLP header from open()
124
- # @param output_dir [String] Directory to extract files to
95
+ # @param header [Models::HLPHeader, Models::WinHelpHeader] Parsed header
96
+ # @param output_dir [String] Output directory
125
97
  # @return [Integer] Number of files extracted
126
- # @raise [Errors::DecompressionError] if extraction fails
127
98
  def extract_all(header, output_dir)
128
- raise ArgumentError, "Header must not be nil" unless header
129
- raise ArgumentError, "Output directory must not be nil" unless
130
- output_dir
99
+ raise ArgumentError, "Header must not be nil" if header.nil?
131
100
 
132
- # Create output directory if needed
133
- FileUtils.mkdir_p(output_dir)
134
-
135
- extracted = 0
136
- header.files.each do |hlp_file|
137
- output_path = ::File.join(output_dir, hlp_file.filename)
138
-
139
- # Create subdirectories if needed
140
- output_subdir = ::File.dirname(output_path)
141
- FileUtils.mkdir_p(output_subdir)
101
+ if output_dir.nil?
102
+ raise ArgumentError,
103
+ "Output directory must not be nil"
104
+ end
142
105
 
143
- extract_file(header, hlp_file, output_path)
144
- extracted += 1
106
+ case @current_format
107
+ when :quickhelp
108
+ @delegate.extract_all(header, output_dir)
109
+ when :winhelp
110
+ @delegate.extract_all(output_dir)
145
111
  end
112
+ end
146
113
 
147
- extracted
114
+ # Extract (alternate API taking filename directly)
115
+ #
116
+ # @param filename [String] Path to HLP file
117
+ # @param output_dir [String] Output directory
118
+ # @return [Integer] Number of files extracted
119
+ def self.extract(filename, output_dir, io_system = nil)
120
+ io_sys = io_system || System::IOSystem.new
121
+ decompressor = new(io_sys)
122
+ header = decompressor.open(filename)
123
+ decompressor.extract_all(header, output_dir)
148
124
  end
149
125
 
150
126
  private
151
127
 
152
- # Decompress a file using LZSS MODE_MSHELP
128
+ # Detect HLP format
153
129
  #
154
- # @param input_handle [System::FileHandle] Input file handle
155
- # @param output_handle [System::FileHandle, System::MemoryHandle]
156
- # Output handle
157
- # @param hlp_file [Models::HLPFile] File metadata
158
- # @return [Integer] Number of bytes written
159
- def decompress_file(input_handle, output_handle, hlp_file)
160
- # Create LZSS decompressor with MODE_MSHELP
161
- decompressor = Decompressors::LZSS.new(
162
- @io_system,
163
- input_handle,
164
- output_handle,
165
- @buffer_size,
166
- Decompressors::LZSS::MODE_MSHELP,
167
- )
168
-
169
- # Decompress
170
- decompressor.decompress(hlp_file.compressed_length)
171
- end
130
+ # @param filename [String] Path to file
131
+ # @return [Symbol] :quickhelp or :winhelp
132
+ def detect_format(filename)
133
+ handle = @io_system.open(filename, Constants::MODE_READ)
172
134
 
173
- # Copy uncompressed file data
174
- #
175
- # @param input_handle [System::FileHandle] Input file handle
176
- # @param output_handle [System::FileHandle, System::MemoryHandle]
177
- # Output handle
178
- # @param hlp_file [Models::HLPFile] File metadata
179
- # @return [Integer] Number of bytes written
180
- def copy_file(input_handle, output_handle, hlp_file)
181
- bytes_written = 0
182
- remaining = hlp_file.length
183
-
184
- while remaining.positive?
185
- chunk_size = [remaining, @buffer_size].min
186
- data = @io_system.read(input_handle, chunk_size)
187
- break if data.empty?
188
-
189
- written = @io_system.write(output_handle, data)
190
- bytes_written += written
191
- remaining -= written
192
- end
135
+ begin
136
+ sig_data = @io_system.read(handle, 4)
137
+
138
+ # Check QuickHelp signature ("LN" = 0x4C 0x4E)
139
+ return :quickhelp if sig_data[0..1] == Binary::HLPStructures::SIGNATURE
140
+
141
+ # Check WinHelp 3.x magic (little-endian 16-bit: 0x35F3)
142
+ magic_word = sig_data[0..1].unpack1("v")
143
+ return :winhelp if magic_word == 0x35F3
193
144
 
194
- bytes_written
145
+ # Check WinHelp 4.x magic (little-endian 32-bit, low 16 bits: 0x5F3F)
146
+ magic_dword = sig_data.unpack1("V")
147
+ return :winhelp if (magic_dword & 0xFFFF) == 0x5F3F
148
+
149
+ raise Cabriolet::ParseError, "Unknown HLP format: #{sig_data.bytes.map do |b|
150
+ format('0x%02X', b)
151
+ end.join(' ')}"
152
+ ensure
153
+ @io_system.close(handle)
154
+ end
195
155
  end
196
156
  end
197
157
  end