cabriolet 0.1.2 → 0.2.1

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +703 -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 +167 -16
  6. data/lib/cabriolet/binary/bitstream_writer.rb +150 -21
  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 +108 -84
  13. data/lib/cabriolet/cab/decompressor.rb +16 -20
  14. data/lib/cabriolet/cab/extractor.rb +142 -66
  15. data/lib/cabriolet/cab/file_compression_work.rb +52 -0
  16. data/lib/cabriolet/cab/file_compression_worker.rb +89 -0
  17. data/lib/cabriolet/checksum.rb +49 -0
  18. data/lib/cabriolet/chm/command_handler.rb +227 -0
  19. data/lib/cabriolet/chm/compressor.rb +7 -3
  20. data/lib/cabriolet/chm/decompressor.rb +39 -21
  21. data/lib/cabriolet/chm/parser.rb +5 -2
  22. data/lib/cabriolet/cli/base_command_handler.rb +127 -0
  23. data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
  24. data/lib/cabriolet/cli/command_registry.rb +83 -0
  25. data/lib/cabriolet/cli.rb +356 -607
  26. data/lib/cabriolet/collections/file_collection.rb +175 -0
  27. data/lib/cabriolet/compressors/base.rb +1 -1
  28. data/lib/cabriolet/compressors/lzx.rb +241 -54
  29. data/lib/cabriolet/compressors/mszip.rb +35 -3
  30. data/lib/cabriolet/compressors/quantum.rb +36 -95
  31. data/lib/cabriolet/decompressors/base.rb +1 -1
  32. data/lib/cabriolet/decompressors/lzss.rb +13 -3
  33. data/lib/cabriolet/decompressors/lzx.rb +70 -33
  34. data/lib/cabriolet/decompressors/mszip.rb +126 -39
  35. data/lib/cabriolet/decompressors/quantum.rb +83 -53
  36. data/lib/cabriolet/errors.rb +3 -0
  37. data/lib/cabriolet/extraction/base_extractor.rb +88 -0
  38. data/lib/cabriolet/extraction/extractor.rb +171 -0
  39. data/lib/cabriolet/extraction/file_extraction_work.rb +60 -0
  40. data/lib/cabriolet/extraction/file_extraction_worker.rb +106 -0
  41. data/lib/cabriolet/file_entry.rb +156 -0
  42. data/lib/cabriolet/file_manager.rb +144 -0
  43. data/lib/cabriolet/format_base.rb +79 -0
  44. data/lib/cabriolet/hlp/command_handler.rb +282 -0
  45. data/lib/cabriolet/hlp/compressor.rb +28 -238
  46. data/lib/cabriolet/hlp/decompressor.rb +107 -147
  47. data/lib/cabriolet/hlp/parser.rb +52 -101
  48. data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
  49. data/lib/cabriolet/hlp/quickhelp/compressor.rb +151 -0
  50. data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
  51. data/lib/cabriolet/hlp/quickhelp/file_writer.rb +125 -0
  52. data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
  53. data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
  54. data/lib/cabriolet/hlp/quickhelp/offset_calculator.rb +61 -0
  55. data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
  56. data/lib/cabriolet/hlp/quickhelp/structure_builder.rb +93 -0
  57. data/lib/cabriolet/hlp/quickhelp/topic_builder.rb +52 -0
  58. data/lib/cabriolet/hlp/quickhelp/topic_compressor.rb +83 -0
  59. data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
  60. data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
  61. data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
  62. data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
  63. data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
  64. data/lib/cabriolet/huffman/encoder.rb +15 -12
  65. data/lib/cabriolet/huffman/tree.rb +85 -1
  66. data/lib/cabriolet/kwaj/command_handler.rb +213 -0
  67. data/lib/cabriolet/kwaj/compressor.rb +7 -3
  68. data/lib/cabriolet/kwaj/decompressor.rb +18 -12
  69. data/lib/cabriolet/lit/command_handler.rb +221 -0
  70. data/lib/cabriolet/lit/compressor.rb +119 -168
  71. data/lib/cabriolet/lit/content_encoder.rb +76 -0
  72. data/lib/cabriolet/lit/content_type_detector.rb +50 -0
  73. data/lib/cabriolet/lit/decompressor.rb +518 -152
  74. data/lib/cabriolet/lit/directory_builder.rb +153 -0
  75. data/lib/cabriolet/lit/guid_generator.rb +16 -0
  76. data/lib/cabriolet/lit/header_writer.rb +124 -0
  77. data/lib/cabriolet/lit/parser.rb +670 -0
  78. data/lib/cabriolet/lit/piece_builder.rb +74 -0
  79. data/lib/cabriolet/lit/structure_builder.rb +252 -0
  80. data/lib/cabriolet/models/hlp_file.rb +130 -29
  81. data/lib/cabriolet/models/hlp_header.rb +105 -17
  82. data/lib/cabriolet/models/lit_header.rb +212 -25
  83. data/lib/cabriolet/models/szdd_header.rb +10 -2
  84. data/lib/cabriolet/models/winhelp_header.rb +127 -0
  85. data/lib/cabriolet/oab/command_handler.rb +257 -0
  86. data/lib/cabriolet/oab/compressor.rb +17 -8
  87. data/lib/cabriolet/oab/decompressor.rb +41 -10
  88. data/lib/cabriolet/offset_calculator.rb +81 -0
  89. data/lib/cabriolet/plugin.rb +233 -0
  90. data/lib/cabriolet/plugin_manager.rb +453 -0
  91. data/lib/cabriolet/plugin_validator.rb +422 -0
  92. data/lib/cabriolet/quantum_shared.rb +105 -0
  93. data/lib/cabriolet/system/io_system.rb +3 -0
  94. data/lib/cabriolet/system/memory_handle.rb +17 -4
  95. data/lib/cabriolet/szdd/command_handler.rb +217 -0
  96. data/lib/cabriolet/szdd/compressor.rb +15 -11
  97. data/lib/cabriolet/szdd/decompressor.rb +18 -9
  98. data/lib/cabriolet/version.rb +1 -1
  99. data/lib/cabriolet.rb +181 -20
  100. metadata +69 -4
  101. data/lib/cabriolet/auto.rb +0 -173
  102. data/lib/cabriolet/parallel.rb +0 -333
