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,626 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ module HLP
5
+ module QuickHelp
6
+ # Compressor creates QuickHelp (.HLP) compressed archives
7
+ #
8
+ # QuickHelp files (DOS format) contain topics with Huffman encoding
9
+ # and optional keyword compression using LZSS MODE_MSHELP.
10
+ #
11
+ # NOTE: This implementation is based on the DosHelp project specification
12
+ # for the QuickHelp format used in DOS-era development tools.
13
+ class Compressor
14
+ attr_reader :io_system
15
+
16
+ # Default buffer size for I/O operations
17
+ DEFAULT_BUFFER_SIZE = 2048
18
+
19
+ # Initialize a new QuickHelp compressor
20
+ #
21
+ # @param io_system [System::IOSystem, nil] Custom I/O system or nil for
22
+ # default
23
+ # @param algorithm_factory [AlgorithmFactory, nil] Custom algorithm factory or nil for default
24
+ def initialize(io_system = nil, algorithm_factory = nil)
25
+ @io_system = io_system || System::IOSystem.new
26
+ @algorithm_factory = algorithm_factory || Cabriolet.algorithm_factory
27
+ @files = []
28
+ end
29
+
30
+ # Add a file to the QuickHelp archive
31
+ #
32
+ # @param source_path [String] Path to source file
33
+ # @param hlp_path [String] Path within QuickHelp archive
34
+ # @param compress [Boolean] Whether to compress the file
35
+ # @return [void]
36
+ def add_file(source_path, hlp_path, compress: true)
37
+ @files << {
38
+ source: source_path,
39
+ hlp_path: hlp_path,
40
+ compress: compress,
41
+ }
42
+ end
43
+
44
+ # Add data from memory to the QuickHelp archive
45
+ #
46
+ # @param data [String] Data to add
47
+ # @param hlp_path [String] Path within QuickHelp archive
48
+ # @param compress [Boolean] Whether to compress the data
49
+ # @return [void]
50
+ def add_data(data, hlp_path, compress: true)
51
+ @files << {
52
+ data: data,
53
+ hlp_path: hlp_path,
54
+ compress: compress,
55
+ }
56
+ end
57
+
58
+ # Generate HLP archive
59
+ #
60
+ # @param output_file [String] Path to output HLP file
61
+ # @param options [Hash] Compression options
62
+ # @option options [Integer] :version QuickHelp format version (default: 2)
63
+ # @option options [String] :database_name Database name for external links (max 13 chars)
64
+ # @option options [Integer] :control_character Control character (default: 0x3A ':')
65
+ # @option options [Boolean] :case_sensitive Case-sensitive contexts (default: false)
66
+ # @return [Integer] Bytes written to output file
67
+ # @raise [Cabriolet::CompressionError] if compression fails
68
+ def generate(output_file, **options)
69
+ version = options.fetch(:version, 2)
70
+ database_name = options.fetch(:database_name, "")
71
+ control_char = options.fetch(:control_character, 0x3A) # ':'
72
+ case_sensitive = options.fetch(:case_sensitive, false)
73
+
74
+ raise ArgumentError, "No files added to archive" if @files.empty?
75
+ raise ArgumentError, "Version must be 2" unless version == 2
76
+
77
+ if database_name.length > 13
78
+ raise ArgumentError,
79
+ "Database name too long (max 13 chars)"
80
+ end
81
+
82
+ # Prepare topics from files
83
+ topics = prepare_topics
84
+
85
+ # Build QuickHelp structure
86
+ qh_structure = build_quickhelp_structure(
87
+ topics,
88
+ version,
89
+ database_name,
90
+ control_char,
91
+ case_sensitive,
92
+ )
93
+
94
+ # Write to output file
95
+ output_handle = @io_system.open(output_file, Constants::MODE_WRITE)
96
+ begin
97
+ bytes_written = write_quickhelp_file(output_handle, qh_structure)
98
+ bytes_written
99
+ ensure
100
+ @io_system.close(output_handle)
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ # Compress all files and collect metadata
107
+ #
108
+ # @return [Array<Hash>] Array of file information hashes
109
+ def compress_all_files
110
+ @files.map do |file_spec|
111
+ compress_file_spec(file_spec)
112
+ end
113
+ end
114
+
115
+ # Compress a single file specification
116
+ #
117
+ # @param file_spec [Hash] File specification
118
+ # @return [Hash] File information with compressed data
119
+ def compress_file_spec(file_spec)
120
+ # Get source data
121
+ data = file_spec[:data] || read_file_data(file_spec[:source])
122
+
123
+ # Compress if requested
124
+ compressed_data = if file_spec[:compress]
125
+ compress_data_lzss(data)
126
+ else
127
+ data
128
+ end
129
+
130
+ {
131
+ hlp_path: file_spec[:hlp_path],
132
+ uncompressed_size: data.bytesize,
133
+ compressed_data: compressed_data,
134
+ compressed: file_spec[:compress],
135
+ }
136
+ end
137
+
138
+ # Read file data from disk
139
+ #
140
+ # @param filename [String] Path to file
141
+ # @return [String] File contents
142
+ def read_file_data(filename)
143
+ handle = @io_system.open(filename, Constants::MODE_READ)
144
+ begin
145
+ data = +""
146
+ loop do
147
+ chunk = @io_system.read(handle, DEFAULT_BUFFER_SIZE)
148
+ break if chunk.empty?
149
+
150
+ data << chunk
151
+ end
152
+ data
153
+ ensure
154
+ @io_system.close(handle)
155
+ end
156
+ end
157
+
158
+ # Compress data using LZSS MODE_MSHELP
159
+ #
160
+ # @param data [String] Data to compress
161
+ # @return [String] Compressed data
162
+ def compress_data_lzss(data)
163
+ input_handle = System::MemoryHandle.new(data)
164
+ output_handle = System::MemoryHandle.new("", Constants::MODE_WRITE)
165
+
166
+ compressor = @algorithm_factory.create(
167
+ :lzss,
168
+ :compressor,
169
+ @io_system,
170
+ input_handle,
171
+ output_handle,
172
+ DEFAULT_BUFFER_SIZE,
173
+ mode: Compressors::LZSS::MODE_MSHELP,
174
+ )
175
+
176
+ compressor.compress
177
+ output_handle.data
178
+ end
179
+
180
+ # Calculate directory size
181
+ #
182
+ # @param compressed_files [Array<Hash>] Compressed file information
183
+ # @return [Integer] Directory size in bytes
184
+ def calculate_directory_size(compressed_files)
185
+ size = 0
186
+ compressed_files.each do |file_info|
187
+ # 4 bytes for filename length
188
+ # N bytes for filename
189
+ # 4 + 4 + 4 + 1 = 13 bytes for file metadata
190
+ size += 4 + file_info[:hlp_path].bytesize + 13
191
+ end
192
+ size
193
+ end
194
+
195
+ # Write QuickHelp header
196
+ #
197
+ # @param output_handle [System::FileHandle] Output file handle
198
+ # @param version [Integer] Format version
199
+ # @param file_count [Integer] Number of files
200
+ # @param directory_offset [Integer] Offset to directory
201
+ # @return [Integer] Number of bytes written
202
+ def write_header(output_handle, version, file_count, directory_offset)
203
+ # NOTE: This is a simplified header format and does not match the actual QuickHelp FileHeader structure
204
+ # TODO: Implement proper QuickHelp FileHeader usage
205
+ header = Binary::HLPStructures::FileHeader.new
206
+ header.signature = Binary::HLPStructures::SIGNATURE
207
+ header.version = version
208
+ header.file_count = file_count
209
+ header.directory_offset = directory_offset
210
+
211
+ header_data = header.to_binary_s
212
+ written = @io_system.write(output_handle, header_data)
213
+
214
+ unless written == header_data.bytesize
215
+ raise Errors::CompressionError,
216
+ "Failed to write QuickHelp header"
217
+ end
218
+
219
+ written
220
+ end
221
+
222
+ # Write file directory
223
+ #
224
+ # @param output_handle [System::FileHandle] Output file handle
225
+ # @param compressed_files [Array<Hash>] Compressed file information
226
+ # @return [Integer] Number of bytes written
227
+ def write_directory(output_handle, compressed_files)
228
+ bytes_written = 0
229
+
230
+ compressed_files.each do |file_info|
231
+ # Write filename length
232
+ filename = file_info[:hlp_path].b
233
+ length_data = [filename.bytesize].pack("V")
234
+ bytes_written += @io_system.write(output_handle, length_data)
235
+
236
+ # Write filename
237
+ bytes_written += @io_system.write(output_handle, filename)
238
+
239
+ # Write file metadata
240
+ metadata = [
241
+ file_info[:offset],
242
+ file_info[:uncompressed_size],
243
+ file_info[:compressed_data].bytesize,
244
+ file_info[:compressed] ? 1 : 0,
245
+ ].pack("V3C")
246
+ bytes_written += @io_system.write(output_handle, metadata)
247
+ end
248
+
249
+ bytes_written
250
+ end
251
+
252
+ # Write file data
253
+ #
254
+ # @param output_handle [System::FileHandle] Output file handle
255
+ # @param compressed_files [Array<Hash>] Compressed file information
256
+ # @return [Integer] Number of bytes written
257
+ def write_file_data(output_handle, compressed_files)
258
+ bytes_written = 0
259
+
260
+ compressed_files.each do |file_info|
261
+ written = @io_system.write(
262
+ output_handle,
263
+ file_info[:compressed_data],
264
+ )
265
+ bytes_written += written
266
+ end
267
+
268
+ bytes_written
269
+ end
270
+
271
+ # Prepare topics from added files
272
+ #
273
+ # @return [Array<Hash>] Topic information
274
+ def prepare_topics
275
+ @files.map.with_index do |file_spec, index|
276
+ # Get source data
277
+ data = file_spec[:data] || read_file_data(file_spec[:source])
278
+
279
+ {
280
+ index: index,
281
+ text: data,
282
+ context: file_spec[:hlp_path],
283
+ compress: file_spec[:compress],
284
+ }
285
+ end
286
+ end
287
+
288
+ # Build complete QuickHelp structure
289
+ #
290
+ # @param topics [Array<Hash>] Topic data
291
+ # @param version [Integer] Format version
292
+ # @param database_name [String] Database name
293
+ # @param control_char [Integer] Control character
294
+ # @param case_sensitive [Boolean] Case-sensitive contexts
295
+ # @return [Hash] Complete structure ready for writing
296
+ def build_quickhelp_structure(topics, version, database_name,
297
+ control_char, case_sensitive)
298
+ structure = {}
299
+
300
+ # Compress topics
301
+ structure[:topics] = compress_topics(topics)
302
+
303
+ # Build context data
304
+ structure[:contexts] = topics.map { |t| t[:context] }
305
+ structure[:context_map] = topics.map.with_index { |_t, i| i }
306
+
307
+ # Calculate offsets
308
+ structure[:offsets] = calculate_offsets(structure)
309
+
310
+ # Build header
311
+ structure[:header] = build_header(
312
+ structure,
313
+ version,
314
+ database_name,
315
+ control_char,
316
+ case_sensitive,
317
+ )
318
+
319
+ structure
320
+ end
321
+
322
+ # Compress all topics
323
+ #
324
+ # @param topics [Array<Hash>] Topic data
325
+ # @return [Array<Hash>] Compressed topics
326
+ def compress_topics(topics)
327
+ topics.map do |topic|
328
+ compressed = if topic[:compress]
329
+ compress_topic_text(topic[:text])
330
+ else
331
+ topic[:text]
332
+ end
333
+
334
+ {
335
+ text: topic[:text],
336
+ compressed: compressed,
337
+ decompressed_length: topic[:text].bytesize,
338
+ compressed_length: compressed.bytesize,
339
+ compress: topic[:compress],
340
+ }
341
+ end
342
+ end
343
+
344
+ # Compress topic text using QuickHelp keyword compression
345
+ #
346
+ # QuickHelp format uses:
347
+ # - 0x00-0x0F: Literal bytes
348
+ # - 0x10-0x17: Dictionary entry references
349
+ # - 0x18: Run of spaces
350
+ # - 0x19: Run of bytes (repeat)
351
+ # - 0x1A: Escape byte (next byte is literal)
352
+ # - 0x1B-0xFF: Literal bytes
353
+ #
354
+ # Without a dictionary, we encode literals directly and escape
355
+ # control characters (0x10-0x1A) with 0x1A prefix.
356
+ #
357
+ # Topic text format:
358
+ # - Each line: [len][text][newline][attr_len][attrs][0xFF]
359
+ # - len includes itself, text, newline, attr_len
360
+ # - attr_len includes itself and attrs, minimum 1 (just 0xFF terminator)
361
+ #
362
+ # @param text [String] Topic text
363
+ # @return [String] Compressed data with length header
364
+ def compress_topic_text(text)
365
+ # Build topic in QuickHelp internal format
366
+ topic_data = build_topic_data(text)
367
+
368
+ # Encode using QuickHelp keyword compression format
369
+ encoded = encode_keyword_compression(topic_data)
370
+
371
+ # Prepend decompressed length (2 bytes)
372
+ length_header = [topic_data.bytesize].pack("v")
373
+ length_header + encoded
374
+ end
375
+
376
+ # Build topic data in QuickHelp internal format
377
+ #
378
+ # Topic format as expected by decompressor:
379
+ # - Each line: [text_length][text][newline][attr_len][attrs][0xFF]
380
+ # - text_length = text + newline + 1 (for attr_len byte) = text_bytes + 2
381
+ # - Line structure: text_length byte + text + newline + attr_len byte + attr_data
382
+ #
383
+ # The decompressor reads:
384
+ # - text_length = data.getbyte(pos)
385
+ # - text_bytes = text_length - 2 (reads text, skips newline)
386
+ # - attr_length = data.getbyte(pos after text + newline)
387
+ #
388
+ # @param text [String] Raw topic text
389
+ # @return [String] Formatted topic data
390
+ def build_topic_data(text)
391
+ result = +""
392
+
393
+ # Split text into lines
394
+ lines = text.split("\n")
395
+
396
+ lines.each do |line|
397
+ text_bytes = line.b
398
+ newline = "\x0D" # Carriage return
399
+
400
+ # Attribute section: just 0xFF terminator (attr_len = 1)
401
+ attr_data = "\xFF"
402
+ attr_len = 1
403
+
404
+ # text_length = text + newline + 1 (for attr_len byte)
405
+ # This ensures text_bytes = text_length - 2 gives correct text length
406
+ text_length = text_bytes.bytesize + 2
407
+
408
+ result << text_length.chr
409
+ result << text_bytes
410
+ result << newline
411
+ result << attr_len.chr
412
+ result << attr_data
413
+ end
414
+
415
+ result
416
+ end
417
+
418
+ # Encode data using QuickHelp keyword compression format
419
+ #
420
+ # @param data [String] Data to encode
421
+ # @return [String] Encoded data
422
+ def encode_keyword_compression(data)
423
+ result = +""
424
+
425
+ data.bytes.each do |byte|
426
+ if byte < 0x10 || byte == 0x1B || byte > 0x1A
427
+ # Literal byte (except control range 0x10-0x1A)
428
+ result << byte.chr
429
+ elsif byte.between?(0x10, 0x1A)
430
+ # Control byte - escape it
431
+ result << 0x1A.chr << byte.chr
432
+ else
433
+ # 0x1B is also literal (above control range)
434
+ result << byte.chr
435
+ end
436
+ end
437
+
438
+ result
439
+ end
440
+
441
+ # Calculate all offsets in the file
442
+ #
443
+ # @param structure [Hash] QuickHelp structure
444
+ # @return [Hash] Calculated offsets
445
+ def calculate_offsets(structure)
446
+ offsets = {}
447
+
448
+ # Start after file header (70 bytes = 2 signature + 68 header)
449
+ current_offset = 70
450
+
451
+ # Topic index: (topic_count + 1) * 4 bytes
452
+ offsets[:topic_index] = current_offset
453
+ topic_count = structure[:topics].size
454
+ current_offset += (topic_count + 1) * 4
455
+
456
+ # Context strings: sum of string lengths + null terminators
457
+ offsets[:context_strings] = current_offset
458
+ structure[:contexts].each do |ctx|
459
+ current_offset += ctx.bytesize + 1 # +1 for null terminator
460
+ end
461
+
462
+ # Context map: context_count * 2 bytes
463
+ offsets[:context_map] = current_offset
464
+ current_offset += structure[:context_map].size * 2
465
+
466
+ # Keywords: not implemented yet, set to 0
467
+ offsets[:keywords] = 0
468
+
469
+ # Huffman tree: not implemented yet, set to 0
470
+ offsets[:huffman_tree] = 0
471
+
472
+ # Topic text: starts after context map
473
+ offsets[:topic_text] = current_offset
474
+
475
+ # Calculate topic text offsets
476
+ offsets[:topic_offsets] = []
477
+ structure[:topics].each do |topic|
478
+ offsets[:topic_offsets] << (current_offset - offsets[:topic_text])
479
+ current_offset += topic[:compressed_length]
480
+ end
481
+ # Add end marker
482
+ offsets[:topic_offsets] << (current_offset - offsets[:topic_text])
483
+
484
+ # Total database size
485
+ offsets[:database_size] = current_offset
486
+
487
+ offsets
488
+ end
489
+
490
+ # Build file header
491
+ #
492
+ # @param structure [Hash] QuickHelp structure
493
+ # @param version [Integer] Format version
494
+ # @param database_name [String] Database name
495
+ # @param control_char [Integer] Control character
496
+ # @param case_sensitive [Boolean] Case-sensitive contexts
497
+ # @return [Hash] Header information
498
+ def build_header(structure, version, database_name, control_char,
499
+ case_sensitive)
500
+ attributes = 0
501
+ attributes |= Binary::HLPStructures::Attributes::CASE_SENSITIVE if case_sensitive
502
+
503
+ {
504
+ version: version,
505
+ attributes: attributes,
506
+ control_character: control_char,
507
+ topic_count: structure[:topics].size,
508
+ context_count: structure[:contexts].size,
509
+ display_width: 80,
510
+ predefined_ctx_count: 0,
511
+ database_name: database_name.ljust(14, "\x00")[0, 14],
512
+ offsets: structure[:offsets],
513
+ }
514
+ end
515
+
516
+ # Write complete QuickHelp file
517
+ #
518
+ # @param output_handle [System::FileHandle] Output file handle
519
+ # @param structure [Hash] QuickHelp structure
520
+ # @return [Integer] Bytes written
521
+ def write_quickhelp_file(output_handle, structure)
522
+ bytes_written = 0
523
+
524
+ # Write file header
525
+ bytes_written += write_file_header(output_handle, structure[:header])
526
+
527
+ # Write topic index
528
+ bytes_written += write_topic_index(output_handle,
529
+ structure[:header][:offsets])
530
+
531
+ # Write context strings
532
+ bytes_written += write_context_strings(output_handle,
533
+ structure[:contexts])
534
+
535
+ # Write context map
536
+ bytes_written += write_context_map(output_handle,
537
+ structure[:context_map])
538
+
539
+ # Write topic texts
540
+ bytes_written += write_topic_texts(output_handle, structure[:topics])
541
+
542
+ bytes_written
543
+ end
544
+
545
+ # Write file header
546
+ #
547
+ # @param output_handle [System::FileHandle] Output file handle
548
+ # @param header_info [Hash] Header information
549
+ # @return [Integer] Bytes written
550
+ def write_file_header(output_handle, header_info)
551
+ header = Binary::HLPStructures::FileHeader.new
552
+ header.signature = Binary::HLPStructures::SIGNATURE
553
+ header.version = header_info[:version]
554
+ header.attributes = header_info[:attributes]
555
+ header.control_character = header_info[:control_character]
556
+ header.padding1 = 0
557
+ header.topic_count = header_info[:topic_count]
558
+ header.context_count = header_info[:context_count]
559
+ header.display_width = header_info[:display_width]
560
+ header.padding2 = 0
561
+ header.predefined_ctx_count = header_info[:predefined_ctx_count]
562
+ header.database_name = header_info[:database_name]
563
+ header.reserved1 = 0
564
+ header.topic_index_offset = header_info[:offsets][:topic_index]
565
+ header.context_strings_offset = header_info[:offsets][:context_strings]
566
+ header.context_map_offset = header_info[:offsets][:context_map]
567
+ header.keywords_offset = header_info[:offsets][:keywords]
568
+ header.huffman_tree_offset = header_info[:offsets][:huffman_tree]
569
+ header.topic_text_offset = header_info[:offsets][:topic_text]
570
+ header.reserved2 = 0
571
+ header.reserved3 = 0
572
+ header.database_size = header_info[:offsets][:database_size]
573
+
574
+ header_data = header.to_binary_s
575
+ @io_system.write(output_handle, header_data)
576
+ end
577
+
578
+ # Write topic index
579
+ #
580
+ # @param output_handle [System::FileHandle] Output file handle
581
+ # @param offsets [Hash] Offset information
582
+ # @return [Integer] Bytes written
583
+ def write_topic_index(output_handle, offsets)
584
+ # Write all topic offsets including end marker
585
+ index_data = offsets[:topic_offsets].pack("V*")
586
+ @io_system.write(output_handle, index_data)
587
+ end
588
+
589
+ # Write context strings
590
+ #
591
+ # @param output_handle [System::FileHandle] Output file handle
592
+ # @param contexts [Array<String>] Context strings
593
+ # @return [Integer] Bytes written
594
+ def write_context_strings(output_handle, contexts)
595
+ data = contexts.map { |ctx| "#{ctx}\u0000" }.join
596
+ @io_system.write(output_handle, data)
597
+ end
598
+
599
+ # Write context map
600
+ #
601
+ # @param output_handle [System::FileHandle] Output file handle
602
+ # @param context_map [Array<Integer>] Topic indices
603
+ # @return [Integer] Bytes written
604
+ def write_context_map(output_handle, context_map)
605
+ map_data = context_map.pack("v*")
606
+ @io_system.write(output_handle, map_data)
607
+ end
608
+
609
+ # Write topic texts
610
+ #
611
+ # @param output_handle [System::FileHandle] Output file handle
612
+ # @param topics [Array<Hash>] Compressed topics
613
+ # @return [Integer] Bytes written
614
+ def write_topic_texts(output_handle, topics)
615
+ total_bytes = 0
616
+
617
+ topics.each do |topic|
618
+ total_bytes += @io_system.write(output_handle, topic[:compressed])
619
+ end
620
+
621
+ total_bytes
622
+ end
623
+ end
624
+ end
625
+ end
626
+ end