anydoc-ruby 0.1.7-aarch64-linux
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/LICENSE +21 -0
- data/README.md +149 -0
- data/lib/anydoc/3.2/anydoc.so +0 -0
- data/lib/anydoc/3.3/anydoc.so +0 -0
- data/lib/anydoc/3.4/anydoc.so +0 -0
- data/lib/anydoc/4.0/anydoc.so +0 -0
- data/lib/anydoc/document.rb +224 -0
- data/lib/anydoc/errors.rb +43 -0
- data/lib/anydoc/version.rb +6 -0
- data/lib/anydoc.rb +128 -0
- data/sig/anydoc.rbs +163 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8c4ad832495b176c63d22f5c081121a2dbfa6f44aa672c3bd9a0f024ff7a1287
|
|
4
|
+
data.tar.gz: 179e918f328a4a766dc082d03be3f39e087c61ab1f8a8877d5b7baf556dbf6ba
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e9361cdb238260b7bef297b97fcfd5722e7a5f607c57fb8b16c6de9f21f2a960b6fa7d8ce3271378a4004e8dd43ff5b4c94c3b9af40191669bdbbff9633f5136
|
|
7
|
+
data.tar.gz: 81a6725073be6b7cdcc8df3987f57e323e7d9a8992b9a77f80694ad316e4a177b2f0ee93deab9acf884c95a1133b8704325a392bc37b0f5236484e4745bbc451
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sideguide Technologies Inc.
|
|
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,149 @@
|
|
|
1
|
+
# anydoc-ruby
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/anydoc-ruby)
|
|
4
|
+
[](https://github.com/honzasterba/anydoc/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
Convert Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, and PDF files into clean GitHub-Flavored Markdown. Ruby bindings for the [anydoc](https://crates.io/crates/anydoc) Rust crate, which is built by [Firecrawl](https://firecrawl.dev).
|
|
7
|
+
|
|
8
|
+
Every format parses into one shared document model and renders through a single Markdown serializer, so headings, tables, lists, and footnotes come out the same no matter which format goes in. Conversion releases the GVL, so other threads keep running. RBS signatures ship with the gem.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bundle add anydoc-ruby
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The gem installs as `anydoc-ruby` and requires as `anydoc`, so a Gemfile entry that Bundler auto-requires needs `gem "anydoc-ruby", require: "anydoc"`. Precompiled native gems cover Linux (glibc and musl), macOS, and Windows on Ruby 3.2+; anywhere else, `gem install` builds the extension from source, which needs a [Rust toolchain](https://rustup.rs).
|
|
15
|
+
|
|
16
|
+
## Supported formats
|
|
17
|
+
|
|
18
|
+
| Format | Extensions |
|
|
19
|
+
| ---------------- | ---------------------------------------------------------- |
|
|
20
|
+
| Word | `.doc`, `.docx`, `.docm` |
|
|
21
|
+
| PowerPoint | `.ppt`, `.pps`, `.pot`, `.pptx`, `.pptm`, `.ppsx`, `.ppsm` |
|
|
22
|
+
| Excel | `.xls`, `.xlsx`, `.xlsm`, `.xlsb` |
|
|
23
|
+
| OpenDocument | `.odt`, `.ods`, `.odp` |
|
|
24
|
+
| Rich Text Format | `.rtf` |
|
|
25
|
+
| EPUB | `.epub` |
|
|
26
|
+
| CSV | `.csv` |
|
|
27
|
+
| PDF | `.pdf` |
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "anydoc"
|
|
33
|
+
|
|
34
|
+
# From a file path:
|
|
35
|
+
markdown = Anydoc.to_markdown("report.docx")
|
|
36
|
+
|
|
37
|
+
# From bytes, with the format detected from the content:
|
|
38
|
+
markdown = Anydoc.to_markdown_bytes(data)
|
|
39
|
+
|
|
40
|
+
# Or name it, which signature-less formats (CSV) need:
|
|
41
|
+
markdown = Anydoc.to_markdown_bytes(data, format: :csv)
|
|
42
|
+
|
|
43
|
+
# Or stop at the document model, which also carries embedded assets:
|
|
44
|
+
document = Anydoc.to_document(data)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`to_markdown` takes anything path-like, including a `Pathname`. A format is named with a symbol from `Anydoc::FORMATS`; a string is accepted too.
|
|
48
|
+
|
|
49
|
+
## Errors
|
|
50
|
+
|
|
51
|
+
A conversion raises only when no meaningful Markdown could come out of the file. The exception class names what went wrong:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
begin
|
|
55
|
+
Anydoc.to_markdown(path)
|
|
56
|
+
rescue Anydoc::EncryptedError, Anydoc::UnsupportedError => error
|
|
57
|
+
# No document comes out of these, so record the file and take the next one.
|
|
58
|
+
unconverted << [path, error.class]
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Exception | Raised when |
|
|
64
|
+
| -------------------------- | ------------------------------------------------------------------- |
|
|
65
|
+
| `Anydoc::UnsupportedError` | Unknown format, or one that cannot be converted (an image-only PDF) |
|
|
66
|
+
| `Anydoc::MalformedError` | Structurally unusable: no meaningful content could be extracted |
|
|
67
|
+
| `Anydoc::EncryptedError` | Encrypted or password-protected |
|
|
68
|
+
| `Anydoc::ResourceLimitError` | Crossed a fixed safety limit (decompression, nesting, node count) |
|
|
69
|
+
| `Anydoc::MissingPartError` | A part required for any meaningful output is absent |
|
|
70
|
+
| `Errno::*` | The file could not be read, from `to_markdown` only |
|
|
71
|
+
|
|
72
|
+
The five conversion failures subclass `Anydoc::ConvertError`, so rescuing that handles all of them at once. `MalformedError#part` and `MissingPartError#part` name the package part at fault, `ResourceLimitError#limit` names the limit crossed as a symbol, and `#message` carries the whole message. A `format:` argument naming no supported format raises `ArgumentError`.
|
|
73
|
+
|
|
74
|
+
## Format detection
|
|
75
|
+
|
|
76
|
+
The format is read from the file content, using the marker its specification designates: the PDF header, the RTF open group, OLE stream names, the ZIP package mimetype and content types. CSV has no such marker, so detection returns `nil` for it and the extension, or an explicit format, names it instead.
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
Anydoc.format_from_bytes(data) # :docx, or nil when nothing matches
|
|
80
|
+
Anydoc.format_from_extension(".pptm") # :pptx
|
|
81
|
+
Anydoc.format_from_path("report.odt") # :odt
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## The document model
|
|
85
|
+
|
|
86
|
+
`Anydoc.to_document` stops at the parsed model instead of rendering it. Every class in it is a `Data`, so instances are frozen, compare by value, answer `to_h`, and destructure in `case/in`:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
document = Anydoc.to_document(File.binread("report.docx"))
|
|
90
|
+
|
|
91
|
+
titles = document.blocks.filter_map do |block|
|
|
92
|
+
case block
|
|
93
|
+
in Anydoc::Block[kind: :heading, level: 1, content:]
|
|
94
|
+
content.filter_map(&:text).join
|
|
95
|
+
else
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Variants are a `kind` symbol plus the members that kind carries; members belonging to other kinds are `nil`. `Document#blocks` holds the body, `Document#notes` the footnote and endnote bodies that `:note_ref` inlines point at, and `Document#assets` the embedded bytes.
|
|
102
|
+
|
|
103
|
+
PDFs convert straight to Markdown and have no document-model form, so `to_document` raises `UnsupportedError` for them; use `to_markdown_bytes`.
|
|
104
|
+
|
|
105
|
+
## Images and embedded objects
|
|
106
|
+
|
|
107
|
+
Markdown cannot embed bytes, so an embedded image renders as its alt text while the bytes stay on `document.assets` as binary strings, tagged with a media type and the part they came from. Images that carry an external URL render as ordinary Markdown images.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
document.assets.each do |asset|
|
|
111
|
+
File.binwrite("asset-#{asset.id}", asset.data) if asset.media_type.start_with?("image/")
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Full behavior notes and benchmarks live in the [repository README](https://github.com/honzasterba/anydoc#readme).
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
From this directory, with a [Rust toolchain](https://rustup.rs) installed:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
bundle install
|
|
123
|
+
bundle exec rake # compile the extension, then run the tests
|
|
124
|
+
bundle exec rake compile
|
|
125
|
+
bundle exec rake test
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Inside this repository the extension builds against the crate in the parent directory rather than the published one; `.cargo/config.toml` is what redirects it.
|
|
129
|
+
|
|
130
|
+
### Packaging
|
|
131
|
+
|
|
132
|
+
The source gem needs nothing but RubyGems:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
bundle exec rake build # pkg/anydoc-ruby-<version>.gem
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The precompiled gems are cross-compiled in [rb-sys-dock](https://github.com/oxidize-rb/rb-sys) containers, so they need Docker running. Each one carries an extension per Ruby ABI:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
bundle exec rake gems:x86_64-linux # one platform
|
|
142
|
+
bundle exec rake gems:all # the source gem and all seven platforms
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Run these through Rake rather than calling `rb-sys-dock` from this directory: the container mounts the shell's working directory, and the build needs the crate one level above it, so the tasks run the CLI from the repository root with `--directory ruby`. The first build of each platform pulls a multi-gigabyte image.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
[MIT](https://github.com/honzasterba/anydoc/blob/main/LICENSE)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anydoc
|
|
4
|
+
# The document model {Anydoc.to_document} returns.
|
|
5
|
+
#
|
|
6
|
+
# Every class here is a +Data+, so instances are frozen, compare by value,
|
|
7
|
+
# answer +to_h+, and destructure in +case/in+:
|
|
8
|
+
#
|
|
9
|
+
# case block
|
|
10
|
+
# in Anydoc::Block[kind: :heading, level:, content:]
|
|
11
|
+
# ...
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Variants (block kinds, link targets, ...) are a +kind+ symbol plus the
|
|
15
|
+
# members that kind carries; members belonging to other kinds are +nil+.
|
|
16
|
+
#
|
|
17
|
+
# @!parse
|
|
18
|
+
# # Only fully resolved content lives in the model: style cascades,
|
|
19
|
+
# # numbering, and references are resolved before it is built.
|
|
20
|
+
class Document < Data.define(
|
|
21
|
+
# @return [Array<Block>] body content, in reading order.
|
|
22
|
+
:blocks,
|
|
23
|
+
# @return [Array<Note>] footnote and endnote bodies, referenced from text
|
|
24
|
+
# by a +:note_ref+ inline.
|
|
25
|
+
:notes,
|
|
26
|
+
# @return [Array<Asset>] every embedded asset, indexed by its +id+.
|
|
27
|
+
:assets
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# One block-level piece of content.
|
|
32
|
+
#
|
|
33
|
+
# @!attribute [r] kind
|
|
34
|
+
# @return [Symbol] +:heading+, +:paragraph+, +:list+, +:table+,
|
|
35
|
+
# +:block_quote+, +:code_block+, or +:rule+.
|
|
36
|
+
# @!attribute [r] level
|
|
37
|
+
# @return [Integer, nil] heading: 1-6.
|
|
38
|
+
# @!attribute [r] anchor
|
|
39
|
+
# @return [String, nil] heading: stable anchor id, when the document
|
|
40
|
+
# targets this heading.
|
|
41
|
+
# @!attribute [r] content
|
|
42
|
+
# @return [Array<Inline>, nil] heading, paragraph.
|
|
43
|
+
# @!attribute [r] list
|
|
44
|
+
# @return [List, nil]
|
|
45
|
+
# @!attribute [r] table
|
|
46
|
+
# @return [Table, nil]
|
|
47
|
+
# @!attribute [r] blocks
|
|
48
|
+
# @return [Array<Block>, nil] block_quote.
|
|
49
|
+
# @!attribute [r] lang
|
|
50
|
+
# @return [String, nil] code_block.
|
|
51
|
+
# @!attribute [r] text
|
|
52
|
+
# @return [String, nil] code_block.
|
|
53
|
+
class Block < Data.define(
|
|
54
|
+
:kind, :level, :anchor, :content, :list, :table, :blocks, :lang, :text
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# One inline-level piece of content.
|
|
59
|
+
#
|
|
60
|
+
# @!attribute [r] kind
|
|
61
|
+
# @return [Symbol] +:text+, +:link+, +:image+, +:anchor+ (a zero-width
|
|
62
|
+
# marker for an internal link target at this position), +:note_ref+, or
|
|
63
|
+
# +:line_break+.
|
|
64
|
+
# @!attribute [r] text
|
|
65
|
+
# @return [String, nil] text.
|
|
66
|
+
# @!attribute [r] style
|
|
67
|
+
# @return [Style, nil] text.
|
|
68
|
+
# @!attribute [r] content
|
|
69
|
+
# @return [Array<Inline>, nil] link.
|
|
70
|
+
# @!attribute [r] target
|
|
71
|
+
# @return [LinkTarget, nil] link.
|
|
72
|
+
# @!attribute [r] alt
|
|
73
|
+
# @return [String, nil] image.
|
|
74
|
+
# @!attribute [r] source
|
|
75
|
+
# @return [ImageSource, nil] image.
|
|
76
|
+
# @!attribute [r] anchor
|
|
77
|
+
# @return [String, nil] anchor: the anchor id.
|
|
78
|
+
# @!attribute [r] note_id
|
|
79
|
+
# @return [String, nil] note_ref: the id of the note in
|
|
80
|
+
# {Document#notes}.
|
|
81
|
+
class Inline < Data.define(
|
|
82
|
+
:kind, :text, :style, :content, :target, :alt, :source, :anchor, :note_id
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Fully resolved character style.
|
|
87
|
+
#
|
|
88
|
+
# @!attribute [r] bold
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
# @!attribute [r] italic
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
# @!attribute [r] strike
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
# @!attribute [r] code
|
|
95
|
+
# @return [Boolean] monospace, from a code or teletype character style.
|
|
96
|
+
class Style < Data.define(:bold, :italic, :strike, :code)
|
|
97
|
+
# @return [Boolean] whether no toggle is set.
|
|
98
|
+
def plain? = !(bold || italic || strike || code)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Where a link points.
|
|
102
|
+
#
|
|
103
|
+
# @!attribute [r] kind
|
|
104
|
+
# @return [Symbol] +:external+ (absolute URL with a scheme), +:relative+
|
|
105
|
+
# (scheme-less reference, preserved as written), or +:anchor+ (an
|
|
106
|
+
# internal target: a heading anchor or an +:anchor+ inline).
|
|
107
|
+
# @!attribute [r] value
|
|
108
|
+
# @return [String] the URL, relative reference, or anchor id.
|
|
109
|
+
class LinkTarget < Data.define(:kind, :value)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Where an image's bytes come from.
|
|
113
|
+
#
|
|
114
|
+
# @!attribute [r] kind
|
|
115
|
+
# @return [Symbol] +:external+ (absolute URL with a scheme), +:asset+
|
|
116
|
+
# (embedded, carried in {Document#assets}), or +:unavailable+ (the
|
|
117
|
+
# image's part is missing or unreadable and it has no URL, so only the
|
|
118
|
+
# alt text remains).
|
|
119
|
+
# @!attribute [r] url
|
|
120
|
+
# @return [String, nil] external.
|
|
121
|
+
# @!attribute [r] asset_id
|
|
122
|
+
# @return [Integer, nil] asset: index into {Document#assets}.
|
|
123
|
+
class ImageSource < Data.define(:kind, :url, :asset_id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A list and the marker family the source document used for it.
|
|
127
|
+
#
|
|
128
|
+
# @!attribute [r] marker
|
|
129
|
+
# @return [Symbol] +:bullet+, +:decimal+, +:lower_alpha+, +:upper_alpha+,
|
|
130
|
+
# +:lower_roman+, or +:upper_roman+.
|
|
131
|
+
# @!attribute [r] start
|
|
132
|
+
# @return [Integer] the ordinal the first item counts from.
|
|
133
|
+
# @!attribute [r] items
|
|
134
|
+
# @return [Array<ListItem>]
|
|
135
|
+
class List < Data.define(:marker, :start, :items)
|
|
136
|
+
# @return [Boolean] whether the list is numbered.
|
|
137
|
+
def ordered? = marker != :bullet
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# One item of a {List}.
|
|
141
|
+
#
|
|
142
|
+
# @!attribute [r] blocks
|
|
143
|
+
# @return [Array<Block>]
|
|
144
|
+
# @!attribute [r] checked
|
|
145
|
+
# @return [Boolean, nil] task-list state, when the item carries a
|
|
146
|
+
# checkbox.
|
|
147
|
+
# @!attribute [r] marker_label
|
|
148
|
+
# @return [String, nil] literal marker text that overrides the list marker
|
|
149
|
+
# when the source number text cannot be reproduced from the marker and
|
|
150
|
+
# position alone (composite number text such as +1-a)+).
|
|
151
|
+
class ListItem < Data.define(:blocks, :checked, :marker_label)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A table as a canonical grid: every logical grid position appears exactly
|
|
155
|
+
# once. Content and spans live on the origin slot, and each position a span
|
|
156
|
+
# covers holds a +:covered+ slot pointing back at that origin.
|
|
157
|
+
#
|
|
158
|
+
# @!attribute [r] grid
|
|
159
|
+
# @return [Array<Array<CellSlot>>] rows of slots. Rows may differ in
|
|
160
|
+
# length when the source is ragged.
|
|
161
|
+
# @!attribute [r] header_rows
|
|
162
|
+
# @return [Integer] number of leading rows that are header rows (0 = no
|
|
163
|
+
# header).
|
|
164
|
+
# @!attribute [r] kind
|
|
165
|
+
# @return [Symbol] +:data+ for a real data table, +:layout+ for layout
|
|
166
|
+
# scaffolding (text boxes, positioning tables).
|
|
167
|
+
class Table < Data.define(:grid, :header_rows, :kind)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# One position in a {Table#grid}: either a cell or the shadow of one.
|
|
171
|
+
#
|
|
172
|
+
# @!attribute [r] kind
|
|
173
|
+
# @return [Symbol] +:origin+ or +:covered+.
|
|
174
|
+
# @!attribute [r] cell
|
|
175
|
+
# @return [Cell, nil] origin.
|
|
176
|
+
# @!attribute [r] origin_row
|
|
177
|
+
# @return [Integer, nil] covered: row of the origin this position belongs
|
|
178
|
+
# to.
|
|
179
|
+
# @!attribute [r] origin_col
|
|
180
|
+
# @return [Integer, nil] covered: column of the origin this position
|
|
181
|
+
# belongs to.
|
|
182
|
+
class CellSlot < Data.define(:kind, :cell, :origin_row, :origin_col)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A table cell and the extent it spans.
|
|
186
|
+
#
|
|
187
|
+
# @!attribute [r] blocks
|
|
188
|
+
# @return [Array<Block>]
|
|
189
|
+
# @!attribute [r] col_span
|
|
190
|
+
# @return [Integer] columns covered, at least 1.
|
|
191
|
+
# @!attribute [r] row_span
|
|
192
|
+
# @return [Integer] rows covered, at least 1.
|
|
193
|
+
class Cell < Data.define(:blocks, :col_span, :row_span)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# A footnote or endnote body, referenced from text by a +:note_ref+ inline.
|
|
197
|
+
#
|
|
198
|
+
# @!attribute [r] id
|
|
199
|
+
# @return [String] the id the referencing inline carries.
|
|
200
|
+
# @!attribute [r] kind
|
|
201
|
+
# @return [Symbol] +:footnote+ or +:endnote+, by where the source places
|
|
202
|
+
# the note.
|
|
203
|
+
# @!attribute [r] blocks
|
|
204
|
+
# @return [Array<Block>] the note's own content.
|
|
205
|
+
class Note < Data.define(:id, :kind, :blocks)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# An embedded binary asset (image, object payload). Bytes are always
|
|
209
|
+
# retained, so a document stays self-contained.
|
|
210
|
+
#
|
|
211
|
+
# @!attribute [r] id
|
|
212
|
+
# @return [Integer] index into {Document#assets}, as referenced by an
|
|
213
|
+
# image source.
|
|
214
|
+
# @!attribute [r] media_type
|
|
215
|
+
# @return [String] MIME type, e.g. +image/png+.
|
|
216
|
+
# @!attribute [r] origin_part
|
|
217
|
+
# @return [String] package part or stream the asset came from, for
|
|
218
|
+
# provenance.
|
|
219
|
+
# @!attribute [r] data
|
|
220
|
+
# @return [String] the payload, exactly as stored in the source, as a
|
|
221
|
+
# binary (ASCII-8BIT) string.
|
|
222
|
+
class Asset < Data.define(:id, :media_type, :origin_part, :data)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anydoc
|
|
4
|
+
# Base class for everything this gem raises. Unreadable files raise the
|
|
5
|
+
# +Errno+ exception any other read of them would, and a format argument
|
|
6
|
+
# naming no supported format raises +ArgumentError+.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Meaningful conversion was impossible. Rescue this to handle every kind of
|
|
10
|
+
# failure at once, or one of its subclasses to single one out.
|
|
11
|
+
#
|
|
12
|
+
# Recoverable producer quirks never reach here: they are recovered or
|
|
13
|
+
# skipped while conversion continues.
|
|
14
|
+
class ConvertError < Error; end
|
|
15
|
+
|
|
16
|
+
# The format is unknown, or cannot be converted at all: a scanned or
|
|
17
|
+
# image-only PDF needs OCR, which anydoc does not do.
|
|
18
|
+
class UnsupportedError < ConvertError; end
|
|
19
|
+
|
|
20
|
+
# The document is structurally unusable: no meaningful content could be
|
|
21
|
+
# extracted.
|
|
22
|
+
class MalformedError < ConvertError
|
|
23
|
+
# @return [String, nil] the package part or stream at fault, or +nil+ when
|
|
24
|
+
# no single part is.
|
|
25
|
+
attr_reader :part
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The document is encrypted or password-protected.
|
|
29
|
+
class EncryptedError < ConvertError; end
|
|
30
|
+
|
|
31
|
+
# A fixed safety limit was crossed: decompression, nesting depth, node
|
|
32
|
+
# count, repeat expansion, or retained asset bytes.
|
|
33
|
+
class ResourceLimitError < ConvertError
|
|
34
|
+
# @return [Symbol] the limit that was crossed, e.g. +:max_entry_bytes+.
|
|
35
|
+
attr_reader :limit
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A part required for any meaningful output is absent.
|
|
39
|
+
class MissingPartError < ConvertError
|
|
40
|
+
# @return [String] the part or stream that is missing.
|
|
41
|
+
attr_reader :part
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/anydoc.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "anydoc/version"
|
|
4
|
+
require_relative "anydoc/errors"
|
|
5
|
+
require_relative "anydoc/document"
|
|
6
|
+
|
|
7
|
+
# Precompiled gems carry one extension per Ruby ABI; a gem built from source
|
|
8
|
+
# puts its single extension straight into lib/anydoc.
|
|
9
|
+
begin
|
|
10
|
+
RUBY_VERSION =~ /(\d+\.\d+)/
|
|
11
|
+
require_relative "anydoc/#{Regexp.last_match(1)}/anydoc"
|
|
12
|
+
rescue LoadError
|
|
13
|
+
require_relative "anydoc/anydoc"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Convert documents (Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV,
|
|
17
|
+
# and PDF) to GitHub-Flavored Markdown.
|
|
18
|
+
#
|
|
19
|
+
# Anydoc.to_markdown("report.docx")
|
|
20
|
+
#
|
|
21
|
+
# Every format parses into one shared document model and renders through a
|
|
22
|
+
# single Markdown serializer, so headings, tables, lists, and footnotes come
|
|
23
|
+
# out the same no matter which format goes in.
|
|
24
|
+
#
|
|
25
|
+
# @see FORMATS the formats that can be named explicitly.
|
|
26
|
+
module Anydoc
|
|
27
|
+
# @!parse
|
|
28
|
+
# # Every format anydoc reads, named after the extension that identifies
|
|
29
|
+
# # it. Container variants that share a parser (+.docm+, +.xlsm+,
|
|
30
|
+
# # +.ppsx+, ...) map onto these through {format_from_bytes} or
|
|
31
|
+
# # {format_from_extension}.
|
|
32
|
+
# #
|
|
33
|
+
# # @return [Array<Symbol>]
|
|
34
|
+
# FORMATS = %i[doc docx odt pdf ppt pptx rtf epub xlsx ods odp csv].freeze
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Convert a document file to Markdown. The format is detected from the
|
|
38
|
+
# file content; the extension is the fallback for signature-less formats
|
|
39
|
+
# (CSV) and unrecognizable containers.
|
|
40
|
+
#
|
|
41
|
+
# @param path [String, Pathname] the file to convert.
|
|
42
|
+
# @return [String] GitHub-Flavored Markdown.
|
|
43
|
+
# @raise [ConvertError] if no meaningful Markdown could come out of it.
|
|
44
|
+
# @raise [SystemCallError] if the file could not be read.
|
|
45
|
+
def to_markdown(path)
|
|
46
|
+
path = File.path(path)
|
|
47
|
+
data = File.binread(path)
|
|
48
|
+
format = format_from_bytes(data) || format_from_path(path)
|
|
49
|
+
unless format
|
|
50
|
+
raise UnsupportedError,
|
|
51
|
+
"unsupported input: unrecognized file content and extension: #{path}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
to_markdown_bytes(data, format: format)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Convert an in-memory document to Markdown.
|
|
58
|
+
#
|
|
59
|
+
# @param data [String] the document's bytes.
|
|
60
|
+
# @param format [Symbol, String, nil] which format to parse it as. Left
|
|
61
|
+
# out, the format is detected from the content, which signature-less
|
|
62
|
+
# formats (CSV) have to name explicitly.
|
|
63
|
+
# @return [String] GitHub-Flavored Markdown.
|
|
64
|
+
# @raise [ConvertError] if no meaningful Markdown could come out of it.
|
|
65
|
+
# @raise [ArgumentError] if +format+ names no supported format.
|
|
66
|
+
def to_markdown_bytes(data, format: nil)
|
|
67
|
+
Native.to_markdown_bytes(data, format_symbol(format))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Parse an in-memory document into the document model, which also carries
|
|
71
|
+
# the embedded assets.
|
|
72
|
+
#
|
|
73
|
+
# Unsupported for +:pdf+: PDF conversion produces Markdown directly and
|
|
74
|
+
# has no document-model form; use {to_markdown_bytes}.
|
|
75
|
+
#
|
|
76
|
+
# @param data [String] the document's bytes.
|
|
77
|
+
# @param format [Symbol, String, nil] which format to parse it as. Left
|
|
78
|
+
# out, the format is detected from the content.
|
|
79
|
+
# @return [Document]
|
|
80
|
+
# @raise [ConvertError] if the document could not be parsed.
|
|
81
|
+
# @raise [ArgumentError] if +format+ names no supported format.
|
|
82
|
+
def to_document(data, format: nil)
|
|
83
|
+
Native.to_document(data, format_symbol(format))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Detect the format from the content itself: the signature and identity
|
|
87
|
+
# each container specification designates (PDF header, RTF open group, OLE
|
|
88
|
+
# stream names, ZIP package mimetype/content types).
|
|
89
|
+
#
|
|
90
|
+
# @param data [String] the document's bytes.
|
|
91
|
+
# @return [Symbol, nil] +nil+ for plain-text formats, which carry no
|
|
92
|
+
# signature, and for anything unrecognized.
|
|
93
|
+
def format_from_bytes(data)
|
|
94
|
+
Native.format_from_bytes(data)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The format an extension names, with or without a leading dot, matched
|
|
98
|
+
# case-insensitively.
|
|
99
|
+
#
|
|
100
|
+
# @param extension [String, Symbol] e.g. +".pptm"+.
|
|
101
|
+
# @return [Symbol, nil] +nil+ for anything unrecognized.
|
|
102
|
+
def format_from_extension(extension)
|
|
103
|
+
Native.format_from_extension(extension.to_s.delete_prefix("."))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The format a path's extension names.
|
|
107
|
+
#
|
|
108
|
+
# @param path [String, Pathname] the path to read the extension off.
|
|
109
|
+
# @return [Symbol, nil] +nil+ when the path has no extension or names
|
|
110
|
+
# nothing recognized.
|
|
111
|
+
def format_from_path(path)
|
|
112
|
+
extension = File.extname(File.path(path))
|
|
113
|
+
extension.empty? ? nil : format_from_extension(extension)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Format arguments are symbols; strings are accepted for convenience.
|
|
119
|
+
def format_symbol(format)
|
|
120
|
+
case format
|
|
121
|
+
when nil, Symbol then format
|
|
122
|
+
when String then format.to_sym
|
|
123
|
+
else
|
|
124
|
+
raise ArgumentError, "format must be a Symbol, a String, or nil, got #{format.class}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/sig/anydoc.rbs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Type signatures for anydoc-ruby.
|
|
2
|
+
|
|
3
|
+
module Anydoc
|
|
4
|
+
VERSION: String
|
|
5
|
+
|
|
6
|
+
# Every format anydoc reads, named after the extension that identifies it.
|
|
7
|
+
FORMATS: Array[Symbol]
|
|
8
|
+
|
|
9
|
+
type format = Symbol | String
|
|
10
|
+
type path = String | ::Pathname | _ToPath
|
|
11
|
+
|
|
12
|
+
def self.to_markdown: (path path) -> String
|
|
13
|
+
def self.to_markdown_bytes: (String data, ?format: format?) -> String
|
|
14
|
+
def self.to_document: (String data, ?format: format?) -> Document
|
|
15
|
+
def self.format_from_bytes: (String data) -> Symbol?
|
|
16
|
+
def self.format_from_extension: (String | Symbol extension) -> Symbol?
|
|
17
|
+
def self.format_from_path: (path path) -> Symbol?
|
|
18
|
+
|
|
19
|
+
# Argument coercion and file reading happen in Ruby; these are the raw
|
|
20
|
+
# entry points the extension defines.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
module Native
|
|
24
|
+
def self.to_markdown_bytes: (String data, Symbol? format) -> String
|
|
25
|
+
def self.to_document: (String data, Symbol? format) -> Document
|
|
26
|
+
def self.format_from_bytes: (String data) -> Symbol?
|
|
27
|
+
def self.format_from_extension: (String extension) -> Symbol?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class Error < StandardError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class ConvertError < Error
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class UnsupportedError < ConvertError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class MalformedError < ConvertError
|
|
40
|
+
attr_reader part: String?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class EncryptedError < ConvertError
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class ResourceLimitError < ConvertError
|
|
47
|
+
attr_reader limit: Symbol
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class MissingPartError < ConvertError
|
|
51
|
+
attr_reader part: String
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Document < ::Data
|
|
55
|
+
attr_reader blocks: Array[Block]
|
|
56
|
+
attr_reader notes: Array[Note]
|
|
57
|
+
attr_reader assets: Array[Asset]
|
|
58
|
+
|
|
59
|
+
def self.new: (Array[Block] blocks, Array[Note] notes, Array[Asset] assets) -> Document
|
|
60
|
+
| (blocks: Array[Block], notes: Array[Note], assets: Array[Asset]) -> Document
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class Block < ::Data
|
|
64
|
+
# :heading | :paragraph | :list | :table | :block_quote | :code_block | :rule
|
|
65
|
+
attr_reader kind: Symbol
|
|
66
|
+
attr_reader level: Integer?
|
|
67
|
+
attr_reader anchor: String?
|
|
68
|
+
attr_reader content: Array[Inline]?
|
|
69
|
+
attr_reader list: List?
|
|
70
|
+
attr_reader table: Table?
|
|
71
|
+
attr_reader blocks: Array[Block]?
|
|
72
|
+
attr_reader lang: String?
|
|
73
|
+
attr_reader text: String?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Inline < ::Data
|
|
77
|
+
# :text | :link | :image | :anchor | :note_ref | :line_break
|
|
78
|
+
attr_reader kind: Symbol
|
|
79
|
+
attr_reader text: String?
|
|
80
|
+
attr_reader style: Style?
|
|
81
|
+
attr_reader content: Array[Inline]?
|
|
82
|
+
attr_reader target: LinkTarget?
|
|
83
|
+
attr_reader alt: String?
|
|
84
|
+
attr_reader source: ImageSource?
|
|
85
|
+
attr_reader anchor: String?
|
|
86
|
+
attr_reader note_id: String?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class Style < ::Data
|
|
90
|
+
attr_reader bold: bool
|
|
91
|
+
attr_reader italic: bool
|
|
92
|
+
attr_reader strike: bool
|
|
93
|
+
attr_reader code: bool
|
|
94
|
+
|
|
95
|
+
def self.new: (bool bold, bool italic, bool strike, bool code) -> Style
|
|
96
|
+
| (bold: bool, italic: bool, strike: bool, code: bool) -> Style
|
|
97
|
+
|
|
98
|
+
def plain?: () -> bool
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class LinkTarget < ::Data
|
|
102
|
+
# :external | :relative | :anchor
|
|
103
|
+
attr_reader kind: Symbol
|
|
104
|
+
attr_reader value: String
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class ImageSource < ::Data
|
|
108
|
+
# :external | :asset | :unavailable
|
|
109
|
+
attr_reader kind: Symbol
|
|
110
|
+
attr_reader url: String?
|
|
111
|
+
attr_reader asset_id: Integer?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class List < ::Data
|
|
115
|
+
# :bullet | :decimal | :lower_alpha | :upper_alpha | :lower_roman | :upper_roman
|
|
116
|
+
attr_reader marker: Symbol
|
|
117
|
+
attr_reader start: Integer
|
|
118
|
+
attr_reader items: Array[ListItem]
|
|
119
|
+
|
|
120
|
+
def ordered?: () -> bool
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class ListItem < ::Data
|
|
124
|
+
attr_reader blocks: Array[Block]
|
|
125
|
+
attr_reader checked: bool?
|
|
126
|
+
attr_reader marker_label: String?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class Table < ::Data
|
|
130
|
+
attr_reader grid: Array[Array[CellSlot]]
|
|
131
|
+
attr_reader header_rows: Integer
|
|
132
|
+
# :data | :layout
|
|
133
|
+
attr_reader kind: Symbol
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
class CellSlot < ::Data
|
|
137
|
+
# :origin | :covered
|
|
138
|
+
attr_reader kind: Symbol
|
|
139
|
+
attr_reader cell: Cell?
|
|
140
|
+
attr_reader origin_row: Integer?
|
|
141
|
+
attr_reader origin_col: Integer?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class Cell < ::Data
|
|
145
|
+
attr_reader blocks: Array[Block]
|
|
146
|
+
attr_reader col_span: Integer
|
|
147
|
+
attr_reader row_span: Integer
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class Note < ::Data
|
|
151
|
+
attr_reader id: String
|
|
152
|
+
# :footnote | :endnote
|
|
153
|
+
attr_reader kind: Symbol
|
|
154
|
+
attr_reader blocks: Array[Block]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class Asset < ::Data
|
|
158
|
+
attr_reader id: Integer
|
|
159
|
+
attr_reader media_type: String
|
|
160
|
+
attr_reader origin_part: String
|
|
161
|
+
attr_reader data: String
|
|
162
|
+
end
|
|
163
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: anydoc-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.7
|
|
5
|
+
platform: aarch64-linux
|
|
6
|
+
authors:
|
|
7
|
+
- Jan Sterba
|
|
8
|
+
- Firecrawl
|
|
9
|
+
autorequire:
|
|
10
|
+
bindir: bin
|
|
11
|
+
cert_chain: []
|
|
12
|
+
date: 2026-08-06 00:00:00.000000000 Z
|
|
13
|
+
dependencies: []
|
|
14
|
+
description: Ruby bindings for the anydoc Rust crate. Converts Word, PowerPoint, Excel,
|
|
15
|
+
OpenDocument, RTF, EPUB, CSV, and PDF documents into clean GitHub-Flavored Markdown,
|
|
16
|
+
with one consistent output no matter which format goes in.
|
|
17
|
+
email:
|
|
18
|
+
- info@jansterba.com
|
|
19
|
+
executables: []
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/anydoc.rb
|
|
26
|
+
- lib/anydoc/3.2/anydoc.so
|
|
27
|
+
- lib/anydoc/3.3/anydoc.so
|
|
28
|
+
- lib/anydoc/3.4/anydoc.so
|
|
29
|
+
- lib/anydoc/4.0/anydoc.so
|
|
30
|
+
- lib/anydoc/document.rb
|
|
31
|
+
- lib/anydoc/errors.rb
|
|
32
|
+
- lib/anydoc/version.rb
|
|
33
|
+
- sig/anydoc.rbs
|
|
34
|
+
homepage: https://github.com/honzasterba/anydoc#readme
|
|
35
|
+
licenses:
|
|
36
|
+
- MIT
|
|
37
|
+
metadata:
|
|
38
|
+
homepage_uri: https://github.com/honzasterba/anydoc#readme
|
|
39
|
+
source_code_uri: https://github.com/honzasterba/anydoc/tree/main/ruby
|
|
40
|
+
bug_tracker_uri: https://github.com/honzasterba/anydoc/issues
|
|
41
|
+
changelog_uri: https://github.com/honzasterba/anydoc/releases
|
|
42
|
+
documentation_uri: https://github.com/honzasterba/anydoc/blob/main/ruby/README.md
|
|
43
|
+
rubygems_mfa_required: 'true'
|
|
44
|
+
post_install_message:
|
|
45
|
+
rdoc_options: []
|
|
46
|
+
require_paths:
|
|
47
|
+
- lib
|
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '3.2'
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: 4.1.dev
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.5.23
|
|
63
|
+
signing_key:
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets,
|
|
66
|
+
csv) to GitHub-Flavored Markdown
|
|
67
|
+
test_files: []
|