readword 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c151bf301945512c356cbaadc83fc03f939c712293015a052be64a62c0c56f1
4
- data.tar.gz: 3d9f112fa2fff0582d9040b6e146d9b7e587b6933330773df746e41414ac0b2f
3
+ metadata.gz: b700bdd0e0d5132d8179dca381922454528ac0c2606ae6bd792990250569d57e
4
+ data.tar.gz: 4a17928587b6725fa57b2adeeaca3db33e375ed9ac27c21d9c6da1e2b1d18da0
5
5
  SHA512:
6
- metadata.gz: 876795d221df033bbddf1b407e1cbf6969ac8d39e0956e7b81056b72bae81b90d2df7a887e729494a4ce5e83cce8fbf19db02a5a6e5d69887653d5836f868bf7
7
- data.tar.gz: 5ed50d5820cb66dc8b529ea78ce09b1fee80b7783e3977b5239db1bb0d2372798ad8d0483fbbc1ed1963a4d4c96b0dd7369fbadbdbaaee00c6b79d1c18eb4a81
6
+ metadata.gz: 9fef45c6b23b951af4e7c1d6a27101f9526f3dc137528a6fb21d5e4a8cc02ad5a52bb1ded37c738cc3d3af51c7578b85569cfb5366d129f04c19199343142ce3
7
+ data.tar.gz: 302d28516c6f17151795e84a6d2c71d0f2b877d172c4364ef8eb5d52e4881015fb0f5dd8200db3fd94977ef575d5d64b432156b7fd582ec20a496764f638a65f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-10-22)
4
+
5
+ - Keeps bold, italics and links, as `**bold**`, `*italic*` and `[text](url)`. Only links to web pages and email addresses are kept. Headings, and header cells that are bold as a whole, stay plain.
6
+ - The size limit is now an option: `max_size:` in Ruby and `--max-size` on the command line, in megabytes. The default went up from 10 to 32, which reads a 300-page report, and 0 turns the limit off.
7
+
3
8
  ## 0.1.1 (2026-10-22)
4
9
 
5
10
  Same code as 0.1.0. Only the author name in the gem's details changed.
data/README.md CHANGED
@@ -43,8 +43,9 @@ End of report.
43
43
  - Tracked changes as if accepted: inserted text stays and deleted text goes.
44
44
  - Field results, such as page numbers, dates and merge fields. Field codes are left out.
45
45
  - Characters added with Insert Symbol, such as µ, ±, ≤ and °, as the real characters.
46
+ - Bold, italics and links, as `**bold**`, `*italic*` and `[text](https://example.com)`. Only links to web pages and email addresses are kept. Headings, and header cells that are bold as a whole, stay plain.
46
47
 
47
- It does not keep bold, italics, links, images, charts, comments or embedded objects (an Excel table pasted into the document as an object, for example).
48
+ It does not keep underlining, colors, images, charts, comments in `.docx` files, or embedded objects (an Excel table pasted into the document as an object, for example).
48
49
 
49
50
  ## Use it with a coding agent
50
51
 
@@ -57,13 +58,14 @@ To read a Word file (.doc or .docx), run `readword path/to/file` and read the Ma
57
58
  ## Command line
58
59
 
59
60
  ```console
60
- $ readword [--max-characters N] FILE...
61
+ $ readword [--max-characters N] [--max-size MB] FILE...
61
62
  ```
62
63
 
63
64
  - With several files, each one starts with a `==> path <==` line.
64
65
  - Use `-` as the file name to read from standard input.
65
66
  - If a file cannot be read, the reason goes to standard error and the exit code is 1. The other files are still printed.
66
67
  - `--max-characters N` refuses documents longer than N characters, which is useful to stay inside a model's context window.
