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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +124 -0
- data/exe/readword +44 -0
- data/lib/readword/binary.rb +17 -0
- data/lib/readword/compound_file.rb +148 -0
- data/lib/readword/doc.rb +526 -0
- data/lib/readword/docx.rb +294 -0
- data/lib/readword/markdown.rb +87 -0
- data/lib/readword/numbering.rb +75 -0
- data/lib/readword/version.rb +5 -0
- data/lib/readword.rb +87 -0
- metadata +96 -0
data/lib/readword/doc.rb
ADDED
|
@@ -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
|