@@ -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
@@ -1,19 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "quickhelp/parser"
4
+ require_relative "winhelp/parser"
5
+
3
6
  module Cabriolet
4
7
  module HLP
5
- # Parser for HLP (Windows Help) files
8
+ # Main parser for HLP files
6
9
  #
7
- # NOTE: This implementation is based on the knowledge that HLP files use
8
- # LZSS compression with MODE_MSHELP, but cannot be fully validated due to
9
- # lack of test fixtures and incomplete libmspack implementation.
10
+ # Detects the HLP format variant and delegates to the appropriate parser:
11
+ # - QuickHelp (DOS format with "LN" signature)
12
+ # - Windows Help (WinHelp 3.x/4.x format)
10
13
  class Parser
11
14
  attr_reader :io_system
12
15
 
13
16
  # Initialize parser
14
17
  #
15
- # @param io_system [System::IOSystem, nil] Custom I/O system or nil for
16
- # default
18
+ # @param io_system [System::IOSystem, nil] Custom I/O system or nil for default
17
19
  def initialize(io_system = nil)
18
20
  @io_system = io_system || System::IOSystem.new
19
21
  end
@@ -21,111 +23,60 @@ module Cabriolet
21
23
  # Parse an HLP file
22
24
  #
23
25
  # @param filename [String] Path to HLP file
24
- # @return [Models::HLPHeader] Parsed header
25
- # @raise [Errors::ParseError] if file is not valid HLP
26
+ # @return [Models::HLPHeader, Models::WinHelpHeader] Parsed header with metadata
27
+ # @raise [Cabriolet::ParseError] if file is not a valid HLP format
26
28
  def parse(filename)
27
- handle = @io_system.open(filename, Constants::MODE_READ)
28
-
29
- begin
30
- parse_header(handle)
31
- ensure
32
- @io_system.close(handle)
29
+ # Detect format
30
+ format = detect_format(filename)
31
+
32
+ # Dispatch to appropriate parser
33
+ case format
34
+ when :quickhelp
35
+ QuickHelp::Parser.new(@io_system).parse(filename)
36
+ when :winhelp
37
+ WinHelp::Parser.new(@io_system).parse(filename)
38
+ else
39
+ raise Cabriolet::ParseError,
40
+ "Unknown HLP format in file: #{filename}"
33
41
  end
34
42
  end
35
43
 
36
44
  private
37
45
 