68
+ - `--max-size MB` sets the size limit described under [Safe on untrusted files](#safe-on-untrusted-files). The default is 32, and 0 turns the limit off.
67
69
 
68
70
  ## Use it from Ruby
69
71
 
@@ -72,6 +74,7 @@ require 'readword'
72
74
 
73
75
  Readword.convert_file('report.doc') # => "# Test report\n\n..."
74
76
  Readword.convert(File.binread('report.docx'), max_characters: 150_000)
77
+ Readword.convert_file('manual.docx', max_size: 100) # a size limit of 100 MB instead of 32
75
78
  ```
76
79
 
77
80
  `convert` takes the file's bytes and works out the format from them, so a `.docx` saved with a `.doc` name still reads. Files that cannot be read raise `Readword::Error`. Its `reason` is one of:
@@ -80,23 +83,48 @@ Readword.convert(File.binread('report.docx'), max_characters: 150_000)
80
83
  | --- | --- |
81
84
  | `:unreadable` | Not a Word document (RTF or plain text named `.doc`, for example), or damaged |
82
85
  | `:no_text` | The document has no text, only images for example |
83
- | `:too_long` | Over `max_characters`, or over the reader's own size limits |
86
+ | `:too_long` | Over `max_characters`, or over the size limit (`max_size`) |
84
87
  | `:password_protected` | Encrypted with a password |
85
88
  | `:old_format` | Saved by Word 6 or Word 95 |
86
89
 
87
90
  ## Safe on untrusted files
88
91
 
89
92
  - The `.doc` reader is plain Ruby, so a crafted file cannot cause the memory errors that C readers are prone to. The `.docx` reader uses Nokogiri and rubyzip.
90
- - Size limits on unpacked XML, XML nodes, text and formatting, so a small zip bomb or a crafted file cannot make it use gigabytes of memory.
93
+ - A size limit on the unpacked XML, its elements, the text and the formatting, so a small zip bomb or a crafted file cannot make it use gigabytes of memory. It is 32 MB by default, which reads a 300-page report. Dense documents count as bigger, since the limit also counts XML elements. At the default, the worst file we could craft, an 11 KB zip holding 1.28 million empty paragraphs, takes about 10 seconds and 330 MB of memory. Lower `max_size` for tighter bounds, or raise it for bigger documents.
91
94
  - XML parts that declare their own entities are refused, which stops entity bombs.
95
+ - Links are kept only when they point to a web page or an email address, so a document cannot put a `javascript:` link into the Markdown.
92
96
  - Loop guards in the `.doc` container reader, so a crafted sector chain cannot make it run forever.
93
- - Fuzzed with 3,000,000 broken copies of real Word files, and 300,000 more after headings and lists were added, with no crash and no hang. The worst crafted files found so far use under 50 MB of memory.
97
+ - Fuzzed with 3,000,000 broken copies of real Word files, 300,000 more after headings and lists were added, and 50,000 more after bold, italics and links, with no crash and no hang.
94
98
 
95
99
  ## How it was checked
96
100
 
97
- readword was compared with Apache Tika, LibreOffice and macOS textutil on 650 Word files from the Apache POI and LibreOffice test suites.
101
+ readword was compared with Apache Tika, LibreOffice and macOS textutil on 650 Word files from the Apache POI and LibreOffice test suites, and with other tools that read Word files.
98
102
 
99
- | | antiword 0.37 | docx gem 0.13 | readword |
103
+ ### Compared with anydoc
104
+
105
+ [anydoc](https://github.com/firecrawl/anydoc) is Firecrawl's open-source converter, written in Rust. It reads many formats and also runs as an MCP server. Version 0.2.4 was run on the same 650 files. The counts below are for the 300 `.doc` and 212 `.docx` files where both Tika and textutil found text.
106
+
107
+ | | anydoc 0.2.4 | readword 0.2 |
108
+ | --- | --- | --- |
109
+ | `.doc` files read | 260 | 281 |
110
+ | `.doc` files missing words that both Tika and textutil found | 122 | 29, of which 13 are Word 6 or 95 files that neither tool reads |
111
+ | Text left out of `.doc` files | page headers and footers, comments, some text boxes | field codes, embedded objects |
112
+ | `.doc` files saved by macOS TextEdit | refused | read |
113
+ | `.docx` files read | 208 | 210 |
114
+ | `.docx` files missing words that both Tika and textutil found | 9, for example content controls in tables and deeply nested tables | 2: an entity test file refused on purpose, and a link that splits a name from its footnote number |
115
+ | List numbers | ordinal styles shown as plain numbers, some continued and restarted lists numbered differently | match LibreOffice |
116
+ | Lists inside table cells | numbers dropped | numbers kept |
117
+ | Bold, italics and links | kept | kept |
118
+ | Other formats | PDF, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV and more | none |
119
+ | Median time per file | 0.2 ms | 0.5 ms |
120
+ | 10,000 crafted `.doc` files | no crash or hang | no crash or hang |
121
+ | Use from | Rust, Node, Python, WebAssembly, command line, MCP | Ruby, command line |
122
+
123
+ For Word files, readword keeps more of the text and the list numbers Word shows. anydoc is the better pick for other formats or when speed matters most.
124
+
125
+ ### Compared with antiword and the docx gem
126
+
127
+ | | antiword 0.37 | docx gem 0.13 | readword 0.1 |
100
128
  | --- | --- | --- | --- |
101
129
  | Reads `.doc` | yes | no | yes |
102
130
  | Reads `.docx` | no | yes | yes |
@@ -108,7 +136,7 @@ readword was compared with Apache Tika, LibreOffice and macOS textutil on 650 Wo
108
136
  | A 1 MB zip that unpacks to 1 GB | | 3.2 GB of memory used | refused in 0.17 s |
109
137
  | Install | system package (no Homebrew formula) | gem | gem |
110
138
 
111
- The words readword misses are field codes, embedded objects, charts and picture names.
139
+ This comparison was made earlier, with readword 0.1 and a smaller set of the test files. The words readword misses are field codes, embedded objects, charts and picture names.
112
140
 
113
141
  List numbers were compared with LibreOffice's on the 45 test files that have numbered lists. All numbers match in 41 of them. Of the other four, two are deliberately corrupted fuzz files, one changed its numbering with Track Changes, and one numbers an empty paragraph that holds a section break, where LibreOffice drops the paragraph.
114
142
 
data/exe/readword CHANGED
@@ -4,6 +4,8 @@
4
4
  require 'optparse'
5
5
  require 'readword'
6
6
 
7
+ Signal.trap('PIPE', 'SYSTEM_DEFAULT')
8
+
7
9
  options = {}
8
10
  parser = OptionParser.new do |opts|
9
11
  opts.banner = 'Usage: readword [options] FILE...'
@@ -13,6 +15,10 @@ parser = OptionParser.new do |opts|
13
15
  opts.on('--max-characters N', Integer, 'Fail when a document has more than N characters') do |count|
14
16
  options[:max_characters] = count
15
17
  end
18
+ opts.on('--max-size MB', Integer,
19
+ "Fail when a document unpacks to more than MB megabytes (default #{Readword::DEFAULT_MAX_SIZE}, 0 for no limit)") do |megabytes|
20
+ options[:max_size] = megabytes
21
+ end
16
22
  opts.on('-v', '--version', 'Print the version') do
17
23
  puts Readword::VERSION
18
24
  exit
data/lib/readword/doc.rb CHANGED
@@ -40,15 +40,18 @@ module Readword
40
40
  MAX_PAPX_RUNS_PER_PAGE = 0x1D
41
41
  MAX_CHPX_RUNS_PER_PAGE = 0x65
42
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
43
+ TEXT_POSITIONS_PER_MEGABYTE = 40_000
44
+ PROPERTY_BYTES_PER_MEGABYTE = 800 * 1024
45
45
  NO_FLAGS = {}.freeze
46
46
 
47
47
  # Paragraph sprms: in a table, end of a table row, and the same two for a table nested in a cell.
48
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.
49
+ # sprmCFRMarkDel (deleted with Track Changes), sprmCFBold and sprmCFItalic. Their ToggleOperand is 1, or 0x81 for the opposite of the style.
50
50
  DELETED = 0x0800
51
- DELETED_VALUES = [0x01, 0x81].freeze
51
+ BOLD = 0x0835
52
+ ITALIC = 0x0836
53
+ ON_VALUES = [0x01, 0x81].freeze
54
+ PLAIN = [false, false, nil].freeze
52
55
  # sprmCSymbol: Insert Symbol stores "(" in the text, and the font number and real character here.
53
56
  SYMBOL = 0x6A09
54
57
  SYMBOL_PLACEHOLDER = '('
@@ -89,7 +92,9 @@ module Readword
89
92
  byte.chr.force_encoding(Encoding::Windows_1252).encode(Encoding::UTF_8, undef: :replace, replace: '')
90
93
  end.freeze
91
94
 
92
- def initialize(bytes)
95
+ def initialize(bytes, max_size: DEFAULT_MAX_SIZE)
96
+ @max_text_positions = max_size ? max_size * TEXT_POSITIONS_PER_MEGABYTE : Float::INFINITY
97
+ @max_property_bytes = max_size ? max_size * PROPERTY_BYTES_PER_MEGABYTE : Float::INFINITY
93
98
  @file = CompoundFile.new(bytes)
94
99
  @word = @file.stream('WordDocument').to_s
95
100
  raise Error, PASSWORD_PROTECTED if @word.empty? && @file.stream?('EncryptedPackage')
@@ -102,7 +107,7 @@ module Readword
102
107
 
103
108
  def blocks
104
109
  pieces = text_pieces
105
- raise Error, TOO_LONG if pieces.sum { |piece| [piece.finish - piece.start, 0].max } > MAX_TEXT_POSITIONS
110
+ raise Error, TOO_LONG if pieces.sum { |piece| [piece.finish - piece.start, 0].max } > @max_text_positions
106
111
 
107
112
  @property_bytes = 0
108
113
  @runs = paragraph_runs
@@ -112,12 +117,13 @@ module Readword
112
117
  @chains = {}
113
118
  @paragraph_start = 0
114
119
  @row = []
115
- @text = +''
120
+ @text = []
121
+ @format = PLAIN
116
122
  read_characters(pieces)
117
123
  if @row.any?
118
124
  end_row
119
125
  else
120
- @blocks << Paragraph.new(text: @text)
126
+ @blocks << Paragraph.new(text: Inline.render(@text))
121
127
  end
122
128
  @blocks
123
129
  end
@@ -128,28 +134,58 @@ module Readword
128
134
  def read_characters(pieces)
129
135
  fields = []
130
136
  code_depth = 0
137
+ link = nil
131
138
  each_character(pieces) do |character, offset, width|
132
- marks = @marked.empty? ? {} : run_flags(@marked, offset)
139
+ marks = @marked.empty? ? NO_FLAGS : run_flags(@marked, offset)
133
140
  next if marks[:deleted]
134
141
 
135
142
  character = font_symbol(*marks[:symbol]) if marks[:symbol] && character == SYMBOL_PLACEHOLDER
136
143
  case character
137
144
  when FIELD_BEGIN
138
- fields.push(:code)
145
+ fields.push({ state: :code, code: +'' })
139
146
  code_depth += 1
140
147
  when FIELD_SEPARATOR
141
- if fields.last == :code
142
- fields[-1] = :result
148
+ field = fields.last
149
+ if field && field[:state] == :code
150
+ field.merge!(state: :result, link: Readword.hyperlink(field[:code]))
143
151
  code_depth -= 1
144
152
  end
153
+ link = field_link(fields)
145
154
  when FIELD_END
146
- code_depth -= 1 if fields.pop == :code
155
+ code_depth -= 1 if fields.pop&.fetch(:state) == :code
156
+ link = field_link(fields)
147
157
  else
148
- read_character(character, offset, width) if code_depth.zero?
158
+ if code_depth.zero?
159
+ @format = text_format(marks, link)
160
+ read_character(character, offset, width)
161
+ else
162
+ fields.reverse_each.find { |field| field[:state] == :code }&.fetch(:code)&.<<(character)
163
+ end
149
164
  end
150
165
  end
151
166
  end
152
167
 
168
+ def field_link(fields)
169
+ fields.reverse_each { |field| return field[:link] if field[:link] }
170
+ nil
171
+ end
172
+
173
+ def text_format(marks, link)
174
+ bold = marks[:bold] == true
175
+ italic = marks[:italic] == true
176
+ bold == @format[0] && italic == @format[1] && link == @format[2] ? @format : [bold, italic, link]
177
+ end
178
+
179
+ def append(text, format = PLAIN)
180
+ last = @text.last
181
+ bold, italic, link = format
182
+ if last && @text.size > @paragraph_start && last.bold == bold && last.italic == italic && last.link == link
183
+ last.text << text
184
+ else
185
+ @text << Span.new(text.dup, bold, italic, link)
186
+ end
187
+ end
188
+
153
189
  # A page or column break ends its paragraph only when it closes the paragraph's run, as a section break does.
154
190
  def read_character(character, offset, width)
155
191
  if character == CELL_MARK
@@ -158,9 +194,9 @@ module Readword
158
194
  elsif character == PARAGRAPH_MARK || (PARAGRAPH_ENDS.include?(character) && paragraph_end?(offset, width))
159
195
  end_paragraph(paragraph_flags(offset))
160
196
  elsif PARAGRAPH_ENDS.include?(character)
161
- @text << "\n"
197
+ append("\n", @format)
162
198
  elsif character >= ' ' || REPLACEMENTS.key?(character)
163
- @text << REPLACEMENTS.fetch(character, character)
199
+ append(REPLACEMENTS.fetch(character, character), @format)
164
200
  end
165
201
  end
166
202
 
@@ -169,34 +205,35 @@ module Readword
169
205
  elsif flags[:inner_cell] then end_cell(flags)
170
206
  elsif flags[:in_table]
171
207
  label_paragraph(flags)
172
- @text << ' '
208
+ append(' ')
173
209
  @paragraph_start = @text.size
174
210
  else
175
211
  end_row if @row.any?
176
- @blocks << Paragraph.new(text: @text, heading: heading_level(flags), list: list_item(flags))
177
- @text = +''
212
+ heading = heading_level(flags)
213
+ @blocks << Paragraph.new(text: Inline.render(@text, bold: !heading), heading: heading, list: list_item(flags))
214
+ @text = []
178
215
  @paragraph_start = 0
179
216
  end
180
217
  end
181
218
 
182
219
  def end_cell(flags = nil)
183
220
  label_paragraph(flags) if flags
184
- @row << Readword.squish(@text)
185
- @text = +''
221
+ @row << Readword.squish(Inline.render(@text))
222
+ @text = []
186
223
  @paragraph_start = 0
187
224
  end
188
225
 
189
226
  def end_row
190
- end_cell unless Readword.blank?(@text)
227
+ end_cell unless Readword.blank?(@text.map(&:text).join)
191
228
  @blocks << Row.new(@row) unless @row.all? { |cell| Readword.blank?(cell) }
192
229
  @row = []
193
- @text = +''
230
+ @text = []
194
231
  @paragraph_start = 0
195
232
  end
196
233
 
197
234
  def label_paragraph(flags)
198
235
  item = list_item(flags) or return
199
- @text.insert(@paragraph_start, "#{item.bullet ? '•' : item.label} ")
236
+ @text.insert(@paragraph_start, Span.new("#{item.bullet ? '•' : item.label} ", false, false, nil))
200
237
  end
201
238
 
202
239
  def heading_level(flags)
@@ -351,7 +388,7 @@ module Readword
351
388
  table = clx.byteslice(position + 5, u32(clx, position + 1).to_i).to_s
352
389
  count = (table.bytesize - 4) / 12
353
390
  raise Error, UNREADABLE unless count.positive?
354
- raise Error, TOO_LONG if count > MAX_TEXT_POSITIONS
391
+ raise Error, TOO_LONG if count > @max_text_positions
355
392
 
356
393
  positions = table.unpack("V#{count + 1}")
357
394
  Array.new(count) do |index|
@@ -366,7 +403,7 @@ module Readword
366
403
  runs = []
367
404
  property_pages(FIB_PAPX_TABLE, FIB_PAPX_TABLE_BYTES).each do |offset|
368
405
  runs.concat(page_runs(offset))
369
- raise Error, TOO_LONG if runs.size > MAX_TEXT_POSITIONS
406
+ raise Error, TOO_LONG if runs.size > @max_text_positions
370
407
  end
371
408
  runs.sort_by(&:start)
372
409
  end
@@ -374,8 +411,8 @@ module Readword
374
411
  def marked_runs
375
412
  runs = []
376
413
  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
414
+ runs.concat(character_runs(offset).select { |run| run.flags.values_at(:deleted, :symbol, :bold, :italic).any? })
415
+ raise Error, TOO_LONG if runs.size > @max_text_positions
379
416
  end
380
417
  runs.sort_by(&:start)
381
418
  end
@@ -436,7 +473,7 @@ module Readword
436
473
 
437
474
  def sprm_flags(grpprl, nested: false)
438
475
  @property_bytes += grpprl.bytesize
439
- raise Error, UNREADABLE if @property_bytes > MAX_PROPERTY_BYTES
476
+ raise Error, UNREADABLE if @property_bytes > @max_property_bytes
440
477
 
441
478
  flags = {}
442
479
  position = 0
@@ -445,7 +482,9 @@ module Readword
445
482
  size = operand_size(sprm, grpprl, position + 2) or break
446
483
  operand = grpprl.byteslice(position + 2, size).to_s
447
484
  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))
485
+ elsif sprm == DELETED then flags[:deleted] = ON_VALUES.include?(operand.getbyte(0))
486
+ elsif sprm == BOLD then flags[:bold] = ON_VALUES.include?(operand.getbyte(0))
487
+ elsif sprm == ITALIC then flags[:italic] = ON_VALUES.include?(operand.getbyte(0))
449
488
  elsif sprm == SYMBOL then flags[:symbol] = [u16(operand, 0).to_i, u16(operand, 2).to_i]
450
489
  elsif sprm == OUTLINE_LEVEL then flags[:outline] = operand.getbyte(0)
451
490
  elsif sprm == LIST_LEVEL then flags[:list_level] = operand.getbyte(0)
@@ -496,10 +535,10 @@ module Readword
496
535
 
497
536
  def run_flags(runs, offset)
498
537
  index = (runs.bsearch_index { |run| run.start > offset } || runs.size) - 1
499
- return {} if index.negative?
538
+ return NO_FLAGS if index.negative?
500
539
 
501
540
  run = runs[index]
502
- offset < run.finish ? run.flags : {}
541
+ offset < run.finish ? run.flags : NO_FLAGS
503
542
  end
504
543
 
505
544
  def font_symbol(font, code)
data/lib/readword/docx.rb CHANGED
@@ -5,14 +5,17 @@ require 'zip'
5
5
  module Readword
6
6
  # Reads the text of a Word 2007 or later .docx (ECMA-376 WordprocessingML) from its XML parts.
7
7
  class Docx
8
- MAX_XML_BYTES = 10 * 1024 * 1024
9
- MAX_XML_NODES = 400_000
8
+ NODES_PER_MEGABYTE = 40_000
10
9
  MAX_GRID_SPAN = 63
11
10
  MAX_STYLE_DEPTH = 16
12
11
  UTF16_STARTS = ["\xFF\xFE".b, "\xFE\xFF".b, "<\0".b, "\0<".b].freeze
13
12
  RELATIONSHIP = %r{/relationships/(officeDocument|header|footer|footnotes|endnotes|styles|numbering)\z}
14
13
  SKIPPED = %w[del moveFrom instrText delText rPr pPr sectPr tblPr trPr tcPr sdtPr].freeze
15
14
  CHARACTERS = %w[t tab ptab br cr noBreakHyphen sym].freeze
15
+ OFF = %w[0 false off].freeze
16
+ LINK = %r{/relationships/hyperlink\z}
17
+ Format = Struct.new(:bold, :italic, :link)
18
+ PLAIN = Format.new(false, false, nil).freeze
16
19
  SEPARATOR_NOTES = %w[separator continuationSeparator continuationNotice].freeze
17
20
  HEADING_STYLE = /\Aheading ([1-9])\z/i
18
21
  TITLE_STYLE = /\Atitle\z/i
@@ -21,8 +24,10 @@ module Readword
21
24
  'lowerLetter' => :lower_letter, 'decimalZero' => :decimal_zero, 'ordinal' => :ordinal,
22
25
  'bullet' => :bullet, 'none' => :none }.freeze
23
26
 
24
- def initialize(bytes)
27
+ def initialize(bytes, max_size: DEFAULT_MAX_SIZE)
25
28
  @bytes = bytes
29
+ @max_xml_bytes = max_size ? max_size * MEGABYTE : Float::INFINITY
30
+ @max_xml_nodes = max_size ? max_size * NODES_PER_MEGABYTE : Float::INFINITY
26
31
  end
27
32
 
28
33
  def blocks
@@ -73,10 +78,10 @@ module Readword
73
78
 
74
79
  def xml(path, budget = :content)
75
80
  entry = @entries[path.downcase] or return
76
- remaining = MAX_XML_BYTES - @budgets[budget][:bytes]
81
+ remaining = @max_xml_bytes - @budgets[budget][:bytes]
77
82
  raise Error, TOO_LONG if entry.size > remaining
78
83
 
79
- data = entry.get_input_stream { |stream| stream.read(remaining + 1) }.to_s
84
+ data = entry.get_input_stream { |stream| stream.read(remaining.finite? ? remaining + 1 : nil) }.to_s
80
85
  raise Error, TOO_LONG if data.bytesize > remaining
81
86
 
82
87
  @budgets[budget][:bytes] += data.bytesize
@@ -93,6 +98,7 @@ module Readword
93
98
 
94
99
  def part(path)
95
100
  @counter.reset
101
+ @links = link_targets(path)
96
102
  content(xml(path))
97
103
  end
98
104
 
@@ -103,6 +109,7 @@ module Readword
103
109
  def notes(paths)
104
110
  paths.flat_map do |path|
105
111
  @counter.reset
112
+ @links = link_targets(path)
106
113
  found = Array(xml(path)&.xpath('//*[self::footnote or self::endnote]'))
107
114
  found.reject { |note| SEPARATOR_NOTES.include?(note['type']) }.flat_map { |note| content(note) }
108
115
  end
@@ -124,13 +131,25 @@ module Readword
124
131
 
125
132
  def paragraph_blocks(paragraph)
126
133
  boxes = []
127
- text = inline(paragraph, boxes, [])
134
+ spans = []
128
135
  properties = child_named(paragraph, 'pPr')
129
136
  style = value(properties, 'pStyle')
130
- block = Paragraph.new(text: text, heading: heading_level(properties, style), list: list_item(properties, style))
137
+ heading = heading_level(properties, style)
138
+ inline(paragraph, boxes, [], spans, PLAIN)
139
+ block = Paragraph.new(text: Inline.render(spans, bold: !heading), heading: heading, list: list_item(properties, style))
131
140
  [block, *boxes.flat_map { |box| content(box) }]
132
141
  end
133
142
 
143
+ def link_targets(path)
144
+ document = path && xml(File.join(File.dirname(path), '_rels', "#{File.basename(path)}.rels"))
145
+ return {} unless document
146
+
147
+ document.xpath('//Relationship').each_with_object({}) do |relationship, links|
148
+ target = relationship['Target'].to_s.scrub('')
149
+ links[relationship['Id']] = target if LINK.match?(relationship['Type'].to_s) && WEB_ADDRESS.match?(target)
150
+ end
151
+ end
152
+
134
153
  def heading_level(properties, style)
135
154
  chain = style_chain(style)
136
155
  outline = value(properties, 'outlineLvl') || chain.filter_map { |entry| entry[:outline] }.first
@@ -170,8 +189,13 @@ module Readword
170
189
  end
171
190
 
172
191
  def read_styles(document)
192
+ @character_styles = {}
173
193
  return {} unless document
174
194
 
195
+ @character_styles = document.xpath('//style[@type="character"]').to_h do |style|
196
+ properties = child_named(style, 'rPr')
197
+ [style['styleId'], { bold: flag(properties, 'b', false), italic: flag(properties, 'i', false) }]
198
+ end
175
199
  document.xpath('//style[@type="paragraph"]').to_h do |style|
176
200
  properties = child_named(style, 'pPr')
177
201
  numbering = child_named(properties, 'numPr')
@@ -212,19 +236,47 @@ module Readword
212
236
  text.split(/(%[1-9])/).reject(&:empty?).map { |part| LEVEL_REFERENCE.match?(part) ? part[1].to_i - 1 : part }
213
237
  end
214
238
 
215
- def inline(node, boxes, fields)
216
- node.element_children.map do |child|
239
+ def inline(node, boxes, fields, spans, format)
240
+ node.element_children.each do |child|
217
241
  case child.name
218
- when *CHARACTERS then fields.include?(:code) ? '' : character(child)
242
+ when *CHARACTERS then spans << Span.new(character(child), format.bold, format.italic, format.link || field_link(fields)) unless code?(fields)
243
+ when 'instrText' then fields.last[:code] << child.text.scrub('') if code?(fields)
219
244
  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)
245
+ when 'r' then inline(child, boxes, fields, spans, run_format(child, format))
246
+ when 'hyperlink' then inline(child, boxes, fields, spans, link_format(child, format))
247
+ when 'txbxContent' then boxes << child
248
+ when 'AlternateContent' then inline(alternative(child), boxes, fields, spans, format)
249
+ when *SKIPPED then nil
250
+ else inline(child, boxes, fields, spans, format)
226
251
  end
227
- end.join
252
+ end
253
+ end
254
+
255
+ def run_format(run, format)
256
+ properties = child_named(run, 'rPr')
257
+ return format unless properties
258
+
259
+ style = @character_styles[value(properties, 'rStyle')] || {}
260
+ Format.new(flag(properties, 'b', style[:bold]), flag(properties, 'i', style[:italic]), format.link)
261
+ end
262
+
263
+ def flag(properties, name, inherited)
264
+ node = child_named(properties, name)
265
+ node ? !OFF.include?(node['val'].to_s.downcase) : inherited || false
266
+ end
267
+
268
+ def link_format(node, format)
269
+ target = @links[node['id'].to_s]
270
+ target ? Format.new(format.bold, format.italic, target) : format
271
+ end
272
+
273
+ def code?(fields)
274
+ fields.any? { |field| field[:state] == :code }
275
+ end
276
+
277
+ def field_link(fields)
278
+ fields.reverse_each { |field| return field[:link] if field[:state] == :result && field[:link] }
279
+ nil
228
280
  end
229
281
 
230
282
  def character(node)
@@ -239,11 +291,12 @@ module Readword
239
291
 
240
292
  def field_char(type, fields)
241
293
  case type
242
- when 'begin' then fields.push(:code)
243
- when 'separate' then fields[-1] = :result if fields.last == :code
294
+ when 'begin' then fields.push({ state: :code, code: +'' })
295
+ when 'separate'
296
+ field = fields.last
297
+ field.merge!(state: :result, link: Readword.hyperlink(field[:code])) if field && field[:state] == :code
244
298
  when 'end' then fields.pop
245
299
  end
246
- ''
247
300
  end
248
301
 
249
302
  def alternative(node)
@@ -271,7 +324,7 @@ module Readword
271
324
 
272
325
  def count_nodes(count, budget = :content)
273
326
  @budgets[budget][:nodes] += count
274
- raise Error, TOO_LONG if @budgets[budget][:nodes] > MAX_XML_NODES
327
+ raise Error, TOO_LONG if @budgets[budget][:nodes] > @max_xml_nodes
275
328
  end
276
329
 
277
330
  def children_named(node, name)
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Readword
4
+ # Writes formatted pieces of a paragraph as Markdown: bold, italics and links, opening and closing markers only where the
5
+ # formatting changes, keeping spaces outside the markers and ignoring emphasis that starts or ends inside a word.
6
+ module Inline
7
+ EDGE_SPACE = /\A[[:space:]]+|[[:space:]]+\z/
8
+ LEADING_SPACE = /\A[[:space:]]*/
9
+ TRAILING_SPACE = /[[:space:]]*\z/
10
+ WORD_START = /\A[[:alnum:]]/
11
+ WORD_END = /[[:alnum:]]\z/
12
+
13
+ module_function
14
+
15
+ def render(spans, bold: true)
16
+ return spans.map(&:text).join unless spans.any? { |span| (bold && span.bold) || span.italic || span.link }
17
+
18
+ pieces = merge(spans.map { |span| [span.text, bold && span.bold, span.italic, span.link] })
19
+ pieces = merge(pieces.each_with_index.map { |piece, index| inside_word?(pieces, index) ? [piece[0], false, false, piece[3]] : piece })
20
+ state = [false, false]
21
+ held = +''
22
+ text = +''
23
+ pieces.each do |content, strong, emphasis, link|
24
+ if link
25
+ text << close(state) << held << decorate(content, strong, emphasis, link)
26
+ held = +''
27
+ next
28
+ end
29
+ core = content.gsub(EDGE_SPACE, '')
30
+ next held << content if core.empty?
31
+
32
+ core = core.gsub('*', '\\*') if strong || emphasis
33
+ text << switch(state, strong, emphasis, held + content[LEADING_SPACE]) << core
34
+ held = +content[TRAILING_SPACE]
35
+ end
36
+ text << close(state) << held
37
+ end
38
+
39
+ def merge(pieces)
40
+ pieces.each_with_object([]) do |piece, merged|
41
+ next merged.last[0] += piece[0] if merged.last && merged.last[1..] == piece[1..]
42
+
43
+ merged << [piece[0].dup, *piece[1..]]
44
+ end
45
+ end
46
+
47
+ def inside_word?(pieces, index)
48
+ content, strong, emphasis, link = pieces[index]
49
+ return false unless (strong || emphasis) && !link
50
+
51
+ before = pieces[index - 1]&.first if index.positive?
52
+ after = pieces[index + 1]&.first
53
+ (before&.match?(WORD_END) && content.match?(WORD_START)) || (after&.match?(WORD_START) && content.match?(WORD_END))
54
+ end
55
+
56
+ def switch(state, strong, emphasis, gap)
57
+ bold_open, italic_open = state
58
+ closing = +''
59
+ opening = +''
60
+ if italic_open && (!emphasis || (bold_open && !strong) || (strong && !bold_open))
61
+ closing << '*'
62
+ italic_open = false
63
+ end
64
+ if bold_open && !strong
65
+ closing << '**'
66
+ bold_open = false
67
+ end
68
+ if strong && !bold_open
69
+ opening << '**'
70
+ bold_open = true
71
+ end
72
+ if emphasis && !italic_open
73
+ opening << '*'
74
+ italic_open = true
75
+ end
76
+ state.replace([bold_open, italic_open])
77
+ "#{closing}#{gap}#{opening}"
78
+ end
79
+
80
+ def close(state)
81
+ markers = "#{'*' if state[1]}#{'**' if state[0]}"
82
+ state.replace([false, false])
83
+ markers
84
+ end
85
+
86
+ def decorate(text, strong, emphasis, link)
87
+ core = text.gsub(EDGE_SPACE, '')
88
+ return text if core.empty? || !(strong || emphasis || link)
89
+
90
+ core = core.gsub('*', '\\*') if strong || emphasis
91
+ marker = if strong && emphasis then '***'
92
+ elsif strong then '**'
93
+ elsif emphasis then '*'
94
+ else ''
95
+ end
96
+ core = "#{marker}#{core}#{marker}"
97
+ core = "[#{core}](#{link.match?(/[[:space:]()<>]/) ? "<#{link.delete('<>')}>" : link})" if link
98
+ "#{text[LEADING_SPACE]}#{core}#{text[TRAILING_SPACE]}"
99
+ end
100
+ end
101
+ end
@@ -4,6 +4,7 @@ module Readword
4
4
  Paragraph = Struct.new(:text, :heading, :list, keyword_init: true)
5
5
  ListItem = Struct.new(:level, :label, :bullet, :list, keyword_init: true)
6
6
  Row = Struct.new(:cells)
7
+ Span = Struct.new(:text, :bold, :italic, :link)
7
8
 
8
9
  # Writes the readers' paragraphs and table rows as Markdown: headings, nested lists and pipe tables.
9
10
  module Markdown
@@ -73,6 +74,8 @@ module Readword
73
74
 
74
75
  def table(rows)
75
76
  width = rows.map(&:size).max
77
+ header, *body = rows
78
+ rows = [header.map { |cell| unbold(cell) }, *body]
76
79
  lines = rows.map do |cells|
77
80
  padded = cells.map { |cell| cell.gsub('|', '\|') } + Array.new(width - cells.size, '')
78
81
  "| #{padded.join(' | ')} |"
@@ -80,6 +83,11 @@ module Readword
80
83
  lines.insert(1, "| #{Array.new(width, '---').join(' | ')} |").join("\n")
81
84
  end
82
85
 
86
+ def unbold(cell)
87
+ inner = cell[/\A\*\*(.*)\*\*\z/m, 1]
88
+ inner && !inner.include?('**') ? inner : cell
89
+ end
90
+
83
91
  def escape(text)
84
92
  LITERAL_START.match?(text) ? "\\#{text}" : text
85
93
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Readword
4
- VERSION = '0.1.1'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/readword.rb CHANGED
@@ -15,6 +15,10 @@ module Readword
15
15
  0xB0 => '°', 0xB1 => '±', 0xB3 => '≥', 0xB4 => '×', 0xB7 => '•', 0xB9 => '≠', 0xBB => '≈'
16
16
  }.freeze
17
17
  ZIP_SIGNATURE = "PK\x03\x04".b.freeze
18
+ DEFAULT_MAX_SIZE = 32
19
+ LINK_SWITCHES = ['\\l', '\\o', '\\t'].freeze
20
+ WEB_ADDRESS = %r{\A((https?|ftp)://|mailto:)[^[:cntrl:]]*\z}i
21
+ MEGABYTE = 1024 * 1024
18
22
 
19
23
  UNREADABLE = :unreadable
20
24
  NO_TEXT = :no_text
@@ -44,8 +48,9 @@ module Readword
44
48
  convert(File.binread(path), **options)
45
49
  end
46
50
 
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
51
+ def self.convert(bytes, max_characters: nil, max_size: DEFAULT_MAX_SIZE)
52
+ max_size = nil unless max_size.to_i.positive?
53
+ text = Markdown.render(reader(bytes.b, max_size).blocks).split("\n").map(&:rstrip).join("\n").gsub(/\n{3,}/, "\n\n").strip
49
54
  raise Error, NO_TEXT if text.empty?
50
55
  raise Error, TOO_LONG if max_characters && text.size > max_characters
51
56
 
@@ -56,9 +61,9 @@ module Readword
56
61
  raise Error, UNREADABLE
57
62
  end
58
63
 
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)
64
+ def self.reader(bytes, max_size)
65
+ if bytes.start_with?(CompoundFile::SIGNATURE) then Doc.new(bytes, max_size: max_size)
66
+ elsif bytes.start_with?(ZIP_SIGNATURE) then Docx.new(bytes, max_size: max_size)
62
67
  else raise Error, UNREADABLE
63
68
  end
64
69
  end
@@ -70,6 +75,22 @@ module Readword
70
75
  code.between?(0x20, 0xD7FF) ? code.chr(Encoding::UTF_8) : ''
71
76
  end
72
77
 
78
+ def self.hyperlink(code)
79
+ words = code.to_s.scan(/"[^"]*"|\S+/)
80
+ return unless words.shift&.casecmp?('HYPERLINK')
81
+
82
+ while (word = words.shift)
83
+ if word.start_with?('\\')
84
+ words.shift if LINK_SWITCHES.include?(word.downcase)
85
+ next
86
+ end
87
+ url = word.delete('"')
88
+ return url if WEB_ADDRESS.match?(url)
89
+
90
+ break
91
+ end
92
+ end
93
+
73
94
  def self.blank?(text)
74
95
  text.nil? || text.match?(/\A[[:space:]]*\z/)
75
96
  end
@@ -80,6 +101,7 @@ module Readword
80
101
  end
81
102
 
82
103
  require_relative 'readword/markdown'
104
+ require_relative 'readword/inline'
83
105
  require_relative 'readword/numbering'
84
106
  require_relative 'readword/binary'
85
107
  require_relative 'readword/compound_file'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: readword
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - urbullet
@@ -63,6 +63,7 @@ files:
63
63
  - lib/readword/compound_file.rb
64
64
  - lib/readword/doc.rb
65
65
  - lib/readword/docx.rb
66
+ - lib/readword/inline.rb
66
67
  - lib/readword/markdown.rb
67
68
  - lib/readword/numbering.rb
68
69
  - lib/readword/version.rb