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.
- checksums.yaml +4 -4
- data/README.adoc +703 -38
- data/lib/cabriolet/algorithm_factory.rb +250 -0
- data/lib/cabriolet/base_compressor.rb +206 -0
- data/lib/cabriolet/binary/bitstream.rb +167 -16
- data/lib/cabriolet/binary/bitstream_writer.rb +150 -21
- data/lib/cabriolet/binary/chm_structures.rb +2 -2
- data/lib/cabriolet/binary/hlp_structures.rb +258 -37
- data/lib/cabriolet/binary/lit_structures.rb +231 -65
- data/lib/cabriolet/binary/oab_structures.rb +17 -1
- data/lib/cabriolet/cab/command_handler.rb +226 -0
- data/lib/cabriolet/cab/compressor.rb +108 -84
- data/lib/cabriolet/cab/decompressor.rb +16 -20
- data/lib/cabriolet/cab/extractor.rb +142 -66
- data/lib/cabriolet/cab/file_compression_work.rb +52 -0
- data/lib/cabriolet/cab/file_compression_worker.rb +89 -0
- data/lib/cabriolet/checksum.rb +49 -0
- data/lib/cabriolet/chm/command_handler.rb +227 -0
- data/lib/cabriolet/chm/compressor.rb +7 -3
- data/lib/cabriolet/chm/decompressor.rb +39 -21
- data/lib/cabriolet/chm/parser.rb +5 -2
- data/lib/cabriolet/cli/base_command_handler.rb +127 -0
- data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
- data/lib/cabriolet/cli/command_registry.rb +83 -0
- data/lib/cabriolet/cli.rb +356 -607
- data/lib/cabriolet/collections/file_collection.rb +175 -0
- data/lib/cabriolet/compressors/base.rb +1 -1
- data/lib/cabriolet/compressors/lzx.rb +241 -54
- data/lib/cabriolet/compressors/mszip.rb +35 -3
- data/lib/cabriolet/compressors/quantum.rb +36 -95
- data/lib/cabriolet/decompressors/base.rb +1 -1
- data/lib/cabriolet/decompressors/lzss.rb +13 -3
- data/lib/cabriolet/decompressors/lzx.rb +70 -33
- data/lib/cabriolet/decompressors/mszip.rb +126 -39
- data/lib/cabriolet/decompressors/quantum.rb +83 -53
- data/lib/cabriolet/errors.rb +3 -0
- data/lib/cabriolet/extraction/base_extractor.rb +88 -0
- data/lib/cabriolet/extraction/extractor.rb +171 -0
- data/lib/cabriolet/extraction/file_extraction_work.rb +60 -0
- data/lib/cabriolet/extraction/file_extraction_worker.rb +106 -0
- data/lib/cabriolet/file_entry.rb +156 -0
- data/lib/cabriolet/file_manager.rb +144 -0
- data/lib/cabriolet/format_base.rb +79 -0
- data/lib/cabriolet/hlp/command_handler.rb +282 -0
- data/lib/cabriolet/hlp/compressor.rb +28 -238
- data/lib/cabriolet/hlp/decompressor.rb +107 -147
- data/lib/cabriolet/hlp/parser.rb +52 -101
- data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
- data/lib/cabriolet/hlp/quickhelp/compressor.rb +151 -0
- data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
- data/lib/cabriolet/hlp/quickhelp/file_writer.rb +125 -0
- data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
- data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
- data/lib/cabriolet/hlp/quickhelp/offset_calculator.rb +61 -0
- data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
- data/lib/cabriolet/hlp/quickhelp/structure_builder.rb +93 -0
- data/lib/cabriolet/hlp/quickhelp/topic_builder.rb +52 -0
- data/lib/cabriolet/hlp/quickhelp/topic_compressor.rb +83 -0
- data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
- data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
- data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
- data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
- data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
- data/lib/cabriolet/huffman/encoder.rb +15 -12
- data/lib/cabriolet/huffman/tree.rb +85 -1
- data/lib/cabriolet/kwaj/command_handler.rb +213 -0
- data/lib/cabriolet/kwaj/compressor.rb +7 -3
- data/lib/cabriolet/kwaj/decompressor.rb +18 -12
- data/lib/cabriolet/lit/command_handler.rb +221 -0
- data/lib/cabriolet/lit/compressor.rb +119 -168
- data/lib/cabriolet/lit/content_encoder.rb +76 -0
- data/lib/cabriolet/lit/content_type_detector.rb +50 -0
- data/lib/cabriolet/lit/decompressor.rb +518 -152
- data/lib/cabriolet/lit/directory_builder.rb +153 -0
- data/lib/cabriolet/lit/guid_generator.rb +16 -0
- data/lib/cabriolet/lit/header_writer.rb +124 -0
- data/lib/cabriolet/lit/parser.rb +670 -0
- data/lib/cabriolet/lit/piece_builder.rb +74 -0
- data/lib/cabriolet/lit/structure_builder.rb +252 -0
- data/lib/cabriolet/models/hlp_file.rb +130 -29
- data/lib/cabriolet/models/hlp_header.rb +105 -17
- data/lib/cabriolet/models/lit_header.rb +212 -25
- data/lib/cabriolet/models/szdd_header.rb +10 -2
- data/lib/cabriolet/models/winhelp_header.rb +127 -0
- data/lib/cabriolet/oab/command_handler.rb +257 -0
- data/lib/cabriolet/oab/compressor.rb +17 -8
- data/lib/cabriolet/oab/decompressor.rb +41 -10
- data/lib/cabriolet/offset_calculator.rb +81 -0
- data/lib/cabriolet/plugin.rb +233 -0
- data/lib/cabriolet/plugin_manager.rb +453 -0
- data/lib/cabriolet/plugin_validator.rb +422 -0
- data/lib/cabriolet/quantum_shared.rb +105 -0
- data/lib/cabriolet/system/io_system.rb +3 -0
- data/lib/cabriolet/system/memory_handle.rb +17 -4
- data/lib/cabriolet/szdd/command_handler.rb +217 -0
- data/lib/cabriolet/szdd/compressor.rb +15 -11
- data/lib/cabriolet/szdd/decompressor.rb +18 -9
- data/lib/cabriolet/version.rb +1 -1
- data/lib/cabriolet.rb +181 -20
- metadata +69 -4
- data/lib/cabriolet/auto.rb +0 -173
- 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
|
-
#
|
|
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
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
#
|
|
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
|
-
@
|
|
22
|
+
@delegate = nil
|
|
23
|
+
@current_format = nil
|
|
28
24
|
end
|
|
29
25
|
|
|
30
|
-
# Open and parse
|
|
26
|
+
# Open and parse HLP file
|
|
31
27
|
#
|
|
32
|
-
# @param filename [String] Path to
|
|
33
|
-
# @return [Models::HLPHeader] Parsed header
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
45
|
+
# Close HLP file
|
|
42
46
|
#
|
|
43
|
-
# @param
|
|
44
|
-
# @return [
|
|
45
|
-
def close(
|
|
46
|
-
|
|
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
|
|
54
|
+
# Extract a file
|
|
52
55
|
#
|
|
53
|
-
# @param header [Models::HLPHeader]
|
|
54
|
-
# @param hlp_file [Models::HLPFile] File to extract
|
|
55
|
-
# @param output_path [String]
|
|
56
|
-
# @return [Integer]
|
|
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"
|
|
60
|
-
raise ArgumentError, "HLP file must not be nil"
|
|
61
|
-
raise ArgumentError, "Output path must not be nil"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
75
|
+
# Extract file to memory
|
|
92
76
|
#
|
|
93
|
-
# @param header [Models::HLPHeader]
|
|
77
|
+
# @param header [Models::HLPHeader, Models::WinHelpHeader] Parsed header
|
|
94
78
|
# @param hlp_file [Models::HLPFile] File to extract
|
|
95
|
-
# @return [String]
|
|
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"
|
|
99
|
-
raise ArgumentError, "HLP file must not be nil"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
93
|
+
# Extract all files
|
|
122
94
|
#
|
|
123
|
-
# @param header [Models::HLPHeader]
|
|
124
|
-
# @param output_dir [String]
|
|
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"
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
128
|
+
# Detect HLP format
|
|
153
129
|
#
|
|
154
|
-
# @param
|
|
155
|
-
# @
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
data/lib/cabriolet/hlp/parser.rb
CHANGED
|
@@ -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
|
-
#
|
|
8
|
+
# Main parser for HLP files
|
|
6
9
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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 [
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
#
|
|
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
|
|
76
|
-
# @
|
|
77
|
-
# @
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|