readword 0.1.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.
@@ -0,0 +1,526 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Readword
4
+ # Reads the text of a Word 97 to 2003 .doc (MS-DOC) from its WordDocument and table streams.
5
+ class Doc
6
+ include Binary
7
+
8
+ Piece = Struct.new(:start, :finish, :offset, :compressed)
9
+ Run = Struct.new(:start, :finish, :flags)
10
+
11
+ # FibBase: wIdent is always 0xA5EC, and an nFib below 106 means Word 6 or 95.
12
+ WORD_IDENT = 0xA5EC
13
+ FIRST_WORD97_VERSION = 106
14
+ # Byte offsets into the FIB, the header at the start of the WordDocument stream (FibBase, FibRgFcLcb97).
15
+ FIB_VERSION = 0x02
16
+ FIB_FLAGS = 0x0A
17
+ FIB_STYLES = 0x00A2
18
+ FIB_STYLES_BYTES = 0x00A6
19
+ FIB_CHPX_TABLE = 0x00FA
20
+ FIB_CHPX_TABLE_BYTES = 0x00FE
21
+ FIB_PAPX_TABLE = 0x0102
22
+ FIB_PAPX_TABLE_BYTES = 0x0106
23
+ FIB_FONT_TABLE = 0x0112
24
+ FIB_FONT_TABLE_BYTES = 0x0116
25
+ FIB_PIECE_TABLE = 0x01A2
26
+ FIB_PIECE_TABLE_BYTES = 0x01A6
27
+ FIB_LISTS = 0x02E2
28
+ FIB_LISTS_BYTES = 0x02E6
29
+ FIB_LIST_OVERRIDES = 0x02EA
30
+ FIB_LIST_OVERRIDES_BYTES = 0x02EE
31
+ FIB_MIN_BYTES = 0x01AA
32
+ # FibBase flag bits fEncrypted and fWhichTblStm (set means the 1Table stream).
33
+ ENCRYPTED = 0x0100
34
+ TABLE_STREAM_ONE = 0x0200
35
+ # FcCompressed: bit 30 set means 8-bit Windows-1252 text stored at half the offset.
36
+ COMPRESSED_TEXT = 0x40000000
37
+ TEXT_OFFSET = 0x3FFFFFFF
38
+ # Formatting is stored in 512-byte FKP pages of at most 29 paragraph runs or 101 character runs.
39
+ PAGE_BYTES = 512
40
+ MAX_PAPX_RUNS_PER_PAGE = 0x1D
41
+ MAX_CHPX_RUNS_PER_PAGE = 0x65
42
+ # Limits against crafted files, so the reader cannot be made to work forever.
43
+ MAX_PROPERTY_BYTES = 8 * 1024 * 1024
44
+ MAX_TEXT_POSITIONS = 400_000
45
+ NO_FLAGS = {}.freeze
46
+
47
+ # Paragraph sprms: in a table, end of a table row, and the same two for a table nested in a cell.
48
+ PARAGRAPH_SPRMS = { 0x2416 => :in_table, 0x2417 => :row_end, 0x244B => :inner_cell, 0x244C => :inner_row_end }.freeze
49
+ # sprmCFRMarkDel: deleted with Track Changes. Word writes 1, or 0x81, a ToggleOperand meaning the opposite of the style.
50
+ DELETED = 0x0800
51
+ DELETED_VALUES = [0x01, 0x81].freeze
52
+ # sprmCSymbol: Insert Symbol stores "(" in the text, and the font number and real character here.
53
+ SYMBOL = 0x6A09
54
+ SYMBOL_PLACEHOLDER = '('
55
+ # FFN, one font in the SttbfFfn table: its name starts after 40 bytes of size, flags, weight, charset, panose and signature.
56
+ FONT_NAME_OFFSET = 40
57
+ # sprmPItap (table depth), sprmPHugePapx (properties kept in the Data stream), and two sprms with unusual operand sizes.
58
+ TABLE_DEPTH = 0x6649
59
+ HUGE_PAPX = 0x6646
60
+ DEFINE_TABLE = 0xD608
61
+ CHANGE_TABS = 0xC615
62
+ # sprmPOutLvl (outline level, 9 is body text), sprmPIlvl (list level) and sprmPIlfo (1-based index into the PlfLfo).
63
+ OUTLINE_LEVEL = 0x2640
64
+ LIST_LEVEL = 0x260A
65
+ LIST = 0x460B
66
+ # STSH: a Stshi (cstd, cbSTDBaseInFile), then one LPStd per style; StdfBase packs sti, stk, istdBase and cupx.
67
+ PARAGRAPH_STYLE = 1
68
+ NO_STYLE = 0x0FFF
69
+ TITLE_STYLE = 62
70
+ MAX_STYLE_DEPTH = 16
71
+ # PlfLst: 28-byte LSTF records, then each list's LVLs (a 28-byte LVLF, its grpprls, then the number text);
72
+ # PlfLfo: 16-byte LFO records, then each LFO's LFOLVL overrides. nfc picks the number style.
73
+ LIST_RECORD_BYTES = 28
74
+ LEVEL_RECORD_BYTES = 28
75
+ OVERRIDE_RECORD_BYTES = 16
76
+ MAX_LISTS = 2048
77
+ NUMBER_FORMATS = { 1 => :upper_roman, 2 => :lower_roman, 3 => :upper_letter, 4 => :lower_letter, 5 => :ordinal,
78
+ 22 => :decimal_zero, 23 => :bullet, 255 => :none }.freeze
79
+
80
+ # Special characters in the text: field begin, separator and end, the cell mark, the paragraph mark, and page and column breaks.
81
+ FIELD_BEGIN = "\u0013"
82
+ FIELD_SEPARATOR = "\u0014"
83
+ FIELD_END = "\u0015"
84
+ CELL_MARK = "\a"
85
+ PARAGRAPH_MARK = "\r"
86
+ PARAGRAPH_ENDS = ["\f", "\u000E"].freeze
87
+ REPLACEMENTS = { "\v" => "\n", "\t" => "\t", "\u001E" => '-', "\u00A0" => ' ' }.freeze
88
+ CP1252 = Array.new(256) do |byte|
89
+ byte.chr.force_encoding(Encoding::Windows_1252).encode(Encoding::UTF_8, undef: :replace, replace: '')
90
+ end.freeze
91
+
92
+ def initialize(bytes)
93
+ @file = CompoundFile.new(bytes)
94
+ @word = @file.stream('WordDocument').to_s
95
+ raise Error, PASSWORD_PROTECTED if @word.empty? && @file.stream?('EncryptedPackage')
96
+ raise Error, OLD_FORMAT if u16(@word, FIB_VERSION).to_i.between?(1, FIRST_WORD97_VERSION - 1)
97
+ raise Error, UNREADABLE unless @word.bytesize >= FIB_MIN_BYTES && u16(@word, 0) == WORD_IDENT
98
+ raise Error, PASSWORD_PROTECTED if fib_flags.anybits?(ENCRYPTED)
99
+
100
+ @table = @file.stream(fib_flags.anybits?(TABLE_STREAM_ONE) ? '1Table' : '0Table') || raise(Error, UNREADABLE)
101
+ end
102
+
103
+ def blocks
104
+ pieces = text_pieces
105
+ raise Error, TOO_LONG if pieces.sum { |piece| [piece.finish - piece.start, 0].max } > MAX_TEXT_POSITIONS
106
+
107
+ @property_bytes = 0
108
+ @runs = paragraph_runs
109
+ @marked = marked_runs
110
+ @blocks = []
111
+ @counter = Numbering.new
112
+ @chains = {}
113
+ @paragraph_start = 0
114
+ @row = []
115
+ @text = +''
116
+ read_characters(pieces)
117
+ if @row.any?
118
+ end_row
119
+ else
120
+ @blocks << Paragraph.new(text: @text)
121
+ end
122
+ @blocks
123
+ end
124
+
125
+ private
126
+
127
+ # Field codes sit between a field begin and its separator; only the result after the separator is text.
128
+ def read_characters(pieces)
129
+ fields = []
130
+ code_depth = 0
131
+ each_character(pieces) do |character, offset, width|
132
+ marks = @marked.empty? ? {} : run_flags(@marked, offset)
133
+ next if marks[:deleted]
134
+
135
+ character = font_symbol(*marks[:symbol]) if marks[:symbol] && character == SYMBOL_PLACEHOLDER
136
+ case character
137
+ when FIELD_BEGIN
138
+ fields.push(:code)
139
+ code_depth += 1
140
+ when FIELD_SEPARATOR
141
+ if fields.last == :code
142
+ fields[-1] = :result
143
+ code_depth -= 1
144
+ end
145
+ when FIELD_END
146
+ code_depth -= 1 if fields.pop == :code
147
+ else
148
+ read_character(character, offset, width) if code_depth.zero?
149
+ end
150
+ end
151
+ end
152
+
153
+ # A page or column break ends its paragraph only when it closes the paragraph's run, as a section break does.
154
+ def read_character(character, offset, width)
155
+ if character == CELL_MARK
156
+ flags = paragraph_flags(offset)
157
+ flags[:row_end] ? end_row : end_cell(flags)
158
+ elsif character == PARAGRAPH_MARK || (PARAGRAPH_ENDS.include?(character) && paragraph_end?(offset, width))
159
+ end_paragraph(paragraph_flags(offset))
160
+ elsif PARAGRAPH_ENDS.include?(character)
161
+ @text << "\n"
162
+ elsif character >= ' ' || REPLACEMENTS.key?(character)
163
+ @text << REPLACEMENTS.fetch(character, character)
164
+ end
165
+ end
166
+
167
+ def end_paragraph(flags)
168
+ if flags[:inner_row_end] then end_row
169
+ elsif flags[:inner_cell] then end_cell(flags)
170
+ elsif flags[:in_table]
171
+ label_paragraph(flags)
172
+ @text << ' '
173
+ @paragraph_start = @text.size
174
+ else
175
+ end_row if @row.any?
176
+ @blocks << Paragraph.new(text: @text, heading: heading_level(flags), list: list_item(flags))
177
+ @text = +''
178
+ @paragraph_start = 0
179
+ end
180
+ end
181
+
182
+ def end_cell(flags = nil)
183
+ label_paragraph(flags) if flags
184
+ @row << Readword.squish(@text)
185
+ @text = +''
186
+ @paragraph_start = 0
187
+ end
188
+
189
+ def end_row
190
+ end_cell unless Readword.blank?(@text)
191
+ @blocks << Row.new(@row) unless @row.all? { |cell| Readword.blank?(cell) }
192
+ @row = []
193
+ @text = +''
194
+ @paragraph_start = 0
195
+ end
196
+
197
+ def label_paragraph(flags)
198
+ item = list_item(flags) or return
199
+ @text.insert(@paragraph_start, "#{item.bullet ? '•' : item.label} ")
200
+ end
201
+
202
+ def heading_level(flags)
203
+ chain = style_chain(flags[:style])
204
+ outline = flags[:outline] || chain.filter_map { |style| style[:properties][:outline] }.first
205
+ level = outline || (0 if chain.first&.fetch(:sti) == TITLE_STYLE) ||
206
+ chain.filter_map { |style| style[:sti] - 1 if style[:sti].between?(1, 9) }.first
207
+ [level + 1, 6].min if level&.between?(0, 8)
208
+ end
209
+
210
+ def list_item(flags)
211
+ chain = style_chain(flags[:style])
212
+ ilfo = flags[:list] || chain.filter_map { |style| style[:properties][:list] }.first
213
+ level = flags[:list_level] || chain.filter_map { |style| style[:properties][:list_level] }.first
214
+ list = lists[ilfo] if ilfo&.between?(1, MAX_LISTS) && level.to_i.between?(0, 8)
215
+ list && @counter.next_item(list[:key], list[:levels], level.to_i, list[:starts])
216
+ end
217
+
218
+ def style_chain(istd)
219
+ @chains[istd] ||= begin
220
+ chain = []
221
+ while istd && istd != NO_STYLE && (style = styles[istd]) && chain.size < MAX_STYLE_DEPTH && !chain.include?(style)
222
+ chain << style
223
+ istd = style[:base]
224
+ end
225
+ chain
226
+ end
227
+ end
228
+
229
+ def styles
230
+ @styles ||= begin
231
+ sheet = @table.byteslice(u32(@word, FIB_STYLES).to_i, u32(@word, FIB_STYLES_BYTES).to_i).to_s
232
+ base_bytes = u16(sheet, 4).to_i
233
+ position = 2 + u16(sheet, 0).to_i
234
+ Array.new(u16(sheet, 2).to_i) do
235
+ size = u16(sheet, position).to_i
236
+ style = paragraph_style(sheet.byteslice(position + 2, size).to_s, base_bytes)
237
+ position += 2 + size
238
+ style
239
+ end
240
+ end
241
+ end
242
+
243
+ # STD: StdfBase, the style name (a count, the UTF-16 characters and a terminator), then UpxPapx (istd and grpprl).
244
+ def paragraph_style(std, base_bytes)
245
+ return unless base_bytes.between?(10, std.bytesize) && (u16(std, 2) & 0x000F) == PARAGRAPH_STYLE
246
+
247
+ upx_at = base_bytes + 4 + (2 * u16(std, base_bytes).to_i)
248
+ upx_bytes = u16(std, upx_at).to_i
249
+ papx = u16(std, 4).anybits?(0x000F) && upx_bytes >= 2 ? std.byteslice(upx_at + 4, upx_bytes - 2).to_s : ''
250
+ { sti: u16(std, 0) & 0x0FFF, base: u16(std, 2) >> 4, properties: sprm_flags(papx) }
251
+ end
252
+
253
+ def lists
254
+ @lists ||= list_overrides(list_definitions)
255
+ end
256
+
257
+ def list_definitions
258
+ at = u32(@word, FIB_LISTS).to_i
259
+ return {} unless u32(@word, FIB_LISTS_BYTES).to_i.positive?
260
+
261
+ count = [u16(@table, at).to_i, MAX_LISTS].min
262
+ position = at + 2 + (LIST_RECORD_BYTES * count)
263
+ Array.new(count) do |index|
264
+ record = @table.byteslice(at + 2 + (LIST_RECORD_BYTES * index), LIST_RECORD_BYTES).to_s
265
+ levels = {}
266
+ (record.getbyte(26).to_i.anybits?(0x01) ? 1 : 9).times do |level|
267
+ definition, position = list_level(position)
268
+ break unless definition
269
+
270
+ levels[level] = definition
271
+ end
272
+ [u32(record, 0), levels]
273
+ end.to_h
274
+ end
275
+
276
+ def list_overrides(definitions)
277
+ at = u32(@word, FIB_LIST_OVERRIDES).to_i
278
+ return {} unless u32(@word, FIB_LIST_OVERRIDES_BYTES).to_i.positive?
279
+
280
+ count = [u32(@table, at).to_i, MAX_LISTS].min
281
+ position = at + 4 + (OVERRIDE_RECORD_BYTES * count)
282
+ (1..count).to_h do |ilfo|
283
+ record = @table.byteslice(at + 4 + (OVERRIDE_RECORD_BYTES * (ilfo - 1)), OVERRIDE_RECORD_BYTES).to_s
284
+ lsid = u32(record, 0)
285
+ levels = definitions.fetch(lsid, {})
286
+ starts = {}
287
+ position += 4
288
+ record.getbyte(12).to_i.times do
289
+ start = @table.byteslice(position, 4).to_s.unpack1('l<')
290
+ options = u32(@table, position + 4).to_i
291
+ position += 8
292
+ starts[options & 0x0F] = start if start && options.anybits?(0x10)
293
+ next unless options.anybits?(0x20)
294
+
295
+ definition, position = list_level(position)
296
+ levels = levels.merge(options & 0x0F => definition) if definition
297
+ end
298
+ [ilfo, { key: starts.empty? ? "list #{lsid}" : "override #{ilfo}", levels: levels, starts: starts }]
299
+ end
300
+ end
301
+
302
+ # LVL: an LVLF (iStartAt, nfc, ..., cbGrpprlChpx, cbGrpprlPapx), both grpprls, then the number text, whose characters 0 to 8
303
+ # stand for the numbers of those levels.
304
+ def list_level(position)
305
+ record = @table.byteslice(position, LEVEL_RECORD_BYTES)
306
+ return [nil, position] unless record&.bytesize == LEVEL_RECORD_BYTES
307
+
308
+ text_at = position + LEVEL_RECORD_BYTES + record.getbyte(24) + record.getbyte(25)
309
+ count = u16(@table, text_at).to_i
310
+ units = @table.byteslice(text_at + 2, 2 * count).to_s.unpack('v*').grep_v(0xD800..0xDFFF)
311
+ template = units.slice_when { |first, second| first < 9 || second < 9 }.map { |group| group.first < 9 ? group.first : group.pack('U*') }
312
+ [Numbering::Level.new(start: record.unpack1('l<'), format: NUMBER_FORMATS.fetch(record.getbyte(4), :decimal), template: template),
313
+ text_at + 2 + (2 * count)]
314
+ end
315
+
316
+ def each_character(pieces, &)
317
+ pieces.each do |piece|
318
+ count = piece.finish - piece.start
319
+ next unless count.positive?
320
+
321
+ if piece.compressed
322
+ @word.byteslice(piece.offset, count).to_s.each_byte.with_index { |byte, index| yield CP1252[byte], piece.offset + index, 1 }
323
+ else
324
+ each_utf16(@word.byteslice(piece.offset, count * 2).to_s.unpack('v*'), piece.offset, &)
325
+ end
326
+ end
327
+ end
328
+
329
+ def each_utf16(units, offset)
330
+ index = 0
331
+ while index < units.size
332
+ unit = units[index]
333
+ low = units[index + 1].to_i
334
+ if unit.between?(0xD800, 0xDBFF) && low.between?(0xDC00, 0xDFFF)
335
+ yield (0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00)).chr(Encoding::UTF_8), offset + (2 * index), 4
336
+ index += 1
337
+ elsif !unit.between?(0xD800, 0xDFFF)
338
+ yield unit.chr(Encoding::UTF_8), offset + (2 * index), 2
339
+ end
340
+ index += 1
341
+ end
342
+ end
343
+
344
+ # Clx: optional Prc entries (clxt 1), then a Pcdt (clxt 2) whose PlcPcd lists where each run of text is stored.
345
+ def text_pieces
346
+ clx = @table.byteslice(u32(@word, FIB_PIECE_TABLE), u32(@word, FIB_PIECE_TABLE_BYTES)).to_s
347
+ position = 0
348
+ position += 3 + u16(clx, position + 1) while clx.getbyte(position) == 0x01
349
+ raise Error, UNREADABLE unless clx.getbyte(position) == 0x02
350
+
351
+ table = clx.byteslice(position + 5, u32(clx, position + 1).to_i).to_s
352
+ count = (table.bytesize - 4) / 12
353
+ raise Error, UNREADABLE unless count.positive?
354
+ raise Error, TOO_LONG if count > MAX_TEXT_POSITIONS
355
+
356
+ positions = table.unpack("V#{count + 1}")
357
+ Array.new(count) do |index|
358
+ fc = u32(table, (4 * (count + 1)) + (8 * index) + 2)
359
+ compressed = fc.anybits?(COMPRESSED_TEXT)
360
+ offset = fc & TEXT_OFFSET
361
+ Piece.new(positions[index], positions[index + 1], compressed ? offset / 2 : offset, compressed)
362
+ end
363
+ end
364
+
365
+ def paragraph_runs
366
+ runs = []
367
+ property_pages(FIB_PAPX_TABLE, FIB_PAPX_TABLE_BYTES).each do |offset|
368
+ runs.concat(page_runs(offset))
369
+ raise Error, TOO_LONG if runs.size > MAX_TEXT_POSITIONS
370
+ end
371
+ runs.sort_by(&:start)
372
+ end
373
+
374
+ def marked_runs
375
+ runs = []
376
+ property_pages(FIB_CHPX_TABLE, FIB_CHPX_TABLE_BYTES).each do |offset|
377
+ runs.concat(character_runs(offset).select { |run| run.flags[:deleted] || run.flags[:symbol] })
378
+ raise Error, TOO_LONG if runs.size > MAX_TEXT_POSITIONS
379
+ end
380
+ runs.sort_by(&:start)
381
+ end
382
+
383
+ # PlcBtePapx and PlcBteChpx: FC boundaries, then the FKP page numbers in the low 22 bits.
384
+ def property_pages(table_at, bytes_at)
385
+ table = @table.byteslice(u32(@word, table_at), u32(@word, bytes_at)).to_s
386
+ count = (table.bytesize - 4) / 8
387
+ return [] unless count.positive?
388
+
389
+ limit = [count, @word.bytesize / PAGE_BYTES].min
390
+ table.unpack("@#{4 * (count + 1)}V#{limit}").map { |page| (page & 0x3FFFFF) * PAGE_BYTES }.uniq
391
+ end
392
+
393
+ # PapxFkp: run boundaries, then 13-byte BxPap entries whose first byte, doubled, points at the run's PapxInFkp.
394
+ def page_runs(offset)
395
+ page = @word.byteslice(offset, PAGE_BYTES).to_s
396
+ return [] unless page.bytesize == PAGE_BYTES
397
+
398
+ count = page.getbyte(PAGE_BYTES - 1)
399
+ return [] unless count.between?(1, MAX_PAPX_RUNS_PER_PAGE)
400
+
401
+ bounds = page.unpack("V#{count + 1}")
402
+ properties = {}
403
+ Array.new(count) do |index|
404
+ at = page.getbyte((4 * (count + 1)) + (13 * index)) * 2
405
+ Run.new(bounds[index], bounds[index + 1], at.zero? ? NO_FLAGS : (properties[at] ||= papx_flags(page, at)))
406
+ end
407
+ end
408
+
409
+ # ChpxFkp: run boundaries, then 1-byte offsets that, doubled, point at each run's Chpx.
410
+ def character_runs(offset)
411
+ page = @word.byteslice(offset, PAGE_BYTES).to_s
412
+ return [] unless page.bytesize == PAGE_BYTES
413
+
414
+ count = page.getbyte(PAGE_BYTES - 1)
415
+ return [] unless count.between?(1, MAX_CHPX_RUNS_PER_PAGE)
416
+
417
+ bounds = page.unpack("V#{count + 1}")
418
+ properties = {}
419
+ Array.new(count) do |index|
420
+ at = page.getbyte((4 * (count + 1)) + index) * 2
421
+ Run.new(bounds[index], bounds[index + 1], at.zero? ? NO_FLAGS : (properties[at] ||= chpx_flags(page, at)))
422
+ end
423
+ end
424
+
425
+ # PapxInFkp: a size byte (0 means the next byte holds it), then a 2-byte style index and the sprms.
426
+ def papx_flags(page, offset)
427
+ size = page.getbyte(offset) or return {}
428
+ length, start = size.zero? ? [2 * page.getbyte(offset + 1).to_i, offset + 2] : [(2 * size) - 1, offset + 1]
429
+ sprm_flags(page.byteslice(start + 2, length - 2).to_s).merge(style: u16(page, start))
430
+ end
431
+
432
+ def chpx_flags(page, offset)
433
+ size = page.getbyte(offset) or return {}
434
+ sprm_flags(page.byteslice(offset + 1, size).to_s)
435
+ end
436
+
437
+ def sprm_flags(grpprl, nested: false)
438
+ @property_bytes += grpprl.bytesize
439
+ raise Error, UNREADABLE if @property_bytes > MAX_PROPERTY_BYTES
440
+
441
+ flags = {}
442
+ position = 0
443
+ while position + 2 <= grpprl.bytesize
444
+ sprm = u16(grpprl, position)
445
+ size = operand_size(sprm, grpprl, position + 2) or break
446
+ operand = grpprl.byteslice(position + 2, size).to_s
447
+ if PARAGRAPH_SPRMS.key?(sprm) then flags[PARAGRAPH_SPRMS[sprm]] = operand.getbyte(0) == 1
448
+ elsif sprm == DELETED then flags[:deleted] = DELETED_VALUES.include?(operand.getbyte(0))
449
+ elsif sprm == SYMBOL then flags[:symbol] = [u16(operand, 0).to_i, u16(operand, 2).to_i]
450
+ elsif sprm == OUTLINE_LEVEL then flags[:outline] = operand.getbyte(0)
451
+ elsif sprm == LIST_LEVEL then flags[:list_level] = operand.getbyte(0)
452
+ elsif sprm == LIST then flags[:list] = u16(operand, 0)
453
+ elsif sprm == TABLE_DEPTH then flags[:in_table] ||= u32(operand, 0).to_i.positive?
454
+ elsif sprm == HUGE_PAPX && !nested then flags.merge!(huge_papx_flags(u32(operand, 0).to_i))
455
+ end
456
+ position += 2 + size
457
+ end
458
+ flags
459
+ end
460
+
461
+ # The sprm's top 3 bits (spra) give its operand size; 6 means the size is stored in the operand itself.
462
+ def operand_size(sprm, grpprl, offset)
463
+ case sprm >> 13
464
+ when 0, 1 then 1
465
+ when 2, 4, 5 then 2
466
+ when 3 then 4
467
+ when 7 then 3
468
+ else variable_operand_size(sprm, grpprl, offset)
469
+ end
470
+ end
471
+
472
+ def variable_operand_size(sprm, grpprl, offset)
473
+ return u16(grpprl, offset).to_i + 1 if sprm == DEFINE_TABLE
474
+
475
+ size = grpprl.getbyte(offset) or return
476
+ size + 1 unless sprm == CHANGE_TABS && size == 255
477
+ end
478
+
479
+ def huge_papx_flags(offset)
480
+ @huge_papx ||= {}
481
+ @huge_papx[offset] ||= begin
482
+ @data ||= @file.stream('Data').to_s
483
+ size = u16(@data, offset)
484
+ size ? sprm_flags(@data.byteslice(offset + 2, size).to_s, nested: true) : {}
485
+ end
486
+ end
487
+
488
+ def paragraph_end?(offset, width)
489
+ index = (@runs.bsearch_index { |run| run.start > offset } || @runs.size) - 1
490
+ index >= 0 && @runs[index].finish == offset + width
491
+ end
492
+
493
+ def paragraph_flags(offset)
494
+ run_flags(@runs, offset)
495
+ end
496
+
497
+ def run_flags(runs, offset)
498
+ index = (runs.bsearch_index { |run| run.start > offset } || runs.size) - 1
499
+ return {} if index.negative?
500
+
501
+ run = runs[index]
502
+ offset < run.finish ? run.flags : {}
503
+ end
504
+
505
+ def font_symbol(font, code)
506
+ Readword.symbol(font_names[font], code)
507
+ end
508
+
509
+ def font_names
510
+ @font_names ||= begin
511
+ table = @table.byteslice(u32(@word, FIB_FONT_TABLE), u32(@word, FIB_FONT_TABLE_BYTES)).to_s
512
+ position = 4
513
+ Array.new(u16(table, 0).to_i) do
514
+ size = table.getbyte(position).to_i + 1
515
+ name = table.byteslice(position + FONT_NAME_OFFSET, size - FONT_NAME_OFFSET).to_s
516
+ position += size
517
+ name.unpack('v*').take_while(&:nonzero?).pack('U*').scrub('')
518
+ end
519
+ end
520
+ end
521
+
522
+ def fib_flags
523
+ u16(@word, FIB_FLAGS)
524
+ end
525
+ end
526
+ end