cabriolet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +799 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE +29 -0
- data/README.adoc +1207 -0
- data/exe/cabriolet +6 -0
- data/lib/cabriolet/auto.rb +173 -0
- data/lib/cabriolet/binary/bitstream.rb +148 -0
- data/lib/cabriolet/binary/bitstream_writer.rb +180 -0
- data/lib/cabriolet/binary/chm_structures.rb +213 -0
- data/lib/cabriolet/binary/hlp_structures.rb +66 -0
- data/lib/cabriolet/binary/kwaj_structures.rb +74 -0
- data/lib/cabriolet/binary/lit_structures.rb +107 -0
- data/lib/cabriolet/binary/oab_structures.rb +112 -0
- data/lib/cabriolet/binary/structures.rb +56 -0
- data/lib/cabriolet/binary/szdd_structures.rb +60 -0
- data/lib/cabriolet/cab/compressor.rb +382 -0
- data/lib/cabriolet/cab/decompressor.rb +510 -0
- data/lib/cabriolet/cab/extractor.rb +357 -0
- data/lib/cabriolet/cab/parser.rb +264 -0
- data/lib/cabriolet/chm/compressor.rb +513 -0
- data/lib/cabriolet/chm/decompressor.rb +436 -0
- data/lib/cabriolet/chm/parser.rb +254 -0
- data/lib/cabriolet/cli.rb +776 -0
- data/lib/cabriolet/compressors/base.rb +34 -0
- data/lib/cabriolet/compressors/lzss.rb +250 -0
- data/lib/cabriolet/compressors/lzx.rb +581 -0
- data/lib/cabriolet/compressors/mszip.rb +315 -0
- data/lib/cabriolet/compressors/quantum.rb +446 -0
- data/lib/cabriolet/constants.rb +75 -0
- data/lib/cabriolet/decompressors/base.rb +39 -0
- data/lib/cabriolet/decompressors/lzss.rb +138 -0
- data/lib/cabriolet/decompressors/lzx.rb +726 -0
- data/lib/cabriolet/decompressors/mszip.rb +390 -0
- data/lib/cabriolet/decompressors/none.rb +27 -0
- data/lib/cabriolet/decompressors/quantum.rb +456 -0
- data/lib/cabriolet/errors.rb +39 -0
- data/lib/cabriolet/format_detector.rb +156 -0
- data/lib/cabriolet/hlp/compressor.rb +272 -0
- data/lib/cabriolet/hlp/decompressor.rb +198 -0
- data/lib/cabriolet/hlp/parser.rb +131 -0
- data/lib/cabriolet/huffman/decoder.rb +79 -0
- data/lib/cabriolet/huffman/encoder.rb +108 -0
- data/lib/cabriolet/huffman/tree.rb +138 -0
- data/lib/cabriolet/kwaj/compressor.rb +479 -0
- data/lib/cabriolet/kwaj/decompressor.rb +237 -0
- data/lib/cabriolet/kwaj/parser.rb +183 -0
- data/lib/cabriolet/lit/compressor.rb +255 -0
- data/lib/cabriolet/lit/decompressor.rb +250 -0
- data/lib/cabriolet/models/cabinet.rb +81 -0
- data/lib/cabriolet/models/chm_file.rb +28 -0
- data/lib/cabriolet/models/chm_header.rb +67 -0
- data/lib/cabriolet/models/chm_section.rb +38 -0
- data/lib/cabriolet/models/file.rb +119 -0
- data/lib/cabriolet/models/folder.rb +102 -0
- data/lib/cabriolet/models/folder_data.rb +21 -0
- data/lib/cabriolet/models/hlp_file.rb +45 -0
- data/lib/cabriolet/models/hlp_header.rb +37 -0
- data/lib/cabriolet/models/kwaj_header.rb +98 -0
- data/lib/cabriolet/models/lit_header.rb +55 -0
- data/lib/cabriolet/models/oab_header.rb +95 -0
- data/lib/cabriolet/models/szdd_header.rb +72 -0
- data/lib/cabriolet/modifier.rb +326 -0
- data/lib/cabriolet/oab/compressor.rb +353 -0
- data/lib/cabriolet/oab/decompressor.rb +315 -0
- data/lib/cabriolet/parallel.rb +333 -0
- data/lib/cabriolet/repairer.rb +288 -0
- data/lib/cabriolet/streaming.rb +221 -0
- data/lib/cabriolet/system/file_handle.rb +107 -0
- data/lib/cabriolet/system/io_system.rb +87 -0
- data/lib/cabriolet/system/memory_handle.rb +105 -0
- data/lib/cabriolet/szdd/compressor.rb +217 -0
- data/lib/cabriolet/szdd/decompressor.rb +184 -0
- data/lib/cabriolet/szdd/parser.rb +127 -0
- data/lib/cabriolet/validator.rb +332 -0
- data/lib/cabriolet/version.rb +5 -0
- data/lib/cabriolet.rb +104 -0
- metadata +157 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module KWAJ
|
|
5
|
+
# Parser reads and parses KWAJ file headers
|
|
6
|
+
#
|
|
7
|
+
# KWAJ files support multiple compression methods and have variable-length
|
|
8
|
+
# headers with optional fields determined by flag bits.
|
|
9
|
+
class Parser
|
|
10
|
+
attr_reader :io_system
|
|
11
|
+
|
|
12
|
+
# Initialize a new parser
|
|
13
|
+
#
|
|
14
|
+
# @param io_system [System::IOSystem] I/O system for reading
|
|
15
|
+
def initialize(io_system)
|
|
16
|
+
@io_system = io_system
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parse a KWAJ file and return header information
|
|
20
|
+
#
|
|
21
|
+
# @param filename [String] Path to the KWAJ file
|
|
22
|
+
# @return [Models::KWAJHeader] Parsed header
|
|
23
|
+
# @raise [Errors::ParseError] if the file is not a valid KWAJ
|
|
24
|
+
def parse(filename)
|
|
25
|
+
handle = @io_system.open(filename, Constants::MODE_READ)
|
|
26
|
+
header = parse_handle(handle)
|
|
27
|
+
@io_system.close(handle)
|
|
28
|
+
header
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parse KWAJ header from an already-open handle
|
|
32
|
+
#
|
|
33
|
+
# @param handle [System::FileHandle, System::MemoryHandle] Open handle
|
|
34
|
+
# @return [Models::KWAJHeader] Parsed header
|
|
35
|
+
# @raise [Errors::ParseError] if not a valid KWAJ
|
|
36
|
+
def parse_handle(handle)
|
|
37
|
+
# Read base header (14 bytes)
|
|
38
|
+
base_data = @io_system.read(handle, 14)
|
|
39
|
+
raise ParseError, "Cannot read KWAJ header" if base_data.bytesize < 14
|
|
40
|
+
|
|
41
|
+
# Parse base header
|
|
42
|
+
base = Binary::KWAJStructures::BaseHeader.read(base_data)
|
|
43
|
+
|
|
44
|
+
# Verify signature
|
|
45
|
+
unless Binary::KWAJStructures.valid_signature?(
|
|
46
|
+
base.signature1, base.signature2
|
|
47
|
+
)
|
|
48
|
+
raise ParseError, "Invalid KWAJ signature"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Create header model
|
|
52
|
+
header = Models::KWAJHeader.new
|
|
53
|
+
header.comp_type = base.comp_method
|
|
54
|
+
header.data_offset = base.data_offset
|
|
55
|
+
header.headers = base.flags
|
|
56
|
+
|
|
57
|
+
# Parse optional headers based on flags
|
|
58
|
+
parse_optional_headers(handle, header)
|
|
59
|
+
|
|
60
|
+
header
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Parse optional headers based on flag bits
|
|
66
|
+
#
|
|
67
|
+
# @param handle [System::FileHandle, System::MemoryHandle] Open handle
|
|
68
|
+
# @param header [Models::KWAJHeader] Header to populate
|
|
69
|
+
# @return [void]
|
|
70
|
+
# @raise [Errors::ParseError] if header parsing fails
|
|
71
|
+
def parse_optional_headers(handle, header)
|
|
72
|
+
# Optional length field (4 bytes)
|
|
73
|
+
if header.has_length?
|
|
74
|
+
data = @io_system.read(handle, 4)
|
|
75
|
+
raise ParseError, "Cannot read length field" if data.bytesize < 4
|
|
76
|
+
|
|
77
|
+
header.length = data.unpack1("V") # Little-endian uint32
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Optional unknown field 1 (2 bytes)
|
|
81
|
+
if header.headers.anybits?(Constants::KWAJ_HDR_HASUNKNOWN1)
|
|
82
|
+
data = @io_system.read(handle, 2)
|
|
83
|
+
raise ParseError, "Cannot read unknown1 field" if data.bytesize < 2
|
|
84
|
+
# We read it but don't store it
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Optional unknown field 2 (variable length)
|
|
88
|
+
if header.headers.anybits?(Constants::KWAJ_HDR_HASUNKNOWN2)
|
|
89
|
+
data = @io_system.read(handle, 2)
|
|
90
|
+
raise ParseError, "Cannot read unknown2 length" if data.bytesize < 2
|
|
91
|
+
|
|
92
|
+
length = data.unpack1("v") # Little-endian uint16
|
|
93
|
+
|
|
94
|
+
# Skip the unknown data
|
|
95
|
+
if length.positive?
|
|
96
|
+
skip_data = @io_system.read(handle, length)
|
|
97
|
+
if skip_data.bytesize < length
|
|
98
|
+
raise ParseError,
|
|
99
|
+
"Cannot read unknown2 data"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Optional filename and extension
|
|
105
|
+
if header.has_filename? || header.has_file_extension?
|
|
106
|
+
parse_filename(handle,
|
|
107
|
+
header)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Optional extra text (variable length)
|
|
111
|
+
return unless header.has_extra_text?
|
|
112
|
+
|
|
113
|
+
data = @io_system.read(handle, 2)
|
|
114
|
+
raise ParseError, "Cannot read extra text length" if
|
|
115
|
+
data.bytesize < 2
|
|
116
|
+
|
|
117
|
+
length = data.unpack1("v") # Little-endian uint16
|
|
118
|
+
|
|
119
|
+
return unless length.positive?
|
|
120
|
+
|
|
121
|
+
extra_data = @io_system.read(handle, length)
|
|
122
|
+
if extra_data.bytesize < length
|
|
123
|
+
raise ParseError,
|
|
124
|
+
"Cannot read extra text data"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
header.extra = extra_data
|
|
128
|
+
header.extra_length = length
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Parse filename and extension fields
|
|
132
|
+
#
|
|
133
|
+
# @param handle [System::FileHandle, System::MemoryHandle] Open handle
|
|
134
|
+
# @param header [Models::KWAJHeader] Header to populate
|
|
135
|
+
# @return [void]
|
|
136
|
+
# @raise [Errors::ParseError] if filename parsing fails
|
|
137
|
+
def parse_filename(handle, header)
|
|
138
|
+
filename_parts = []
|
|
139
|
+
|
|
140
|
+
# Read filename (up to 9 bytes, null-terminated)
|
|
141
|
+
if header.has_filename?
|
|
142
|
+
name_data = @io_system.read(handle, 9)
|
|
143
|
+
raise ParseError, "Cannot read filename" if name_data.empty?
|
|
144
|
+
|
|
145
|
+
# Find null terminator or end of data
|
|
146
|
+
null_pos = name_data.index("\x00")
|
|
147
|
+
raise ParseError, "Filename not null-terminated" unless null_pos
|
|
148
|
+
|
|
149
|
+
filename_parts << name_data[0...null_pos]
|
|
150
|
+
# Seek back to position after null terminator
|
|
151
|
+
bytes_to_skip = null_pos + 1 - name_data.bytesize
|
|
152
|
+
@io_system.seek(handle, bytes_to_skip, Constants::SEEK_CUR) if
|
|
153
|
+
bytes_to_skip != 0
|
|
154
|
+
|
|
155
|
+
# No null terminator in 9 bytes is an error
|
|
156
|
+
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Read extension (up to 4 bytes, null-terminated)
|
|
160
|
+
if header.has_file_extension?
|
|
161
|
+
ext_data = @io_system.read(handle, 4)
|
|
162
|
+
raise ParseError, "Cannot read file extension" if ext_data.empty?
|
|
163
|
+
|
|
164
|
+
# Find null terminator or end of data
|
|
165
|
+
null_pos = ext_data.index("\x00")
|
|
166
|
+
raise ParseError, "File extension not null-terminated" unless null_pos
|
|
167
|
+
|
|
168
|
+
extension = ext_data[0...null_pos]
|
|
169
|
+
filename_parts << ".#{extension}" unless extension.empty?
|
|
170
|
+
# Seek back to position after null terminator
|
|
171
|
+
bytes_to_skip = null_pos + 1 - ext_data.bytesize
|
|
172
|
+
@io_system.seek(handle, bytes_to_skip, Constants::SEEK_CUR) if
|
|
173
|
+
bytes_to_skip != 0
|
|
174
|
+
|
|
175
|
+
# No null terminator in 4 bytes is an error
|
|
176
|
+
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
header.filename = filename_parts.join unless filename_parts.empty?
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module LIT
|
|
5
|
+
# Compressor creates LIT eBook files
|
|
6
|
+
#
|
|
7
|
+
# LIT files are Microsoft Reader eBook files that use LZX compression.
|
|
8
|
+
# The compressor allows adding multiple files to create a LIT archive.
|
|
9
|
+
#
|
|
10
|
+
# NOTE: This implementation creates non-encrypted LIT files only.
|
|
11
|
+
# DES encryption (DRM protection) is not implemented.
|
|
12
|
+
class Compressor
|
|
13
|
+
attr_reader :io_system
|
|
14
|
+
attr_accessor :files
|
|
15
|
+
|
|
16
|
+
# Initialize a new LIT compressor
|
|
17
|
+
#
|
|
18
|
+
# @param io_system [System::IOSystem, nil] Custom I/O system or nil for
|
|
19
|
+
# default
|
|
20
|
+
def initialize(io_system = nil)
|
|
21
|
+
@io_system = io_system || System::IOSystem.new
|
|
22
|
+
@files = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Add a file to the LIT archive
|
|
26
|
+
#
|
|
27
|
+
# @param source_path [String] Path to the source file
|
|
28
|
+
# @param lit_path [String] Path within the LIT archive
|
|
29
|
+
# @param options [Hash] Options for the file
|
|
30
|
+
# @option options [Boolean] :compress Whether to compress the file
|
|
31
|
+
# (default: true)
|
|
32
|
+
# @return [void]
|
|
33
|
+
def add_file(source_path, lit_path, **options)
|
|
34
|
+
compress = options.fetch(:compress, true)
|
|
35
|
+
|
|
36
|
+
@files << {
|
|
37
|
+
source: source_path,
|
|
38
|
+
lit_path: lit_path,
|
|
39
|
+
compress: compress,
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Generate the LIT archive
|
|
44
|
+
#
|
|
45
|
+
# @param output_file [String] Path to output LIT file
|
|
46
|
+
# @param options [Hash] Generation options
|
|
47
|
+
# @option options [Integer] :version LIT format version (default: 1)
|
|
48
|
+
# @option options [Boolean] :encrypt Whether to encrypt (not supported,
|
|
49
|
+
# raises error)
|
|
50
|
+
# @return [Integer] Bytes written to output file
|
|
51
|
+
# @raise [Errors::CompressionError] if generation fails
|
|
52
|
+
# @raise [NotImplementedError] if encryption is requested
|
|
53
|
+
def generate(output_file, **options)
|
|
54
|
+
version = options.fetch(:version, 1)
|
|
55
|
+
encrypt = options.fetch(:encrypt, false)
|
|
56
|
+
|
|
57
|
+
if encrypt
|
|
58
|
+
raise NotImplementedError,
|
|
59
|
+
"DES encryption is not implemented. " \
|
|
60
|
+
"LIT files will be created without encryption."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "No files added to archive" if @files.empty?
|
|
64
|
+
|
|
65
|
+
output_handle = @io_system.open(output_file, Constants::MODE_WRITE)
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
# Prepare file data
|
|
69
|
+
file_data = prepare_files
|
|
70
|
+
|
|
71
|
+
# Write header
|
|
72
|
+
header_bytes = write_header(
|
|
73
|
+
output_handle,
|
|
74
|
+
version,
|
|
75
|
+
file_data.size,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Write file entries
|
|
79
|
+
entries_bytes = write_file_entries(output_handle, file_data)
|
|
80
|
+
|
|
81
|
+
# Write file contents
|
|
82
|
+
content_bytes = write_file_contents(
|
|
83
|
+
output_handle,
|
|
84
|
+
file_data,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
header_bytes + entries_bytes + content_bytes
|
|
88
|
+
ensure
|
|
89
|
+
@io_system.close(output_handle) if output_handle
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Prepare file data for archiving
|
|
96
|
+
#
|
|
97
|
+
# @return [Array<Hash>] Array of file information hashes
|
|
98
|
+
def prepare_files
|
|
99
|
+
@files.map do |file_info|
|
|
100
|
+
source = file_info[:source]
|
|
101
|
+
lit_path = file_info[:lit_path]
|
|
102
|
+
compress = file_info[:compress]
|
|
103
|
+
|
|
104
|
+
# Read source file
|
|
105
|
+
handle = @io_system.open(source, Constants::MODE_READ)
|
|
106
|
+
begin
|
|
107
|
+
size = @io_system.seek(handle, 0, Constants::SEEK_END)
|
|
108
|
+
@io_system.seek(handle, 0, Constants::SEEK_START)
|
|
109
|
+
data = @io_system.read(handle, size)
|
|
110
|
+
ensure
|
|
111
|
+
@io_system.close(handle)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
lit_path: lit_path,
|
|
116
|
+
data: data,
|
|
117
|
+
uncompressed_size: data.bytesize,
|
|
118
|
+
compress: compress,
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Write LIT header
|
|
124
|
+
#
|
|
125
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
126
|
+
# @param version [Integer] LIT format version
|
|
127
|
+
# @param file_count [Integer] Number of files
|
|
128
|
+
# @return [Integer] Number of bytes written
|
|
129
|
+
def write_header(output_handle, version, file_count)
|
|
130
|
+
header = Binary::LITStructures::LITHeader.new
|
|
131
|
+
header.signature = Binary::LITStructures::SIGNATURE
|
|
132
|
+
header.version = version
|
|
133
|
+
header.flags = 0 # Not encrypted
|
|
134
|
+
header.file_count = file_count
|
|
135
|
+
header.header_size = 24 # Size of the header structure
|
|
136
|
+
|
|
137
|
+
header_data = header.to_binary_s
|
|
138
|
+
written = @io_system.write(output_handle, header_data)
|
|
139
|
+
|
|
140
|
+
unless written == header_data.bytesize
|
|
141
|
+
raise Errors::CompressionError,
|
|
142
|
+
"Failed to write LIT header"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
written
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Write file entries directory
|
|
149
|
+
#
|
|
150
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
151
|
+
# @param file_data [Array<Hash>] Array of file information
|
|
152
|
+
# @return [Integer] Number of bytes written
|
|
153
|
+
def write_file_entries(output_handle, file_data)
|
|
154
|
+
total_bytes = 0
|
|
155
|
+
current_offset = calculate_header_size(file_data)
|
|
156
|
+
|
|
157
|
+
file_data.each do |file_info|
|
|
158
|
+
# Compress or store data
|
|
159
|
+
if file_info[:compress]
|
|
160
|
+
compressed = compress_data(file_info[:data])
|
|
161
|
+
compressed_size = compressed.bytesize
|
|
162
|
+
flags = Binary::LITStructures::FileFlags::COMPRESSED
|
|
163
|
+
else
|
|
164
|
+
compressed = file_info[:data]
|
|
165
|
+
compressed_size = compressed.bytesize
|
|
166
|
+
flags = 0
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Store compressed data for later writing
|
|
170
|
+
file_info[:compressed_data] = compressed
|
|
171
|
+
file_info[:compressed_size] = compressed_size
|
|
172
|
+
file_info[:offset] = current_offset
|
|
173
|
+
|
|
174
|
+
# Write file entry
|
|
175
|
+
entry = Binary::LITStructures::LITFileEntry.new
|
|
176
|
+
entry.filename_length = file_info[:lit_path].bytesize
|
|
177
|
+
entry.filename = file_info[:lit_path]
|
|
178
|
+
entry.offset = current_offset
|
|
179
|
+
entry.compressed_size = compressed_size
|
|
180
|
+
entry.uncompressed_size = file_info[:uncompressed_size]
|
|
181
|
+
entry.flags = flags
|
|
182
|
+
|
|
183
|
+
entry_data = entry.to_binary_s
|
|
184
|
+
written = @io_system.write(output_handle, entry_data)
|
|
185
|
+
total_bytes += written
|
|
186
|
+
|
|
187
|
+
current_offset += compressed_size
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
total_bytes
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Write file contents
|
|
194
|
+
#
|
|
195
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
196
|
+
# @param file_data [Array<Hash>] Array of file information with
|
|
197
|
+
# compressed data
|
|
198
|
+
# @return [Integer] Number of bytes written
|
|
199
|
+
def write_file_contents(output_handle, file_data)
|
|
200
|
+
total_bytes = 0
|
|
201
|
+
|
|
202
|
+
file_data.each do |file_info|
|
|
203
|
+
written = @io_system.write(
|
|
204
|
+
output_handle,
|
|
205
|
+
file_info[:compressed_data],
|
|
206
|
+
)
|
|
207
|
+
total_bytes += written
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
total_bytes
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Calculate total header size (header + all file entries)
|
|
214
|
+
#
|
|
215
|
+
# @param file_data [Array<Hash>] Array of file information
|
|
216
|
+
# @return [Integer] Total header size in bytes
|
|
217
|
+
def calculate_header_size(file_data)
|
|
218
|
+
# Header: 24 bytes
|
|
219
|
+
header_size = 24
|
|
220
|
+
|
|
221
|
+
# File entries: variable size
|
|
222
|
+
file_data.each do |file_info|
|
|
223
|
+
# 4 bytes filename length + filename + 28 bytes metadata
|
|
224
|
+
header_size += 4 + file_info[:lit_path].bytesize + 28
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
header_size
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Compress data using LZX
|
|
231
|
+
#
|
|
232
|
+
# @param data [String] Data to compress
|
|
233
|
+
# @return [String] Compressed data
|
|
234
|
+
def compress_data(data)
|
|
235
|
+
input_handle = System::MemoryHandle.new(data)
|
|
236
|
+
output_handle = System::MemoryHandle.new("", Constants::MODE_WRITE)
|
|
237
|
+
|
|
238
|
+
begin
|
|
239
|
+
compressor = Compressors::LZX.new(
|
|
240
|
+
@io_system,
|
|
241
|
+
input_handle,
|
|
242
|
+
output_handle,
|
|
243
|
+
32_768,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
compressor.compress
|
|
247
|
+
|
|
248
|
+
output_handle.data
|
|
249
|
+
|
|
250
|
+
# Memory handles don't need closing but maintain consistency
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module LIT
|
|
5
|
+
# Decompressor is the main interface for LIT file operations
|
|
6
|
+
#
|
|
7
|
+
# LIT files are Microsoft Reader eBook files that use LZX compression.
|
|
8
|
+
#
|
|
9
|
+
# NOTE: This implementation handles non-encrypted LIT files only.
|
|
10
|
+
# DES-encrypted (DRM-protected) LIT files are not supported.
|
|
11
|
+
# For encrypted files, use Microsoft Reader or convert to another format
|
|
12
|
+
# first.
|
|
13
|
+
class Decompressor
|
|
14
|
+
attr_reader :io_system
|
|
15
|
+
attr_accessor :buffer_size
|
|
16
|
+
|
|
17
|
+
# Input buffer size for decompression
|
|
18
|
+
DEFAULT_BUFFER_SIZE = 32_768
|
|
19
|
+
|
|
20
|
+
# Initialize a new LIT decompressor
|
|
21
|
+
#
|
|
22
|
+
# @param io_system [System::IOSystem, nil] Custom I/O system or nil for
|
|
23
|
+
# default
|
|
24
|
+
def initialize(io_system = nil)
|
|
25
|
+
@io_system = io_system || System::IOSystem.new
|
|
26
|
+
@buffer_size = DEFAULT_BUFFER_SIZE
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Open and parse a LIT file
|
|
30
|
+
#
|
|
31
|
+
# @param filename [String] Path to the LIT file
|
|
32
|
+
# @return [Models::LITHeader] Parsed header with file list
|
|
33
|
+
# @raise [Errors::ParseError] if the file is not a valid LIT
|
|
34
|
+
# @raise [NotImplementedError] if the file is DES-encrypted
|
|
35
|
+
def open(filename)
|
|
36
|
+
header = parse_header(filename)
|
|
37
|
+
header.filename = filename
|
|
38
|
+
|
|
39
|
+
# Check for encryption
|
|
40
|
+
if header.encrypted?
|
|
41
|
+
raise NotImplementedError,
|
|
42
|
+
"DES-encrypted LIT files not yet supported. " \
|
|
43
|
+
"Use Microsoft Reader or another tool to decrypt first."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
header
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Close a LIT file (no-op for compatibility)
|
|
50
|
+
#
|
|
51
|
+
# @param _header [Models::LITHeader] Header to close
|
|
52
|
+
# @return [void]
|
|
53
|
+
def close(_header)
|
|
54
|
+
# No resources to free in the header itself
|
|
55
|
+
# File handles are managed separately during extraction
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Extract a file from LIT archive
|
|
60
|
+
#
|
|
61
|
+
# @param header [Models::LITHeader] LIT header from open()
|
|
62
|
+
# @param file [Models::LITFile] File entry to extract
|
|
63
|
+
# @param output_path [String] Where to write the decompressed file
|
|
64
|
+
# @return [Integer] Number of bytes written
|
|
65
|
+
# @raise [Errors::DecompressionError] if decompression fails
|
|
66
|
+
# @raise [NotImplementedError] if the file is encrypted
|
|
67
|
+
def extract(header, file, output_path)
|
|
68
|
+
raise ArgumentError, "Header must not be nil" unless header
|
|
69
|
+
raise ArgumentError, "File must not be nil" unless file
|
|
70
|
+
raise ArgumentError, "Output path must not be nil" unless output_path
|
|
71
|
+
|
|
72
|
+
if file.encrypted?
|
|
73
|
+
raise NotImplementedError,
|
|
74
|
+
"DES-encrypted files not yet supported. " \
|
|
75
|
+
"Use Microsoft Reader or another tool to decrypt first."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
input_handle = @io_system.open(header.filename, Constants::MODE_READ)
|
|
79
|
+
output_handle = @io_system.open(output_path, Constants::MODE_WRITE)
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
# Seek to file data
|
|
83
|
+
@io_system.seek(input_handle, file.offset, Constants::SEEK_START)
|
|
84
|
+
|
|
85
|
+
bytes_written = if file.compressed?
|
|
86
|
+
# Decompress using LZX
|
|
87
|
+
decompress_lzx(
|
|
88
|
+
input_handle, output_handle, file.length
|
|
89
|
+
)
|
|
90
|
+
else
|
|
91
|
+
# Direct copy
|
|
92
|
+
copy_data(
|
|
93
|
+
input_handle, output_handle, file.length
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
bytes_written
|
|
98
|
+
ensure
|
|
99
|
+
@io_system.close(input_handle) if input_handle
|
|
100
|
+
@io_system.close(output_handle) if output_handle
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Extract all files from LIT archive
|
|
105
|
+
#
|
|
106
|
+
# @param header [Models::LITHeader] LIT header from open()
|
|
107
|
+
# @param output_dir [String] Directory to extract files to
|
|
108
|
+
# @return [Integer] Number of files extracted
|
|
109
|
+
# @raise [Errors::DecompressionError] if extraction fails
|
|
110
|
+
def extract_all(header, output_dir)
|
|
111
|
+
raise ArgumentError, "Header must not be nil" unless header
|
|
112
|
+
raise ArgumentError, "Output dir must not be nil" unless output_dir
|
|
113
|
+
|
|
114
|
+
# Create output directory if it doesn't exist
|
|
115
|
+
::FileUtils.mkdir_p(output_dir)
|
|
116
|
+
|
|
117
|
+
extracted = 0
|
|
118
|
+
header.files.each do |file|
|
|
119
|
+
output_path = ::File.join(output_dir, file.filename)
|
|
120
|
+
|
|
121
|
+
# Create subdirectories if needed
|
|
122
|
+
file_dir = ::File.dirname(output_path)
|
|
123
|
+
::FileUtils.mkdir_p(file_dir) unless ::File.directory?(file_dir)
|
|
124
|
+
|
|
125
|
+
extract(header, file, output_path)
|
|
126
|
+
extracted += 1
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
extracted
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Parse LIT file header
|
|
135
|
+
#
|
|
136
|
+
# @param filename [String] Path to LIT file
|
|
137
|
+
# @return [Models::LITHeader] Parsed header
|
|
138
|
+
# @raise [Errors::ParseError] if file is not valid LIT
|
|
139
|
+
def parse_header(filename)
|
|
140
|
+
handle = @io_system.open(filename, Constants::MODE_READ)
|
|
141
|
+
|
|
142
|
+
begin
|
|
143
|
+
# Read and verify signature
|
|
144
|
+
signature = @io_system.read(handle, 8)
|
|
145
|
+
unless signature.start_with?(Binary::LITStructures::SIGNATURE[0..3])
|
|
146
|
+
raise Errors::ParseError,
|
|
147
|
+
"Not a valid LIT file: invalid signature"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Seek back to start
|
|
151
|
+
@io_system.seek(handle, 0, Constants::SEEK_START)
|
|
152
|
+
|
|
153
|
+
# Read header structure
|
|
154
|
+
header_data = @io_system.read(handle, 24)
|
|
155
|
+
lit_header = Binary::LITStructures::LITHeader.read(header_data)
|
|
156
|
+
|
|
157
|
+
# Create header model
|
|
158
|
+
header = Models::LITHeader.new
|
|
159
|
+
header.version = lit_header.version
|
|
160
|
+
header.encrypted = lit_header.flags.anybits?(0x01)
|
|
161
|
+
|
|
162
|
+
# Parse file entries
|
|
163
|
+
header.files = parse_file_entries(
|
|
164
|
+
handle, lit_header.file_count
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
header
|
|
168
|
+
ensure
|
|
169
|
+
@io_system.close(handle) if handle
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Parse file entries from LIT archive
|
|
174
|
+
#
|
|
175
|
+
# @param handle [System::FileHandle] File handle positioned at file
|
|
176
|
+
# entries
|
|
177
|
+
# @param file_count [Integer] Number of files to parse
|
|
178
|
+
# @return [Array<Models::LITFile>] List of file entries
|
|
179
|
+
def parse_file_entries(handle, file_count)
|
|
180
|
+
files = []
|
|
181
|
+
|
|
182
|
+
file_count.times do
|
|
183
|
+
# Read filename length
|
|
184
|
+
len_data = @io_system.read(handle, 4)
|
|
185
|
+
filename_length = len_data.unpack1("V")
|
|
186
|
+
|
|
187
|
+
# Read filename
|
|
188
|
+
filename = @io_system.read(handle, filename_length)
|
|
189
|
+
|
|
190
|
+
# Read file metadata
|
|
191
|
+
metadata = @io_system.read(handle, 28)
|
|
192
|
+
offset, _, uncompressed_size, flags =
|
|
193
|
+
metadata.unpack("QQQV")
|
|
194
|
+
|
|
195
|
+
# Create file entry
|
|
196
|
+
file = Models::LITFile.new
|
|
197
|
+
file.filename = filename
|
|
198
|
+
file.offset = offset
|
|
199
|
+
file.length = uncompressed_size
|
|
200
|
+
file.compressed = flags.anybits?(Binary::LITStructures::FileFlags::COMPRESSED)
|
|
201
|
+
file.encrypted = flags.anybits?(Binary::LITStructures::FileFlags::ENCRYPTED)
|
|
202
|
+
|
|
203
|
+
files << file
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
files
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Decompress data using LZX
|
|
210
|
+
#
|
|
211
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
212
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
213
|
+
# @param expected_size [Integer] Expected output size
|
|
214
|
+
# @return [Integer] Number of bytes written
|
|
215
|
+
def decompress_lzx(input_handle, output_handle, expected_size)
|
|
216
|
+
decompressor = Decompressors::LZX.new(
|
|
217
|
+
@io_system,
|
|
218
|
+
input_handle,
|
|
219
|
+
output_handle,
|
|
220
|
+
@buffer_size,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
decompressor.decompress(expected_size)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Copy data directly without decompression
|
|
227
|
+
#
|
|
228
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
229
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
230
|
+
# @param size [Integer] Number of bytes to copy
|
|
231
|
+
# @return [Integer] Number of bytes written
|
|
232
|
+
def copy_data(input_handle, output_handle, size)
|
|
233
|
+
bytes_written = 0
|
|
234
|
+
remaining = size
|
|
235
|
+
|
|
236
|
+
while remaining.positive?
|
|
237
|
+
chunk_size = [remaining, @buffer_size].min
|
|
238
|
+
data = @io_system.read(input_handle, chunk_size)
|
|
239
|
+
break if data.empty?
|
|
240
|
+
|
|
241
|
+
written = @io_system.write(output_handle, data)
|
|
242
|
+
bytes_written += written
|
|
243
|
+
remaining -= written
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
bytes_written
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|