cabriolet 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +700 -38
  3. data/lib/cabriolet/algorithm_factory.rb +250 -0
  4. data/lib/cabriolet/base_compressor.rb +206 -0
  5. data/lib/cabriolet/binary/bitstream.rb +154 -14
  6. data/lib/cabriolet/binary/bitstream_writer.rb +129 -17
  7. data/lib/cabriolet/binary/chm_structures.rb +2 -2
  8. data/lib/cabriolet/binary/hlp_structures.rb +258 -37
  9. data/lib/cabriolet/binary/lit_structures.rb +231 -65
  10. data/lib/cabriolet/binary/oab_structures.rb +17 -1
  11. data/lib/cabriolet/cab/command_handler.rb +226 -0
  12. data/lib/cabriolet/cab/compressor.rb +35 -43
  13. data/lib/cabriolet/cab/decompressor.rb +14 -19
  14. data/lib/cabriolet/cab/extractor.rb +140 -31
  15. data/lib/cabriolet/chm/command_handler.rb +227 -0
  16. data/lib/cabriolet/chm/compressor.rb +7 -3
  17. data/lib/cabriolet/chm/decompressor.rb +39 -21
  18. data/lib/cabriolet/chm/parser.rb +5 -2
  19. data/lib/cabriolet/cli/base_command_handler.rb +127 -0
  20. data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
  21. data/lib/cabriolet/cli/command_registry.rb +83 -0
  22. data/lib/cabriolet/cli.rb +356 -607
  23. data/lib/cabriolet/compressors/base.rb +1 -1
  24. data/lib/cabriolet/compressors/lzx.rb +241 -54
  25. data/lib/cabriolet/compressors/mszip.rb +35 -3
  26. data/lib/cabriolet/compressors/quantum.rb +34 -45
  27. data/lib/cabriolet/decompressors/base.rb +1 -1
  28. data/lib/cabriolet/decompressors/lzss.rb +13 -3
  29. data/lib/cabriolet/decompressors/lzx.rb +70 -33
  30. data/lib/cabriolet/decompressors/mszip.rb +126 -39
  31. data/lib/cabriolet/decompressors/quantum.rb +3 -2
  32. data/lib/cabriolet/errors.rb +3 -0
  33. data/lib/cabriolet/file_entry.rb +156 -0
  34. data/lib/cabriolet/file_manager.rb +144 -0
  35. data/lib/cabriolet/hlp/command_handler.rb +282 -0
  36. data/lib/cabriolet/hlp/compressor.rb +28 -238
  37. data/lib/cabriolet/hlp/decompressor.rb +107 -147
  38. data/lib/cabriolet/hlp/parser.rb +52 -101
  39. data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
  40. data/lib/cabriolet/hlp/quickhelp/compressor.rb +626 -0
  41. data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
  42. data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
  43. data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
  44. data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
  45. data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
  46. data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
  47. data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
  48. data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
  49. data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
  50. data/lib/cabriolet/huffman/tree.rb +85 -1
  51. data/lib/cabriolet/kwaj/command_handler.rb +213 -0
  52. data/lib/cabriolet/kwaj/compressor.rb +7 -3
  53. data/lib/cabriolet/kwaj/decompressor.rb +18 -12
  54. data/lib/cabriolet/lit/command_handler.rb +221 -0
  55. data/lib/cabriolet/lit/compressor.rb +633 -38
  56. data/lib/cabriolet/lit/decompressor.rb +518 -152
  57. data/lib/cabriolet/lit/parser.rb +670 -0
  58. data/lib/cabriolet/models/hlp_file.rb +130 -29
  59. data/lib/cabriolet/models/hlp_header.rb +105 -17
  60. data/lib/cabriolet/models/lit_header.rb +212 -25
  61. data/lib/cabriolet/models/szdd_header.rb +10 -2
  62. data/lib/cabriolet/models/winhelp_header.rb +127 -0
  63. data/lib/cabriolet/oab/command_handler.rb +257 -0
  64. data/lib/cabriolet/oab/compressor.rb +17 -8
  65. data/lib/cabriolet/oab/decompressor.rb +41 -10
  66. data/lib/cabriolet/offset_calculator.rb +81 -0
  67. data/lib/cabriolet/plugin.rb +233 -0
  68. data/lib/cabriolet/plugin_manager.rb +453 -0
  69. data/lib/cabriolet/plugin_validator.rb +422 -0
  70. data/lib/cabriolet/system/io_system.rb +3 -0
  71. data/lib/cabriolet/system/memory_handle.rb +17 -4
  72. data/lib/cabriolet/szdd/command_handler.rb +217 -0
  73. data/lib/cabriolet/szdd/compressor.rb +15 -11
  74. data/lib/cabriolet/szdd/decompressor.rb +18 -9
  75. data/lib/cabriolet/version.rb +1 -1
  76. data/lib/cabriolet.rb +67 -17
  77. metadata +33 -2
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../binary/hlp_structures"
4
+ require "stringio"
5
+
6
+ module Cabriolet
7
+ module HLP
8
+ module WinHelp
9
+ # B+ tree builder for WinHelp 4.x directory format
10
+ #
11
+ # Builds B+ tree directory structure for WinHelp 4.x files.
12
+ # The directory maps filenames to file offsets using a B+ tree
13
+ # with fixed-size pages.
14
+ class BTreeBuilder
15
+ # Default page size for WinHelp 4.x directory (1KB for catalog/directory)
16
+ DEFAULT_PAGE_SIZE = 0x0400 # 1KB
17
+
18
+ # Page types
19
+ PAGE_TYPE_LEAF = 0
20
+ PAGE_TYPE_INDEX = 1
21
+
22
+ # B+ tree magic number
23
+ BTREE_MAGIC = 0x293B
24
+
25
+ # Flags for B+ tree header
26
+ # Bit 0x0002 is always 1
27
+ # Bit 0x0400 is 1 for catalog/directory
28
+ FLAGS_MAGIC_BIT = 0x0002
29
+ FLAGS_CATALOG_BIT = 0x0400
30
+
31
+ attr_reader :page_size, :structure
32
+
33
+ # Initialize B+ tree builder
34
+ #
35
+ # @param page_size [Integer] Page size in bytes (default: 1KB)
36
+ # @param structure [String] Structure string describing data format
37
+ def initialize(page_size: DEFAULT_PAGE_SIZE, structure: "FFz")
38
+ @page_size = page_size
39
+ @structure = structure
40
+ @entries = []
41
+ end
42
+
43
+ # Add a file entry to the B+ tree
44
+ #
45
+ # @param filename [String] Internal filename (e.g., "|SYSTEM")
46
+ # @param offset [Integer] File offset in help file
47
+ # @param size [Integer] File size in bytes
48
+ def add_entry(filename, offset, size)
49
+ @entries << { filename: filename, offset: offset, size: size }
50
+ end
51
+
52
+ # Build B+ tree structure
53
+ #
54
+ # @return [Hash] Hash containing :header, :pages
55
+ def build
56
+ return build_empty if @entries.empty?
57
+
58
+ # Sort entries by filename
59
+ sorted_entries = @entries.sort_by { |e| e[:filename] }
60
+
61
+ # Build leaf pages
62
+ leaf_pages = build_leaf_pages(sorted_entries)
63
+
64
+ # Build index pages if needed
65
+ if leaf_pages.size > 1
66
+ index_pages = build_index_pages(leaf_pages)
67
+ root_page = index_pages.first[:page_num]
68
+ n_levels = 2
69
+ else
70
+ index_pages = []
71
+ root_page = leaf_pages.first[:page_num]
72
+ n_levels = 1
73
+ end
74
+
75
+ # Build B+ tree header
76
+ header = build_header(
77
+ total_pages: leaf_pages.size + index_pages.size,
78
+ root_page: root_page,
79
+ n_levels: n_levels,
80
+ total_entries: @entries.size,
81
+ )
82
+
83
+ # Combine all pages
84
+ all_pages = index_pages + leaf_pages
85
+
86
+ { header: header, pages: all_pages }
87
+ end
88
+
89
+ private
90
+
91
+ # Build empty B+ tree (single empty leaf page)
92
+ #
93
+ # @return [Hash] Hash containing :header, :pages
94
+ def build_empty
95
+ # Create empty leaf page
96
+ leaf_page = {
97
+ page_num: 0,
98
+ data: build_empty_leaf_page,
99
+ }
100
+
101
+ header = build_header(
102
+ total_pages: 1,
103
+ root_page: 0,
104
+ n_levels: 1,
105
+ total_entries: 0,
106
+ )
107
+
108
+ { header: header, pages: [leaf_page] }
109
+ end
110
+
111
+ # Build B+ tree header
112
+ #
113
+ # @param total_pages [Integer] Total number of pages
114
+ # @param root_page [Integer] Root page number
115
+ # @param n_levels [Integer] Number of levels in tree
116
+ # @param total_entries [Integer] Total number of entries
117
+ # @return [Binary::HLPStructures::WinHelpBTreeHeader] B+ tree header
118
+ def build_header(total_pages:, root_page:, n_levels:, total_entries:)
119
+ Binary::HLPStructures::WinHelpBTreeHeader.new(
120
+ magic: BTREE_MAGIC,
121
+ flags: FLAGS_MAGIC_BIT | FLAGS_CATALOG_BIT,
122
+ page_size: @page_size,
123
+ structure: @structure.ljust(16, "\x00"),
124
+ must_be_zero: 0,
125
+ page_splits: 0,
126
+ root_page: root_page,
127
+ must_be_neg_one: 0xFFFF,
128
+ total_pages: total_pages,
129
+ n_levels: n_levels,
130
+ total_btree_entries: total_entries,
131
+ )
132
+ end
133
+
134
+ # Build leaf pages from entries
135
+ #
136
+ # @param entries [Array<Hash>] Sorted file entries
137
+ # @return [Array<Hash>] Array of page hashes with :page_num and :data
138
+ def build_leaf_pages(entries)
139
+ pages = []
140
+ current_page_data = StringIO.new
141
+ page_num = 0
142
+
143
+ entries.each do |entry|
144
+ # Check if this entry fits in current page
145
+ entry_size = entry[:filename].bytesize + 1 + 4 # filename + null + offset
146
+ header_size = 8 # leaf node header
147
+
148
+ # Check if we need a new page
149
+ if (current_page_data.size + entry_size + header_size) > @page_size
150
+ # Finish current page and start new one
151
+ pages << finish_leaf_page(current_page_data, page_num,
152
+ pages.empty?)
153
+ page_num += 1
154
+ current_page_data = StringIO.new
155
+ end
156
+
157
+ # Write entry to current page
158
+ current_page_data.write(entry[:filename])
159
+ current_page_data.write("\x00") # null terminator
160
+ current_page_data.write([entry[:offset]].pack("V")) # 4-byte offset
161
+ end
162
+
163
+ # Finish last page
164
+ if current_page_data.size.positive?
165
+ pages << finish_leaf_page(current_page_data, page_num, pages.empty?)
166
+ end
167
+
168
+ pages
169
+ end
170
+
171
+ # Finish a leaf page by adding header
172
+ #
173
+ # @param page_data [StringIO] Page data without header
174
+ # @param page_num [Integer] Page number
175
+ # @param is_first [Boolean] Whether this is the first page
176
+ # @return [Hash] Page hash with :page_num and :data
177
+ def finish_leaf_page(page_data, page_num, is_first)
178
+ data = page_data.string
179
+ n_entries = count_entries(data)
180
+
181
+ # Build leaf node header
182
+ # - 2 bytes: unused (we use 0)
183
+ # - 2 bytes: nEntries
184
+ # - 2 bytes: PreviousPage (0xFFFF for first)
185
+ # - 2 bytes: NextPage (0xFFFF for last, to be determined)
186
+ header = [
187
+ 0, # unused
188
+ n_entries,
189
+ is_first ? 0xFFFF : page_num - 1, # previous page
190
+ 0xFFFF, # next page (will update if more pages added)
191
+ ].pack("vvvv")
192
+
193
+ { page_num: page_num, data: header + data }
194
+ end
195
+
196
+ # Build empty leaf page
197
+ #
198
+ # @return [String] Empty leaf page data
199
+ def build_empty_leaf_page
200
+ # Empty leaf has header with nEntries = 0
201
+ [
202
+ 0, # unused
203
+ 0, # nEntries = 0
204
+ 0xFFFF, # previous page
205
+ 0xFFFF, # next page
206
+ ].pack("vvvv")
207
+ end
208
+
209
+ # Build index pages from leaf pages
210
+ #
211
+ # @param leaf_pages [Array<Hash>] Leaf page hashes
212
+ # @return [Array<Hash>] Array of index page hashes
213
+ def build_index_pages(leaf_pages)
214
+ # For simplicity, create single index page pointing to all leaf pages
215
+ # In a real implementation, this would recursively build index pages
216
+ index_data = StringIO.new
217
+
218
+ leaf_pages.each do |page|
219
+ # For index pages, entries are: (filename, page_number)
220
+ # We use the first filename from each leaf page as key
221
+ first_filename = extract_first_filename(page[:data])
222
+ index_data.write(first_filename)
223
+ index_data.write("\x00") # null terminator
224
+ index_data.write([page[:page_num]].pack("v")) # 2-byte page number
225
+ end
226
+
227
+ data = index_data.string
228
+ n_entries = leaf_pages.size
229
+
230
+ # Build index node header
231
+ # - 2 bytes: unused (we use 0)
232
+ # - 2 bytes: nEntries
233
+ # - 2 bytes: PreviousPage (0xFFFF - no previous)
234
+ header = [
235
+ 0, # unused
236
+ n_entries,
237
+ 0xFFFF, # previous page (none for root)
238
+ ].pack("vvv")
239
+
240
+ [{
241
+ page_num: leaf_pages.size, # Index pages come after leaf pages
242
+ data: header + data,
243
+ }]
244
+ end
245
+
246
+ # Extract first filename from page data
247
+ #
248
+ # @param page_data [String] Page data with header
249
+ # @return [String] First filename in page
250
+ def extract_first_filename(page_data)
251
+ # Skip 8-byte header
252
+ data_start = 8
253
+ data = page_data[data_start..]
254
+
255
+ # Filename is null-terminated
256
+ null_pos = data.index("\x00")
257
+ return "" if null_pos.nil?
258
+
259
+ data[0...null_pos]
260
+ end
261
+
262
+ # Count entries in page data
263
+ #
264
+ # @param data [String] Page data without header
265
+ # @return [Integer] Number of entries
266
+ def count_entries(data)
267
+ count = 0
268
+ pos = 0
269
+
270
+ while pos < data.bytesize
271
+ # Find null terminator
272
+ null_pos = data.index("\x00", pos)
273
+ break if null_pos.nil?
274
+
275
+ # Skip filename
276
+ pos = null_pos + 1
277
+
278
+ # Skip 4-byte offset
279
+ pos += 4
280
+
281
+ count += 1
282
+ end
283
+
284
+ count
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "zeck_lz77"
4
+ require_relative "btree_builder"
5
+
6
+ module Cabriolet
7
+ module HLP
8
+ module WinHelp
9
+ # Compressor creates Windows Help (.HLP) files
10
+ #
11
+ # Creates WinHelp 3.x and 4.x format files with Zeck LZ77 compression.
12
+ # Supports creating |SYSTEM, |TOPIC, and other internal files.
13
+ class Compressor
14
+ attr_reader :io_system
15
+
16
+ # Default block size for WinHelp files (4096 bytes)
17
+ BLOCK_SIZE = 4096
18
+
19
+ # Initialize compressor
20
+ #
21
+ # @param io_system [System::IOSystem, nil] Custom I/O system
22
+ def initialize(io_system = nil)
23
+ @io_system = io_system || System::IOSystem.new
24
+ @internal_files = {}
25
+ @version = :winhelp3
26
+ end
27
+
28
+ # Add an internal file to the WinHelp archive
29
+ #
30
+ # @param name [String] Internal filename (e.g., "|SYSTEM", "|TOPIC")
31
+ # @param data [String] File data
32
+ # @return [void]
33
+ def add_internal_file(name, data)
34
+ @internal_files[name] = data
35
+ end
36
+
37
+ # Add |SYSTEM file with metadata
38
+ #
39
+ # @param options [Hash] System file options
40
+ # @option options [String] :title Help file title
41
+ # @option options [String] :copyright Copyright text
42
+ # @option options [String] :contents Contents file path
43
+ # @return [void]
44
+ def add_system_file(**options)
45
+ system_data = build_system_file(options)
46
+ add_internal_file("|SYSTEM", system_data)
47
+ end
48
+
49
+ # Add |TOPIC file with compressed topics
50
+ #
51
+ # @param topics [Array<String>] Array of topic texts
52
+ # @param compress [Boolean] Whether to compress topics
53
+ # @return [void]
54
+ def add_topic_file(topics, compress: true)
55
+ topic_data = build_topic_file(topics, compress)
56
+ add_internal_file("|TOPIC", topic_data)
57
+ end
58
+
59
+ # Generate WinHelp file
60
+ #
61
+ # @param output_file [String] Path to output file
62
+ # @param options [Hash] Generation options
63
+ # @option options [Symbol] :version Format version (:winhelp3 or :winhelp4)
64
+ # @return [Integer] Bytes written
65
+ def generate(output_file, **options)
66
+ @version = options.fetch(:version, :winhelp3)
67
+
68
+ if @internal_files.empty?
69
+ raise ArgumentError,
70
+ "No internal files added"
71
+ end
72
+ raise ArgumentError, "Invalid version" unless %i[winhelp3
73
+ winhelp4].include?(@version)
74
+
75
+ # Build structure
76
+ structure = build_structure
77
+
78
+ # Write to file
79
+ output_handle = @io_system.open(output_file, Constants::MODE_WRITE)
80
+ begin
81
+ write_winhelp_file(output_handle, structure)
82
+ ensure
83
+ @io_system.close(output_handle)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ # Build complete WinHelp structure
90
+ #
91
+ # @return [Hash] Complete structure
92
+ def build_structure
93
+ structure = {
94
+ version: @version,
95
+ internal_files: [],
96
+ }
97
+
98
+ # Prepare internal files with block numbers
99
+ block_number = 1 # Block 0 is reserved for header
100
+ @internal_files.each do |name, data|
101
+ # Calculate blocks needed (round up)
102
+ blocks_needed = (data.bytesize.to_f / BLOCK_SIZE).ceil
103
+
104
+ structure[:internal_files] << {
105
+ name: name,
106
+ data: data,
107
+ size: data.bytesize,
108
+ starting_block: block_number,
109
+ }
110
+
111
+ block_number += blocks_needed
112
+ end
113
+
114
+ # Calculate directory offset
115
+ header_size = @version == :winhelp3 ? 28 : 32
116
+ structure[:directory_offset] = header_size
117
+
118
+ # Calculate directory size
119
+ dir_size = calculate_directory_size(structure[:internal_files])
120
+ structure[:directory_size] = dir_size
121
+
122
+ # Calculate total file size
123
+ structure[:file_size] =
124
+ header_size + dir_size + (block_number * BLOCK_SIZE)
125
+
126
+ structure
127
+ end
128
+
129
+ # Calculate directory size
130
+ #
131
+ # @param files [Array<Hash>] Internal file list
132
+ # @return [Integer] Directory size in bytes
133
+ def calculate_directory_size(files)
134
+ size = 0
135
+ files.each do |file|
136
+ # 4 bytes size + 2 bytes block + filename + null + padding
137
+ size += 4 + 2 + file[:name].bytesize + 1
138
+ # Align to 2-byte boundary
139
+ size += 1 if size.odd?
140
+ end
141
+ # Add end marker (4 bytes of zeros)
142
+ size + 4
143
+ end
144
+
145
+ # Write complete WinHelp file
146
+ #
147
+ # @param output_handle [System::FileHandle] Output handle
148
+ # @param structure [Hash] File structure
149
+ # @return [Integer] Bytes written
150
+ def write_winhelp_file(output_handle, structure)
151
+ bytes_written = 0
152
+
153
+ # Write header
154
+ bytes_written += write_header(output_handle, structure)
155
+
156
+ # Write directory
157
+ bytes_written += write_directory(output_handle, structure)
158
+
159
+ # Pad to first block boundary
160
+ padding_needed = BLOCK_SIZE - (bytes_written % BLOCK_SIZE)
161
+ if padding_needed < BLOCK_SIZE
162
+ bytes_written += @io_system.write(output_handle,
163
+ "\x00" * padding_needed)
164
+ end
165
+
166
+ # Write file data at block boundaries
167
+ structure[:internal_files].each do |file|
168
+ # Seek to correct block
169
+ target_offset = file[:starting_block] * BLOCK_SIZE
170
+ current_offset = bytes_written
171
+
172
+ if target_offset > current_offset
173
+ padding = "\x00" * (target_offset - current_offset)
174
+ bytes_written += @io_system.write(output_handle, padding)
175
+ end
176
+
177
+ # Write file data
178
+ bytes_written += @io_system.write(output_handle, file[:data])
179
+
180
+ # Pad to block boundary
181
+ remainder = file[:data].bytesize % BLOCK_SIZE
182
+ if remainder.positive?
183
+ padding = "\x00" * (BLOCK_SIZE - remainder)
184
+ bytes_written += @io_system.write(output_handle, padding)
185
+ end
186
+ end
187
+
188
+ bytes_written
189
+ end
190
+
191
+ # Write file header
192
+ #
193
+ # @param output_handle [System::FileHandle] Output handle
194
+ # @param structure [Hash] File structure
195
+ # @return [Integer] Bytes written
196
+ def write_header(output_handle, structure)
197
+ if structure[:version] == :winhelp3
198
+ write_header_3x(output_handle, structure)
199
+ else
200
+ write_header_4x(output_handle, structure)
201
+ end
202
+ end
203
+
204
+ # Write WinHelp 3.x header
205
+ #
206
+ # @param output_handle [System::FileHandle] Output handle
207
+ # @param structure [Hash] File structure
208
+ # @return [Integer] Bytes written
209
+ def write_header_3x(output_handle, structure)
210
+ header = Binary::HLPStructures::WinHelp3Header.new
211
+ header.magic = 0x35F3
212
+ header.unknown = 0x0001
213
+ header.directory_offset = structure[:directory_offset]
214
+ header.free_list_offset = 0
215
+ header.file_size = structure[:file_size]
216
+ header.reserved = "\x00" * 12
217
+
218
+ header_data = header.to_binary_s
219
+ @io_system.write(output_handle, header_data)
220
+ end
221
+
222
+ # Write WinHelp 4.x header
223
+ #
224
+ # @param output_handle [System::FileHandle] Output handle
225
+ # @param structure [Hash] File structure
226
+ # @return [Integer] Bytes written
227
+ def write_header_4x(output_handle, structure)
228
+ header = Binary::HLPStructures::WinHelp4Header.new
229
+ header.magic = 0x00033F5F # Magic with low 16 bits = 0x3F5F
230
+ header.directory_offset = structure[:directory_offset]
231
+ header.free_list_offset = 0
232
+ header.file_size = structure[:file_size]
233
+ header.reserved = "\x00" * 16
234
+
235
+ header_data = header.to_binary_s
236
+ @io_system.write(output_handle, header_data)
237
+ end
238
+
239
+ # Write directory
240
+ #
241
+ # @param output_handle [System::FileHandle] Output handle
242
+ # @param structure [Hash] File structure
243
+ # @return [Integer] Bytes written
244
+ def write_directory(output_handle, structure)
245
+ if structure[:version] == :winhelp4
246
+ write_directory_btree(output_handle, structure)
247
+ else
248
+ write_directory_simple(output_handle, structure)
249
+ end
250
+ end
251
+
252
+ # Write simple directory (WinHelp 3.x format)
253
+ #
254
+ # @param output_handle [System::FileHandle] Output handle
255
+ # @param structure [Hash] File structure
256
+ # @return [Integer] Bytes written
257
+ def write_directory_simple(output_handle, structure)
258
+ bytes_written = 0
259
+
260
+ structure[:internal_files].each do |file|
261
+ # Write file size (4 bytes)
262
+ bytes_written += @io_system.write(output_handle,
263
+ [file[:size]].pack("V"))
264
+
265
+ # Write starting block (2 bytes)
266
+ bytes_written += @io_system.write(output_handle,
267
+ [file[:starting_block]].pack("v"))
268
+
269
+ # Write filename with null terminator
270
+ bytes_written += @io_system.write(output_handle,
271
+ "#{file[:name]}\u0000")
272
+
273
+ # Align to 2-byte boundary
274
+ if bytes_written.odd?
275
+ bytes_written += @io_system.write(output_handle, "\x00")
276
+ end
277
+ end
278
+
279
+ # Write end marker
280
+ bytes_written += @io_system.write(output_handle, [0].pack("V"))
281
+
282
+ bytes_written
283
+ end
284
+
285
+ # Write B+ tree directory (WinHelp 4.x format)
286
+ #
287
+ # @param output_handle [System::FileHandle] Output handle
288
+ # @param structure [Hash] File structure
289
+ # @return [Integer] Bytes written
290
+ def write_directory_btree(output_handle, structure)
291
+ bytes_written = 0
292
+
293
+ # Build B+ tree from internal files
294
+ btree = BTreeBuilder.new
295
+ structure[:internal_files].each do |file|
296
+ # Add entry with filename, starting block (offset), and size
297
+ btree.add_entry(file[:name], file[:starting_block] * BLOCK_SIZE,
298
+ file[:size])
299
+ end
300
+
301
+ # Build the tree
302
+ tree = btree.build
303
+
304
+ # Write FILEHEADER (9 bytes) before BTREEHEADER
305
+ # FILEHEADER structure:
306
+ # - 4 bytes: reserved_space (reserved space in help file incl. FILEHEADER)
307
+ # - 4 bytes: used_space (used space in help file excl. FILEHEADER)
308
+ # - 1 byte: file_flags (normally 4)
309
+ # For directory, we set these to 0 for now
310
+ file_header = Binary::HLPStructures::WinHelpFileHeader.new
311
+ file_header.reserved_space = 0
312
+ file_header.used_space = 0
313
+ file_header.file_flags = 4
314
+ file_header_data = file_header.to_binary_s
315
+ bytes_written += @io_system.write(output_handle, file_header_data)
316
+
317
+ # Write BTREEHEADER (38 bytes)
318
+ header = tree[:header]
319
+ header_data = header.to_binary_s
320
+ bytes_written += @io_system.write(output_handle, header_data)
321
+
322
+ # Write pages (sorted by page_num)
323
+ sorted_pages = tree[:pages].sort_by { |p| p[:page_num] }
324
+ sorted_pages.each do |page|
325
+ # Write page data
326
+ bytes_written += @io_system.write(output_handle, page[:data])
327
+ end
328
+
329
+ bytes_written
330
+ end
331
+
332
+ # Build |SYSTEM file
333
+ #
334
+ # @param options [Hash] System file options
335
+ # @return [String] System file data
336
+ def build_system_file(options)
337
+ data = +""
338
+
339
+ # Write title if provided
340
+ if options[:title]
341
+ data << build_system_record(1, options[:title])
342
+ end
343
+
344
+ # Write copyright if provided
345
+ if options[:copyright]
346
+ data << build_system_record(2, options[:copyright])
347
+ end
348
+
349
+ # Write contents if provided
350
+ if options[:contents]
351
+ data << build_system_record(3, options[:contents])
352
+ end
353
+
354
+ data
355
+ end
356
+
357
+ # Build a system record
358
+ #
359
+ # @param type [Integer] Record type
360
+ # @param text [String] Record text
361
+ # @return [String] Record data
362
+ def build_system_record(type, text)
363
+ record = +""
364
+ record << [type].pack("v") # Record type (2 bytes)
365
+ record << [text.bytesize + 1].pack("v") # Length including null (2 bytes)
366
+ record << text
367
+ record << "\x00" # Null terminator
368
+ record
369
+ end
370
+
371
+ # Build |TOPIC file
372
+ #
373
+ # @param topics [Array<String>] Topic texts
374
+ # @param compress [Boolean] Whether to compress
375
+ # @return [String] Topic file data
376
+ def build_topic_file(topics, compress)
377
+ # Simplified: just concatenate topic data
378
+ # Full implementation would include topic headers and blocks
379
+ data = +""
380
+ zeck = ZeckLZ77.new
381
+
382
+ topics.each do |topic_text|
383
+ compressed_data = if compress
384
+ # Compress using Zeck LZ77
385
+ zeck.compress(topic_text)
386
+ else
387
+ topic_text
388
+ end
389
+
390
+ # Write topic with 2-byte length header
391
+ data << [compressed_data.bytesize].pack("v")
392
+ data << compressed_data
393
+ end
394
+
395
+ data
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end