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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
//! Ruby bindings for anydoc.
|
|
2
|
+
//!
|
|
3
|
+
//! The Rust side is deliberately thin: it converts, builds the document model
|
|
4
|
+
//! out of the `Data` classes `lib/anydoc/document.rb` declares, and raises the
|
|
5
|
+
//! error classes `lib/anydoc/errors.rb` declares. Argument coercion, keyword
|
|
6
|
+
//! arguments, and file reading live in Ruby, on `Anydoc`.
|
|
7
|
+
|
|
8
|
+
use magnus::prelude::*;
|
|
9
|
+
use magnus::{
|
|
10
|
+
Error, Exception, IntoValue, RClass, RModule, RObject, RString, Ruby, StaticSymbol, Symbol,
|
|
11
|
+
TryConvert, Value, function,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
mod document;
|
|
15
|
+
mod nogvl;
|
|
16
|
+
|
|
17
|
+
use nogvl::nogvl;
|
|
18
|
+
|
|
19
|
+
/// Format names, as the extension that identifies each format. Container
|
|
20
|
+
/// variants that share a parser (`.docm`, `.xlsm`, `.ppsx`, ...) map onto
|
|
21
|
+
/// these through `format_from_bytes` or `format_from_extension`.
|
|
22
|
+
const FORMATS: [(&str, anydoc::Format); 12] = [
|
|
23
|
+
("doc", anydoc::Format::Doc),
|
|
24
|
+
("docx", anydoc::Format::Docx),
|
|
25
|
+
("odt", anydoc::Format::Odt),
|
|
26
|
+
("pdf", anydoc::Format::Pdf),
|
|
27
|
+
("ppt", anydoc::Format::Ppt),
|
|
28
|
+
("pptx", anydoc::Format::Pptx),
|
|
29
|
+
("rtf", anydoc::Format::Rtf),
|
|
30
|
+
("epub", anydoc::Format::Epub),
|
|
31
|
+
("xlsx", anydoc::Format::Excel),
|
|
32
|
+
("ods", anydoc::Format::Ods),
|
|
33
|
+
("odp", anydoc::Format::Odp),
|
|
34
|
+
("csv", anydoc::Format::Csv),
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/// The format a symbol names, or an `ArgumentError` listing the ones that
|
|
38
|
+
/// exist. `nil` asks for detection, which the conversion itself does.
|
|
39
|
+
fn parse_format(ruby: &Ruby, format: Option<Symbol>) -> Result<Option<anydoc::Format>, Error> {
|
|
40
|
+
let Some(symbol) = format else {
|
|
41
|
+
return Ok(None);
|
|
42
|
+
};
|
|
43
|
+
let name = symbol.name()?;
|
|
44
|
+
FORMATS
|
|
45
|
+
.iter()
|
|
46
|
+
.find(|(candidate, _)| *candidate == name.as_ref())
|
|
47
|
+
.map(|(_, format)| Some(*format))
|
|
48
|
+
.ok_or_else(|| {
|
|
49
|
+
let names: Vec<&str> = FORMATS.iter().map(|(name, _)| *name).collect();
|
|
50
|
+
Error::new(
|
|
51
|
+
ruby.exception_arg_error(),
|
|
52
|
+
format!("unknown format :{name}; expected one of {}", names.join(", ")),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// The symbol naming a format.
|
|
58
|
+
fn format_symbol(ruby: &Ruby, format: anydoc::Format) -> StaticSymbol {
|
|
59
|
+
let name = FORMATS
|
|
60
|
+
.iter()
|
|
61
|
+
.find(|(_, candidate)| *candidate == format)
|
|
62
|
+
.map(|(name, _)| *name)
|
|
63
|
+
.expect("every format is named");
|
|
64
|
+
ruby.sym_new(name)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// The string's bytes, copied while the GVL is still held: the conversion runs
|
|
68
|
+
/// without it, where another thread could move or mutate the buffer.
|
|
69
|
+
fn bytes(data: RString) -> Vec<u8> {
|
|
70
|
+
unsafe { data.as_slice() }.to_vec()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Run a conversion off the GVL and turn its failure into a Ruby exception.
|
|
74
|
+
fn convert<T>(
|
|
75
|
+
ruby: &Ruby,
|
|
76
|
+
conversion: impl FnOnce() -> Result<T, anydoc::ConvertError>,
|
|
77
|
+
) -> Result<T, Error> {
|
|
78
|
+
match nogvl(conversion) {
|
|
79
|
+
Ok(Ok(converted)) => Ok(converted),
|
|
80
|
+
Ok(Err(error)) => Err(convert_error(ruby, error)),
|
|
81
|
+
Err(_) => Err(Error::new(
|
|
82
|
+
ruby.exception_runtime_error(),
|
|
83
|
+
"anydoc panicked while converting; please report this at \
|
|
84
|
+
https://github.com/firecrawl/anydoc/issues",
|
|
85
|
+
)),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Raise the subclass that names the failure, carrying the part or limit at
|
|
90
|
+
/// fault where the variant knows one. A variant added later raises the base
|
|
91
|
+
/// class until it is named here.
|
|
92
|
+
fn convert_error(ruby: &Ruby, error: anydoc::ConvertError) -> Error {
|
|
93
|
+
let message = error.to_string();
|
|
94
|
+
let (class, detail) = match &error {
|
|
95
|
+
anydoc::ConvertError::Unsupported(_) => ("UnsupportedError", None),
|
|
96
|
+
anydoc::ConvertError::Malformed { part, .. } => {
|
|
97
|
+
("MalformedError", Some(("@part", part.clone().into_value_with(ruby))))
|
|
98
|
+
}
|
|
99
|
+
anydoc::ConvertError::Encrypted => ("EncryptedError", None),
|
|
100
|
+
anydoc::ConvertError::ResourceLimit { limit, .. } => {
|
|
101
|
+
("ResourceLimitError", Some(("@limit", ruby.sym_new(*limit).into_value_with(ruby))))
|
|
102
|
+
}
|
|
103
|
+
anydoc::ConvertError::MissingPart { part } => {
|
|
104
|
+
("MissingPartError", Some(("@part", part.clone().into_value_with(ruby))))
|
|
105
|
+
}
|
|
106
|
+
// Nothing here reads a file, so this is unreachable in practice.
|
|
107
|
+
anydoc::ConvertError::Io(_) => return Error::new(ruby.exception_io_error(), message),
|
|
108
|
+
_ => ("ConvertError", None),
|
|
109
|
+
};
|
|
110
|
+
build_error(ruby, class, message, detail).unwrap_or_else(|error| error)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fn build_error(
|
|
114
|
+
ruby: &Ruby,
|
|
115
|
+
class: &str,
|
|
116
|
+
message: String,
|
|
117
|
+
detail: Option<(&str, Value)>,
|
|
118
|
+
) -> Result<Error, Error> {
|
|
119
|
+
let anydoc: RModule = ruby.class_object().const_get("Anydoc")?;
|
|
120
|
+
let class: RClass = anydoc.const_get(class)?;
|
|
121
|
+
// Built as an object so the detail can be set on it, then handed back as
|
|
122
|
+
// the exception to raise.
|
|
123
|
+
let exception: RObject = class.funcall("new", (message,))?;
|
|
124
|
+
if let Some((name, value)) = detail {
|
|
125
|
+
exception.ivar_set(name, value)?;
|
|
126
|
+
}
|
|
127
|
+
Ok(Error::from(Exception::try_convert(exception.as_value())?))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Detect the format from the content itself: the signature and identity each
|
|
131
|
+
/// container specification designates (PDF header, RTF open group, OLE stream
|
|
132
|
+
/// names, ZIP package mimetype/content types). Plain-text formats (CSV) carry
|
|
133
|
+
/// no signature and return `nil`; so does anything unrecognized.
|
|
134
|
+
fn format_from_bytes(ruby: &Ruby, data: RString) -> Option<StaticSymbol> {
|
|
135
|
+
anydoc::Format::from_bytes(unsafe { data.as_slice() }).map(|format| format_symbol(ruby, format))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// The format a bare extension names (no leading dot), matched
|
|
139
|
+
/// case-insensitively.
|
|
140
|
+
fn format_from_extension(ruby: &Ruby, extension: String) -> Option<StaticSymbol> {
|
|
141
|
+
anydoc::Format::from_extension(&extension).map(|format| format_symbol(ruby, format))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// Convert an in-memory document to Markdown. Without a format, it is
|
|
145
|
+
/// detected from the content, which signature-less formats (CSV) have to name
|
|
146
|
+
/// explicitly.
|
|
147
|
+
fn to_markdown_bytes(ruby: &Ruby, data: RString, format: Option<Symbol>) -> Result<String, Error> {
|
|
148
|
+
let format = parse_format(ruby, format)?;
|
|
149
|
+
let bytes = bytes(data);
|
|
150
|
+
convert(ruby, || anydoc::to_markdown_bytes(&bytes, format))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Parse an in-memory document into the document model, which also carries the
|
|
154
|
+
/// embedded assets. Without a format, it is detected from the content.
|
|
155
|
+
fn to_document(ruby: &Ruby, data: RString, format: Option<Symbol>) -> Result<Value, Error> {
|
|
156
|
+
let format = parse_format(ruby, format)?;
|
|
157
|
+
let bytes = bytes(data);
|
|
158
|
+
let parsed = convert(ruby, || anydoc::to_document(&bytes, format))?;
|
|
159
|
+
document::build(ruby, parsed)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#[magnus::init]
|
|
163
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
164
|
+
let anydoc = ruby.define_module("Anydoc")?;
|
|
165
|
+
|
|
166
|
+
let formats = ruby.ary_new_capa(FORMATS.len());
|
|
167
|
+
for (name, _) in FORMATS {
|
|
168
|
+
formats.push(ruby.sym_new(name))?;
|
|
169
|
+
}
|
|
170
|
+
formats.freeze();
|
|
171
|
+
anydoc.const_set("FORMATS", formats)?;
|
|
172
|
+
|
|
173
|
+
// The Ruby side wraps these: it takes keyword arguments, accepts strings
|
|
174
|
+
// where a format symbol is wanted, and reads files.
|
|
175
|
+
let native = anydoc.define_module("Native")?;
|
|
176
|
+
native.define_singleton_method("format_from_bytes", function!(format_from_bytes, 1))?;
|
|
177
|
+
native.define_singleton_method("format_from_extension", function!(format_from_extension, 1))?;
|
|
178
|
+
native.define_singleton_method("to_markdown_bytes", function!(to_markdown_bytes, 2))?;
|
|
179
|
+
native.define_singleton_method("to_document", function!(to_document, 2))?;
|
|
180
|
+
|
|
181
|
+
Ok(())
|
|
182
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//! Running a conversion with the GVL released.
|
|
2
|
+
|
|
3
|
+
use std::ffi::c_void;
|
|
4
|
+
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
5
|
+
use std::ptr::null_mut;
|
|
6
|
+
use std::thread::Result;
|
|
7
|
+
|
|
8
|
+
/// Run `f` with the GVL released, so other Ruby threads keep running while a
|
|
9
|
+
/// document converts. `f` must not touch the Ruby VM in any way.
|
|
10
|
+
///
|
|
11
|
+
/// No unblocking function is registered: a conversion is a bounded CPU-bound
|
|
12
|
+
/// call with no handle to interrupt, so signals and `Thread#kill` are handled
|
|
13
|
+
/// once it returns.
|
|
14
|
+
///
|
|
15
|
+
/// A panic is caught here rather than left to unwind through the C frame,
|
|
16
|
+
/// which would abort the process; the caller turns it into a Ruby exception.
|
|
17
|
+
pub fn nogvl<F, R>(f: F) -> Result<R>
|
|
18
|
+
where
|
|
19
|
+
F: FnOnce() -> R,
|
|
20
|
+
{
|
|
21
|
+
unsafe extern "C" fn call<F, R>(arg: *mut c_void) -> *mut c_void
|
|
22
|
+
where
|
|
23
|
+
F: FnOnce() -> R,
|
|
24
|
+
{
|
|
25
|
+
// `arg` is the caller's `f` slot, which outlives this call. Ruby calls
|
|
26
|
+
// the function exactly once, so the closure is there to take.
|
|
27
|
+
let f = unsafe { &mut *arg.cast::<Option<F>>() }.take().expect("called once");
|
|
28
|
+
Box::into_raw(Box::new(catch_unwind(AssertUnwindSafe(f)))).cast()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let mut f = Some(f);
|
|
32
|
+
let result = unsafe {
|
|
33
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
34
|
+
Some(call::<F, R>),
|
|
35
|
+
(&raw mut f).cast(),
|
|
36
|
+
None,
|
|
37
|
+
null_mut(),
|
|
38
|
+
)
|
|
39
|
+
};
|
|
40
|
+
// The callback boxed its result; take ownership of it back.
|
|
41
|
+
*unsafe { Box::from_raw(result.cast::<Result<R>>()) }
|
|
42
|
+
}
|
|
@@ -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
|