readword 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +124 -0
- data/exe/readword +44 -0
- data/lib/readword/binary.rb +17 -0
- data/lib/readword/compound_file.rb +148 -0
- data/lib/readword/doc.rb +526 -0
- data/lib/readword/docx.rb +294 -0
- data/lib/readword/markdown.rb +87 -0
- data/lib/readword/numbering.rb +75 -0
- data/lib/readword/version.rb +5 -0
- data/lib/readword.rb +87 -0
- metadata +96 -0
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
|