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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +36 -8
- data/exe/readword +6 -0
- data/lib/readword/doc.rb +71 -32
- data/lib/readword/docx.rb +74 -21
- data/lib/readword/inline.rb +101 -0
- data/lib/readword/markdown.rb +8 -0
- data/lib/readword/version.rb +1 -1
- data/lib/readword.rb +27 -5
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b700bdd0e0d5132d8179dca381922454528ac0c2606ae6bd792990250569d57e
|
|
4
|
+
data.tar.gz: 4a17928587b6725fa57b2adeeaca3db33e375ed9ac27c21d9c6da1e2b1d18da0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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 } >
|
|
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? ?
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
+
append("\n", @format)
|
|
162
198
|
elsif character >= ' ' || REPLACEMENTS.key?(character)
|
|
163
|
-
|
|
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
|
-
|
|
208
|
+
append(' ')
|
|
173
209
|
@paragraph_start = @text.size
|
|
174
210
|
else
|
|
175
211
|
end_row if @row.any?
|
|
176
|
-
|
|
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 >
|
|
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 >
|
|
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
|
|
378
|
-
raise Error, TOO_LONG if runs.size >
|
|
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 >
|
|
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] =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
134
|
+
spans = []
|
|
128
135
|
properties = child_named(paragraph, 'pPr')
|
|
129
136
|
style = value(properties, 'pStyle')
|
|
130
|
-
|
|
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.
|
|
239
|
+
def inline(node, boxes, fields, spans, format)
|
|
240
|
+
node.element_children.each do |child|
|
|
217
241
|
case child.name
|
|
218
|
-
when *CHARACTERS then
|
|
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 '
|
|
221
|
-
|
|
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
|
|
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'
|
|
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] >
|
|
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
|
data/lib/readword/markdown.rb
CHANGED
|
@@ -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
|
data/lib/readword/version.rb
CHANGED
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
|
-
|
|
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.
|
|
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
|