anydoc-ruby 0.1.7
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 +12 -0
- data/LICENSE +21 -0
- data/README.md +149 -0
- data/ext/anydoc/Cargo.toml +22 -0
- data/ext/anydoc/extconf.rb +10 -0
- data/ext/anydoc/src/document.rs +310 -0
- data/ext/anydoc/src/lib.rs +182 -0
- data/ext/anydoc/src/nogvl.rs +42 -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 +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b73a92386a7dc619a85892686d5fb139df873bd2c762be8e78c29f602261e405
|
|
4
|
+
data.tar.gz: 694607679a045c2599469b6b499c8970aa4963134364b9aebf1904a321518f50
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6fef910cd6101ca804b3e6d58b3fbcd4cb6250238349d0b0c387c57e040ed4a4d141e1f8b6814790847d7e070e01ac550547ed43b144ca4e7dc1a4b2ddc8e9ce
|
|
7
|
+
data.tar.gz: 736c2870384cf448e7fc504e328ce835ac1b255bd6f09fff10011ba6a63b6852b544d6af9643bfa2088d15d80ae6c1c793b2019dedbbbaf3fb3864d29738e71f
|
data/Cargo.toml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Workspace root for the extension crate. It ships with the source gem, so an
|
|
2
|
+
# installed gem builds against this root instead of walking up into whatever
|
|
3
|
+
# directory RubyGems unpacked it into.
|
|
4
|
+
[workspace]
|
|
5
|
+
members = ["ext/anydoc"]
|
|
6
|
+
resolver = "3"
|
|
7
|
+
|
|
8
|
+
# Profiles belong to the workspace root, so the extension's release build is
|
|
9
|
+
# configured here.
|
|
10
|
+
[profile.release]
|
|
11
|
+
lto = "thin"
|
|
12
|
+
strip = "symbols"
|
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)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "anydoc"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
description = "Ruby bindings for anydoc"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
repository = "https://github.com/honzasterba/anydoc"
|
|
8
|
+
publish = false
|
|
9
|
+
|
|
10
|
+
[lib]
|
|
11
|
+
# Names the artifact the extension loads (anydoc.so / anydoc.bundle) and the
|
|
12
|
+
# Init_anydoc entry point rb_sys wires up.
|
|
13
|
+
crate-type = ["cdylib"]
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
# From crates.io, so an installed source gem builds anywhere. Builds inside
|
|
17
|
+
# this repository redirect it to the local crate through ruby/.cargo/config.toml,
|
|
18
|
+
# and scripts/check-versions.sh keeps this requirement on the release version.
|
|
19
|
+
anydoc = "0.1.7"
|
|
20
|
+
magnus = "0.8"
|
|
21
|
+
# For rb_thread_call_without_gvl; magnus resolves to the same major version.
|
|
22
|
+
rb-sys = "0.9"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mkmf"
|
|
4
|
+
require "rb_sys/mkmf"
|
|
5
|
+
|
|
6
|
+
# Builds ext/anydoc into lib/anydoc/anydoc.<dlext>, whose Init_anydoc entry
|
|
7
|
+
# point is the #[magnus::init] function in src/lib.rs.
|
|
8
|
+
create_rust_makefile("anydoc/anydoc") do |ext|
|
|
9
|
+
ext.profile = ENV.fetch("RB_SYS_CARGO_PROFILE", "release").to_sym
|
|
10
|
+
end
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
//! The document model, built eagerly out of the `Data` classes declared in
|
|
2
|
+
//! `lib/anydoc/document.rb`.
|
|
3
|
+
//!
|
|
4
|
+
//! Variant enums (block kinds, link targets, ...) surface as snake_case
|
|
5
|
+
//! symbols on a `kind` member, with the variant's payload on members that are
|
|
6
|
+
//! `nil` for every other kind - the same shape the Node and Python bindings
|
|
7
|
+
//! give them. Members are passed positionally, in the order the Ruby classes
|
|
8
|
+
//! declare them, which `test/document_test.rb` pins.
|
|
9
|
+
|
|
10
|
+
use magnus::prelude::*;
|
|
11
|
+
use magnus::{Error, RArray, RClass, RModule, Ruby, Value};
|
|
12
|
+
|
|
13
|
+
use anydoc::model;
|
|
14
|
+
|
|
15
|
+
/// Builds one document. The classes are looked up once per conversion rather
|
|
16
|
+
/// than once per node.
|
|
17
|
+
struct Build<'a> {
|
|
18
|
+
ruby: &'a Ruby,
|
|
19
|
+
classes: Classes,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct Classes {
|
|
23
|
+
document: RClass,
|
|
24
|
+
block: RClass,
|
|
25
|
+
inline: RClass,
|
|
26
|
+
style: RClass,
|
|
27
|
+
link_target: RClass,
|
|
28
|
+
image_source: RClass,
|
|
29
|
+
list: RClass,
|
|
30
|
+
list_item: RClass,
|
|
31
|
+
table: RClass,
|
|
32
|
+
cell_slot: RClass,
|
|
33
|
+
cell: RClass,
|
|
34
|
+
note: RClass,
|
|
35
|
+
asset: RClass,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
impl Classes {
|
|
39
|
+
fn new(ruby: &Ruby) -> Result<Self, Error> {
|
|
40
|
+
let anydoc: RModule = ruby.class_object().const_get("Anydoc")?;
|
|
41
|
+
Ok(Classes {
|
|
42
|
+
document: anydoc.const_get("Document")?,
|
|
43
|
+
block: anydoc.const_get("Block")?,
|
|
44
|
+
inline: anydoc.const_get("Inline")?,
|
|
45
|
+
style: anydoc.const_get("Style")?,
|
|
46
|
+
link_target: anydoc.const_get("LinkTarget")?,
|
|
47
|
+
image_source: anydoc.const_get("ImageSource")?,
|
|
48
|
+
list: anydoc.const_get("List")?,
|
|
49
|
+
list_item: anydoc.const_get("ListItem")?,
|
|
50
|
+
table: anydoc.const_get("Table")?,
|
|
51
|
+
cell_slot: anydoc.const_get("CellSlot")?,
|
|
52
|
+
cell: anydoc.const_get("Cell")?,
|
|
53
|
+
note: anydoc.const_get("Note")?,
|
|
54
|
+
asset: anydoc.const_get("Asset")?,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Build `Anydoc::Document` from a parsed document.
|
|
60
|
+
pub fn build(ruby: &Ruby, document: model::Document) -> Result<Value, Error> {
|
|
61
|
+
Build { ruby, classes: Classes::new(ruby)? }.document(document)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
impl Build<'_> {
|
|
65
|
+
fn document(&self, document: model::Document) -> Result<Value, Error> {
|
|
66
|
+
let blocks = self.array(document.blocks, Self::block)?;
|
|
67
|
+
let notes = self.array(document.notes, Self::note)?;
|
|
68
|
+
let assets = self.array(document.assets, Self::asset)?;
|
|
69
|
+
self.classes.document.funcall("new", (blocks, notes, assets))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn block(&self, block: model::Block) -> Result<Value, Error> {
|
|
73
|
+
let fields = match block {
|
|
74
|
+
model::Block::Heading { level, anchor, content } => BlockFields {
|
|
75
|
+
level: Some(level),
|
|
76
|
+
anchor,
|
|
77
|
+
content: Some(self.array(content, Self::inline)?),
|
|
78
|
+
..BlockFields::of("heading")
|
|
79
|
+
},
|
|
80
|
+
model::Block::Paragraph(content) => BlockFields {
|
|
81
|
+
content: Some(self.array(content, Self::inline)?),
|
|
82
|
+
..BlockFields::of("paragraph")
|
|
83
|
+
},
|
|
84
|
+
model::Block::List(list) => {
|
|
85
|
+
BlockFields { list: Some(self.list(list)?), ..BlockFields::of("list") }
|
|
86
|
+
}
|
|
87
|
+
model::Block::Table(table) => {
|
|
88
|
+
BlockFields { table: Some(self.table(table)?), ..BlockFields::of("table") }
|
|
89
|
+
}
|
|
90
|
+
model::Block::BlockQuote(blocks) => BlockFields {
|
|
91
|
+
blocks: Some(self.array(blocks, Self::block)?),
|
|
92
|
+
..BlockFields::of("block_quote")
|
|
93
|
+
},
|
|
94
|
+
model::Block::CodeBlock { lang, text } => {
|
|
95
|
+
BlockFields { lang, text: Some(text), ..BlockFields::of("code_block") }
|
|
96
|
+
}
|
|
97
|
+
model::Block::Rule => BlockFields::of("rule"),
|
|
98
|
+
};
|
|
99
|
+
self.classes.block.funcall(
|
|
100
|
+
"new",
|
|
101
|
+
(
|
|
102
|
+
self.ruby.sym_new(fields.kind),
|
|
103
|
+
fields.level,
|
|
104
|
+
fields.anchor,
|
|
105
|
+
fields.content,
|
|
106
|
+
fields.list,
|
|
107
|
+
fields.table,
|
|
108
|
+
fields.blocks,
|
|
109
|
+
fields.lang,
|
|
110
|
+
fields.text,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn inline(&self, inline: model::Inline) -> Result<Value, Error> {
|
|
116
|
+
let fields = match inline {
|
|
117
|
+
model::Inline::Text { text, style } => InlineFields {
|
|
118
|
+
text: Some(text),
|
|
119
|
+
style: Some(self.style(style)?),
|
|
120
|
+
..InlineFields::of("text")
|
|
121
|
+
},
|
|
122
|
+
model::Inline::Link { content, target } => InlineFields {
|
|
123
|
+
content: Some(self.array(content, Self::inline)?),
|
|
124
|
+
target: Some(self.link_target(target)?),
|
|
125
|
+
..InlineFields::of("link")
|
|
126
|
+
},
|
|
127
|
+
model::Inline::Image { alt, source } => InlineFields {
|
|
128
|
+
alt: Some(alt),
|
|
129
|
+
source: Some(self.image_source(source)?),
|
|
130
|
+
..InlineFields::of("image")
|
|
131
|
+
},
|
|
132
|
+
model::Inline::Anchor(id) => {
|
|
133
|
+
InlineFields { anchor: Some(id), ..InlineFields::of("anchor") }
|
|
134
|
+
}
|
|
135
|
+
model::Inline::NoteRef(id) => {
|
|
136
|
+
InlineFields { note_id: Some(id), ..InlineFields::of("note_ref") }
|
|
137
|
+
}
|
|
138
|
+
model::Inline::LineBreak => InlineFields::of("line_break"),
|
|
139
|
+
};
|
|
140
|
+
self.classes.inline.funcall(
|
|
141
|
+
"new",
|
|
142
|
+
(
|
|
143
|
+
self.ruby.sym_new(fields.kind),
|
|
144
|
+
fields.text,
|
|
145
|
+
fields.style,
|
|
146
|
+
fields.content,
|
|
147
|
+
fields.target,
|
|
148
|
+
fields.alt,
|
|
149
|
+
fields.source,
|
|
150
|
+
fields.anchor,
|
|
151
|
+
fields.note_id,
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn style(&self, style: model::Style) -> Result<Value, Error> {
|
|
157
|
+
self.classes.style.funcall("new", (style.bold, style.italic, style.strike, style.code))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fn link_target(&self, target: model::LinkTarget) -> Result<Value, Error> {
|
|
161
|
+
let (kind, value) = match target {
|
|
162
|
+
model::LinkTarget::External(value) => ("external", value),
|
|
163
|
+
model::LinkTarget::Relative(value) => ("relative", value),
|
|
164
|
+
model::LinkTarget::Anchor(value) => ("anchor", value),
|
|
165
|
+
};
|
|
166
|
+
self.classes.link_target.funcall("new", (self.ruby.sym_new(kind), value))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn image_source(&self, source: model::ImageSource) -> Result<Value, Error> {
|
|
170
|
+
let (kind, url, asset_id) = match source {
|
|
171
|
+
model::ImageSource::External(url) => ("external", Some(url), None),
|
|
172
|
+
model::ImageSource::Asset(id) => ("asset", None, Some(id.0)),
|
|
173
|
+
model::ImageSource::Unavailable => ("unavailable", None, None),
|
|
174
|
+
};
|
|
175
|
+
self.classes.image_source.funcall("new", (self.ruby.sym_new(kind), url, asset_id))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn list(&self, list: model::List) -> Result<Value, Error> {
|
|
179
|
+
let marker = match list.marker {
|
|
180
|
+
model::MarkerKind::Bullet => "bullet",
|
|
181
|
+
model::MarkerKind::Decimal => "decimal",
|
|
182
|
+
model::MarkerKind::LowerAlpha => "lower_alpha",
|
|
183
|
+
model::MarkerKind::UpperAlpha => "upper_alpha",
|
|
184
|
+
model::MarkerKind::LowerRoman => "lower_roman",
|
|
185
|
+
model::MarkerKind::UpperRoman => "upper_roman",
|
|
186
|
+
};
|
|
187
|
+
let items = self.array(list.items, Self::list_item)?;
|
|
188
|
+
self.classes.list.funcall("new", (self.ruby.sym_new(marker), list.start, items))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fn list_item(&self, item: model::ListItem) -> Result<Value, Error> {
|
|
192
|
+
let blocks = self.array(item.blocks, Self::block)?;
|
|
193
|
+
self.classes.list_item.funcall("new", (blocks, item.checked, item.marker_label))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fn table(&self, table: model::Table) -> Result<Value, Error> {
|
|
197
|
+
let grid = self.ruby.ary_new_capa(table.grid.len());
|
|
198
|
+
for row in table.grid {
|
|
199
|
+
grid.push(self.array(row, Self::cell_slot)?)?;
|
|
200
|
+
}
|
|
201
|
+
let kind = match table.kind {
|
|
202
|
+
model::TableKind::Data => "data",
|
|
203
|
+
model::TableKind::Layout => "layout",
|
|
204
|
+
};
|
|
205
|
+
self.classes.table.funcall("new", (grid, table.header_rows, self.ruby.sym_new(kind)))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fn cell_slot(&self, slot: model::CellSlot) -> Result<Value, Error> {
|
|
209
|
+
let (kind, cell, origin) = match slot {
|
|
210
|
+
model::CellSlot::Origin(cell) => ("origin", Some(self.cell(cell)?), None),
|
|
211
|
+
model::CellSlot::Covered { origin_row, origin_col } => {
|
|
212
|
+
("covered", None, Some((origin_row, origin_col)))
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
self.classes.cell_slot.funcall(
|
|
216
|
+
"new",
|
|
217
|
+
(self.ruby.sym_new(kind), cell, origin.map(|(row, _)| row), origin.map(|(_, col)| col)),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fn cell(&self, cell: model::Cell) -> Result<Value, Error> {
|
|
222
|
+
let blocks = self.array(cell.blocks, Self::block)?;
|
|
223
|
+
self.classes.cell.funcall("new", (blocks, cell.col_span, cell.row_span))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fn note(&self, note: model::Note) -> Result<Value, Error> {
|
|
227
|
+
let kind = match note.kind {
|
|
228
|
+
model::NoteKind::Footnote => "footnote",
|
|
229
|
+
model::NoteKind::Endnote => "endnote",
|
|
230
|
+
};
|
|
231
|
+
let blocks = self.array(note.blocks, Self::block)?;
|
|
232
|
+
self.classes.note.funcall("new", (note.id, self.ruby.sym_new(kind), blocks))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
fn asset(&self, asset: model::Asset) -> Result<Value, Error> {
|
|
236
|
+
// Binary string: asset bytes are payloads, not text.
|
|
237
|
+
let data = self.ruby.str_from_slice(&asset.bytes);
|
|
238
|
+
self.classes.asset.funcall("new", (asset.id.0, asset.media_type, asset.origin_part, data))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
fn array<T>(
|
|
242
|
+
&self,
|
|
243
|
+
items: Vec<T>,
|
|
244
|
+
build: fn(&Self, T) -> Result<Value, Error>,
|
|
245
|
+
) -> Result<RArray, Error> {
|
|
246
|
+
let array = self.ruby.ary_new_capa(items.len());
|
|
247
|
+
for item in items {
|
|
248
|
+
array.push(build(self, item)?)?;
|
|
249
|
+
}
|
|
250
|
+
Ok(array)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Every member of `Anydoc::Block`; a variant sets only the ones it carries.
|
|
255
|
+
struct BlockFields {
|
|
256
|
+
kind: &'static str,
|
|
257
|
+
level: Option<u8>,
|
|
258
|
+
anchor: Option<String>,
|
|
259
|
+
content: Option<RArray>,
|
|
260
|
+
list: Option<Value>,
|
|
261
|
+
table: Option<Value>,
|
|
262
|
+
blocks: Option<RArray>,
|
|
263
|
+
lang: Option<String>,
|
|
264
|
+
text: Option<String>,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
impl BlockFields {
|
|
268
|
+
fn of(kind: &'static str) -> BlockFields {
|
|
269
|
+
BlockFields {
|
|
270
|
+
kind,
|
|
271
|
+
level: None,
|
|
272
|
+
anchor: None,
|
|
273
|
+
content: None,
|
|
274
|
+
list: None,
|
|
275
|
+
table: None,
|
|
276
|
+
blocks: None,
|
|
277
|
+
lang: None,
|
|
278
|
+
text: None,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Every member of `Anydoc::Inline`; a variant sets only the ones it carries.
|
|
284
|
+
struct InlineFields {
|
|
285
|
+
kind: &'static str,
|
|
286
|
+
text: Option<String>,
|
|
287
|
+
style: Option<Value>,
|
|
288
|
+
content: Option<RArray>,
|
|
289
|
+
target: Option<Value>,
|
|
290
|
+
alt: Option<String>,
|
|
291
|
+
source: Option<Value>,
|
|
292
|
+
anchor: Option<String>,
|
|
293
|
+
note_id: Option<String>,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
impl InlineFields {
|
|
297
|
+
fn of(kind: &'static str) -> InlineFields {
|
|
298
|
+
InlineFields {
|
|
299
|
+
kind,
|
|
300
|
+
text: None,
|
|
301
|
+
style: None,
|
|
302
|
+
content: None,
|
|
303
|
+
target: None,
|
|
304
|
+
alt: None,
|
|
305
|
+
source: None,
|
|
306
|
+
anchor: None,
|
|
307
|
+
note_id: None,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|