1use std::io::{Cursor, Read};
4use std::path::{Path, PathBuf};
5
6use encoding_rs::Encoding;
7use runmat_builtins::{
8 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
9 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10 ResolveContext, Type, Value,
11};
12use runmat_macros::runtime_builtin;
13
14use crate::builtins::strings::core::compat::scalar_text;
15use crate::builtins::strings::text_analytics::html::{extract_html_text_value, ExtractionMethod};
16use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult};
17
18const NAME: &str = "extractFileText";
19const MAX_FILE_BYTES: usize = 512 * 1024 * 1024;
20const MAX_DOCX_XML_BYTES: usize = 128 * 1024 * 1024;
21
22const OUT_TEXT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
23 name: "str",
24 ty: BuiltinParamType::StringScalar,
25 arity: BuiltinParamArity::Required,
26 default: None,
27 description: "Extracted text.",
28}];
29
30const IN_FILE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31 name: "filename",
32 ty: BuiltinParamType::Any,
33 arity: BuiltinParamArity::Required,
34 default: None,
35 description: "Path to a text, HTML, or DOCX file.",
36}];
37
38const IN_FILE_REST: [BuiltinParamDescriptor; 2] = [
39 BuiltinParamDescriptor {
40 name: "filename",
41 ty: BuiltinParamType::Any,
42 arity: BuiltinParamArity::Required,
43 default: None,
44 description: "Path to a text, HTML, or DOCX file.",
45 },
46 BuiltinParamDescriptor {
47 name: "NameValue",
48 ty: BuiltinParamType::Any,
49 arity: BuiltinParamArity::Variadic,
50 default: None,
51 description: "Name-value options: Encoding and ExtractionMethod in this slice.",
52 },
53];
54
55const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
56 code: "RM.EXTRACT_FILE_TEXT.INVALID_INPUT",
57 identifier: Some("RunMat:extractFileText:InvalidInput"),
58 when: "Inputs do not match a supported extractFileText form.",
59 message: "extractFileText: invalid input",
60};
61
62const ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
63 code: "RM.EXTRACT_FILE_TEXT.IO",
64 identifier: Some("RunMat:extractFileText:IOError"),
65 when: "The requested file cannot be read.",
66 message: "extractFileText: file read failed",
67};
68
69const ERROR_UNSUPPORTED: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
70 code: "RM.EXTRACT_FILE_TEXT.UNSUPPORTED",
71 identifier: Some("RunMat:extractFileText:UnsupportedFormat"),
72 when: "The requested file type or option requires unsupported extraction infrastructure.",
73 message: "extractFileText: unsupported file type or option",
74};
75
76const ERRORS: [BuiltinErrorDescriptor; 3] = [ERROR_INVALID_INPUT, ERROR_IO, ERROR_UNSUPPORTED];
77
78pub const EXTRACT_FILE_TEXT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
79 signatures: &[
80 BuiltinSignatureDescriptor {
81 label: "str = extractFileText(filename)",
82 inputs: &IN_FILE,
83 outputs: &OUT_TEXT,
84 },
85 BuiltinSignatureDescriptor {
86 label: "str = extractFileText(filename,Name,Value)",
87 inputs: &IN_FILE_REST,
88 outputs: &OUT_TEXT,
89 },
90 ],
91 output_mode: BuiltinOutputMode::Fixed,
92 completion_policy: BuiltinCompletionPolicy::Public,
93 errors: &ERRORS,
94};
95
96fn string_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
97 Type::String
98}
99
100fn extract_error(
101 error: &'static BuiltinErrorDescriptor,
102 message: impl Into<String>,
103) -> crate::RuntimeError {
104 let mut builder = build_runtime_error(message).with_builtin(NAME);
105 if let Some(identifier) = error.identifier {
106 builder = builder.with_identifier(identifier);
107 }
108 builder.build()
109}
110
111#[runtime_builtin(
112 name = "extractFileText",
113 category = "strings/text_analytics",
114 summary = "Read text from text, HTML, and DOCX files.",
115 keywords = "extractFileText,text analytics,file,text,HTML,DOCX",
116 accel = "sink",
117 type_resolver(string_type),
118 descriptor(crate::builtins::strings::text_analytics::file_text::EXTRACT_FILE_TEXT_DESCRIPTOR),
119 builtin_path = "crate::builtins::strings::text_analytics::file_text"
120)]
121async fn extract_file_text_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
122 let args = gather_args(args).await?;
123 let (source, options) = parse_args(args)?;
124 if is_url(&source) {
125 return Err(extract_error(
126 &ERROR_UNSUPPORTED,
127 "extractFileText: URL extraction requires website fetching support and remains tracked",
128 ));
129 }
130 let path = PathBuf::from(&source);
131 let kind = FileKind::from_path(&path);
132 validate_options_for_kind(kind, &options)?;
133 let bytes = read_file(&path).await?;
134 let text = match kind {
135 FileKind::Html => {
136 let html = decode_bytes(&bytes, options.encoding.as_deref())?;
137 let method = options.extraction_method.unwrap_or(ExtractionMethod::Tree);
138 match extract_html_text_value(Value::String(html), method)? {
139 Value::String(text) => text,
140 other => {
141 return Err(extract_error(
142 &ERROR_INVALID_INPUT,
143 format!("extractFileText: unexpected HTML extraction output {other:?}"),
144 ))
145 }
146 }
147 }
148 FileKind::Docx => extract_docx_text(&bytes)?,
149 FileKind::Pdf => unreachable!("PDF files are rejected before file IO"),
150 FileKind::PlainText => decode_bytes(&bytes, options.encoding.as_deref())?,
151 };
152 Ok(Value::String(text))
153}
154
155async fn gather_args(args: Vec<Value>) -> BuiltinResult<Vec<Value>> {
156 let mut out = Vec::with_capacity(args.len());
157 for arg in args {
158 out.push(gather_if_needed_async(&arg).await.map_err(|err| {
159 extract_error(
160 &ERROR_INVALID_INPUT,
161 format!("extractFileText: failed to gather input: {err}"),
162 )
163 })?);
164 }
165 Ok(out)
166}
167
168#[derive(Clone, Debug, Default)]
169struct ExtractFileTextOptions {
170 encoding: Option<String>,
171 extraction_method: Option<ExtractionMethod>,
172 password: Option<String>,
173 pages: Option<Vec<usize>>,
174}
175
176fn parse_args(args: Vec<Value>) -> BuiltinResult<(String, ExtractFileTextOptions)> {
177 if args.is_empty() {
178 return Err(extract_error(
179 &ERROR_INVALID_INPUT,
180 "extractFileText: expected filename input",
181 ));
182 }
183 if !(args.len() - 1).is_multiple_of(2) {
184 return Err(extract_error(
185 &ERROR_INVALID_INPUT,
186 "extractFileText: name-value options must appear in pairs",
187 ));
188 }
189 let source = scalar_file_input(&args[0])?;
190 let mut options = ExtractFileTextOptions::default();
191 let mut idx = 1usize;
192 while idx < args.len() {
193 let name = scalar_text(&args[idx], NAME)
194 .map_err(|err| extract_error(&ERROR_INVALID_INPUT, err.to_string()))?;
195 match name.to_ascii_lowercase().as_str() {
196 "encoding" => {
197 options.encoding = Some(
198 scalar_text(&args[idx + 1], NAME)
199 .map_err(|err| extract_error(&ERROR_INVALID_INPUT, err.to_string()))?,
200 );
201 }
202 "extractionmethod" => {
203 let method = scalar_text(&args[idx + 1], NAME)
204 .map_err(|err| extract_error(&ERROR_INVALID_INPUT, err.to_string()))?;
205 options.extraction_method = Some(
206 ExtractionMethod::parse(&method)
207 .map_err(|err| extract_error(&ERROR_INVALID_INPUT, err.to_string()))?,
208 );
209 }
210 "password" => {
211 options.password = Some(
212 scalar_text(&args[idx + 1], NAME)
213 .map_err(|err| extract_error(&ERROR_INVALID_INPUT, err.to_string()))?,
214 );
215 }
216 "pages" => {
217 options.pages = Some(parse_pages(&args[idx + 1])?);
218 }
219 other => {
220 return Err(extract_error(
221 &ERROR_INVALID_INPUT,
222 format!("extractFileText: unsupported option '{other}'"),
223 ))
224 }
225 }
226 idx += 2;
227 }
228 Ok((source, options))
229}
230
231fn scalar_file_input(value: &Value) -> BuiltinResult<String> {
232 match value {
233 Value::String(value) => nonempty_source(value),
234 Value::StringArray(array) if array.data.len() == 1 => nonempty_source(&array.data[0]),
235 Value::CharArray(array) if array.rows <= 1 => {
236 nonempty_source(&array.data.iter().collect::<String>())
237 }
238 Value::Cell(cell) if cell.data.len() == 1 => scalar_file_input(&cell.data[0]),
239 other => Err(extract_error(
240 &ERROR_INVALID_INPUT,
241 format!("extractFileText: expected scalar filename or URL, got {other:?}"),
242 )),
243 }
244}
245
246fn nonempty_source(value: &str) -> BuiltinResult<String> {
247 let trimmed = value.trim();
248 if trimmed.is_empty() {
249 Err(extract_error(
250 &ERROR_INVALID_INPUT,
251 "extractFileText: filename must not be empty",
252 ))
253 } else {
254 Ok(trimmed.to_string())
255 }
256}
257
258fn parse_pages(value: &Value) -> BuiltinResult<Vec<usize>> {
259 let raw = match value {
260 Value::Num(value) => vec![*value],
261 Value::Tensor(tensor) => tensor.data.clone(),
262 other => {
263 return Err(extract_error(
264 &ERROR_INVALID_INPUT,
265 format!("extractFileText: Pages must be a positive integer vector, got {other:?}"),
266 ))
267 }
268 };
269 if raw.is_empty() {
270 return Err(extract_error(
271 &ERROR_INVALID_INPUT,
272 "extractFileText: Pages must not be empty",
273 ));
274 }
275 raw.into_iter()
276 .map(|value| {
277 if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
278 return Err(extract_error(
279 &ERROR_INVALID_INPUT,
280 format!("extractFileText: Pages values must be positive integers, got {value}"),
281 ));
282 }
283 Ok(value as usize)
284 })
285 .collect()
286}
287
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
289enum FileKind {
290 PlainText,
291 Html,
292 Docx,
293 Pdf,
294}
295
296impl FileKind {
297 fn from_path(path: &Path) -> Self {
298 match path
299 .extension()
300 .and_then(|ext| ext.to_str())
301 .map(|ext| ext.to_ascii_lowercase())
302 .as_deref()
303 {
304 Some("html" | "htm" | "xhtml") => Self::Html,
305 Some("docx") => Self::Docx,
306 Some("pdf") => Self::Pdf,
307 _ => Self::PlainText,
308 }
309 }
310}
311
312fn validate_options_for_kind(
313 kind: FileKind,
314 options: &ExtractFileTextOptions,
315) -> BuiltinResult<()> {
316 if kind == FileKind::Pdf {
317 return Err(extract_error(
318 &ERROR_UNSUPPORTED,
319 "extractFileText: PDF extraction requires PDF text extraction support and remains tracked",
320 ));
321 }
322 if options.password.is_some() || options.pages.is_some() {
323 return Err(extract_error(
324 &ERROR_UNSUPPORTED,
325 "extractFileText: Password and Pages options require PDF extraction support and remain tracked",
326 ));
327 }
328 if options.encoding.is_some() && kind == FileKind::Docx {
329 return Err(extract_error(
330 &ERROR_INVALID_INPUT,
331 "extractFileText: Encoding is supported for text and HTML files only",
332 ));
333 }
334 if options.extraction_method.is_some() && kind != FileKind::Html {
335 return Err(extract_error(
336 &ERROR_INVALID_INPUT,
337 "extractFileText: ExtractionMethod is supported for HTML files only",
338 ));
339 }
340 Ok(())
341}
342
343fn is_url(value: &str) -> bool {
344 value.starts_with("http://") || value.starts_with("https://")
345}
346
347async fn read_file(path: &Path) -> BuiltinResult<Vec<u8>> {
348 let bytes = runmat_filesystem::read_async(path).await.map_err(|err| {
349 extract_error(
350 &ERROR_IO,
351 format!(
352 "extractFileText: unable to read '{}': {err}",
353 path.display()
354 ),
355 )
356 })?;
357 if bytes.len() > MAX_FILE_BYTES {
358 return Err(extract_error(
359 &ERROR_UNSUPPORTED,
360 format!(
361 "extractFileText: file '{}' exceeds the {} byte extraction limit",
362 path.display(),
363 MAX_FILE_BYTES
364 ),
365 ));
366 }
367 Ok(bytes)
368}
369
370fn decode_bytes(bytes: &[u8], encoding: Option<&str>) -> BuiltinResult<String> {
371 let encoding = encoding.unwrap_or("utf-8");
372 let Some(encoding) = Encoding::for_label(encoding.trim().as_bytes()) else {
373 return Err(extract_error(
374 &ERROR_INVALID_INPUT,
375 format!("extractFileText: unsupported Encoding '{encoding}'"),
376 ));
377 };
378 let (text, _, had_errors) = encoding.decode(bytes);
379 if had_errors && encoding.name().eq_ignore_ascii_case("UTF-8") {
380 return Err(extract_error(
381 &ERROR_INVALID_INPUT,
382 "extractFileText: unable to decode file as UTF-8",
383 ));
384 }
385 Ok(text.into_owned())
386}
387
388fn extract_docx_text(bytes: &[u8]) -> BuiltinResult<String> {
389 let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| {
390 extract_error(
391 &ERROR_UNSUPPORTED,
392 format!("extractFileText: unable to open DOCX archive: {err}"),
393 )
394 })?;
395 let mut document = archive.by_name("word/document.xml").map_err(|err| {
396 extract_error(
397 &ERROR_UNSUPPORTED,
398 format!("extractFileText: DOCX archive is missing word/document.xml: {err}"),
399 )
400 })?;
401 if document.size() > MAX_DOCX_XML_BYTES as u64 {
402 return Err(extract_error(
403 &ERROR_UNSUPPORTED,
404 "extractFileText: DOCX document.xml is too large for this slice",
405 ));
406 }
407 let mut xml = String::new();
408 document.read_to_string(&mut xml).map_err(|err| {
409 extract_error(
410 &ERROR_INVALID_INPUT,
411 format!("extractFileText: unable to decode DOCX XML as UTF-8: {err}"),
412 )
413 })?;
414 Ok(text_from_docx_document_xml(&xml))
415}
416
417fn text_from_docx_document_xml(xml: &str) -> String {
418 let mut out = String::new();
419 let mut cursor = 0usize;
420 while cursor < xml.len() {
421 let next_text = xml[cursor..].find("<w:t").map(|idx| cursor + idx);
422 let next_paragraph = xml[cursor..].find("</w:p>").map(|idx| cursor + idx);
423 let next_break = xml[cursor..].find("<w:br").map(|idx| cursor + idx);
424 let next_tab = xml[cursor..].find("<w:tab").map(|idx| cursor + idx);
425 let next = [next_text, next_paragraph, next_break, next_tab]
426 .into_iter()
427 .flatten()
428 .min();
429 let Some(pos) = next else {
430 break;
431 };
432 if Some(pos) == next_text {
433 let Some(start_rel) = xml[pos..].find('>') else {
434 break;
435 };
436 let text_start = pos + start_rel + 1;
437 let Some(end_rel) = xml[text_start..].find("</w:t>") else {
438 break;
439 };
440 let text_end = text_start + end_rel;
441 out.push_str(&xml_unescape(&xml[text_start..text_end]));
442 cursor = text_end + "</w:t>".len();
443 } else if Some(pos) == next_break {
444 if !out.ends_with('\n') {
445 out.push('\n');
446 }
447 cursor = xml[pos..]
448 .find('>')
449 .map(|idx| pos + idx + 1)
450 .unwrap_or(xml.len());
451 } else if Some(pos) == next_tab {
452 out.push('\t');
453 cursor = xml[pos..]
454 .find('>')
455 .map(|idx| pos + idx + 1)
456 .unwrap_or(xml.len());
457 } else {
458 if !out.is_empty() && !out.ends_with('\n') {
459 out.push('\n');
460 }
461 cursor = pos + "</w:p>".len();
462 }
463 }
464 out.trim_end_matches('\n').to_string()
465}
466
467fn xml_unescape(value: &str) -> String {
468 value
469 .replace("<", "<")
470 .replace(">", ">")
471 .replace(""", "\"")
472 .replace("'", "'")
473 .replace("&", "&")
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::builtins::common::test_support;
480 use runmat_builtins::{CellArray, Tensor};
481 use runmat_time::unix_timestamp_ms;
482 use std::io::Write;
483
484 fn run(args: Vec<Value>) -> BuiltinResult<Value> {
485 futures::executor::block_on(extract_file_text_builtin(args))
486 }
487
488 fn unique_path(name: &str, ext: &str) -> PathBuf {
489 let mut path = std::env::temp_dir();
490 path.push(format!(
491 "runmat_extract_file_text_{}_{}_{}.{}",
492 name,
493 std::process::id(),
494 unix_timestamp_ms(),
495 ext
496 ));
497 path
498 }
499
500 fn string_value(value: Value) -> String {
501 match value {
502 Value::String(text) => text,
503 other => panic!("expected string, got {other:?}"),
504 }
505 }
506
507 #[cfg(not(target_arch = "wasm32"))]
508 #[test]
509 fn reads_plain_text_file_with_encoding_option() {
510 let path = unique_path("plain", "txt");
511 test_support::fs::write(&path, b"caf\xe9").expect("write sample");
512 let value = run(vec![
513 Value::String(path.to_string_lossy().to_string()),
514 Value::String("Encoding".to_string()),
515 Value::String("windows-1252".to_string()),
516 ])
517 .expect("extract");
518 assert_eq!(string_value(value), "café");
519 let _ = test_support::fs::remove_file(&path);
520 }
521
522 #[cfg(not(target_arch = "wasm32"))]
523 #[test]
524 fn extracts_visible_text_from_html_file() {
525 let path = unique_path("html", "html");
526 test_support::fs::write(
527 &path,
528 "<html><body><h1>Title</h1><script>hidden()</script><p>Body</p></body></html>",
529 )
530 .expect("write sample");
531 let value = run(vec![
532 Value::String(path.to_string_lossy().to_string()),
533 Value::String("ExtractionMethod".to_string()),
534 Value::String("all-text".to_string()),
535 ])
536 .expect("extract");
537 let text = string_value(value);
538 assert!(text.contains("Title"));
539 assert!(text.contains("Body"));
540 assert!(!text.contains("hidden"));
541 let _ = test_support::fs::remove_file(&path);
542 }
543
544 #[cfg(not(target_arch = "wasm32"))]
545 #[test]
546 fn extracts_text_from_docx_document_xml() {
547 let path = unique_path("docx", "docx");
548 let file = std::fs::File::create(&path).expect("create docx");
549 let mut zip = zip::ZipWriter::new(file);
550 let options = zip::write::SimpleFileOptions::default()
551 .compression_method(zip::CompressionMethod::Stored);
552 zip.start_file("word/document.xml", options)
553 .expect("start document");
554 zip.write_all(
555 br#"<w:document><w:body><w:p><w:r><w:t>Hello & welcome</w:t></w:r></w:p><w:p><w:r><w:t>Second</w:t></w:r></w:p></w:body></w:document>"#,
556 )
557 .expect("write document");
558 zip.finish().expect("finish docx");
559
560 let value = run(vec![Value::String(path.to_string_lossy().to_string())]).expect("extract");
561 assert_eq!(string_value(value), "Hello & welcome\nSecond");
562 let _ = test_support::fs::remove_file(&path);
563 }
564
565 #[cfg(not(target_arch = "wasm32"))]
566 #[test]
567 fn rejects_pdf_options_and_urls_as_tracked_gaps() {
568 let err = run(vec![
569 Value::String("https://example.com".to_string()),
570 Value::String("ExtractionMethod".to_string()),
571 Value::String("tree".to_string()),
572 ])
573 .expect_err("expected URL rejection");
574 assert!(err.to_string().contains("URL extraction"));
575
576 let err = run(vec![
577 Value::String("paper.pdf".to_string()),
578 Value::String("Pages".to_string()),
579 Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).unwrap()),
580 ])
581 .expect_err("expected PDF option rejection");
582 assert!(err.to_string().contains("PDF extraction"));
583
584 let err =
585 run(vec![Value::String("paper.pdf".to_string())]).expect_err("expected PDF rejection");
586 assert!(err.to_string().contains("PDF extraction"));
587
588 let err = run(vec![
589 Value::String("report.docx".to_string()),
590 Value::String("Encoding".to_string()),
591 Value::String("UTF-8".to_string()),
592 ])
593 .expect_err("expected DOCX encoding rejection");
594 assert!(err.to_string().contains("Encoding is supported"));
595 }
596
597 #[cfg(not(target_arch = "wasm32"))]
598 #[test]
599 fn accepts_single_cell_filename_input() {
600 let path = unique_path("cell", "txt");
601 test_support::fs::write(&path, "cell path").expect("write sample");
602 let cell = CellArray::new(
603 vec![Value::String(path.to_string_lossy().to_string())],
604 1,
605 1,
606 )
607 .unwrap();
608 let value = run(vec![Value::Cell(cell)]).expect("extract");
609 assert_eq!(string_value(value), "cell path");
610 let _ = test_support::fs::remove_file(&path);
611 }
612}