anydoc 0.1.3
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/Cargo.toml +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +79 -0
- data/Rakefile +25 -0
- data/ext/anydoc/Cargo.toml +21 -0
- data/ext/anydoc/build.rs +5 -0
- data/ext/anydoc/extconf.rb +6 -0
- data/ext/anydoc/src/document.rs +187 -0
- data/ext/anydoc/src/lib.rs +147 -0
- data/lib/anydoc/version.rb +5 -0
- data/lib/anydoc.rb +56 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ad2ef90ad06d780f64b4d8ffc6e7881654ede6984587679b90b746450ec6c509
|
|
4
|
+
data.tar.gz: 9df934da5ab5c6110ced6e23eb63b6f8d2972fe685460b39d83382abc20deb68
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 22e930d99a40fb5117befacf5e9e4af541c1696eb8eb925a6d92e02a13e46324253e4f65edfd4473f3df7f0fc5e3e1c7452646b80791d46fc85b688a407de8c4
|
|
7
|
+
data.tar.gz: 78f1a749bbab09c9c68f9d1e08f6d8aac41c3ca4d2b5ec6e9ffb72359a5e5bde6dece98b2be284f0fbfa2269c218e76b018b1f51f6a345fd879f9bfdde00b328
|
data/Cargo.toml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# This Cargo.toml is here to let externals tools (IDEs, etc.) know that this is
|
|
2
|
+
# a Rust project. Your extensions dependencies should be added to the Cargo.toml
|
|
3
|
+
# in the ext/ directory.
|
|
4
|
+
|
|
5
|
+
[workspace]
|
|
6
|
+
members = ["./ext/anydoc"]
|
|
7
|
+
resolver = "2"
|
|
8
|
+
|
|
9
|
+
[profile.release]
|
|
10
|
+
lto = "thin"
|
|
11
|
+
strip = "symbols"
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick Pezza
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# anydoc
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/anydoc)
|
|
4
|
+
[](https://github.com/firecrawl/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://github.com/firecrawl/anydoc) Rust crate, built by [Firecrawl](https://firecrawl.dev). Also available as a hosted API through [Firecrawl Parse](https://firecrawl.dev/parse), which adds OCR for scanned pages anydoc cannot read on its own.
|
|
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 consistently. Conversion runs without holding Ruby's global VM lock, and RBS signatures ship with the gem.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
gem install anydoc
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or add it with Bundler:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle add anydoc
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Supported formats
|
|
21
|
+
|
|
22
|
+
| Format | Extensions |
|
|
23
|
+
| ---------------- | ---------------------------------------------------------- |
|
|
24
|
+
| Word | `.doc`, `.docx`, `.docm` |
|
|
25
|
+
| PowerPoint | `.ppt`, `.pps`, `.pot`, `.pptx`, `.pptm`, `.ppsx`, `.ppsm` |
|
|
26
|
+
| Excel | `.xls`, `.xlsx`, `.xlsm`, `.xlsb` |
|
|
27
|
+
| OpenDocument | `.odt`, `.ods`, `.odp` |
|
|
28
|
+
| Rich Text Format | `.rtf` |
|
|
29
|
+
| EPUB | `.epub` |
|
|
30
|
+
| CSV | `.csv` |
|
|
31
|
+
| PDF | `.pdf` |
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require "anydoc"
|
|
37
|
+
|
|
38
|
+
# From a file path. Pathname and other #to_path objects are accepted.
|
|
39
|
+
markdown = Anydoc.to_markdown("report.docx")
|
|
40
|
+
|
|
41
|
+
# From bytes, with the format detected from the content.
|
|
42
|
+
markdown = Anydoc.to_markdown_bytes(data)
|
|
43
|
+
|
|
44
|
+
# Signature-less formats such as CSV need an explicit format.
|
|
45
|
+
markdown = Anydoc.to_markdown_bytes(data, :csv)
|
|
46
|
+
|
|
47
|
+
# Or stop at the immutable document model, which carries embedded assets.
|
|
48
|
+
document = Anydoc.to_document(data)
|
|
49
|
+
document.blocks
|
|
50
|
+
document.notes
|
|
51
|
+
document.assets
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Format detection
|
|
55
|
+
|
|
56
|
+
The format is read from the file content using the marker designated by its specification: the PDF header, RTF open group, OLE stream names, or ZIP package metadata. CSV has no signature, so detection returns `nil`; its extension or an explicit format names it instead.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Anydoc.format_from_bytes(data) # => :docx, or nil
|
|
60
|
+
Anydoc.format_from_extension(".pptm") # => :pptx
|
|
61
|
+
Anydoc.format_from_path("report.odt") # => :odt
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Images and embedded objects
|
|
65
|
+
|
|
66
|
+
Markdown cannot embed bytes. Embedded images render as alt text while their binary strings remain in `document.assets`, tagged with a media type and the package part they came from. Images with external URLs render as ordinary Markdown images.
|
|
67
|
+
|
|
68
|
+
PDF conversion emits Markdown directly and does not have a document-model form; use `Anydoc.to_markdown` or `Anydoc.to_markdown_bytes` for PDFs.
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
bin/setup
|
|
74
|
+
bundle exec rake compile test
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
[MIT](https://github.com/firecrawl/anydoc/blob/main/LICENSE)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
Minitest::TestTask.create
|
|
7
|
+
|
|
8
|
+
require "rubocop/rake_task"
|
|
9
|
+
require "tmpdir"
|
|
10
|
+
|
|
11
|
+
RuboCop::RakeTask.new do |task|
|
|
12
|
+
task.options = ["--cache-root", File.join(File.realpath(Dir.tmpdir), "anydoc-rubocop-cache")]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require "rb_sys/extensiontask"
|
|
16
|
+
|
|
17
|
+
task build: :compile
|
|
18
|
+
|
|
19
|
+
GEMSPEC = Gem::Specification.load("anydoc.gemspec")
|
|
20
|
+
|
|
21
|
+
RbSys::ExtensionTask.new("anydoc", GEMSPEC) do |ext|
|
|
22
|
+
ext.lib_dir = "lib/anydoc"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
task default: %i[compile test rubocop]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "anydoc"
|
|
3
|
+
version = "0.1.3-ruby.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
authors = ["Nick Pezza <pezza@hey.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
publish = false
|
|
8
|
+
|
|
9
|
+
[lib]
|
|
10
|
+
crate-type = ["cdylib"]
|
|
11
|
+
|
|
12
|
+
[dependencies]
|
|
13
|
+
anydoc-core = { package = "anydoc", version = "=0.1.3" }
|
|
14
|
+
magnus = "0.8.2"
|
|
15
|
+
rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback"] }
|
|
16
|
+
|
|
17
|
+
[build-dependencies]
|
|
18
|
+
rb-sys-env = "0.2.2"
|
|
19
|
+
|
|
20
|
+
[dev-dependencies]
|
|
21
|
+
rb-sys-test-helpers = { version = "0.2.2" }
|
data/ext/anydoc/build.rs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
//! The document model, converted eagerly into immutable Ruby `Data` objects.
|
|
2
|
+
|
|
3
|
+
use anydoc_core::model;
|
|
4
|
+
use magnus::{Error, RArray, RClass, RModule, RString, Ruby, Value, prelude::*};
|
|
5
|
+
|
|
6
|
+
fn class(ruby: &Ruby, name: &str) -> Result<RClass, Error> {
|
|
7
|
+
let module: RModule = ruby.class_object().const_get("Anydoc")?;
|
|
8
|
+
module.const_get(name)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
fn array(ruby: &Ruby, values: impl Iterator<Item = Result<Value, Error>>) -> Result<RArray, Error> {
|
|
12
|
+
let values = values.collect::<Result<Vec<_>, _>>()?;
|
|
13
|
+
Ok(ruby.ary_new_from_values(&values))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
fn blocks(ruby: &Ruby, items: Vec<model::Block>) -> Result<RArray, Error> {
|
|
17
|
+
array(ruby, items.into_iter().map(|item| block(ruby, item)))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn inlines(ruby: &Ruby, items: Vec<model::Inline>) -> Result<RArray, Error> {
|
|
21
|
+
array(ruby, items.into_iter().map(|item| inline(ruby, item)))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fn block(ruby: &Ruby, block: model::Block) -> Result<Value, Error> {
|
|
25
|
+
let (kind, level, anchor, content, list, table, inner, lang, text) = match block {
|
|
26
|
+
model::Block::Heading { level, anchor, content } => (
|
|
27
|
+
"heading",
|
|
28
|
+
Some(level),
|
|
29
|
+
anchor,
|
|
30
|
+
Some(inlines(ruby, content)?),
|
|
31
|
+
None,
|
|
32
|
+
None,
|
|
33
|
+
None,
|
|
34
|
+
None,
|
|
35
|
+
None,
|
|
36
|
+
),
|
|
37
|
+
model::Block::Paragraph(content) => {
|
|
38
|
+
("paragraph", None, None, Some(inlines(ruby, content)?), None, None, None, None, None)
|
|
39
|
+
}
|
|
40
|
+
model::Block::List(value) => {
|
|
41
|
+
("list", None, None, None, Some(list_value(ruby, value)?), None, None, None, None)
|
|
42
|
+
}
|
|
43
|
+
model::Block::Table(value) => {
|
|
44
|
+
("table", None, None, None, None, Some(table_value(ruby, value)?), None, None, None)
|
|
45
|
+
}
|
|
46
|
+
model::Block::BlockQuote(value) => {
|
|
47
|
+
("block_quote", None, None, None, None, None, Some(blocks(ruby, value)?), None, None)
|
|
48
|
+
}
|
|
49
|
+
model::Block::CodeBlock { lang, text } => {
|
|
50
|
+
("code_block", None, None, None, None, None, None, lang, Some(text))
|
|
51
|
+
}
|
|
52
|
+
model::Block::Rule => ("rule", None, None, None, None, None, None, None, None),
|
|
53
|
+
};
|
|
54
|
+
class(ruby, "Block")?
|
|
55
|
+
.funcall("new", (kind, level, anchor, content, list, table, inner, lang, text))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn inline(ruby: &Ruby, inline: model::Inline) -> Result<Value, Error> {
|
|
59
|
+
let (kind, text, style, content, target, alt, source, anchor, note_id) = match inline {
|
|
60
|
+
model::Inline::Text { text, style } => (
|
|
61
|
+
"text",
|
|
62
|
+
Some(text),
|
|
63
|
+
Some(style_value(ruby, style)?),
|
|
64
|
+
None,
|
|
65
|
+
None,
|
|
66
|
+
None,
|
|
67
|
+
None,
|
|
68
|
+
None,
|
|
69
|
+
None,
|
|
70
|
+
),
|
|
71
|
+
model::Inline::Link { content, target } => (
|
|
72
|
+
"link",
|
|
73
|
+
None,
|
|
74
|
+
None,
|
|
75
|
+
Some(inlines(ruby, content)?),
|
|
76
|
+
Some(link_target(ruby, target)?),
|
|
77
|
+
None,
|
|
78
|
+
None,
|
|
79
|
+
None,
|
|
80
|
+
None,
|
|
81
|
+
),
|
|
82
|
+
model::Inline::Image { alt, source } => (
|
|
83
|
+
"image",
|
|
84
|
+
None,
|
|
85
|
+
None,
|
|
86
|
+
None,
|
|
87
|
+
None,
|
|
88
|
+
Some(alt),
|
|
89
|
+
Some(image_source(ruby, source)?),
|
|
90
|
+
None,
|
|
91
|
+
None,
|
|
92
|
+
),
|
|
93
|
+
model::Inline::Anchor(id) => ("anchor", None, None, None, None, None, None, Some(id), None),
|
|
94
|
+
model::Inline::NoteRef(id) => {
|
|
95
|
+
("note_ref", None, None, None, None, None, None, None, Some(id))
|
|
96
|
+
}
|
|
97
|
+
model::Inline::LineBreak => ("line_break", None, None, None, None, None, None, None, None),
|
|
98
|
+
};
|
|
99
|
+
class(ruby, "Inline")?
|
|
100
|
+
.funcall("new", (kind, text, style, content, target, alt, source, anchor, note_id))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn style_value(ruby: &Ruby, style: model::Style) -> Result<Value, Error> {
|
|
104
|
+
class(ruby, "Style")?.funcall("new", (style.bold, style.italic, style.strike, style.code))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn link_target(ruby: &Ruby, target: model::LinkTarget) -> Result<Value, Error> {
|
|
108
|
+
let (kind, value) = match target {
|
|
109
|
+
model::LinkTarget::External(value) => ("external", value),
|
|
110
|
+
model::LinkTarget::Relative(value) => ("relative", value),
|
|
111
|
+
model::LinkTarget::Anchor(value) => ("anchor", value),
|
|
112
|
+
};
|
|
113
|
+
class(ruby, "LinkTarget")?.funcall("new", (kind, value))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn image_source(ruby: &Ruby, source: model::ImageSource) -> Result<Value, Error> {
|
|
117
|
+
let (kind, url, asset_id) = match source {
|
|
118
|
+
model::ImageSource::External(url) => ("external", Some(url), None),
|
|
119
|
+
model::ImageSource::Asset(id) => ("asset", None, Some(id.0)),
|
|
120
|
+
model::ImageSource::Unavailable => ("unavailable", None, None),
|
|
121
|
+
};
|
|
122
|
+
class(ruby, "ImageSource")?.funcall("new", (kind, url, asset_id))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn list_value(ruby: &Ruby, list: model::List) -> Result<Value, Error> {
|
|
126
|
+
let marker = match list.marker {
|
|
127
|
+
model::MarkerKind::Bullet => "bullet",
|
|
128
|
+
model::MarkerKind::Decimal => "decimal",
|
|
129
|
+
model::MarkerKind::LowerAlpha => "lower_alpha",
|
|
130
|
+
model::MarkerKind::UpperAlpha => "upper_alpha",
|
|
131
|
+
model::MarkerKind::LowerRoman => "lower_roman",
|
|
132
|
+
model::MarkerKind::UpperRoman => "upper_roman",
|
|
133
|
+
};
|
|
134
|
+
let items = array(ruby, list.items.into_iter().map(|item| list_item(ruby, item)))?;
|
|
135
|
+
class(ruby, "List")?.funcall("new", (marker, list.start, items))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fn list_item(ruby: &Ruby, item: model::ListItem) -> Result<Value, Error> {
|
|
139
|
+
class(ruby, "ListItem")?
|
|
140
|
+
.funcall("new", (blocks(ruby, item.blocks)?, item.checked, item.marker_label))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fn table_value(ruby: &Ruby, table: model::Table) -> Result<Value, Error> {
|
|
144
|
+
let rows = table.grid.into_iter().map(|row| {
|
|
145
|
+
let row = array(ruby, row.into_iter().map(|slot| cell_slot(ruby, slot)))?;
|
|
146
|
+
Ok(row.as_value())
|
|
147
|
+
});
|
|
148
|
+
let grid = array(ruby, rows)?;
|
|
149
|
+
let kind = match table.kind {
|
|
150
|
+
model::TableKind::Data => "data",
|
|
151
|
+
model::TableKind::Layout => "layout",
|
|
152
|
+
};
|
|
153
|
+
class(ruby, "Table")?.funcall("new", (grid, table.header_rows, kind))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn cell_slot(ruby: &Ruby, slot: model::CellSlot) -> Result<Value, Error> {
|
|
157
|
+
let (kind, cell, origin_row, origin_col) = match slot {
|
|
158
|
+
model::CellSlot::Origin(value) => ("origin", Some(cell(ruby, value)?), None, None),
|
|
159
|
+
model::CellSlot::Covered { origin_row, origin_col } => {
|
|
160
|
+
("covered", None, Some(origin_row), Some(origin_col))
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
class(ruby, "CellSlot")?.funcall("new", (kind, cell, origin_row, origin_col))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fn cell(ruby: &Ruby, cell: model::Cell) -> Result<Value, Error> {
|
|
167
|
+
class(ruby, "Cell")?.funcall("new", (blocks(ruby, cell.blocks)?, cell.col_span, cell.row_span))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fn note(ruby: &Ruby, note: model::Note) -> Result<Value, Error> {
|
|
171
|
+
let kind = match note.kind {
|
|
172
|
+
model::NoteKind::Footnote => "footnote",
|
|
173
|
+
model::NoteKind::Endnote => "endnote",
|
|
174
|
+
};
|
|
175
|
+
class(ruby, "Note")?.funcall("new", (note.id, kind, blocks(ruby, note.blocks)?))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn asset(ruby: &Ruby, asset: model::Asset) -> Result<Value, Error> {
|
|
179
|
+
let data: RString = ruby.str_from_slice(&asset.bytes);
|
|
180
|
+
class(ruby, "Asset")?.funcall("new", (asset.id.0, asset.media_type, asset.origin_part, data))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
pub fn document(ruby: &Ruby, document: model::Document) -> Result<Value, Error> {
|
|
184
|
+
let notes = array(ruby, document.notes.into_iter().map(|value| note(ruby, value)))?;
|
|
185
|
+
let assets = array(ruby, document.assets.into_iter().map(|value| asset(ruby, value)))?;
|
|
186
|
+
class(ruby, "Document")?.funcall("new", (blocks(ruby, document.blocks)?, notes, assets))
|
|
187
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//! Ruby bindings for anydoc.
|
|
2
|
+
|
|
3
|
+
use magnus::{
|
|
4
|
+
Error, Exception, RString, Ruby, Value, exception::ExceptionClass, function, prelude::*,
|
|
5
|
+
};
|
|
6
|
+
use std::{ffi::c_void, panic::AssertUnwindSafe, ptr};
|
|
7
|
+
|
|
8
|
+
mod document;
|
|
9
|
+
|
|
10
|
+
const FORMATS: [(&str, anydoc_core::Format); 12] = [
|
|
11
|
+
("doc", anydoc_core::Format::Doc),
|
|
12
|
+
("docx", anydoc_core::Format::Docx),
|
|
13
|
+
("odt", anydoc_core::Format::Odt),
|
|
14
|
+
("pdf", anydoc_core::Format::Pdf),
|
|
15
|
+
("ppt", anydoc_core::Format::Ppt),
|
|
16
|
+
("pptx", anydoc_core::Format::Pptx),
|
|
17
|
+
("rtf", anydoc_core::Format::Rtf),
|
|
18
|
+
("epub", anydoc_core::Format::Epub),
|
|
19
|
+
("xlsx", anydoc_core::Format::Excel),
|
|
20
|
+
("ods", anydoc_core::Format::Ods),
|
|
21
|
+
("odp", anydoc_core::Format::Odp),
|
|
22
|
+
("csv", anydoc_core::Format::Csv),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
fn parse_format(ruby: &Ruby, name: &str) -> Result<anydoc_core::Format, Error> {
|
|
26
|
+
FORMATS.iter().find(|(candidate, _)| *candidate == name).map(|(_, format)| *format).ok_or_else(
|
|
27
|
+
|| {
|
|
28
|
+
let names = FORMATS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ");
|
|
29
|
+
Error::new(
|
|
30
|
+
ruby.exception_arg_error(),
|
|
31
|
+
format!("unknown format {name:?}; expected one of {names}"),
|
|
32
|
+
)
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn format_name(format: anydoc_core::Format) -> &'static str {
|
|
38
|
+
FORMATS
|
|
39
|
+
.iter()
|
|
40
|
+
.find(|(_, candidate)| *candidate == format)
|
|
41
|
+
.map(|(name, _)| *name)
|
|
42
|
+
.expect("every format is named")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn convert_error(ruby: &Ruby, error: anydoc_core::ConvertError) -> Error {
|
|
46
|
+
match error {
|
|
47
|
+
anydoc_core::ConvertError::Io(error) => match error.raw_os_error() {
|
|
48
|
+
Some(errno) => ruby
|
|
49
|
+
.exception_system_call_error()
|
|
50
|
+
.funcall::<_, _, Exception>("new", (error.to_string(), errno))
|
|
51
|
+
.map(Error::from)
|
|
52
|
+
.unwrap_or_else(|error| error),
|
|
53
|
+
None => Error::new(ruby.exception_io_error(), error.to_string()),
|
|
54
|
+
},
|
|
55
|
+
other => {
|
|
56
|
+
let module = ruby.define_module("Anydoc").expect("Anydoc module is defined");
|
|
57
|
+
let class: ExceptionClass =
|
|
58
|
+
module.const_get("ConvertError").expect("Anydoc::ConvertError is defined");
|
|
59
|
+
Error::new(class, other.to_string())
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn bytes(data: RString) -> Vec<u8> {
|
|
65
|
+
// Copy before conversion so Rust never retains a pointer into Ruby's heap.
|
|
66
|
+
unsafe { data.as_slice() }.to_vec()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Run CPU- and I/O-heavy parsing without holding Ruby's global VM lock.
|
|
70
|
+
fn without_gvl<F, T>(function: F) -> T
|
|
71
|
+
where
|
|
72
|
+
F: FnOnce() -> T + Send,
|
|
73
|
+
T: Send,
|
|
74
|
+
{
|
|
75
|
+
struct Call<F, T> {
|
|
76
|
+
function: Option<F>,
|
|
77
|
+
result: Option<std::thread::Result<T>>,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
unsafe extern "C" fn call<F, T>(data: *mut c_void) -> *mut c_void
|
|
81
|
+
where
|
|
82
|
+
F: FnOnce() -> T,
|
|
83
|
+
{
|
|
84
|
+
let call = unsafe { &mut *data.cast::<Call<F, T>>() };
|
|
85
|
+
let function = call.function.take().expect("without_gvl callback runs once");
|
|
86
|
+
call.result = Some(std::panic::catch_unwind(AssertUnwindSafe(function)));
|
|
87
|
+
ptr::null_mut()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let mut state = Call { function: Some(function), result: None };
|
|
91
|
+
unsafe {
|
|
92
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
93
|
+
Some(call::<F, T>),
|
|
94
|
+
ptr::from_mut(&mut state).cast(),
|
|
95
|
+
None,
|
|
96
|
+
ptr::null_mut(),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
match state.result.expect("without_gvl callback completed") {
|
|
100
|
+
Ok(value) => value,
|
|
101
|
+
Err(panic) => std::panic::resume_unwind(panic),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn format_from_bytes(data: RString) -> Option<&'static str> {
|
|
106
|
+
anydoc_core::Format::from_bytes(&bytes(data)).map(format_name)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fn format_from_extension(extension: String) -> Option<&'static str> {
|
|
110
|
+
anydoc_core::Format::from_extension(extension.trim_start_matches('.')).map(format_name)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fn format_from_path(path: String) -> Option<&'static str> {
|
|
114
|
+
anydoc_core::Format::from_path(std::path::Path::new(&path)).map(format_name)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn to_markdown(ruby: &Ruby, path: String) -> Result<String, Error> {
|
|
118
|
+
without_gvl(move || anydoc_core::to_markdown(path)).map_err(|error| convert_error(ruby, error))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fn to_markdown_bytes(ruby: &Ruby, data: RString, format: Option<String>) -> Result<String, Error> {
|
|
122
|
+
let format = format.as_deref().map(|name| parse_format(ruby, name)).transpose()?;
|
|
123
|
+
let data = bytes(data);
|
|
124
|
+
without_gvl(move || anydoc_core::to_markdown_bytes(&data, format))
|
|
125
|
+
.map_err(|error| convert_error(ruby, error))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fn to_document(ruby: &Ruby, data: RString, format: Option<String>) -> Result<Value, Error> {
|
|
129
|
+
let format = format.as_deref().map(|name| parse_format(ruby, name)).transpose()?;
|
|
130
|
+
let data = bytes(data);
|
|
131
|
+
let parsed = without_gvl(move || anydoc_core::to_document(&data, format))
|
|
132
|
+
.map_err(|error| convert_error(ruby, error))?;
|
|
133
|
+
document::document(ruby, parsed)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[magnus::init]
|
|
137
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
138
|
+
let module = ruby.define_module("Anydoc")?;
|
|
139
|
+
module.define_singleton_method("_format_from_bytes", function!(format_from_bytes, 1))?;
|
|
140
|
+
module
|
|
141
|
+
.define_singleton_method("_format_from_extension", function!(format_from_extension, 1))?;
|
|
142
|
+
module.define_singleton_method("_format_from_path", function!(format_from_path, 1))?;
|
|
143
|
+
module.define_singleton_method("_to_markdown", function!(to_markdown, 1))?;
|
|
144
|
+
module.define_singleton_method("_to_markdown_bytes", function!(to_markdown_bytes, 2))?;
|
|
145
|
+
module.define_singleton_method("_to_document", function!(to_document, 2))?;
|
|
146
|
+
Ok(())
|
|
147
|
+
}
|
data/lib/anydoc.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "anydoc/version"
|
|
4
|
+
|
|
5
|
+
module Anydoc
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when an input can be read, but meaningful conversion is impossible.
|
|
9
|
+
class ConvertError < Error; end
|
|
10
|
+
|
|
11
|
+
Document = Data.define(:blocks, :notes, :assets)
|
|
12
|
+
Block = Data.define(:kind, :level, :anchor, :content, :list, :table, :blocks, :lang, :text)
|
|
13
|
+
Inline = Data.define(:kind, :text, :style, :content, :target, :alt, :source, :anchor, :note_id)
|
|
14
|
+
Style = Data.define(:bold, :italic, :strike, :code)
|
|
15
|
+
LinkTarget = Data.define(:kind, :value)
|
|
16
|
+
ImageSource = Data.define(:kind, :url, :asset_id)
|
|
17
|
+
List = Data.define(:marker, :start, :items)
|
|
18
|
+
ListItem = Data.define(:blocks, :checked, :marker_label)
|
|
19
|
+
Table = Data.define(:grid, :header_rows, :kind)
|
|
20
|
+
CellSlot = Data.define(:kind, :cell, :origin_row, :origin_col)
|
|
21
|
+
Cell = Data.define(:blocks, :col_span, :row_span)
|
|
22
|
+
Note = Data.define(:id, :kind, :blocks)
|
|
23
|
+
Asset = Data.define(:id, :media_type, :origin_part, :data)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require "anydoc/anydoc"
|
|
27
|
+
|
|
28
|
+
module Anydoc
|
|
29
|
+
FORMATS = %i[doc docx odt pdf ppt pptx rtf epub xlsx ods odp csv].freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def format_from_bytes(data)
|
|
33
|
+
_format_from_bytes(data)&.to_sym
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_from_extension(extension)
|
|
37
|
+
_format_from_extension(extension)&.to_sym
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def format_from_path(path)
|
|
41
|
+
_format_from_path(File.path(path))&.to_sym
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_markdown(path)
|
|
45
|
+
_to_markdown(File.path(path))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_markdown_bytes(data, format = nil)
|
|
49
|
+
_to_markdown_bytes(data, format&.to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_document(data, format = nil)
|
|
53
|
+
_to_document(data, format&.to_s)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: anydoc
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.3
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nick Pezza
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rb_sys
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.9.128
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.9.128
|
|
26
|
+
description: Ruby bindings for the anydoc Rust document converter.
|
|
27
|
+
email:
|
|
28
|
+
- pezza@hey.com
|
|
29
|
+
executables: []
|
|
30
|
+
extensions:
|
|
31
|
+
- ext/anydoc/extconf.rb
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Cargo.toml
|
|
35
|
+
- LICENSE.txt
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- ext/anydoc/Cargo.toml
|
|
39
|
+
- ext/anydoc/build.rs
|
|
40
|
+
- ext/anydoc/extconf.rb
|
|
41
|
+
- ext/anydoc/src/document.rs
|
|
42
|
+
- ext/anydoc/src/lib.rs
|
|
43
|
+
- lib/anydoc.rb
|
|
44
|
+
- lib/anydoc/version.rb
|
|
45
|
+
homepage: https://github.com/firecrawl/anydoc#readme
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
homepage_uri: https://github.com/firecrawl/anydoc#readme
|
|
50
|
+
source_code_uri: https://github.com/firecrawl/anydoc/tree/main/ruby
|
|
51
|
+
changelog_uri: https://github.com/firecrawl/anydoc/releases
|
|
52
|
+
rubygems_mfa_required: 'true'
|
|
53
|
+
rdoc_options: []
|
|
54
|
+
require_paths:
|
|
55
|
+
- lib
|
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: 3.2.0
|
|
61
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '0'
|
|
66
|
+
requirements: []
|
|
67
|
+
rubygems_version: 4.0.15
|
|
68
|
+
specification_version: 4
|
|
69
|
+
summary: Convert documents to GitHub-Flavored Markdown
|
|
70
|
+
test_files: []
|