readword 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2c151bf301945512c356cbaadc83fc03f939c712293015a052be64a62c0c56f1
4
+ data.tar.gz: 3d9f112fa2fff0582d9040b6e146d9b7e587b6933330773df746e41414ac0b2f
5
+ SHA512:
6
+ metadata.gz: 876795d221df033bbddf1b407e1cbf6969ac8d39e0956e7b81056b72bae81b90d2df7a887e729494a4ce5e83cce8fbf19db02a5a6e5d69887653d5836f868bf7
7
+ data.tar.gz: 5ed50d5820cb66dc8b529ea78ce09b1fee80b7783e3977b5239db1bb0d2372798ad8d0483fbbc1ed1963a4d4c96b0dd7369fbadbdbaaee00c6b79d1c18eb4a81
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 (2026-10-22)
4
+
5
+ Same code as 0.1.0. Only the author name in the gem's details changed.
6
+
7
+ ## 0.1.0 (2026-10-22)
8
+
9
+ First release.
10
+
11
+ - Reads Word 97-2003 `.doc` and Word 2007+ `.docx` files and prints Markdown: paragraphs, headings with their numbers, nested bullet and numbered lists with the numbers Word shows, and tables as pipe tables.
12
+ - Reads headers, footers, footnotes, endnotes and text boxes. Tracked changes read as accepted, field codes are left out, and Insert Symbol characters come out as the real characters.
13
+ - `readword` command for one or more files or standard input, with `--max-characters`.
14
+ - Refuses password-protected files, Word 6 and 95 files, and files that are not Word documents, each with its own reason.
15
+ - Size limits and loop guards for files it does not trust.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 urbullet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # readword
2
+
3
+ Turn Word documents into Markdown, in plain Ruby. It reads both formats, Word 97-2003 `.doc` and Word 2007+ `.docx`, without LibreOffice, Java or antiword. It was built for feeding Word files to LLMs and coding agents, and it is careful with files it does not trust.
4
+
5
+ ```console
6
+ $ gem install readword
7
+ $ readword structured_report.doc
8
+ # Migration test report
9
+
10
+ # 1 Scope
11
+
12
+ Substances tested:
13
+
14
+ - Bisphenol A
15
+ - food simulant A
16
+ - Toluene
17
+
18
+ # 2 Method
19
+
20
+ ## 2.1 Conditions
21
+
22
+ 1. Prepare the sample
23
+ - a) at 40 C
24
+ - b) for 10 days
25
+ 2. Measure
26
+
27
+ ## 2.2 Results
28
+
29
+ | Substance | Result (ug/kg) |
30
+ | --- | --- |
31
+ | Benzene | 10 |
32
+ | Steps | 1. Fill 1. Seal |
33
+
34
+ End of report.
35
+ ```
36
+
37
+ ## What it keeps
38
+
39
+ - Headings as `#` lines, with the numbers Word puts in front of them (`## 2.1 Scope`). Headings come from Word's heading styles, from outline levels, and from styles based on them, in any language.
40
+ - Bullet and numbered lists with their nesting and the numbers Word shows: `1.`, `a)`, `iv.`, `1.2.3`, custom labels, restarted and continued lists.
41
+ - Tables as Markdown pipe tables, one line per row. Merged cells keep their columns, and lists inside a cell stay in that cell.
42
+ - Headers, footers, footnotes, endnotes and text boxes.
43
+ - Tracked changes as if accepted: inserted text stays and deleted text goes.
44
+ - Field results, such as page numbers, dates and merge fields. Field codes are left out.
45
+ - Characters added with Insert Symbol, such as µ, ±, ≤ and °, as the real characters.
46
+
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
+
49
+ ## Use it with a coding agent
50
+
51
+ Install the gem, then add a line like this to your `CLAUDE.md` or `AGENTS.md`:
52
+
53
+ ```markdown
54
+ To read a Word file (.doc or .docx), run `readword path/to/file` and read the Markdown it prints.
55
+ ```
56
+
57
+ ## Command line
58
+
59
+ ```console
60
+ $ readword [--max-characters N] FILE...
61
+ ```
62
+
63
+ - With several files, each one starts with a `==> path <==` line.
64
+ - Use `-` as the file name to read from standard input.
65
+ - 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
+ - `--max-characters N` refuses documents longer than N characters, which is useful to stay inside a model's context window.
67
+
68
+ ## Use it from Ruby
69
+
70
+ ```ruby
71
+ require 'readword'
72
+
73
+ Readword.convert_file('report.doc') # => "# Test report\n\n..."
74
+ Readword.convert(File.binread('report.docx'), max_characters: 150_000)
75
+ ```
76
+
77
+ `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:
78
+
79
+ | Reason | When |
80
+ | --- | --- |
81
+ | `:unreadable` | Not a Word document (RTF or plain text named `.doc`, for example), or damaged |
82
+ | `: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 |
84
+ | `:password_protected` | Encrypted with a password |
85
+ | `:old_format` | Saved by Word 6 or Word 95 |
86
+
87
+ ## Safe on untrusted files
88
+
89
+ - 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.
91
+ - XML parts that declare their own entities are refused, which stops entity bombs.
92
+ - 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.
94
+
95
+ ## How it was checked
96
+
97
+ readword was compared with Apache Tika, LibreOffice and macOS textutil on 650 Word files from the Apache POI and LibreOffice test suites.
98
+
99
+ | | antiword 0.37 | docx gem 0.13 | readword |
100
+ | --- | --- | --- | --- |
101
+ | Reads `.doc` | yes | no | yes |
102
+ | Reads `.docx` | no | yes | yes |
103
+ | Files missing words that both Tika and textutil found | 84 of 246 `.doc` files | 60 of 207 `.docx` files | 8 `.doc` files, no `.docx` files |
104
+ | Headers and footers | not printed | | read |
105
+ | Tables | long or two-paragraph cells spill into fake rows | paragraphs and tables come back as separate lists, so their order is lost | one line per row, in reading order |
106
+ | Track Changes | deleted text kept | inserted text dropped | deleted text left out, inserted text kept |
107
+ | 10,000 crafted `.doc` files | 164 crashes and 387 memory errors | | no crashes |
108
+ | A 1 MB zip that unpacks to 1 GB | | 3.2 GB of memory used | refused in 0.17 s |
109
+ | Install | system package (no Homebrew formula) | gem | gem |
110
+
111
+ The words readword misses are field codes, embedded objects, charts and picture names.
112
+
113
+ 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
+
115
+ ## Development
116
+
117
+ ```console
118
+ $ bundle install
119
+ $ bundle exec rake # tests and RuboCop
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/exe/readword ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require 'readword'
6
+
7
+ options = {}
8
+ parser = OptionParser.new do |opts|
9
+ opts.banner = 'Usage: readword [options] FILE...'
10
+ opts.separator ''
11
+ opts.separator 'Prints Word documents (.doc and .docx) as Markdown. Use - to read from standard input.'
12
+ opts.separator ''
13
+ opts.on('--max-characters N', Integer, 'Fail when a document has more than N characters') do |count|
14
+ options[:max_characters] = count
15
+ end
16
+ opts.on('-v', '--version', 'Print the version') do
17
+ puts Readword::VERSION
18
+ exit
19
+ end
20
+ opts.on('-h', '--help', 'Print this help') do
21
+ puts opts
22
+ exit
23
+ end
24
+ end
25
+
26
+ begin
27
+ paths = parser.parse(ARGV)
28
+ rescue OptionParser::ParseError => e
29
+ abort("readword: #{e.message}\n\n#{parser}")
30
+ end
31
+ abort(parser.to_s) if paths.empty?
32
+
33
+ failed = false
34
+ paths.each_with_index do |path, index|
35
+ bytes = path == '-' ? $stdin.binmode.read : File.binread(path)
36
+ text = Readword.convert(bytes, **options)
37
+ puts if index.positive?
38
+ puts "==> #{path} <==" if paths.size > 1
39
+ puts text
40
+ rescue Readword::Error, SystemCallError => e
41
+ warn "readword: #{path}: #{e.message}"
42
+ failed = true
43
+ end
44
+ exit(1) if failed
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Readword
4
+ # Little-endian number readers for the binary Office formats (MS-CFB, MS-DOC).
5
+ # They return nil past the end of the data, so a crafted offset reads as missing instead of raising.
6
+ module Binary
7
+ private
8
+
9
+ def u16(bytes, offset)
10
+ bytes.unpack1('v', offset: offset) if offset + 2 <= bytes.bytesize
11
+ end
12
+
13
+ def u32(bytes, offset)
14
+ bytes.unpack1('V', offset: offset) if offset + 4 <= bytes.bytesize
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Readword
4
+ # Reads named streams out of a Compound File (MS-CFB), the container every .doc is stored in.
5
+ class CompoundFile
6
+ include Binary
7
+
8
+ # Compound File Header: the signature, then fields at fixed byte offsets in the first 512 bytes.
9
+ SIGNATURE = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b.freeze
10
+ HEADER_BYTES = 512
11
+ SECTOR_SHIFT = 0x1E
12
+ MINI_SECTOR_SHIFT = 0x20
13
+ FAT_SECTOR_COUNT = 0x2C
14
+ FIRST_DIRECTORY_SECTOR = 0x30
15
+ MINI_STREAM_CUTOFF = 0x38
16
+ FIRST_MINI_FAT_SECTOR = 0x3C
17
+ FIRST_DIFAT_SECTOR = 0x44
18
+ DIFAT_SECTOR_COUNT = 0x48
19
+ HEADER_DIFAT = 0x4C
20
+ HEADER_DIFAT_BYTES = 436
21
+ # Sector numbers above MAXREGSECT are markers such as ENDOFCHAIN, never real sectors.
22
+ MAX_REGULAR_SECTOR = 0xFFFFFFFA
23
+ # Directory entries are 128 bytes; NOSTREAM marks an empty link and object type 2 is a stream.
24
+ NO_ENTRY = 0xFFFFFFFF
25
+ STREAM = 2
26
+ ENTRY_BYTES = 128
27
+
28
+ def initialize(bytes)
29
+ @bytes = bytes.b
30
+ raise Error, UNREADABLE unless @bytes.bytesize >= HEADER_BYTES && @bytes.start_with?(SIGNATURE)
31
+
32
+ @sector_size = 1 << u16(@bytes, SECTOR_SHIFT)
33
+ @mini_sector_size = 1 << u16(@bytes, MINI_SECTOR_SHIFT)
34
+ raise Error, UNREADABLE unless [512, 4096].include?(@sector_size) && @mini_sector_size == 64
35
+
36
+ @fat = fat_sector_ids.flat_map { |sector_id| words(sector(sector_id)) }
37
+ @directory = chain_data(u32(@bytes, FIRST_DIRECTORY_SECTOR))
38
+ @root = entry(0) || raise(Error, UNREADABLE)
39
+ end
40
+
41
+ def stream(name)
42
+ found = root_children.find { |child| child[:type] == STREAM && child[:name].casecmp?(name) }
43
+ found && read(found)
44
+ end
45
+
46
+ def stream?(name)
47
+ root_children.any? { |child| child[:type] == STREAM && child[:name].casecmp?(name) }
48
+ end
49
+
50
+ private
51
+
52
+ # The header holds the first 109 FAT sector numbers; bigger files continue the list in chained DIFAT sectors.
53
+ def fat_sector_ids
54
+ limit = [u32(@bytes, FAT_SECTOR_COUNT), (sector_count / (@sector_size / 4)) + 1].min
55
+ ids = words(@bytes.byteslice(HEADER_DIFAT, HEADER_DIFAT_BYTES))
56
+ next_id = u32(@bytes, FIRST_DIFAT_SECTOR)
57
+ remaining = u32(@bytes, DIFAT_SECTOR_COUNT)
58
+ seen = Set.new
59
+ while remaining.positive? && ids.size < limit && next_id <= MAX_REGULAR_SECTOR && seen.add?(next_id)
60
+ difat = words(sector(next_id))
61
+ ids.concat(difat[0...-1])
62
+ next_id = difat.last.to_i
63
+ remaining -= 1
64
+ end
65
+ ids.first(limit).select { |id| id <= MAX_REGULAR_SECTOR }
66
+ end
67
+
68
+ # Directory entries form a tree through their left, right and child links.
69
+ def root_children
70
+ @root_children ||= begin
71
+ pending = [@root[:child]]
72
+ seen = Set.new
73
+ children = []
74
+ until pending.empty?
75
+ id = pending.pop
76
+ next if id == NO_ENTRY || !seen.add?(id)
77
+
78
+ child = entry(id) or next
79
+ children << child
80
+ pending.push(child[:left], child[:right])
81
+ end
82
+ children
83
+ end
84
+ end
85
+
86
+ def entry(index)
87
+ raw = @directory.byteslice(index * ENTRY_BYTES, ENTRY_BYTES)
88
+ return unless raw&.bytesize == ENTRY_BYTES
89
+
90
+ left, right, child = raw.unpack('@68V3')
91
+ { name: entry_name(raw), type: raw.getbyte(66), left: left, right: right, child: child,
92
+ start: raw.unpack1('@116V'), size: raw.unpack1('@120V') }
93
+ end
94
+
95
+ def entry_name(raw)
96
+ length = raw.unpack1('@64v').clamp(2, 64) - 2
97
+ raw.byteslice(0, length).force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
98
+ end
99
+
100
+ # Streams under the mini stream cutoff (4096 bytes) live in 64-byte mini sectors inside the root entry's stream.
101
+ def read(found)
102
+ data = found[:size] < u32(@bytes, MINI_STREAM_CUTOFF) ? mini_chain_data(found[:start]) : chain_data(found[:start])
103
+ data.byteslice(0, found[:size])
104
+ end
105
+
106
+ def chain_data(start)
107
+ chain(start, @fat).map { |sector_id| sector(sector_id) }.join
108
+ end
109
+
110
+ def mini_chain_data(start)
111
+ chain(start, mini_fat).map { |sector_id| mini_stream.byteslice(sector_id * @mini_sector_size, @mini_sector_size) }.join
112
+ end
113
+
114
+ def mini_fat
115
+ @mini_fat ||= chain(u32(@bytes, FIRST_MINI_FAT_SECTOR), @fat).flat_map { |sector_id| words(sector(sector_id)) }
116
+ end
117
+
118
+ def mini_stream
119
+ @mini_stream ||= chain_data(@root[:start])
120
+ end
121
+
122
+ # Follows a FAT or mini FAT chain; a chain longer than its table can only be a loop in a crafted file.
123
+ def chain(start, table)
124
+ ids = []
125
+ id = start
126
+ while id <= MAX_REGULAR_SECTOR
127
+ raise Error, UNREADABLE if id >= table.size || ids.size >= table.size
128
+
129
+ ids << id
130
+ id = table[id]
131
+ end
132
+ ids
133
+ end
134
+
135
+ # Sector 0 starts right after the header sector.
136
+ def sector(id)
137
+ @bytes.byteslice((id + 1) * @sector_size, @sector_size) || raise(Error, UNREADABLE)
138
+ end
139
+
140
+ def sector_count
141
+ @bytes.bytesize / @sector_size
142
+ end
143
+
144
+ def words(bytes)
145
+ bytes.to_s.unpack('V*')
146
+ end
147
+ end
148
+ end