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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zip'
|
|
4
|
+
|
|
5
|
+
module Readword
|
|
6
|
+
# Reads the text of a Word 2007 or later .docx (ECMA-376 WordprocessingML) from its XML parts.
|
|
7
|
+
class Docx
|
|
8
|
+
MAX_XML_BYTES = 10 * 1024 * 1024
|
|
9
|
+
MAX_XML_NODES = 400_000
|
|
10
|
+
MAX_GRID_SPAN = 63
|
|
11
|
+
MAX_STYLE_DEPTH = 16
|
|
12
|
+
UTF16_STARTS = ["\xFF\xFE".b, "\xFE\xFF".b, "<\0".b, "\0<".b].freeze
|
|
13
|
+
RELATIONSHIP = %r{/relationships/(officeDocument|header|footer|footnotes|endnotes|styles|numbering)\z}
|
|
14
|
+
SKIPPED = %w[del moveFrom instrText delText rPr pPr sectPr tblPr trPr tcPr sdtPr].freeze
|
|
15
|
+
CHARACTERS = %w[t tab ptab br cr noBreakHyphen sym].freeze
|
|
16
|
+
SEPARATOR_NOTES = %w[separator continuationSeparator continuationNotice].freeze
|
|
17
|
+
HEADING_STYLE = /\Aheading ([1-9])\z/i
|
|
18
|
+
TITLE_STYLE = /\Atitle\z/i
|
|
19
|
+
LEVEL_REFERENCE = /\A%[1-9]\z/
|
|
20
|
+
FORMATS = { 'upperRoman' => :upper_roman, 'lowerRoman' => :lower_roman, 'upperLetter' => :upper_letter,
|
|
21
|
+
'lowerLetter' => :lower_letter, 'decimalZero' => :decimal_zero, 'ordinal' => :ordinal,
|
|
22
|
+
'bullet' => :bullet, 'none' => :none }.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(bytes)
|
|
25
|
+
@bytes = bytes
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def blocks
|
|
29
|
+
@entries = Zip::File.open_buffer(@bytes).entries.to_h { |entry| [entry.name.downcase, entry] }
|
|
30
|
+
@budgets = { content: { bytes: 0, nodes: 0 }, formatting: { bytes: 0, nodes: 0 } }
|
|
31
|
+
@tables = 0
|
|
32
|
+
@counter = Numbering.new
|
|
33
|
+
@chains = {}
|
|
34
|
+
main = main_part
|
|
35
|
+
parts = related_parts(main)
|
|
36
|
+
directory = File.dirname(main)
|
|
37
|
+
@styles = read_styles(xml(parts['styles'].first || File.join(directory, 'styles.xml'), :formatting))
|
|
38
|
+
@lists = read_lists(xml(parts['numbering'].first || File.join(directory, 'numbering.xml'), :formatting))
|
|
39
|
+
[*unique(parts['header']), *part(main), *notes(parts['footnotes'] + parts['endnotes']), *unique(parts['footer'])]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def main_part
|
|
45
|
+
target = relationships('_rels/.rels').find { |type, _| type == 'officeDocument' }&.last
|
|
46
|
+
target ? resolve('.', target) : 'word/document.xml'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def related_parts(main)
|
|
50
|
+
directory = File.dirname(main)
|
|
51
|
+
parts = Hash.new { |hash, type| hash[type] = [] }
|
|
52
|
+
seen = Set[main.downcase]
|
|
53
|
+
relationships(File.join(directory, '_rels', "#{File.basename(main)}.rels")).each do |type, target|
|
|
54
|
+
path = resolve(directory, target)
|
|
55
|
+
parts[type] << path if seen.add?(path.downcase)
|
|
56
|
+
end
|
|
57
|
+
parts
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def relationships(path)
|
|
61
|
+
document = xml(path)
|
|
62
|
+
return [] unless document
|
|
63
|
+
|
|
64
|
+
document.xpath('//Relationship').filter_map do |relationship|
|
|
65
|
+
type = relationship['Type'].to_s[RELATIONSHIP, 1]
|
|
66
|
+
[type, relationship['Target'].to_s] if type && relationship['TargetMode'] != 'External'
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resolve(directory, target)
|
|
71
|
+
Pathname.new('/').join(directory, target).cleanpath.to_s.delete_prefix('/')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def xml(path, budget = :content)
|
|
75
|
+
entry = @entries[path.downcase] or return
|
|
76
|
+
remaining = MAX_XML_BYTES - @budgets[budget][:bytes]
|
|
77
|
+
raise Error, TOO_LONG if entry.size > remaining
|
|
78
|
+
|
|
79
|
+
data = entry.get_input_stream { |stream| stream.read(remaining + 1) }.to_s
|
|
80
|
+
raise Error, TOO_LONG if data.bytesize > remaining
|
|
81
|
+
|
|
82
|
+
@budgets[budget][:bytes] += data.bytesize
|
|
83
|
+
count_nodes(data.count('<&='), budget)
|
|
84
|
+
|
|
85
|
+
utf16 = data.start_with?(*UTF16_STARTS)
|
|
86
|
+
data = data.b.force_encoding(Encoding::UTF_8).scrub unless utf16
|
|
87
|
+
Nokogiri::XML(data, nil, utf16 ? nil : 'UTF-8').tap do |document|
|
|
88
|
+
raise Error, UNREADABLE if document.internal_subset
|
|
89
|
+
|
|
90
|
+
document.remove_namespaces!
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def part(path)
|
|
95
|
+
@counter.reset
|
|
96
|
+
content(xml(path))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def unique(paths)
|
|
100
|
+
paths.flat_map { |path| part(path) }.reject { |block| block.is_a?(Paragraph) && Readword.blank?(block.text) }.uniq
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def notes(paths)
|
|
104
|
+
paths.flat_map do |path|
|
|
105
|
+
@counter.reset
|
|
106
|
+
found = Array(xml(path)&.xpath('//*[self::footnote or self::endnote]'))
|
|
107
|
+
found.reject { |note| SEPARATOR_NOTES.include?(note['type']) }.flat_map { |note| content(note) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def content(node)
|
|
112
|
+
return [] unless node
|
|
113
|
+
|
|
114
|
+
node.element_children.flat_map do |child|
|
|
115
|
+
case child.name
|
|
116
|
+
when 'p' then paragraph_blocks(child)
|
|
117
|
+
when 'tbl' then table_blocks(child)
|
|
118
|
+
when 'AlternateContent' then content(alternative(child))
|
|
119
|
+
when *SKIPPED then []
|
|
120
|
+
else content(child)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def paragraph_blocks(paragraph)
|
|
126
|
+
boxes = []
|
|
127
|
+
text = inline(paragraph, boxes, [])
|
|
128
|
+
properties = child_named(paragraph, 'pPr')
|
|
129
|
+
style = value(properties, 'pStyle')
|
|
130
|
+
block = Paragraph.new(text: text, heading: heading_level(properties, style), list: list_item(properties, style))
|
|
131
|
+
[block, *boxes.flat_map { |box| content(box) }]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def heading_level(properties, style)
|
|
135
|
+
chain = style_chain(style)
|
|
136
|
+
outline = value(properties, 'outlineLvl') || chain.filter_map { |entry| entry[:outline] }.first
|
|
137
|
+
level = if outline then outline.to_i
|
|
138
|
+
elsif TITLE_STYLE.match?(chain.first&.fetch(:name).to_s) then 0
|
|
139
|
+
else chain.filter_map { |entry| HEADING_STYLE.match(entry[:name].to_s)&.then { |match| match[1].to_i - 1 } }.first
|
|
140
|
+
end
|
|
141
|
+
[level + 1, 6].min if level&.between?(0, 8)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def list_item(properties, style)
|
|
145
|
+
own = child_named(properties, 'numPr')
|
|
146
|
+
num_id = value(own, 'numId')
|
|
147
|
+
level = value(own, 'ilvl')
|
|
148
|
+
unless num_id
|
|
149
|
+
inherited = style_chain(style).find { |entry| entry[:num_id] }
|
|
150
|
+
num_id = inherited&.fetch(:num_id)
|
|
151
|
+
level ||= inherited&.fetch(:level) || style_level(num_id, style)
|
|
152
|
+
end
|
|
153
|
+
list = @lists[num_id] if num_id && num_id != '0' && level.to_i.between?(0, 8)
|
|
154
|
+
list && @counter.next_item(list[:key], list[:levels], level.to_i, list[:starts])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def style_level(num_id, style)
|
|
158
|
+
@lists[num_id]&.fetch(:levels)&.find { |_, definition| definition.style == style }&.first
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def style_chain(id)
|
|
162
|
+
@chains[id] ||= begin
|
|
163
|
+
chain = []
|
|
164
|
+
while id && (entry = @styles[id]) && chain.size < MAX_STYLE_DEPTH && !chain.include?(entry)
|
|
165
|
+
chain << entry
|
|
166
|
+
id = entry[:based_on]
|
|
167
|
+
end
|
|
168
|
+
chain
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def read_styles(document)
|
|
173
|
+
return {} unless document
|
|
174
|
+
|
|
175
|
+
document.xpath('//style[@type="paragraph"]').to_h do |style|
|
|
176
|
+
properties = child_named(style, 'pPr')
|
|
177
|
+
numbering = child_named(properties, 'numPr')
|
|
178
|
+
[style['styleId'], { name: value(style, 'name'), based_on: value(style, 'basedOn'), outline: value(properties, 'outlineLvl'),
|
|
179
|
+
num_id: value(numbering, 'numId'), level: value(numbering, 'ilvl') }]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def read_lists(document)
|
|
184
|
+
return {} unless document
|
|
185
|
+
|
|
186
|
+
abstracts = {}
|
|
187
|
+
linked = {}
|
|
188
|
+
document.xpath('//abstractNum').each do |abstract|
|
|
189
|
+
levels = abstract.element_children.select { |child| child.name == 'lvl' }.to_h { |lvl| [lvl['ilvl'].to_i, list_level(lvl)] }
|
|
190
|
+
abstracts[abstract['abstractNumId']] = { levels: levels, link: value(abstract, 'numStyleLink') }
|
|
191
|
+
link = value(abstract, 'styleLink')
|
|
192
|
+
linked[link] = levels if link
|
|
193
|
+
end
|
|
194
|
+
document.xpath('//num').to_h do |num|
|
|
195
|
+
abstract_id = value(num, 'abstractNumId')
|
|
196
|
+
abstract = abstracts.fetch(abstract_id, { levels: {} })
|
|
197
|
+
levels = abstract[:levels].empty? && abstract[:link] ? linked.fetch(abstract[:link], {}) : abstract[:levels]
|
|
198
|
+
overrides = num.element_children.select { |child| child.name == 'lvlOverride' }
|
|
199
|
+
starts = overrides.to_h { |override| [override['ilvl'].to_i, value(override, 'startOverride')] }.compact.transform_values(&:to_i)
|
|
200
|
+
redefined = overrides.filter_map { |override| child_named(override, 'lvl') }
|
|
201
|
+
levels = levels.merge(redefined.to_h { |lvl| [lvl['ilvl'].to_i, list_level(lvl)] })
|
|
202
|
+
[num['numId'], { key: starts.empty? ? "abstract #{abstract_id}" : "num #{num['numId']}", levels: levels, starts: starts }]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def list_level(lvl)
|
|
207
|
+
Numbering::Level.new(start: value(lvl, 'start')&.to_i, format: FORMATS.fetch(value(lvl, 'numFmt'), :decimal),
|
|
208
|
+
template: template(value(lvl, 'lvlText').to_s), style: value(lvl, 'pStyle'))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def template(text)
|
|
212
|
+
text.split(/(%[1-9])/).reject(&:empty?).map { |part| LEVEL_REFERENCE.match?(part) ? part[1].to_i - 1 : part }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def inline(node, boxes, fields)
|
|
216
|
+
node.element_children.map do |child|
|
|
217
|
+
case child.name
|
|
218
|
+
when *CHARACTERS then fields.include?(:code) ? '' : character(child)
|
|
219
|
+
when 'fldChar' then field_char(child['fldCharType'], fields)
|
|
220
|
+
when 'txbxContent'
|
|
221
|
+
boxes << child
|
|
222
|
+
''
|
|
223
|
+
when 'AlternateContent' then inline(alternative(child), boxes, fields)
|
|
224
|
+
when *SKIPPED then ''
|
|
225
|
+
else inline(child, boxes, fields)
|
|
226
|
+
end
|
|
227
|
+
end.join
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def character(node)
|
|
231
|
+
case node.name
|
|
232
|
+
when 't' then node.text.scrub('')
|
|
233
|
+
when 'tab', 'ptab' then "\t"
|
|
234
|
+
when 'br', 'cr' then "\n"
|
|
235
|
+
when 'noBreakHyphen' then '-'
|
|
236
|
+
when 'sym' then Readword.symbol(node['font'], node['char'].to_s.hex)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def field_char(type, fields)
|
|
241
|
+
case type
|
|
242
|
+
when 'begin' then fields.push(:code)
|
|
243
|
+
when 'separate' then fields[-1] = :result if fields.last == :code
|
|
244
|
+
when 'end' then fields.pop
|
|
245
|
+
end
|
|
246
|
+
''
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def alternative(node)
|
|
250
|
+
child_named(node, 'Choice') || child_named(node, 'Fallback') || node
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def table_blocks(table)
|
|
254
|
+
@tables += 1
|
|
255
|
+
children_named(table, 'tr').flat_map do |row|
|
|
256
|
+
cells = children_named(row, 'tc')
|
|
257
|
+
tables_before = @tables
|
|
258
|
+
rendered = cells.map { |cell| content(cell) }
|
|
259
|
+
next rendered.flatten if @tables > tables_before
|
|
260
|
+
|
|
261
|
+
values = cells.zip(rendered).flat_map { |cell, blocks| cell_values(cell, blocks) }
|
|
262
|
+
values.all? { |text| Readword.blank?(text) } ? [] : [Row.new(values)]
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def cell_values(cell, blocks)
|
|
267
|
+
span = value(child_named(cell, 'tcPr'), 'gridSpan').to_i.clamp(1, MAX_GRID_SPAN)
|
|
268
|
+
count_nodes(span - 1)
|
|
269
|
+
[Readword.squish(blocks.map { |block| Markdown.plain(block) }.join(' ')), *Array.new(span - 1, '')]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def count_nodes(count, budget = :content)
|
|
273
|
+
@budgets[budget][:nodes] += count
|
|
274
|
+
raise Error, TOO_LONG if @budgets[budget][:nodes] > MAX_XML_NODES
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def children_named(node, name)
|
|
278
|
+
node.element_children.flat_map do |child|
|
|
279
|
+
next [child] if child.name == name
|
|
280
|
+
next [] if child.name == 'tbl' || SKIPPED.include?(child.name)
|
|
281
|
+
|
|
282
|
+
children_named(child, name)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def child_named(node, name)
|
|
287
|
+
node&.element_children&.find { |child| child.name == name }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def value(node, name)
|
|
291
|
+
child_named(node, name)&.[]('val')&.scrub('')
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Readword
|
|
4
|
+
Paragraph = Struct.new(:text, :heading, :list, keyword_init: true)
|
|
5
|
+
ListItem = Struct.new(:level, :label, :bullet, :list, keyword_init: true)
|
|
6
|
+
Row = Struct.new(:cells)
|
|
7
|
+
|
|
8
|
+
# Writes the readers' paragraphs and table rows as Markdown: headings, nested lists and pipe tables.
|
|
9
|
+
module Markdown
|
|
10
|
+
ORDERED_MARKER = /\A\d{1,9}[.)]\z/
|
|
11
|
+
LITERAL_START = /\A[#>|]/
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def render(blocks)
|
|
16
|
+
parts = []
|
|
17
|
+
rows = []
|
|
18
|
+
offsets = []
|
|
19
|
+
blocks.each do |block|
|
|
20
|
+
next rows << block.cells if block.is_a?(Row)
|
|
21
|
+
|
|
22
|
+
parts << [:block, table(rows)] if rows.any?
|
|
23
|
+
rows = []
|
|
24
|
+
next if Readword.blank?(block.text)
|
|
25
|
+
|
|
26
|
+
if block.list && !block.heading
|
|
27
|
+
offsets.clear unless parts.last && parts.last[2] == block.list.list
|
|
28
|
+
parts << [:item, item(block, offsets), block.list.list]
|
|
29
|
+
else
|
|
30
|
+
offsets.clear
|
|
31
|
+
parts << [:block, block.heading ? heading(block) : escape(block.text.lstrip)]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
parts << [:block, table(rows)] if rows.any?
|
|
35
|
+
join(parts)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def plain(block)
|
|
39
|
+
return block.cells.join(' | ') if block.is_a?(Row)
|
|
40
|
+
|
|
41
|
+
list = block.list
|
|
42
|
+
return block.text unless list
|
|
43
|
+
|
|
44
|
+
"#{list.bullet ? '•' : list.label} #{block.text}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def join(parts)
|
|
48
|
+
text = +''
|
|
49
|
+
parts.each_with_index do |(kind, chunk, list), index|
|
|
50
|
+
previous_kind, _, previous_list = parts[index - 1]
|
|
51
|
+
text << (kind == :item && previous_kind == :item && list == previous_list ? "\n" : "\n\n") if index.positive?
|
|
52
|
+
text << chunk
|
|
53
|
+
end
|
|
54
|
+
text
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def heading(block)
|
|
58
|
+
label = block.list && !block.list.bullet ? "#{block.list.label} " : ''
|
|
59
|
+
"#{'#' * block.heading} #{label}#{Readword.squish(block.text)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def item(block, offsets)
|
|
63
|
+
list = block.list
|
|
64
|
+
level = list.level
|
|
65
|
+
marker = list.bullet || !ORDERED_MARKER.match?(list.label) ? '-' : list.label
|
|
66
|
+
text = block.text.strip
|
|
67
|
+
text = "#{list.label} #{text}" if marker == '-' && !list.bullet
|
|
68
|
+
indent = (0...level).sum { |parent| offsets[parent] || 2 }
|
|
69
|
+
offsets[level] = marker.size + 1
|
|
70
|
+
offsets.slice!((level + 1)..)
|
|
71
|
+
"#{' ' * indent}#{marker} #{text.gsub("\n", "\n#{' ' * (indent + marker.size + 1)}")}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def table(rows)
|
|
75
|
+
width = rows.map(&:size).max
|
|
76
|
+
lines = rows.map do |cells|
|
|
77
|
+
padded = cells.map { |cell| cell.gsub('|', '\|') } + Array.new(width - cells.size, '')
|
|
78
|
+
"| #{padded.join(' | ')} |"
|
|
79
|
+
end
|
|
80
|
+
lines.insert(1, "| #{Array.new(width, '---').join(' | ')} |").join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def escape(text)
|
|
84
|
+
LITERAL_START.match?(text) ? "\\#{text}" : text
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Readword
|
|
4
|
+
# List numbers the way Word counts them: one counter per list and level, and deeper levels restart
|
|
5
|
+
# whenever a higher level moves on.
|
|
6
|
+
class Numbering
|
|
7
|
+
Level = Struct.new(:start, :format, :template, :style, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
ROMAN = { 1000 => 'M', 900 => 'CM', 500 => 'D', 400 => 'CD', 100 => 'C', 90 => 'XC',
|
|
10
|
+
50 => 'L', 40 => 'XL', 10 => 'X', 9 => 'IX', 5 => 'V', 4 => 'IV', 1 => 'I' }.freeze
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
reset
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def reset
|
|
17
|
+
@counts = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def next_item(key, levels, level, starts = {})
|
|
21
|
+
definition = levels[level] or return
|
|
22
|
+
counts = (@counts[key] ||= {})
|
|
23
|
+
counts.delete_if { |deeper, _| deeper > level }
|
|
24
|
+
(0...level).each { |higher| counts[higher] ||= start(levels, higher, starts) }
|
|
25
|
+
counts[level] = counts.key?(level) ? counts[level] + 1 : start(levels, level, starts)
|
|
26
|
+
return ListItem.new(level: level, label: nil, bullet: true, list: key) if definition.format == :bullet
|
|
27
|
+
|
|
28
|
+
label = definition.template.map do |part|
|
|
29
|
+
part.is_a?(Integer) ? number(counts[part] || start(levels, part, starts), levels[part]&.format) : part
|
|
30
|
+
end.join.strip
|
|
31
|
+
ListItem.new(level: level, label: label, bullet: false, list: key) unless label.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def start(levels, level, starts)
|
|
37
|
+
starts[level] || levels[level]&.start || 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def number(value, format)
|
|
41
|
+
case format
|
|
42
|
+
when :none then ''
|
|
43
|
+
when :upper_roman then roman(value)
|
|
44
|
+
when :lower_roman then roman(value).downcase
|
|
45
|
+
when :upper_letter then letter(value)
|
|
46
|
+
when :lower_letter then letter(value).downcase
|
|
47
|
+
when :decimal_zero then value.between?(0, 9) ? "0#{value}" : value.to_s
|
|
48
|
+
when :ordinal then ordinal(value)
|
|
49
|
+
else value.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def roman(value)
|
|
54
|
+
return value.to_s unless value.between?(1, 3999)
|
|
55
|
+
|
|
56
|
+
ROMAN.reduce(+'') do |text, (amount, letters)|
|
|
57
|
+
count, value = value.divmod(amount)
|
|
58
|
+
text << (letters * count)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def letter(value)
|
|
63
|
+
return value.to_s unless value.positive?
|
|
64
|
+
|
|
65
|
+
(('A'.ord + ((value - 1) % 26)).chr * (((value - 1) / 26) + 1))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def ordinal(value)
|
|
69
|
+
suffix = if (11..13).cover?(value % 100) then 'th'
|
|
70
|
+
else { 1 => 'st', 2 => 'nd', 3 => 'rd' }.fetch(value % 10, 'th')
|
|
71
|
+
end
|
|
72
|
+
"#{value}#{suffix}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/readword.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'set'
|
|
6
|
+
require 'zip'
|
|
7
|
+
|
|
8
|
+
require_relative 'readword/version'
|
|
9
|
+
|
|
10
|
+
# Turns Word documents (.doc and .docx) into Markdown text.
|
|
11
|
+
module Readword
|
|
12
|
+
SYMBOL_FONT = {
|
|
13
|
+
0x44 => 'Δ', 0x61 => 'α', 0x62 => 'β', 0x63 => 'χ', 0x64 => 'δ', 0x65 => 'ε', 0x67 => 'γ', 0x6B => 'κ',
|
|
14
|
+
0x6C => 'λ', 0x6D => 'µ', 0x70 => 'π', 0x72 => 'ρ', 0x73 => 'σ', 0x74 => 'τ', 0x77 => 'ω', 0xA3 => '≤',
|
|
15
|
+
0xB0 => '°', 0xB1 => '±', 0xB3 => '≥', 0xB4 => '×', 0xB7 => '•', 0xB9 => '≠', 0xBB => '≈'
|
|
16
|
+
}.freeze
|
|
17
|
+
ZIP_SIGNATURE = "PK\x03\x04".b.freeze
|
|
18
|
+
|
|
19
|
+
UNREADABLE = :unreadable
|
|
20
|
+
NO_TEXT = :no_text
|
|
21
|
+
TOO_LONG = :too_long
|
|
22
|
+
PASSWORD_PROTECTED = :password_protected
|
|
23
|
+
OLD_FORMAT = :old_format
|
|
24
|
+
|
|
25
|
+
MESSAGES = {
|
|
26
|
+
UNREADABLE => 'The file could not be read as a Word document (.doc or .docx).',
|
|
27
|
+
NO_TEXT => 'The Word document has no text.',
|
|
28
|
+
TOO_LONG => 'The Word document is longer than the reader takes.',
|
|
29
|
+
PASSWORD_PROTECTED => 'The Word document is password-protected.',
|
|
30
|
+
OLD_FORMAT => 'The Word document is older than Word 97 (Word 6 or 95), which is not supported.'
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Raised when a file cannot be turned into text; #reason is one of the symbols above.
|
|
34
|
+
class Error < StandardError
|
|
35
|
+
attr_reader :reason
|
|
36
|
+
|
|
37
|
+
def initialize(reason = UNREADABLE)
|
|
38
|
+
@reason = reason
|
|
39
|
+
super(MESSAGES.fetch(reason))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.convert_file(path, **options)
|
|
44
|
+
convert(File.binread(path), **options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.convert(bytes, max_characters: nil)
|
|
48
|
+
text = Markdown.render(reader(bytes.b).blocks).split("\n").map(&:rstrip).join("\n").gsub(/\n{3,}/, "\n\n").strip
|
|
49
|
+
raise Error, NO_TEXT if text.empty?
|
|
50
|
+
raise Error, TOO_LONG if max_characters && text.size > max_characters
|
|
51
|
+
|
|
52
|
+
text
|
|
53
|
+
rescue Error
|
|
54
|
+
raise
|
|
55
|
+
rescue StandardError
|
|
56
|
+
raise Error, UNREADABLE
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.reader(bytes)
|
|
60
|
+
if bytes.start_with?(CompoundFile::SIGNATURE) then Doc.new(bytes)
|
|
61
|
+
elsif bytes.start_with?(ZIP_SIGNATURE) then Docx.new(bytes)
|
|
62
|
+
else raise Error, UNREADABLE
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
private_class_method :reader
|
|
66
|
+
|
|
67
|
+
def self.symbol(font, code)
|
|
68
|
+
return SYMBOL_FONT.fetch(code & 0xFF, '') if font.to_s.casecmp?('Symbol')
|
|
69
|
+
|
|
70
|
+
code.between?(0x20, 0xD7FF) ? code.chr(Encoding::UTF_8) : ''
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.blank?(text)
|
|
74
|
+
text.nil? || text.match?(/\A[[:space:]]*\z/)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.squish(text)
|
|
78
|
+
text.gsub(/[[:space:]]+/, ' ').strip
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
require_relative 'readword/markdown'
|
|
83
|
+
require_relative 'readword/numbering'
|
|
84
|
+
require_relative 'readword/binary'
|
|
85
|
+
require_relative 'readword/compound_file'
|
|
86
|
+
require_relative 'readword/doc'
|
|
87
|
+
require_relative 'readword/docx'
|
metadata
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: readword
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- urbullet
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: nokogiri
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.13'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.13'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rubyzip
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.3'
|
|
34
|
+
- - "<"
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '4'
|
|
37
|
+
type: :runtime
|
|
38
|
+
prerelease: false
|
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '2.3'
|
|
44
|
+
- - "<"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '4'
|
|
47
|
+
description: Reads Word 97-2003 (.doc) and Word 2007+ (.docx) files without LibreOffice,
|
|
48
|
+
Java or antiword, and prints their text, tables, headers, footers and footnotes
|
|
49
|
+
as Markdown. Built for feeding Word documents to LLMs and coding agents, and safe
|
|
50
|
+
on untrusted files.
|
|
51
|
+
email:
|
|
52
|
+
executables:
|
|
53
|
+
- readword
|
|
54
|
+
extensions: []
|
|
55
|
+
extra_rdoc_files: []
|
|
56
|
+
files:
|
|
57
|
+
- CHANGELOG.md
|
|
58
|
+
- LICENSE.txt
|
|
59
|
+
- README.md
|
|
60
|
+
- exe/readword
|
|
61
|
+
- lib/readword.rb
|
|
62
|
+
- lib/readword/binary.rb
|
|
63
|
+
- lib/readword/compound_file.rb
|
|
64
|
+
- lib/readword/doc.rb
|
|
65
|
+
- lib/readword/docx.rb
|
|
66
|
+
- lib/readword/markdown.rb
|
|
67
|
+
- lib/readword/numbering.rb
|
|
68
|
+
- lib/readword/version.rb
|
|
69
|
+
homepage: https://github.com/urbullet/readword
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
metadata:
|
|
73
|
+
source_code_uri: https://github.com/urbullet/readword
|
|
74
|
+
changelog_uri: https://github.com/urbullet/readword/blob/main/CHANGELOG.md
|
|
75
|
+
bug_tracker_uri: https://github.com/urbullet/readword/issues
|
|
76
|
+
rubygems_mfa_required: 'true'
|
|
77
|
+
post_install_message:
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '3.1'
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 3.5.11
|
|
93
|
+
signing_key:
|
|
94
|
+
specification_version: 4
|
|
95
|
+
summary: Turn Word .doc and .docx files into Markdown, in plain Ruby.
|
|
96
|
+
test_files: []
|