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
|
@@ -2,53 +2,240 @@
|
|
|
2
2
|
|
|
3
3
|
module Cabriolet
|
|
4
4
|
module Models
|
|
5
|
-
# Represents
|
|
5
|
+
# Represents a Microsoft Reader LIT file structure
|
|
6
6
|
#
|
|
7
|
-
# LIT files
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
# LIT files have a complex structure with:
|
|
8
|
+
# - Primary and secondary headers
|
|
9
|
+
# - Piece table pointing to various data structures
|
|
10
|
+
# - Internal directory with IFCM/AOLL/AOLI chunks
|
|
11
|
+
# - DataSpace sections with transformation layers (compression/encryption)
|
|
12
|
+
# - Manifest mapping internal to original filenames
|
|
13
|
+
class LITFile
|
|
14
|
+
attr_accessor :version, :header_guid, :piece3_guid, :piece4_guid,
|
|
15
|
+
:content_offset, :timestamp, :language_id, :creator_id,
|
|
16
|
+
:entry_chunklen, :count_chunklen, :entry_unknown,
|
|
17
|
+
:count_unknown, :drm_level, :sections, :directory, :manifest
|
|
11
18
|
|
|
12
19
|
def initialize
|
|
13
20
|
@version = 0
|
|
14
|
-
@
|
|
15
|
-
@
|
|
16
|
-
@
|
|
17
|
-
@
|
|
21
|
+
@header_guid = ""
|
|
22
|
+
@piece3_guid = ""
|
|
23
|
+
@piece4_guid = ""
|
|
24
|
+
@content_offset = 0
|
|
25
|
+
@timestamp = 0
|
|
26
|
+
@language_id = 0
|
|
27
|
+
@creator_id = 0
|
|
28
|
+
@entry_chunklen = 0
|
|
29
|
+
@count_chunklen = 0
|
|
30
|
+
@entry_unknown = 0
|
|
31
|
+
@count_unknown = 0
|
|
32
|
+
@drm_level = 0
|
|
33
|
+
@sections = []
|
|
34
|
+
@directory = nil
|
|
35
|
+
@manifest = nil
|
|
18
36
|
end
|
|
19
37
|
|
|
20
|
-
# Check if the LIT file
|
|
38
|
+
# Check if the LIT file has DRM encryption
|
|
21
39
|
#
|
|
22
|
-
# @return [Boolean] true if
|
|
40
|
+
# @return [Boolean] true if DRM is present
|
|
23
41
|
def encrypted?
|
|
24
|
-
|
|
42
|
+
drm_level.positive?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get section by name
|
|
46
|
+
#
|
|
47
|
+
# @param name [String] Section name
|
|
48
|
+
# @return [LITSection, nil] The section or nil if not found
|
|
49
|
+
def section(name)
|
|
50
|
+
sections.find { |s| s.name == name }
|
|
25
51
|
end
|
|
26
52
|
end
|
|
27
53
|
|
|
28
|
-
# Represents a
|
|
29
|
-
|
|
30
|
-
|
|
54
|
+
# Represents a section within the LIT file
|
|
55
|
+
#
|
|
56
|
+
# Sections contain compressed/encrypted data with transform layers
|
|
57
|
+
class LITSection
|
|
58
|
+
attr_accessor :name, :transforms, :compressed, :encrypted,
|
|
59
|
+
:uncompressed_length, :compressed_length,
|
|
60
|
+
:window_size, :reset_interval, :reset_table
|
|
31
61
|
|
|
32
62
|
def initialize
|
|
33
|
-
@
|
|
34
|
-
@
|
|
35
|
-
@
|
|
36
|
-
@compressed = true
|
|
63
|
+
@name = ""
|
|
64
|
+
@transforms = []
|
|
65
|
+
@compressed = false
|
|
37
66
|
@encrypted = false
|
|
67
|
+
@uncompressed_length = 0
|
|
68
|
+
@compressed_length = 0
|
|
69
|
+
@window_size = 0
|
|
70
|
+
@reset_interval = 0
|
|
71
|
+
@reset_table = []
|
|
38
72
|
end
|
|
39
73
|
|
|
40
|
-
# Check if
|
|
74
|
+
# Check if section is compressed
|
|
41
75
|
#
|
|
42
|
-
# @return [Boolean] true if
|
|
76
|
+
# @return [Boolean] true if compressed
|
|
43
77
|
def compressed?
|
|
44
|
-
|
|
78
|
+
compressed
|
|
45
79
|
end
|
|
46
80
|
|
|
47
|
-
# Check if
|
|
81
|
+
# Check if section is encrypted
|
|
48
82
|
#
|
|
49
|
-
# @return [Boolean] true if
|
|
83
|
+
# @return [Boolean] true if encrypted
|
|
50
84
|
def encrypted?
|
|
51
|
-
|
|
85
|
+
encrypted
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Represents the internal directory structure
|
|
90
|
+
#
|
|
91
|
+
# Directory contains file entries with encoded integers for efficiency
|
|
92
|
+
class LITDirectory
|
|
93
|
+
attr_accessor :entries, :num_chunks, :entry_chunklen, :count_chunklen
|
|
94
|
+
|
|
95
|
+
def initialize
|
|
96
|
+
@entries = []
|
|
97
|
+
@num_chunks = 0
|
|
98
|
+
@entry_chunklen = 0
|
|
99
|
+
@count_chunklen = 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Find entry by name
|
|
103
|
+
#
|
|
104
|
+
# @param name [String] Entry name
|
|
105
|
+
# @return [LITDirectoryEntry, nil] The entry or nil if not found
|
|
106
|
+
def find(name)
|
|
107
|
+
entries.find { |e| e.name == name }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get all entries in a section
|
|
111
|
+
#
|
|
112
|
+
# @param section_id [Integer] Section ID
|
|
113
|
+
# @return [Array<LITDirectoryEntry>] Entries in the section
|
|
114
|
+
def entries_in_section(section_id)
|
|
115
|
+
entries.select { |e| e.section == section_id }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Represents a single directory entry
|
|
120
|
+
#
|
|
121
|
+
# Entries use variable-length encoded integers to save space
|
|
122
|
+
class LITDirectoryEntry
|
|
123
|
+
attr_accessor :name, :section, :offset, :size
|
|
124
|
+
|
|
125
|
+
def initialize
|
|
126
|
+
@name = ""
|
|
127
|
+
@section = 0
|
|
128
|
+
@offset = 0
|
|
129
|
+
@size = 0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Check if this is a root entry
|
|
133
|
+
#
|
|
134
|
+
# @return [Boolean] true if root entry
|
|
135
|
+
def root?
|
|
136
|
+
["/", ""].include?(name)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Get the directory portion of the name
|
|
140
|
+
#
|
|
141
|
+
# @return [String] Directory path
|
|
142
|
+
def directory
|
|
143
|
+
return "/" if root?
|
|
144
|
+
|
|
145
|
+
parts = name.split("/")
|
|
146
|
+
parts[0..-2].join("/")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Get the filename portion
|
|
150
|
+
#
|
|
151
|
+
# @return [String] Filename
|
|
152
|
+
def filename
|
|
153
|
+
return "" if root?
|
|
154
|
+
|
|
155
|
+
name.split("/").last
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Represents the manifest file
|
|
160
|
+
#
|
|
161
|
+
# Maps internal filenames to original filenames and content types
|
|
162
|
+
class LITManifest
|
|
163
|
+
attr_accessor :mappings
|
|
164
|
+
|
|
165
|
+
def initialize
|
|
166
|
+
@mappings = []
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Find mapping by internal name
|
|
170
|
+
#
|
|
171
|
+
# @param internal_name [String] Internal filename
|
|
172
|
+
# @return [LITManifestMapping, nil] The mapping or nil
|
|
173
|
+
def find_by_internal(internal_name)
|
|
174
|
+
mappings.find { |m| m.internal_name == internal_name }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Find mapping by original name
|
|
178
|
+
#
|
|
179
|
+
# @param original_name [String] Original filename
|
|
180
|
+
# @return [LITManifestMapping, nil] The mapping or nil
|
|
181
|
+
def find_by_original(original_name)
|
|
182
|
+
mappings.find { |m| m.original_name == original_name }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Get all HTML files
|
|
186
|
+
#
|
|
187
|
+
# @return [Array<LITManifestMapping>] HTML file mappings
|
|
188
|
+
def html_files
|
|
189
|
+
mappings.select { |m| m.content_type =~ /html/i }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Get all CSS files
|
|
193
|
+
#
|
|
194
|
+
# @return [Array<LITManifestMapping>] CSS file mappings
|
|
195
|
+
def css_files
|
|
196
|
+
mappings.select { |m| m.content_type =~ /css/i }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Get all image files
|
|
200
|
+
#
|
|
201
|
+
# @return [Array<LITManifestMapping>] Image file mappings
|
|
202
|
+
def image_files
|
|
203
|
+
mappings.select { |m| m.content_type =~ /image/i }
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Represents a single manifest mapping
|
|
208
|
+
class LITManifestMapping
|
|
209
|
+
attr_accessor :offset, :internal_name, :original_name, :content_type,
|
|
210
|
+
:group
|
|
211
|
+
|
|
212
|
+
def initialize
|
|
213
|
+
@offset = 0
|
|
214
|
+
@internal_name = ""
|
|
215
|
+
@original_name = ""
|
|
216
|
+
@content_type = ""
|
|
217
|
+
@group = 0
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Check if this is an HTML file
|
|
221
|
+
#
|
|
222
|
+
# @return [Boolean] true if HTML
|
|
223
|
+
def html?
|
|
224
|
+
content_type =~ /html/i
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Check if this is a CSS file
|
|
228
|
+
#
|
|
229
|
+
# @return [Boolean] true if CSS
|
|
230
|
+
def css?
|
|
231
|
+
content_type =~ /css/i
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Check if this is an image
|
|
235
|
+
#
|
|
236
|
+
# @return [Boolean] true if image
|
|
237
|
+
def image?
|
|
238
|
+
content_type =~ /image/i
|
|
52
239
|
end
|
|
53
240
|
end
|
|
54
241
|
end
|
|
@@ -64,8 +64,16 @@ module Cabriolet
|
|
|
64
64
|
return compressed_filename unless normal_format? && @missing_char
|
|
65
65
|
|
|
66
66
|
# Replace trailing underscore with missing character
|
|
67
|
-
#
|
|
68
|
-
compressed_filename.
|
|
67
|
+
# Uppercase unless all extension characters are lowercase
|
|
68
|
+
extension_match = compressed_filename.match(/\.(\w+)_$/)
|
|
69
|
+
if extension_match
|
|
70
|
+
extension = extension_match[1]
|
|
71
|
+
# Uppercase unless extension is entirely lowercase
|
|
72
|
+
missing_char = extension == extension.downcase ? @missing_char.downcase : @missing_char.upcase
|
|
73
|
+
compressed_filename.sub(/\.(\w+)_$/, ".\\1#{missing_char}")
|
|
74
|
+
else
|
|
75
|
+
compressed_filename
|
|
76
|
+
end
|
|
69
77
|
end
|
|
70
78
|
end
|
|
71
79
|
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module Models
|
|
5
|
+
# Windows Help (WinHelp) file header model
|
|
6
|
+
#
|
|
7
|
+
# Represents the metadata of a Windows Help file (WinHelp 3.x or 4.x).
|
|
8
|
+
# WinHelp files contain an internal file system with |SYSTEM, |TOPIC,
|
|
9
|
+
# and other internal files.
|
|
10
|
+
class WinHelpHeader
|
|
11
|
+
attr_accessor :version, :magic, :directory_offset, :free_list_offset, :file_size, :filename # :winhelp3 or :winhelp4 # Magic number (0x35F3 or 0x3F5F0000)
|
|
12
|
+
|
|
13
|
+
# Internal files in the help file
|
|
14
|
+
# Array of hashes: { filename:, file_size:, starting_block: }
|
|
15
|
+
attr_accessor :internal_files
|
|
16
|
+
|
|
17
|
+
# Parsed |SYSTEM file data (if extracted)
|
|
18
|
+
attr_accessor :system_data
|
|
19
|
+
|
|
20
|
+
# Initialize WinHelp header
|
|
21
|
+
#
|
|
22
|
+
# @param version [Symbol] :winhelp3 or :winhelp4
|
|
23
|
+
# @param magic [Integer] Magic number
|
|
24
|
+
# @param directory_offset [Integer] Offset to internal file directory
|
|
25
|
+
# @param free_list_offset [Integer] Offset to free list
|
|
26
|
+
# @param file_size [Integer] Total file size
|
|
27
|
+
# @param filename [String, nil] Original filename
|
|
28
|
+
def initialize(
|
|
29
|
+
version: :winhelp3,
|
|
30
|
+
magic: 0,
|
|
31
|
+
directory_offset: 0,
|
|
32
|
+
free_list_offset: 0,
|
|
33
|
+
file_size: 0,
|
|
34
|
+
filename: nil
|
|
35
|
+
)
|
|
36
|
+
@version = version
|
|
37
|
+
@magic = magic
|
|
38
|
+
@directory_offset = directory_offset
|
|
39
|
+
@free_list_offset = free_list_offset
|
|
40
|
+
@file_size = file_size
|
|
41
|
+
@filename = filename
|
|
42
|
+
|
|
43
|
+
@internal_files = []
|
|
44
|
+
@system_data = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Check if header is valid
|
|
48
|
+
#
|
|
49
|
+
# @return [Boolean] true if header appears valid
|
|
50
|
+
def valid?
|
|
51
|
+
case @version
|
|
52
|
+
when :winhelp3
|
|
53
|
+
@magic == 0x35F3
|
|
54
|
+
when :winhelp4
|
|
55
|
+
(@magic & 0xFFFF) == 0x3F5F
|
|
56
|
+
else
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check if this is WinHelp 3.x format
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean] true if WinHelp 3.x
|
|
64
|
+
def winhelp3?
|
|
65
|
+
@version == :winhelp3
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if this is WinHelp 4.x format
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean] true if WinHelp 4.x
|
|
71
|
+
def winhelp4?
|
|
72
|
+
@version == :winhelp4
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get list of internal filenames
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<String>] Internal file names
|
|
78
|
+
def internal_filenames
|
|
79
|
+
@internal_files.map { |f| f[:filename] }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Find internal file by name
|
|
83
|
+
#
|
|
84
|
+
# @param name [String] Internal filename (e.g., "|SYSTEM")
|
|
85
|
+
# @return [Hash, nil] File entry or nil if not found
|
|
86
|
+
def find_file(name)
|
|
87
|
+
@internal_files.find { |f| f[:filename] == name }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if |SYSTEM file exists
|
|
91
|
+
#
|
|
92
|
+
# @return [Boolean] true if |SYSTEM file present
|
|
93
|
+
def has_system_file?
|
|
94
|
+
!find_file("|SYSTEM").nil?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if |TOPIC file exists
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean] true if |TOPIC file present
|
|
100
|
+
def has_topic_file?
|
|
101
|
+
!find_file("|TOPIC").nil?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get version string
|
|
105
|
+
#
|
|
106
|
+
# @return [String] Human-readable version
|
|
107
|
+
def version_string
|
|
108
|
+
case @version
|
|
109
|
+
when :winhelp3
|
|
110
|
+
"Windows Help 3.x (16-bit)"
|
|
111
|
+
when :winhelp4
|
|
112
|
+
"Windows Help 4.x (32-bit)"
|
|
113
|
+
else
|
|
114
|
+
"Unknown"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get magic number as hex string
|
|
119
|
+
#
|
|
120
|
+
# @return [String] Hex representation of magic
|
|
121
|
+
def magic_hex
|
|
122
|
+
magic_int = @magic.respond_to?(:to_i) ? @magic.to_i : @magic.to_int
|
|
123
|
+
"0x#{magic_int.to_s(16).upcase}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cli/base_command_handler"
|
|
4
|
+
require_relative "decompressor"
|
|
5
|
+
require_relative "compressor"
|
|
6
|
+
|
|
7
|
+
module Cabriolet
|
|
8
|
+
module OAB
|
|
9
|
+
# Command handler for OAB (Outlook Offline Address Book) format
|
|
10
|
+
#
|
|
11
|
+
# This handler implements the unified command interface for OAB files,
|
|
12
|
+
# wrapping the existing OAB::Decompressor and OAB::Compressor classes.
|
|
13
|
+
# OAB files use LZX compression for address book data.
|
|
14
|
+
#
|
|
15
|
+
# Unlike other formats, OAB is a compressed data format rather than
|
|
16
|
+
# an archive - the "list" command displays header information only.
|
|
17
|
+
#
|
|
18
|
+
class CommandHandler < Commands::BaseCommandHandler
|
|
19
|
+
# List OAB file information
|
|
20
|
+
#
|
|
21
|
+
# Displays information about the OAB file including version,
|
|
22
|
+
# block size, and target size.
|
|
23
|
+
#
|
|
24
|
+
# @param file [String] Path to the OAB file
|
|
25
|
+
# @param options [Hash] Additional options (unused)
|
|
26
|
+
# @return [void]
|
|
27
|
+
def list(file, _options = {})
|
|
28
|
+
validate_file_exists(file)
|
|
29
|
+
|
|
30
|
+
display_oab_info(file)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Extract/decompress OAB file
|
|
34
|
+
#
|
|
35
|
+
# Decompresses the OAB file to its original form.
|
|
36
|
+
# Auto-detects output filename if not specified.
|
|
37
|
+
#
|
|
38
|
+
# @param file [String] Path to the OAB file
|
|
39
|
+
# @param output_dir [String] Output directory (not typically used for OAB)
|
|
40
|
+
# @param options [Hash] Additional options
|
|
41
|
+
# @option options [String] :output Output file path
|
|
42
|
+
# @option options [String] :base_file Base file for incremental patches
|
|
43
|
+
# @return [void]
|
|
44
|
+
def extract(file, output_dir = nil, options = {})
|
|
45
|
+
validate_file_exists(file)
|
|
46
|
+
|
|
47
|
+
output = options[:output]
|
|
48
|
+
|
|
49
|
+
# Auto-detect output name if not provided
|
|
50
|
+
if output.nil? && output_dir.nil?
|
|
51
|
+
output = auto_output_filename(file)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# If output_dir is specified, construct output path
|
|
55
|
+
if output.nil? && output_dir
|
|
56
|
+
base_name = File.basename(file, ".*")
|
|
57
|
+
output = File.join(output_dir, base_name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
decompressor = Decompressor.new
|
|
61
|
+
|
|
62
|
+
# Check if this is an incremental patch
|
|
63
|
+
if options[:base_file]
|
|
64
|
+
base_file = options[:base_file]
|
|
65
|
+
validate_file_exists(base_file)
|
|
66
|
+
|
|
67
|
+
puts "Applying incremental patch: #{file} + #{base_file} -> #{output}" if verbose?
|
|
68
|
+
bytes = decompressor.decompress_incremental(file, base_file, output)
|
|
69
|
+
puts "Applied patch to #{output} (#{bytes} bytes)"
|
|
70
|
+
else
|
|
71
|
+
puts "Decompressing #{file} -> #{output}" if verbose?
|
|
72
|
+
bytes = decompressor.decompress(file, output)
|
|
73
|
+
puts "Decompressed #{file} to #{output} (#{bytes} bytes)"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Create OAB compressed file
|
|
78
|
+
#
|
|
79
|
+
# Compresses a file using OAB LZX compression.
|
|
80
|
+
#
|
|
81
|
+
# @param output [String] Output OAB file path
|
|
82
|
+
# @param files [Array<String>] Input file (single file for OAB)
|
|
83
|
+
# @param options [Hash] Additional options
|
|
84
|
+
# @option options [Integer] :block_size Block size for compression
|
|
85
|
+
# @option options [String] :base_file Base file for creating incremental patch
|
|
86
|
+
# @return [void]
|
|
87
|
+
# @raise [ArgumentError] if no file specified or multiple files
|
|
88
|
+
def create(output, files = [], options = {})
|
|
89
|
+
raise ArgumentError, "No file specified" if files.empty?
|
|
90
|
+
|
|
91
|
+
if files.size > 1
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"OAB format supports only one file at a time"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
file = files.first
|
|
97
|
+
unless File.exist?(file)
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"File does not exist: #{file}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
compressor = Compressor.new
|
|
103
|
+
|
|
104
|
+
# Auto-generate output name if not provided
|
|
105
|
+
if output.nil?
|
|
106
|
+
output = "#{file}.oab"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if options[:base_file]
|
|
110
|
+
base_file = options[:base_file]
|
|
111
|
+
unless File.exist?(base_file)
|
|
112
|
+
raise ArgumentError,
|
|
113
|
+
"Base file does not exist: #{base_file}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
puts "Creating incremental patch: #{file} - #{base_file} -> #{output}" if verbose?
|
|
117
|
+
bytes = compressor.compress_incremental(file, base_file, output,
|
|
118
|
+
**options)
|
|
119
|
+
puts "Created incremental patch #{output} (#{bytes} bytes)"
|
|
120
|
+
else
|
|
121
|
+
block_size = options[:block_size]
|
|
122
|
+
puts "Compressing #{file} -> #{output} (block_size: #{block_size || 'default'})" if verbose?
|
|
123
|
+
bytes = compressor.compress(file, output, **options)
|
|
124
|
+
puts "Compressed #{file} to #{output} (#{bytes} bytes)"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Display detailed OAB file information
|
|
129
|
+
#
|
|
130
|
+
# @param file [String] Path to the OAB file
|
|
131
|
+
# @param options [Hash] Additional options (unused)
|
|
132
|
+
# @return [void]
|
|
133
|
+
def info(file, _options = {})
|
|
134
|
+
validate_file_exists(file)
|
|
135
|
+
|
|
136
|
+
display_oab_info(file)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Test OAB file integrity
|
|
140
|
+
#
|
|
141
|
+
# Verifies the OAB file structure.
|
|
142
|
+
#
|
|
143
|
+
# @param file [String] Path to the OAB file
|
|
144
|
+
# @param options [Hash] Additional options (unused)
|
|
145
|
+
# @return [void]
|
|
146
|
+
def test(file, _options = {})
|
|
147
|
+
validate_file_exists(file)
|
|
148
|
+
|
|
149
|
+
puts "Testing #{file}..."
|
|
150
|
+
|
|
151
|
+
# Try to read and validate header
|
|
152
|
+
decompressor = Decompressor.new
|
|
153
|
+
# We can't easily test without decompressing, so we attempt to read the header
|
|
154
|
+
io_system = decompressor.io_system
|
|
155
|
+
handle = io_system.open(file, Constants::MODE_READ)
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
header_data = io_system.read(handle, 16)
|
|
159
|
+
if header_data.length < 16
|
|
160
|
+
puts "ERROR: Failed to read OAB header"
|
|
161
|
+
return
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Check if it's a full file or patch file
|
|
165
|
+
full_header = Binary::OABStructures::FullHeader.read(header_data)
|
|
166
|
+
if full_header.valid?
|
|
167
|
+
puts "OK: OAB full file structure is valid"
|
|
168
|
+
puts "Version: #{full_header.version_hi}.#{full_header.version_lo}"
|
|
169
|
+
puts "Target size: #{full_header.target_size} bytes"
|
|
170
|
+
puts "Block max: #{full_header.block_max} bytes"
|
|
171
|
+
else
|
|
172
|
+
# Check for patch header
|
|
173
|
+
patch_header = Binary::OABStructures::PatchHeader.read(header_data)
|
|
174
|
+
if patch_header.valid?
|
|
175
|
+
puts "OK: OAB patch file structure is valid"
|
|
176
|
+
puts "Version: #{patch_header.version_hi}.#{patch_header.version_lo}"
|
|
177
|
+
puts "Target size: #{patch_header.target_size} bytes"
|
|
178
|
+
puts "Source size: #{patch_header.source_size} bytes"
|
|
179
|
+
else
|
|
180
|
+
puts "ERROR: Invalid OAB header signature"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
puts "ERROR: OAB file validation failed: #{e.message}"
|
|
185
|
+
ensure
|
|
186
|
+
io_system.close(handle)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
# Display OAB file information
|
|
193
|
+
#
|
|
194
|
+
# @param file [String] Path to the OAB file
|
|
195
|
+
# @return [void]
|
|
196
|
+
def display_oab_info(file)
|
|
197
|
+
puts "OAB File Information"
|
|
198
|
+
puts "=" * 50
|
|
199
|
+
puts "Filename: #{file}"
|
|
200
|
+
|
|
201
|
+
decompressor = Decompressor.new
|
|
202
|
+
io_system = decompressor.io_system
|
|
203
|
+
handle = io_system.open(file, Constants::MODE_READ)
|
|
204
|
+
|
|
205
|
+
begin
|
|
206
|
+
header_data = io_system.read(handle, 28) # Read enough for both header types
|
|
207
|
+
|
|
208
|
+
# Try full file header first
|
|
209
|
+
full_header = Binary::OABStructures::FullHeader.read(header_data[0,
|
|
210
|
+
16])
|
|
211
|
+
if full_header.valid?
|
|
212
|
+
puts "Type: Full OAB file"
|
|
213
|
+
puts "Version: #{full_header.version_hi}.#{full_header.version_lo}"
|
|
214
|
+
puts "Target size: #{full_header.target_size} bytes"
|
|
215
|
+
puts "Block max: #{full_header.block_max} bytes"
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Try patch file header
|
|
220
|
+
patch_header = Binary::OABStructures::PatchHeader.read(header_data[0,
|
|
221
|
+
28])
|
|
222
|
+
if patch_header.valid?
|
|
223
|
+
puts "Type: Incremental OAB patch"
|
|
224
|
+
puts "Version: #{patch_header.version_hi}.#{patch_header.version_lo}"
|
|
225
|
+
puts "Target size: #{patch_header.target_size} bytes"
|
|
226
|
+
puts "Source size: #{patch_header.source_size} bytes"
|
|
227
|
+
puts "Source CRC: 0x#{patch_header.source_crc.to_s(16).upcase}"
|
|
228
|
+
puts "Target CRC: 0x#{patch_header.target_crc.to_s(16).upcase}"
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
puts "Type: Unknown (invalid header)"
|
|
233
|
+
rescue StandardError => e
|
|
234
|
+
puts "Error reading OAB header: #{e.message}"
|
|
235
|
+
ensure
|
|
236
|
+
io_system.close(handle)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Auto-detect output filename from OAB file
|
|
241
|
+
#
|
|
242
|
+
# @param file [String] Original file path
|
|
243
|
+
# @return [String] Detected output filename
|
|
244
|
+
def auto_output_filename(file)
|
|
245
|
+
# Remove .oab extension if present, otherwise just return the basename
|
|
246
|
+
base_name = File.basename(file, ".*")
|
|
247
|
+
# If the file doesn't end with .oab, keep the original name
|
|
248
|
+
if file.end_with?(".oab")
|
|
249
|
+
base_name
|
|
250
|
+
else
|
|
251
|
+
# Return with .dat extension (common for decompressed OAB)
|
|
252
|
+
"#{base_name}.dat"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|