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.
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anydoc
4
+ # The gem version, which is the version of the anydoc crate it wraps.
5
+ VERSION = "0.1.7"
6
+ 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