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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Readword
4
+ VERSION = '0.1.1'
5
+ 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: []