38
- # Parse HLP header from file handle
39
- #
40
- # @param handle [System::FileHandle] Open file handle
41
- # @return [Models::HLPHeader] Parsed header with file list
42
- # @raise [Errors::ParseError] if header is invalid
43
- def parse_header(handle)
44
- # Read header structure
45
- header_data = @io_system.read(handle, 18)
46
- raise Errors::ParseError, "File too small for HLP header" if
47
- header_data.bytesize < 18
48
-
49
- binary_header = Binary::HLPStructures::Header.read(header_data)
50
-
51
- # Validate signature
52
- unless valid_signature?(binary_header.signature)
53
- raise Errors::ParseError,
54
- "Invalid HLP signature: #{binary_header.signature.inspect}"
55
- end
56
-
57
- # Create header model
58
- header = Models::HLPHeader.new(
59
- magic: binary_header.signature,
60
- version: binary_header.version,
61
- length: 0,
62
- )
63
-
64
- # Parse file directory if present
65
- if binary_header.file_count.positive? &&
66
- binary_header.directory_offset.positive?
67
- parse_directory(handle, header, binary_header)
68
- end
69
-
70
- header
71
- end
72
-
73
- # Parse file directory
46
+ # Detect HLP format variant
74
47
  #
75
- # @param handle [System::FileHandle] Open file handle
76
- # @param header [Models::HLPHeader] Header to populate
77
- # @param binary_header [Binary::HLPStructures::Header] Binary header
78
- # @return [void]
79
- def parse_directory(handle, header, binary_header)
80
- # Seek to directory
81
- @io_system.seek(
82
- handle,
83
- binary_header.directory_offset,
84
- Constants::SEEK_START,
85
- )
86
-
87
- # Read each file entry
88
- binary_header.file_count.times do
89
- # Read filename length
90
- length_data = @io_system.read(handle, 4)
91
- break if length_data.bytesize < 4
92
-
93
- filename_length = length_data.unpack1("V")
94
- next if filename_length.zero? || filename_length > 1024
95
-
96
- # Read filename
97
- filename = @io_system.read(handle, filename_length)
98
- next if filename.bytesize != filename_length
99
-
100
- # Read rest of entry (offset, sizes, compression flag)
101
- metadata_data = @io_system.read(handle, 13)
102
- next if metadata_data.bytesize < 13
103
-
104
- offset, uncompressed_size, compressed_size, compression_flag =
105
- metadata_data.unpack("V3C")
106
-
107
- # Create file model
108
- file = Models::HLPFile.new(
109
- filename: filename.force_encoding("ASCII-8BIT"),
110
- offset: offset,
111
- length: uncompressed_size,
112
- compressed_length: compressed_size,
113
- compressed: compression_flag != 0,
114
- )
48
+ # @param filename [String] Path to HLP file
49
+ # @return [Symbol] :quickhelp or :winhelp
50
+ # @raise [Cabriolet::ParseError] if format cannot be determined
51
+ def detect_format(filename)
52
+ handle = @io_system.open(filename, Constants::MODE_READ)
115
53
 
116
- header.files << file
54
+ begin
55
+ # Read first 4 bytes to check signature
56
+ sig_data = @io_system.read(handle, 4)
57
+
58
+ # Check QuickHelp signature ("LN" at offset 0)
59
+ if sig_data[0..1] == Binary::HLPStructures::SIGNATURE
60
+ return :quickhelp
61
+ end
62
+
63
+ # Check WinHelp 3.x magic (0x35F3 at offset 0, 16-bit)
64
+ magic_word = sig_data[0..1].unpack1("v")
65
+ return :winhelp if magic_word == 0x35F3
66
+
67
+ # Check WinHelp 4.x magic (0x5F3F or 0x3F5F in lower 16 bits of 32-bit value)
68
+ magic_dword = sig_data.unpack1("V")
69
+ return :winhelp if (magic_dword & 0xFFFF) == 0x5F3F || (magic_dword & 0xFFFF) == 0x3F5F
70
+
71
+ # Unknown format
72
+ raise Cabriolet::ParseError,
73
+ "Unknown HLP signature: #{sig_data.bytes.map do |b|
74
+ format('0x%02X', b)
75
+ end.join(' ')}"
76
+ ensure
77
+ @io_system.close(handle)
117
78
  end
118
79
  end
119
-
120
- # Check if signature is valid HLP
121
- #
122
- # @param signature [String] Signature bytes
123
- # @return [Boolean] true if valid
124
- def valid_signature?(_signature)
125
- # Accept the placeholder signature or other common HLP signatures
126
- # For now, accept any signature since we're testing without real fixtures
127
- true
128
- end
129
80
  end
130
81
  end
131
82
  end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../binary/bitstream"
