parsekit-bin 0.1.2
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/CHANGELOG.md +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +195 -0
- data/ext/parsekit/Cargo.toml +36 -0
- data/ext/parsekit/extconf.rb +6 -0
- data/ext/parsekit/src/error.rs +45 -0
- data/ext/parsekit/src/format_detector.rs +233 -0
- data/ext/parsekit/src/lib.rs +25 -0
- data/ext/parsekit/src/parser.rs +630 -0
- data/lib/parsekit/error.rb +15 -0
- data/lib/parsekit/parser.rb +253 -0
- data/lib/parsekit/version.rb +5 -0
- data/lib/parsekit.rb +93 -0
- metadata +130 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
use magnus::{
|
|
2
|
+
function, method, prelude::*, scan_args, Error, Module, RHash, RModule, Ruby, Value,
|
|
3
|
+
};
|
|
4
|
+
use crate::format_detector::{FileFormat, FormatDetector};
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone)]
|
|
7
|
+
#[magnus::wrap(class = "ParseKit::Parser", free_immediately, size)]
|
|
8
|
+
pub struct Parser {
|
|
9
|
+
config: ParserConfig,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
#[derive(Debug, Clone)]
|
|
13
|
+
struct ParserConfig {
|
|
14
|
+
strict_mode: bool,
|
|
15
|
+
max_depth: usize,
|
|
16
|
+
encoding: String,
|
|
17
|
+
max_size: usize,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
impl Default for ParserConfig {
|
|
21
|
+
fn default() -> Self {
|
|
22
|
+
Self {
|
|
23
|
+
strict_mode: false,
|
|
24
|
+
max_depth: 100,
|
|
25
|
+
encoding: "UTF-8".to_string(),
|
|
26
|
+
max_size: 100 * 1024 * 1024, // 100MB default limit
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Error handling helpers
|
|
32
|
+
impl Parser {
|
|
33
|
+
/// Create a RuntimeError with formatted message
|
|
34
|
+
fn runtime_error<E: std::fmt::Display>(context: &str, err: E) -> Error {
|
|
35
|
+
Error::new(
|
|
36
|
+
Ruby::get().unwrap().exception_runtime_error(),
|
|
37
|
+
format!("{}: {}", context, err),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Create an ArgumentError with message
|
|
42
|
+
fn argument_error(msg: &str) -> Error {
|
|
43
|
+
Error::new(
|
|
44
|
+
Ruby::get().unwrap().exception_arg_error(),
|
|
45
|
+
msg.to_string(),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Create an IOError with formatted message
|
|
50
|
+
fn io_error<E: std::fmt::Display>(context: &str, err: E) -> Error {
|
|
51
|
+
Error::new(
|
|
52
|
+
Ruby::get().unwrap().exception_io_error(),
|
|
53
|
+
format!("{}: {}", context, err),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
impl Parser {
|
|
59
|
+
/// Create a new Parser instance with optional configuration
|
|
60
|
+
fn new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
|
|
61
|
+
let args = scan_args::scan_args::<(), (Option<RHash>,), (), (), (), ()>(args)?;
|
|
62
|
+
let options = args.optional.0;
|
|
63
|
+
|
|
64
|
+
let mut config = ParserConfig::default();
|
|
65
|
+
|
|
66
|
+
if let Some(opts) = options {
|
|
67
|
+
if let Some(strict) = opts.get(ruby.to_symbol("strict_mode")) {
|
|
68
|
+
config.strict_mode = bool::try_convert(strict)?;
|
|
69
|
+
}
|
|
70
|
+
if let Some(depth) = opts.get(ruby.to_symbol("max_depth")) {
|
|
71
|
+
config.max_depth = usize::try_convert(depth)?;
|
|
72
|
+
}
|
|
73
|
+
if let Some(encoding) = opts.get(ruby.to_symbol("encoding")) {
|
|
74
|
+
config.encoding = String::try_convert(encoding)?;
|
|
75
|
+
}
|
|
76
|
+
if let Some(max_size) = opts.get(ruby.to_symbol("max_size")) {
|
|
77
|
+
config.max_size = usize::try_convert(max_size)?;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Ok(Self { config })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Parse input bytes based on file type (internal helper)
|
|
85
|
+
fn parse_bytes_internal(&self, data: Vec<u8>, filename: Option<&str>) -> Result<String, Error> {
|
|
86
|
+
// Check size limit
|
|
87
|
+
if data.len() > self.config.max_size {
|
|
88
|
+
return Err(Self::runtime_error(
|
|
89
|
+
"File size exceeds limit",
|
|
90
|
+
format!("{} bytes exceeds maximum allowed size of {} bytes",
|
|
91
|
+
data.len(), self.config.max_size)
|
|
92
|
+
));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Use centralized format detection
|
|
96
|
+
let format = FormatDetector::detect(filename, Some(&data));
|
|
97
|
+
|
|
98
|
+
// Use centralized dispatch
|
|
99
|
+
self.dispatch_to_parser(format, data)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Centralized dispatch logic - routes format to appropriate parser
|
|
103
|
+
fn dispatch_to_parser(&self, format: FileFormat, data: Vec<u8>) -> Result<String, Error> {
|
|
104
|
+
match format {
|
|
105
|
+
FileFormat::Pdf => self.parse_pdf(data),
|
|
106
|
+
FileFormat::Docx => self.parse_docx(data),
|
|
107
|
+
FileFormat::Pptx => self.parse_pptx(data),
|
|
108
|
+
FileFormat::Xlsx | FileFormat::Xls => self.parse_xlsx(data),
|
|
109
|
+
FileFormat::Json => self.parse_json(data),
|
|
110
|
+
FileFormat::Xml | FileFormat::Html => self.parse_xml(data),
|
|
111
|
+
FileFormat::Png | FileFormat::Jpeg | FileFormat::Tiff | FileFormat::Bmp => self.ocr_image(data),
|
|
112
|
+
FileFormat::Text | FileFormat::Unknown => self.parse_text(data),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Ruby-accessible method to detect format from bytes
|
|
117
|
+
fn detect_format_from_bytes(&self, data: Vec<u8>) -> String {
|
|
118
|
+
let format = FormatDetector::detect_from_content(&data);
|
|
119
|
+
// For compatibility with Ruby tests, return "xlsx" for old Excel
|
|
120
|
+
match format {
|
|
121
|
+
FileFormat::Xls => "xlsx".to_string(), // Compatibility with existing tests
|
|
122
|
+
_ => format.to_symbol().to_string(),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Ruby-accessible method to detect format from filename
|
|
127
|
+
fn detect_format_from_filename(&self, filename: String) -> String {
|
|
128
|
+
let format = FormatDetector::detect_from_extension(&filename);
|
|
129
|
+
format.to_symbol().to_string()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Perform OCR on image data using Tesseract
|
|
133
|
+
fn ocr_image(&self, data: Vec<u8>) -> Result<String, Error> {
|
|
134
|
+
use tesseract_rs::TesseractAPI;
|
|
135
|
+
|
|
136
|
+
// Create tesseract instance
|
|
137
|
+
let tesseract = TesseractAPI::new();
|
|
138
|
+
|
|
139
|
+
// Try to initialize with appropriate tessdata path
|
|
140
|
+
// Even in bundled mode, we need to find tessdata files
|
|
141
|
+
#[cfg(feature = "bundled-tesseract")]
|
|
142
|
+
let init_result = {
|
|
143
|
+
// Build list of tessdata paths to try
|
|
144
|
+
let mut tessdata_paths = Vec::new();
|
|
145
|
+
|
|
146
|
+
// Check TESSDATA_PREFIX environment variable first (for CI)
|
|
147
|
+
if let Ok(env_path) = std::env::var("TESSDATA_PREFIX") {
|
|
148
|
+
tessdata_paths.push(env_path);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Add common system paths
|
|
152
|
+
tessdata_paths.extend_from_slice(&[
|
|
153
|
+
"/usr/share/tessdata".to_string(),
|
|
154
|
+
"/usr/local/share/tessdata".to_string(),
|
|
155
|
+
"/opt/homebrew/share/tessdata".to_string(),
|
|
156
|
+
"/opt/local/share/tessdata".to_string(),
|
|
157
|
+
"tessdata".to_string(), // Local tessdata directory
|
|
158
|
+
".".to_string(), // Current directory as fallback
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
let mut result = Err(tesseract_rs::TesseractError::InitError);
|
|
162
|
+
for path in &tessdata_paths {
|
|
163
|
+
// Check if path exists first to avoid noisy error messages
|
|
164
|
+
if std::path::Path::new(path).exists() {
|
|
165
|
+
if tesseract.init(path.as_str(), "eng").is_ok() {
|
|
166
|
+
result = Ok(());
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
result
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
#[cfg(not(feature = "bundled-tesseract"))]
|
|
175
|
+
let init_result = {
|
|
176
|
+
// Try common system tessdata paths
|
|
177
|
+
let tessdata_paths = vec![
|
|
178
|
+
"/usr/share/tessdata",
|
|
179
|
+
"/usr/local/share/tessdata",
|
|
180
|
+
"/opt/homebrew/share/tessdata",
|
|
181
|
+
"/opt/local/share/tessdata",
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
let mut result = Err(tesseract_rs::TesseractError::InitError);
|
|
185
|
+
for path in &tessdata_paths {
|
|
186
|
+
if std::path::Path::new(path).exists() {
|
|
187
|
+
if tesseract.init(path, "eng").is_ok() {
|
|
188
|
+
result = Ok(());
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
result
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if let Err(e) = init_result {
|
|
197
|
+
return Err(Self::runtime_error("Failed to initialize Tesseract", e));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Load the image from bytes
|
|
201
|
+
let img = image::load_from_memory(&data)
|
|
202
|
+
.map_err(|e| Self::runtime_error("Failed to load image", e))?;
|
|
203
|
+
|
|
204
|
+
// Convert to RGBA8 format
|
|
205
|
+
let rgba_img = img.to_rgba8();
|
|
206
|
+
let (width, height) = rgba_img.dimensions();
|
|
207
|
+
let raw_data = rgba_img.into_raw();
|
|
208
|
+
|
|
209
|
+
// Set image data
|
|
210
|
+
tesseract.set_image(
|
|
211
|
+
&raw_data,
|
|
212
|
+
width as i32,
|
|
213
|
+
height as i32,
|
|
214
|
+
4, // bytes per pixel (RGBA)
|
|
215
|
+
(width * 4) as i32, // bytes per line
|
|
216
|
+
).map_err(|e| Self::runtime_error("Failed to set image", e))?;
|
|
217
|
+
|
|
218
|
+
// Extract text
|
|
219
|
+
tesseract.get_utf8_text()
|
|
220
|
+
.map(|text| text.trim().to_string())
|
|
221
|
+
.map_err(|e| Self::runtime_error("Failed to perform OCR", e))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
/// Parse PDF files using MuPDF (statically linked) - exposed to Ruby
|
|
226
|
+
fn parse_pdf(&self, data: Vec<u8>) -> Result<String, Error> {
|
|
227
|
+
use mupdf::Document;
|
|
228
|
+
|
|
229
|
+
// Try to load the PDF from memory
|
|
230
|
+
// The magic parameter helps MuPDF identify the file type
|
|
231
|
+
let doc = Document::from_bytes(&data, "pdf")
|
|
232
|
+
.map_err(|e| Self::runtime_error("Failed to parse PDF", e))?;
|
|
233
|
+
|
|
234
|
+
let mut all_text = String::new();
|
|
235
|
+
|
|
236
|
+
// Get page count
|
|
237
|
+
let page_count = doc.page_count()
|
|
238
|
+
.map_err(|e| Self::runtime_error("Failed to get page count", e))?;
|
|
239
|
+
|
|
240
|
+
// Iterate through pages
|
|
241
|
+
for page_num in 0..page_count {
|
|
242
|
+
// Continue on page errors rather than failing entirely
|
|
243
|
+
if let Ok(page) = doc.load_page(page_num) {
|
|
244
|
+
// Extract text from the page
|
|
245
|
+
if let Ok(text) = page.to_text() {
|
|
246
|
+
all_text.push_str(&text);
|
|
247
|
+
all_text.push('\n');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if all_text.is_empty() {
|
|
253
|
+
Ok("PDF contains no extractable text (might be scanned/image-based)".to_string())
|
|
254
|
+
} else {
|
|
255
|
+
Ok(all_text.trim().to_string())
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// Parse DOCX (Word) files - exposed to Ruby
|
|
260
|
+
fn parse_docx(&self, data: Vec<u8>) -> Result<String, Error> {
|
|
261
|
+
use docx_rs::read_docx;
|
|
262
|
+
|
|
263
|
+
match read_docx(&data) {
|
|
264
|
+
Ok(docx) => {
|
|
265
|
+
let mut result = String::new();
|
|
266
|
+
|
|
267
|
+
// Extract text from all document children
|
|
268
|
+
// For simplicity, we'll focus on paragraphs only for now
|
|
269
|
+
// Tables require more complex handling with the current API
|
|
270
|
+
for child in docx.document.children.iter() {
|
|
271
|
+
if let docx_rs::DocumentChild::Paragraph(p) = child {
|
|
272
|
+
// Extract text from paragraph
|
|
273
|
+
for p_child in &p.children {
|
|
274
|
+
if let docx_rs::ParagraphChild::Run(r) = p_child {
|
|
275
|
+
for run_child in &r.children {
|
|
276
|
+
if let docx_rs::RunChild::Text(t) = run_child {
|
|
277
|
+
result.push_str(&t.text);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
result.push('\n');
|
|
283
|
+
}
|
|
284
|
+
// Note: Table text extraction would require iterating through
|
|
285
|
+
// table.rows -> TableChild::TableRow -> row.cells -> TableRowChild
|
|
286
|
+
// which has a more complex structure in docx-rs
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
Ok(result.trim().to_string())
|
|
290
|
+
}
|
|
291
|
+
Err(e) => Err(Self::runtime_error("Failed to parse DOCX file", e)),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/// Parse PPTX (PowerPoint) files - exposed to Ruby
|
|
296
|
+
fn parse_pptx(&self, data: Vec<u8>) -> Result<String, Error> {
|
|
297
|
+
use std::io::{Cursor, Read};
|
|
298
|
+
use zip::ZipArchive;
|
|
299
|
+
|
|
300
|
+
let cursor = Cursor::new(data);
|
|
301
|
+
let mut archive = ZipArchive::new(cursor)
|
|
302
|
+
.map_err(|e| Self::runtime_error("Failed to open PPTX as ZIP", e))?;
|
|
303
|
+
|
|
304
|
+
let mut all_text = Vec::new();
|
|
305
|
+
let mut slide_numbers = Vec::new();
|
|
306
|
+
|
|
307
|
+
// First, collect slide numbers and sort them
|
|
308
|
+
for i in 0..archive.len() {
|
|
309
|
+
let file = match archive.by_index(i) {
|
|
310
|
+
Ok(file) => file,
|
|
311
|
+
Err(_) => continue,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
let name = file.name();
|
|
315
|
+
// Match slide XML files (e.g., ppt/slides/slide1.xml)
|
|
316
|
+
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") && !name.contains("_rels") {
|
|
317
|
+
// Extract slide number from filename
|
|
318
|
+
if let Some(num_str) = name
|
|
319
|
+
.strip_prefix("ppt/slides/slide")
|
|
320
|
+
.and_then(|s| s.strip_suffix(".xml"))
|
|
321
|
+
{
|
|
322
|
+
if let Ok(num) = num_str.parse::<usize>() {
|
|
323
|
+
slide_numbers.push((num, i));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Sort by slide number to maintain order
|
|
330
|
+
slide_numbers.sort_by_key(|&(num, _)| num);
|
|
331
|
+
|
|
332
|
+
// Now process slides in order
|
|
333
|
+
for (_, index) in slide_numbers {
|
|
334
|
+
let mut file = match archive.by_index(index) {
|
|
335
|
+
Ok(file) => file,
|
|
336
|
+
Err(_) => continue,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
let mut contents = String::new();
|
|
340
|
+
if file.read_to_string(&mut contents).is_ok() {
|
|
341
|
+
// Extract text from slide XML
|
|
342
|
+
let text = self.extract_text_from_slide_xml(&contents);
|
|
343
|
+
if !text.is_empty() {
|
|
344
|
+
all_text.push(text);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Also extract notes if present
|
|
350
|
+
for i in 0..archive.len() {
|
|
351
|
+
let mut file = match archive.by_index(i) {
|
|
352
|
+
Ok(file) => file,
|
|
353
|
+
Err(_) => continue,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
let name = file.name();
|
|
357
|
+
// Match notes slide XML files
|
|
358
|
+
if name.starts_with("ppt/notesSlides/notesSlide") && name.ends_with(".xml") && !name.contains("_rels") {
|
|
359
|
+
let mut contents = String::new();
|
|
360
|
+
if file.read_to_string(&mut contents).is_ok() {
|
|
361
|
+
let text = self.extract_text_from_slide_xml(&contents);
|
|
362
|
+
if !text.is_empty() {
|
|
363
|
+
all_text.push(format!("[Notes: {}]", text));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if all_text.is_empty() {
|
|
370
|
+
Ok("".to_string())
|
|
371
|
+
} else {
|
|
372
|
+
Ok(all_text.join("\n\n"))
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/// Helper method to extract text from slide XML
|
|
377
|
+
fn extract_text_from_slide_xml(&self, xml_content: &str) -> String {
|
|
378
|
+
use quick_xml::events::Event;
|
|
379
|
+
use quick_xml::Reader;
|
|
380
|
+
|
|
381
|
+
let mut reader = Reader::from_str(xml_content);
|
|
382
|
+
|
|
383
|
+
let mut text_parts = Vec::new();
|
|
384
|
+
let mut buf = Vec::new();
|
|
385
|
+
let mut in_text_element = false;
|
|
386
|
+
|
|
387
|
+
loop {
|
|
388
|
+
match reader.read_event_into(&mut buf) {
|
|
389
|
+
Ok(Event::Start(ref e)) => {
|
|
390
|
+
// Look for text elements (a:t or t)
|
|
391
|
+
let name = e.name();
|
|
392
|
+
let local_name_bytes = name.local_name();
|
|
393
|
+
let local_name = std::str::from_utf8(local_name_bytes.as_ref()).unwrap_or("");
|
|
394
|
+
if local_name == "t" {
|
|
395
|
+
in_text_element = true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
Ok(Event::Text(e)) => {
|
|
399
|
+
if in_text_element {
|
|
400
|
+
if let Ok(text) = e.decode() {
|
|
401
|
+
let text_str = text.trim();
|
|
402
|
+
if !text_str.is_empty() {
|
|
403
|
+
text_parts.push(text_str.to_string());
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
Ok(Event::End(ref e)) => {
|
|
409
|
+
let name = e.name();
|
|
410
|
+
let local_name_bytes = name.local_name();
|
|
411
|
+
let local_name = std::str::from_utf8(local_name_bytes.as_ref()).unwrap_or("");
|
|
412
|
+
if local_name == "t" {
|
|
413
|
+
in_text_element = false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
Ok(Event::Eof) => break,
|
|
417
|
+
_ => {}
|
|
418
|
+
}
|
|
419
|
+
buf.clear();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
text_parts.join(" ")
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/// Parse Excel files - exposed to Ruby
|
|
426
|
+
fn parse_xlsx(&self, data: Vec<u8>) -> Result<String, Error> {
|
|
427
|
+
use calamine::{Reader, Xlsx};
|
|
428
|
+
use std::io::Cursor;
|
|
429
|
+
|
|
430
|
+
let cursor = Cursor::new(data);
|
|
431
|
+
match Xlsx::new(cursor) {
|
|
432
|
+
Ok(mut workbook) => {
|
|
433
|
+
let mut result = String::new();
|
|
434
|
+
|
|
435
|
+
for sheet_name in workbook.sheet_names().to_owned() {
|
|
436
|
+
result.push_str(&format!("Sheet: {}\n", sheet_name));
|
|
437
|
+
|
|
438
|
+
if let Ok(range) = workbook.worksheet_range(&sheet_name) {
|
|
439
|
+
for row in range.rows() {
|
|
440
|
+
for cell in row {
|
|
441
|
+
result.push_str(&format!("{}\t", cell));
|
|
442
|
+
}
|
|
443
|
+
result.push('\n');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
result.push('\n');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
Ok(result)
|
|
450
|
+
}
|
|
451
|
+
Err(e) => Err(Self::runtime_error("Failed to parse Excel file", e)),
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/// Parse JSON files - exposed to Ruby
|
|
456
|
+
fn parse_json(&self, data: Vec<u8>) -> Result<String, Error> {
|
|
457
|
+
let text = String::from_utf8_lossy(&data);
|
|
458
|
+
match serde_json::from_str::<serde_json::Value>(&text) {
|
|
459
|
+
Ok(json) => {
|
|
460
|
+
Ok(serde_json::to_string_pretty(&json).unwrap_or_else(|_| text.to_string()))
|
|
461
|
+
}
|
|
462
|
+
Err(_) => Ok(text.to_string()),
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/// Parse XML/HTML files - exposed to Ruby
|
|
467
|
+
fn parse_xml(&self, data: Vec<u8>) -> Result<String, Error> {
|
|
468
|
+
use quick_xml::events::Event;
|
|
469
|
+
use quick_xml::Reader;
|
|
470
|
+
|
|
471
|
+
let mut reader = Reader::from_reader(&data[..]);
|
|
472
|
+
let mut txt = String::new();
|
|
473
|
+
let mut buf = Vec::new();
|
|
474
|
+
|
|
475
|
+
loop {
|
|
476
|
+
match reader.read_event_into(&mut buf) {
|
|
477
|
+
Ok(Event::Text(e)) => {
|
|
478
|
+
txt.push_str(&e.decode().unwrap_or_default());
|
|
479
|
+
txt.push(' ');
|
|
480
|
+
}
|
|
481
|
+
Ok(Event::Eof) => break,
|
|
482
|
+
Err(e) => {
|
|
483
|
+
return Err(Self::runtime_error("XML parse error", e))
|
|
484
|
+
}
|
|
485
|
+
_ => {}
|
|
486
|
+
}
|
|
487
|
+
buf.clear();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
Ok(txt.trim().to_string())
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/// Parse plain text with encoding detection - exposed to Ruby
|
|
494
|
+
fn parse_text(&self, data: Vec<u8>) -> Result<String, Error> {
|
|
495
|
+
// Detect encoding
|
|
496
|
+
let (decoded, _encoding, malformed) = encoding_rs::UTF_8.decode(&data);
|
|
497
|
+
|
|
498
|
+
if malformed {
|
|
499
|
+
// Try other encodings
|
|
500
|
+
let (decoded, _encoding, _malformed) = encoding_rs::WINDOWS_1252.decode(&data);
|
|
501
|
+
Ok(decoded.to_string())
|
|
502
|
+
} else {
|
|
503
|
+
Ok(decoded.to_string())
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/// Parse input string (for text content)
|
|
508
|
+
fn parse(&self, input: String) -> Result<String, Error> {
|
|
509
|
+
if input.is_empty() {
|
|
510
|
+
return Err(Self::argument_error("Input cannot be empty"));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// For string input, just return cleaned text
|
|
514
|
+
// If strict mode is on, append indicator for testing
|
|
515
|
+
if self.config.strict_mode {
|
|
516
|
+
Ok(format!("{} strict=true", input.trim()))
|
|
517
|
+
} else {
|
|
518
|
+
Ok(input.trim().to_string())
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/// Parse a file
|
|
523
|
+
fn parse_file(&self, path: String) -> Result<String, Error> {
|
|
524
|
+
use std::fs;
|
|
525
|
+
|
|
526
|
+
let data = fs::read(&path)
|
|
527
|
+
.map_err(|e| Self::io_error("Failed to read file", e))?;
|
|
528
|
+
|
|
529
|
+
self.parse_bytes_internal(data, Some(&path))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/// Parse bytes from Ruby
|
|
533
|
+
fn parse_bytes(&self, data: Vec<u8>) -> Result<String, Error> {
|
|
534
|
+
if data.is_empty() {
|
|
535
|
+
return Err(Self::argument_error("Data cannot be empty"));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
self.parse_bytes_internal(data, None)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/// Get parser configuration
|
|
542
|
+
fn config(&self) -> Result<RHash, Error> {
|
|
543
|
+
let ruby = Ruby::get().unwrap();
|
|
544
|
+
let hash = ruby.hash_new();
|
|
545
|
+
hash.aset(ruby.to_symbol("strict_mode"), self.config.strict_mode)?;
|
|
546
|
+
hash.aset(ruby.to_symbol("max_depth"), self.config.max_depth)?;
|
|
547
|
+
hash.aset(ruby.to_symbol("encoding"), self.config.encoding.as_str())?;
|
|
548
|
+
hash.aset(ruby.to_symbol("max_size"), self.config.max_size)?;
|
|
549
|
+
Ok(hash)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/// Check if parser is in strict mode
|
|
553
|
+
fn strict_mode(&self) -> bool {
|
|
554
|
+
self.config.strict_mode
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/// Check supported file types
|
|
558
|
+
fn supported_formats() -> Vec<String> {
|
|
559
|
+
// Use the centralized list from FormatDetector
|
|
560
|
+
FormatDetector::supported_extensions()
|
|
561
|
+
.iter()
|
|
562
|
+
.map(|&s| s.to_string())
|
|
563
|
+
.collect()
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/// Detect if file extension is supported
|
|
567
|
+
fn supports_file(&self, path: String) -> bool {
|
|
568
|
+
if let Some(ext) = std::path::Path::new(&path)
|
|
569
|
+
.extension()
|
|
570
|
+
.and_then(|s| s.to_str())
|
|
571
|
+
{
|
|
572
|
+
Self::supported_formats().contains(&ext.to_lowercase())
|
|
573
|
+
} else {
|
|
574
|
+
false
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/// Module-level convenience function for parsing files
|
|
580
|
+
fn parse_file_direct(path: String) -> Result<String, Error> {
|
|
581
|
+
let parser = Parser {
|
|
582
|
+
config: ParserConfig::default(),
|
|
583
|
+
};
|
|
584
|
+
parser.parse_file(path)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/// Module-level convenience function for parsing binary data
|
|
588
|
+
fn parse_bytes_direct(data: Vec<u8>) -> Result<String, Error> {
|
|
589
|
+
let parser = Parser {
|
|
590
|
+
config: ParserConfig::default(),
|
|
591
|
+
};
|
|
592
|
+
parser.parse_bytes_internal(data, None)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/// Initialize the Parser class
|
|
596
|
+
pub fn init(_ruby: &Ruby, module: RModule) -> Result<(), Error> {
|
|
597
|
+
let class = module.define_class("Parser", Ruby::get().unwrap().class_object())?;
|
|
598
|
+
|
|
599
|
+
// Instance methods
|
|
600
|
+
class.define_singleton_method("new", function!(Parser::new, -1))?;
|
|
601
|
+
class.define_method("parse", method!(Parser::parse, 1))?;
|
|
602
|
+
class.define_method("parse_file", method!(Parser::parse_file, 1))?;
|
|
603
|
+
class.define_method("parse_bytes", method!(Parser::parse_bytes, 1))?;
|
|
604
|
+
class.define_method("config", method!(Parser::config, 0))?;
|
|
605
|
+
class.define_method("strict_mode?", method!(Parser::strict_mode, 0))?;
|
|
606
|
+
class.define_method("supports_file?", method!(Parser::supports_file, 1))?;
|
|
607
|
+
|
|
608
|
+
// Individual parser methods exposed to Ruby
|
|
609
|
+
class.define_method("parse_pdf", method!(Parser::parse_pdf, 1))?;
|
|
610
|
+
class.define_method("parse_docx", method!(Parser::parse_docx, 1))?;
|
|
611
|
+
class.define_method("parse_pptx", method!(Parser::parse_pptx, 1))?;
|
|
612
|
+
class.define_method("parse_xlsx", method!(Parser::parse_xlsx, 1))?;
|
|
613
|
+
class.define_method("parse_json", method!(Parser::parse_json, 1))?;
|
|
614
|
+
class.define_method("parse_xml", method!(Parser::parse_xml, 1))?;
|
|
615
|
+
class.define_method("parse_text", method!(Parser::parse_text, 1))?;
|
|
616
|
+
class.define_method("ocr_image", method!(Parser::ocr_image, 1))?;
|
|
617
|
+
|
|
618
|
+
// Format detection methods
|
|
619
|
+
class.define_method("detect_format_from_bytes", method!(Parser::detect_format_from_bytes, 1))?;
|
|
620
|
+
class.define_method("detect_format_from_filename", method!(Parser::detect_format_from_filename, 1))?;
|
|
621
|
+
|
|
622
|
+
// Class methods
|
|
623
|
+
class.define_singleton_method("supported_formats", function!(Parser::supported_formats, 0))?;
|
|
624
|
+
|
|
625
|
+
// Module-level convenience methods
|
|
626
|
+
module.define_singleton_method("parse_file", function!(parse_file_direct, 1))?;
|
|
627
|
+
module.define_singleton_method("parse_bytes", function!(parse_bytes_direct, 1))?;
|
|
628
|
+
|
|
629
|
+
Ok(())
|
|
630
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ParseKit
|
|
4
|
+
# Error classes are defined in the native extension
|
|
5
|
+
# This file is kept for documentation purposes
|
|
6
|
+
|
|
7
|
+
# Base error class for ParseKit (defined in native extension)
|
|
8
|
+
# class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Raised when parsing fails (defined in native extension)
|
|
11
|
+
# class ParseError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when configuration is invalid (defined in native extension)
|
|
14
|
+
# class ConfigError < Error; end
|
|
15
|
+
end
|