4
+
5
+ module Cabriolet
6
+ module HLP
7
+ module QuickHelp
8
+ # Compression stream decoder for QuickHelp topics
9
+ #
10
+ # Handles dictionary substitution (keyword compression) and run-length
11
+ # encoding as specified in the QuickHelp format.
12
+ #
13
+ # Control bytes 0x10-0x1A have special meanings:
14
+ # - 0x10-0x17: Dictionary entry (with optional space append)
15
+ # - 0x18: Run of spaces
16
+ # - 0x19: Run of bytes
17
+ # - 0x1A: Escape byte
18
+ class CompressionStream
19
+ # Initialize compression stream decoder
20
+ #
21
+ # @param input [String, IO] Input data (compressed)
22
+ # @param keywords [Array<String>] Keyword dictionary
23
+ def initialize(input, keywords = [])
24
+ @input = input.is_a?(String) ? StringIO.new(input) : input
25
+ @keywords = keywords || []
26
+ @buffer = ""
27
+ @buffer_pos = 0
28
+ end
29
+
30
+ # Read bytes from the decompressed stream
31
+ #
32
+ # @param length [Integer] Number of bytes to read
33
+ # @return [String] Decompressed data
34
+ def read(length)
35
+ result = String.new(encoding: Encoding::BINARY)
36
+
37
+ while result.bytesize < length
38
+ # Fill buffer if needed
39
+ fill_buffer(length - result.bytesize) if @buffer_pos >= @buffer.bytesize
40
+
41
+ # Check for EOF
42
+ break if @buffer_pos >= @buffer.bytesize
43
+
44
+ # Copy from buffer
45
+ available = @buffer.bytesize - @buffer_pos
46
+ to_copy = [length - result.bytesize, available].min
47
+ result << @buffer[@buffer_pos, to_copy]
48
+ @buffer_pos += to_copy
49
+ end
50
+
51
+ result
52
+ end
53
+
54
+ # Check if at end of stream
55
+ #
56
+ # @return [Boolean] true if EOF
57
+ def eof?
58
+ @buffer_pos >= @buffer.bytesize && @input.eof?
59
+ end
60
+
61
+ private
62
+
63
+ # Fill internal buffer by decoding compressed data
64
+ #
65
+ # @param max_bytes [Integer] Maximum bytes to decode
66
+ def fill_buffer(max_bytes)
67
+ @buffer = String.new(encoding: Encoding::BINARY)
68
+ @buffer_pos = 0
69
+
70
+ # Decode until buffer has enough data or we hit EOF
71
+ while @buffer.bytesize <= 256 && @buffer.bytesize < max_bytes
72
+ byte = read_byte
73
+ break if byte.nil? # EOF
74
+
75
+ if byte < 0x10 || byte > 0x1A
76
+ # Regular value byte
77
+ @buffer << byte.chr
78
+ elsif byte == 0x1A
79
+ # Escape byte - next byte is literal
80
+ escaped = read_byte
81
+ if escaped.nil?
82
+ raise Cabriolet::DecompressionError,
83
+ "Unexpected EOF after escape byte"
84
+ end
85
+
86
+ @buffer << escaped.chr
87
+ elsif byte == 0x19
88
+ # Run of bytes: REPEAT-BYTE, REPEAT-COUNT
89
+ repeat_byte = read_byte
90
+ repeat_count = read_byte
91
+ if repeat_byte.nil? || repeat_count.nil?
92
+ raise Cabriolet::DecompressionError,
93
+ "Unexpected EOF in byte run"
94
+ end
95
+
96
+ @buffer << (repeat_byte.chr * repeat_count)
97
+ elsif byte == 0x18
98
+ # Run of spaces: SPACE-COUNT
99
+ space_count = read_byte
100
+ if space_count.nil?
101
+ raise Cabriolet::DecompressionError,
102
+ "Unexpected EOF in space run"
103
+ end
104
+
105
+ @buffer << (" " * space_count)
106
+ else
107
+ # Dictionary entry (0x10-0x17)
108
+ dict_index_low = read_byte
109
+ if dict_index_low.nil?
110
+ raise Cabriolet::DecompressionError,
111
+ "Unexpected EOF reading dictionary index"
112
+ end
113
+
114
+ # Extract append-space flag (bit 2) and index (bits 0-1 + next 8 bits)
115
+ append_space = byte.anybits?(0x04)
116
+ dict_index = ((byte & 0x03) << 8) | dict_index_low
117
+
118
+ if dict_index >= @keywords.length
119
+ raise Cabriolet::DecompressionError,
120
+ "Dictionary index #{dict_index} out of range (max #{@keywords.length - 1})"
121
+ end
122
+
123
+ @buffer << @keywords[dict_index]
124
+ @buffer << " " if append_space
125
+ end
126
+ end
127
+ end
128
+
129
+ # Read a single byte from input
130
+ #
131
+ # @return [Integer, nil] Byte value or nil on EOF
132
+ def read_byte
133
+ @input.getbyte
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end