1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use crate::error::SdsError;
5
6const DEFAULT_MAX_LLM_CHARS: usize = 80_000;
8
9const MAX_BINARY_INPUT_BYTES: u64 = 500 * 1024 * 1024; const MAX_TEXT_INPUT_BYTES: u64 = 100 * 1024 * 1024; const OCR_FALLBACK_THRESHOLD: usize = 200;
14
15const PDFTOTEXT_MIN_FRACTION: usize = 75; pub enum InputFormat {
25 Pdf,
26 Docx,
27 Txt,
28 Xlsx,
29 Html,
30 Url,
31}
32
33pub fn detect_format(path: &Path) -> Result<InputFormat, SdsError> {
34 detect_format_str(
35 path.to_str()
36 .ok_or_else(|| SdsError::UnsupportedFormat("(invalid path)".to_string()))?,
37 )
38}
39
40pub fn detect_format_str(input: &str) -> Result<InputFormat, SdsError> {
42 if input.starts_with("http://") || input.starts_with("https://") {
43 return Ok(InputFormat::Url);
44 }
45 let ext = std::path::Path::new(input)
46 .extension()
47 .and_then(|e| e.to_str())
48 .map(|e| e.to_ascii_lowercase());
49 match ext.as_deref() {
50 Some("pdf") => Ok(InputFormat::Pdf),
51 Some("docx") => Ok(InputFormat::Docx),
52 Some("txt") => Ok(InputFormat::Txt),
53 Some("xlsx") | Some("xls") | Some("xlsm") => Ok(InputFormat::Xlsx),
54 Some("html") | Some("htm") => Ok(InputFormat::Html),
55 Some(e) => Err(SdsError::UnsupportedFormat(e.to_string())),
56 None => Err(SdsError::UnsupportedFormat("(no extension)".to_string())),
57 }
58}
59
60pub async fn extract_text(path: &Path) -> Result<String, SdsError> {
61 extract_text_limited(path, DEFAULT_MAX_LLM_CHARS).await
62}
63
64pub async fn detect_language_from_file(path: &Path) -> Result<crate::language::Language, SdsError> {
69 let sample = extract_text_limited(path, 5_000).await.unwrap_or_default();
70 Ok(crate::language::detect_language(&sample))
71}
72
73pub async fn detect_language_from_url(url: &str) -> Result<crate::language::Language, SdsError> {
75 let sample = extract_text_from_url_limited(url, 5_000).await.unwrap_or_default();
76 Ok(crate::language::detect_language(&sample))
77}
78
79pub async fn extract_text_from_url(url: &str) -> Result<String, SdsError> {
81 extract_text_from_url_limited(url, DEFAULT_MAX_LLM_CHARS).await
82}
83
84fn is_private_host(host: &str) -> bool {
86 use std::net::IpAddr;
87 if let Ok(ip) = host.parse::<IpAddr>() {
89 return match ip {
90 IpAddr::V4(v4) => {
91 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast()
96 }
97 IpAddr::V6(v6) => {
98 v6.is_loopback()
99 || v6.is_unspecified()
100 || v6.segments()[0] & 0xfe00 == 0xfc00
102 || v6.segments()[0] & 0xffc0 == 0xfe80
104 || {
106 let segs = v6.segments();
107 segs[0] == 0 && segs[1] == 0 && segs[2] == 0
108 && segs[3] == 0 && segs[4] == 0 && segs[5] == 0xffff
109 && {
110 let v4 = std::net::Ipv4Addr::new(
111 (segs[6] >> 8) as u8, segs[6] as u8,
112 (segs[7] >> 8) as u8, segs[7] as u8,
113 );
114 v4.is_loopback() || v4.is_private() || v4.is_link_local()
115 }
116 }
117 }
118 };
119 }
120 matches!(host,
122 "localhost" | "metadata.google.internal" | "instance-data"
123 )
124}
125
126pub async fn extract_text_from_url_limited(url: &str, max_chars: usize) -> Result<String, SdsError> {
128 let parsed = reqwest::Url::parse(url)
130 .map_err(|e| SdsError::Extract(format!("Invalid URL: {e}")))?;
131 let host = parsed
132 .host_str()
133 .ok_or_else(|| SdsError::Extract("URL has no host".into()))?;
134 if is_private_host(host) {
135 return Err(SdsError::Extract(
136 "URL points to a private/reserved address".into(),
137 ));
138 }
139
140 const MAX_BODY_BYTES: usize = 50 * 1024 * 1024; let response = shared_http_client()
143 .get(url)
144 .send()
145 .await
146 .map_err(|e| SdsError::Extract(format!("HTTP GET failed: {e}")))?;
147
148 if let Some(content_length) = response.content_length() {
150 if content_length > MAX_BODY_BYTES as u64 {
151 return Err(SdsError::Extract(format!(
152 "URL response too large ({} bytes, limit 50 MB)", content_length
153 )));
154 }
155 }
156
157 let bytes = response
158 .bytes()
159 .await
160 .map_err(|e| SdsError::Extract(format!("response body failed: {e}")))?;
161 if bytes.len() > MAX_BODY_BYTES {
162 return Err(SdsError::Extract(format!(
163 "URL response too large ({} bytes, limit 50 MB)", bytes.len()
164 )));
165 }
166 let html = String::from_utf8_lossy(&bytes).into_owned();
167 let raw = extract_text_from_html_str(&html);
168 Ok(clean_extracted_text(&raw, max_chars))
169}
170
171pub async fn extract_text_limited(path: &Path, max_chars: usize) -> Result<String, SdsError> {
173 let input_format = detect_format(path)?;
174 let size_limit = match &input_format {
175 InputFormat::Txt | InputFormat::Html => MAX_TEXT_INPUT_BYTES,
176 _ => MAX_BINARY_INPUT_BYTES,
177 };
178 let file_size = std::fs::metadata(path)
179 .map_err(|e| SdsError::Extract(format!("file stat failed: {e}")))?
180 .len();
181 if file_size > size_limit {
182 return Err(SdsError::Extract(format!(
183 "input file too large ({} bytes, limit {} MB)",
184 file_size,
185 size_limit / 1024 / 1024
186 )));
187 }
188 let raw = match input_format {
189 InputFormat::Pdf => {
190 let path_a = path.to_path_buf();
191 let path_b = path.to_path_buf();
192 let path_c = path.to_path_buf();
193
194 let raw = tokio::task::spawn_blocking(move || {
202 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
203 pdf_extract::extract_text(&path_a)
204 })) {
205 Ok(Ok(text)) => text,
206 Ok(Err(e)) => {
207 tracing::debug!("pdf-extract error: {e}; will try pdftotext/OCR");
208 String::new()
209 }
210 Err(_) => {
211 tracing::debug!(
212 "pdf-extract panicked (unsupported font encoding?); \
213 will try pdftotext/OCR"
214 );
215 String::new()
216 }
217 }
218 })
219 .await
220 .unwrap_or_default(); let pdf_extract_chars = raw.trim().chars().count();
223
224 let pt_text = tokio::task::spawn_blocking(move || pdftotext_fallback(&path_b))
231 .await
232 .unwrap_or(None);
233
234 let raw = if let Some(pt) = pt_text {
235 let pt_chars = pt.trim().chars().count();
236 let prefer_pt = pdf_extract_chars < OCR_FALLBACK_THRESHOLD
243 || (pt_chars >= OCR_FALLBACK_THRESHOLD
244 && pt_chars.saturating_mul(100)
245 >= pdf_extract_chars.saturating_mul(PDFTOTEXT_MIN_FRACTION));
246 if prefer_pt {
247 tracing::debug!(
248 pdf_extract_chars,
249 pt_chars,
250 "pdftotext preferred over pdf-extract"
251 );
252 pt
253 } else {
254 tracing::debug!(
255 pdf_extract_chars,
256 pt_chars,
257 "pdf-extract preferred over pdftotext (insufficient pdftotext output)"
258 );
259 raw
260 }
261 } else {
262 raw
263 };
264
265 if raw.trim().chars().count() < OCR_FALLBACK_THRESHOLD {
267 let ocr = tokio::task::spawn_blocking(move || ocr_pdf_with_tesseract(&path_c))
268 .await
269 .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())));
270
271 match ocr {
272 Ok(text) if !text.trim().is_empty() => text,
273 Err(e) => {
274 return Err(SdsError::ImageOnlyPdf(e.to_string()));
276 }
277 Ok(_) => raw, }
279 } else {
280 raw
281 }
282 }
283 InputFormat::Docx => {
284 let path = path.to_path_buf();
285 tokio::task::spawn_blocking(move || extract_text_from_docx(&path))
286 .await
287 .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())))?
288 }
289 InputFormat::Txt => {
290 let path = path.to_path_buf();
291 tokio::task::spawn_blocking(move || {
292 std::fs::read_to_string(&path).map_err(|e| SdsError::Extract(e.to_string()))
293 })
294 .await
295 .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())))?
296 }
297 InputFormat::Xlsx => {
298 let path = path.to_path_buf();
299 tokio::task::spawn_blocking(move || extract_text_from_xlsx(&path))
300 .await
301 .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())))?
302 }
303 InputFormat::Html => {
304 let path = path.to_path_buf();
305 tokio::task::spawn_blocking(move || {
306 let html = std::fs::read_to_string(&path)
307 .map_err(|e| SdsError::Extract(e.to_string()))?;
308 Ok(extract_text_from_html_str(&html))
309 })
310 .await
311 .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())))?
312 }
313 InputFormat::Url => {
314 return Err(SdsError::Extract(
315 "Use extract_text_from_url() for URL inputs".to_string(),
316 ));
317 }
318 };
319 Ok(clean_extracted_text(&raw, max_chars))
320}
321
322fn pdftotext_fallback(path: &Path) -> Option<String> {
338 let path_str = path.to_str()?;
339 let out = std::process::Command::new("pdftotext")
343 .args([path_str, "-"]) .output()
345 .ok()?;
346 if !out.status.success() {
347 return None;
348 }
349 let text = String::from_utf8_lossy(&out.stdout).into_owned();
350 if text.trim().is_empty() { None } else { Some(text) }
351}
352
353fn ocr_pdf_with_tesseract(pdf_path: &Path) -> Result<String, SdsError> {
362 use std::path::PathBuf;
363
364 let tmp = tempfile::tempdir()
365 .map_err(|e| SdsError::Extract(format!("OCR tmpdir: {e}")))?;
366
367 let page_prefix = tmp.path().join("page");
368
369 let status = std::process::Command::new("pdftoppm")
371 .args([
372 "-r", "300",
373 "-png",
374 pdf_path.to_str().unwrap_or(""),
375 page_prefix.to_str().unwrap_or(""),
376 ])
377 .status()
378 .map_err(|e| SdsError::Extract(format!(
379 "pdftoppm not found ({e}). \
380 Install poppler: `brew install poppler` / `apt install poppler-utils` / \
381 https://github.com/oschwartz10612/poppler-windows/releases"
382 )))?;
383
384 if !status.success() {
385 return Err(SdsError::Extract(format!("pdftoppm exited with {status}")));
386 }
387
388 let mut pngs: Vec<PathBuf> = std::fs::read_dir(tmp.path())
390 .map_err(|e| SdsError::Extract(e.to_string()))?
391 .filter_map(|e| e.ok())
392 .map(|e| e.path())
393 .filter(|p| {
394 p.extension()
395 .and_then(|e| e.to_str())
396 .map(|e| e.eq_ignore_ascii_case("png"))
397 .unwrap_or(false)
398 })
399 .collect();
400 pngs.sort();
401
402 if pngs.is_empty() {
403 return Err(SdsError::Extract("pdftoppm produced no images".to_string()));
404 }
405
406 let ocr_stem = tmp.path().join("ocr");
408 let mut combined = String::new();
409
410 for png in &pngs {
411 let ok = try_tesseract(png, &ocr_stem, "jpn+eng")
413 .or_else(|_| try_tesseract(png, &ocr_stem, "eng"))
414 .is_ok();
415
416 if ok {
417 let txt = ocr_stem.with_extension("txt");
418 if let Ok(page_text) = std::fs::read_to_string(&txt) {
419 combined.push_str(&page_text);
420 combined.push('\n');
421 }
422 }
423 }
424
425 Ok(combined)
427}
428
429fn try_tesseract(input: &Path, output_stem: &Path, lang: &str) -> Result<(), SdsError> {
430 let status = std::process::Command::new("tesseract")
431 .arg(input.to_str().unwrap_or(""))
432 .arg(output_stem.to_str().unwrap_or(""))
433 .args(["-l", lang])
434 .status()
435 .map_err(|e| SdsError::Extract(format!(
436 "tesseract not found ({e}). \
437 Install: `brew install tesseract tesseract-lang` / \
438 `apt install tesseract-ocr tesseract-ocr-jpn` / \
439 https://github.com/UB-Mannheim/tesseract/wiki"
440 )))?;
441
442 if !status.success() {
443 return Err(SdsError::Extract(format!(
444 "tesseract exited with {status} (lang={lang}; \
445 ensure the language pack is installed)"
446 )));
447 }
448 Ok(())
449}
450
451pub fn clean_extracted_text(text: &str, max_chars: usize) -> String {
458 let mut out = String::with_capacity(text.len().min(max_chars + 1024));
460 let mut blank_run = 0usize;
461
462 for line in text.lines() {
463 let trimmed = line.trim();
464
465 let trimmed: String = trimmed
467 .chars()
468 .filter(|&c| c >= ' ' || c == '\t')
469 .collect();
470 let trimmed = trimmed.trim();
471
472 if !trimmed.is_empty()
474 && trimmed.chars().all(|c| {
475 matches!(c,
476 '-' | '=' | '_' | '*' | '─' | '━' | '╌' | '╍'
477 | '┄' | '┅' | '┈' | '┉' | '╴' | '╶' | '╸'
478 | '·' | '•' | '~' | '/' | '\\' | '|' | '+' | '#'
479 )
480 })
481 && trimmed.chars().count() >= 3
482 {
483 continue;
484 }
485
486 if trimmed.is_empty() {
487 blank_run += 1;
488 if blank_run <= 1 {
489 out.push('\n');
490 }
491 } else {
492 blank_run = 0;
493 out.push_str(trimmed);
494 out.push('\n');
495 }
496 }
497
498 {
504 let mut freq: HashMap<String, usize> = HashMap::new();
505 for line in out.lines() {
506 if !line.is_empty() && line.len() <= 80 {
508 *freq.entry(line.to_string()).or_default() += 1;
509 }
510 }
511 let mut first_seen: HashSet<String> = HashSet::new();
512 let mut deduped = String::with_capacity(out.len());
513 for line in out.lines() {
514 let count = freq.get(line).copied().unwrap_or(1);
515 if !line.is_empty() && line.len() <= 80 && count >= 4 {
516 if first_seen.insert(line.to_string()) {
517 deduped.push_str(line);
518 deduped.push('\n');
519 }
520 } else {
521 deduped.push_str(line);
522 deduped.push('\n');
523 }
524 }
525 out = deduped;
526 }
527
528 if out.chars().count() > max_chars {
531 let byte_offset = out
532 .char_indices()
533 .nth(max_chars)
534 .map(|(i, _)| i)
535 .unwrap_or(out.len());
536 out.truncate(byte_offset);
537 out.push_str("\n[テキスト省略]\n");
538 }
539
540 out
541}
542
543pub fn extract_text_from_docx(path: &Path) -> Result<String, SdsError> {
544 let docx = docx_rust::DocxFile::from_file(path)
545 .map_err(|e| SdsError::Docx(format!("open failed: {e:?}")))?;
546 let docx = docx
547 .parse()
548 .map_err(|e| SdsError::Docx(format!("parse failed: {e:?}")))?;
549 Ok(docx.document.body.text())
550}
551
552pub fn extract_text_from_xlsx(path: &Path) -> Result<String, SdsError> {
553 use calamine::{open_workbook_auto, Reader};
554 let mut wb = open_workbook_auto(path)
555 .map_err(|e| SdsError::Extract(format!("xlsx open failed: {e}")))?;
556 let mut out = String::new();
557 for sheet_name in wb.sheet_names().to_owned() {
558 if let Ok(range) = wb.worksheet_range(&sheet_name) {
559 for row in range.rows() {
560 let cells: Vec<String> = row
561 .iter()
562 .map(|c| c.to_string())
563 .filter(|s| !s.is_empty())
564 .collect();
565 if !cells.is_empty() {
566 out.push_str(&cells.join("\t"));
567 out.push('\n');
568 }
569 }
570 }
571 }
572 Ok(out)
573}
574
575fn html_selectors() -> (&'static scraper::Selector, &'static scraper::Selector, &'static scraper::Selector) {
577 use scraper::Selector;
578 use std::sync::OnceLock;
579 static ROW: OnceLock<Selector> = OnceLock::new();
580 static CELL: OnceLock<Selector> = OnceLock::new();
581 static BODY: OnceLock<Selector> = OnceLock::new();
582 (
583 ROW.get_or_init(|| Selector::parse("tr").expect("static CSS selector is valid")),
584 CELL.get_or_init(|| Selector::parse("td, th").expect("static CSS selector is valid")),
585 BODY.get_or_init(|| Selector::parse("body").expect("static CSS selector is valid")),
586 )
587}
588
589fn shared_http_client() -> &'static reqwest::Client {
592 use std::sync::OnceLock;
593 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
594 CLIENT.get_or_init(|| {
595 reqwest::Client::builder()
596 .timeout(std::time::Duration::from_secs(60))
597 .redirect(reqwest::redirect::Policy::none())
599 .build()
600 .expect("failed to build shared HTTP client")
601 })
602}
603
604pub fn extract_text_from_html_str(html: &str) -> String {
607 use scraper::Html;
608
609 let document = Html::parse_document(html);
610 let (row_sel, cell_sel, body_sel) = html_selectors();
611
612 let body = match document.select(&body_sel).next() {
613 Some(b) => b,
614 None => return String::new(),
615 };
616
617 let mut out = String::new();
618
619 for node in body.children() {
620 collect_node_text(
621 scraper::ElementRef::wrap(node),
622 &row_sel,
623 &cell_sel,
624 &mut out,
625 );
626 }
627
628 out
629}
630
631fn collect_node_text(
632 node: Option<scraper::ElementRef<'_>>,
633 row_sel: &scraper::Selector,
634 cell_sel: &scraper::Selector,
635 out: &mut String,
636) {
637 let Some(el) = node else { return };
638 let tag = el.value().name();
639
640 if tag == "table" {
641 for row in el.select(row_sel) {
642 let cells: Vec<String> = row
643 .select(cell_sel)
644 .map(|c| c.text().collect::<String>().trim().to_string())
645 .filter(|s| !s.is_empty())
646 .collect();
647 if !cells.is_empty() {
648 out.push_str(&cells.join("\t"));
649 out.push('\n');
650 }
651 }
652 return;
653 }
654
655 if matches!(tag, "script" | "style" | "nav" | "header" | "footer" | "noscript") {
657 return;
658 }
659
660 let is_block = matches!(
662 tag,
663 "p" | "div" | "section" | "article" | "li" | "dt" | "dd"
664 | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
665 | "br" | "hr" | "blockquote" | "pre"
666 );
667
668 if is_block && !out.ends_with('\n') {
669 out.push('\n');
670 }
671
672 for child in el.children() {
673 if let Some(text) = child.value().as_text() {
674 let t = text.trim();
675 if !t.is_empty() {
676 out.push_str(t);
677 out.push(' ');
678 }
679 } else if let Some(child_el) = scraper::ElementRef::wrap(child) {
680 collect_node_text(Some(child_el), row_sel, cell_sel, out);
681 }
682 }
683
684 if is_block && !out.ends_with('\n') {
685 out.push('\n');
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn separator_lines_are_dropped() {
695 let input = "Section 1\n---\nContent\n===\nMore content\n";
696 let result = clean_extracted_text(input, 1000);
697 assert!(!result.contains("---"));
698 assert!(!result.contains("==="));
699 assert!(result.contains("Section 1"));
700 assert!(result.contains("Content"));
701 }
702
703 #[test]
704 fn multiple_blank_lines_collapse_to_one() {
705 let input = "Line A\n\n\n\nLine B\n";
706 let result = clean_extracted_text(input, 1000);
707 assert!(!result.contains("\n\n\n"));
709 assert!(result.contains("Line A"));
710 assert!(result.contains("Line B"));
711 }
712
713 #[test]
714 fn cjk_content_passes_through() {
715 let input = "第1節 化学品の名称\n製品名:テスト化学物質\n";
716 let result = clean_extracted_text(input, 1000);
717 assert!(result.contains("第1節"));
718 assert!(result.contains("テスト化学物質"));
719 }
720
721 #[test]
722 fn truncation_lands_on_utf8_boundary() {
723 let input: String = "あ".repeat(100);
724 let result = clean_extracted_text(&input, 10);
725 assert!(std::str::from_utf8(result.as_bytes()).is_ok());
726 }
727
728 #[test]
729 fn repeated_header_lines_deduplicated() {
730 let header = "Company Inc. SDS";
731 let mut input = String::new();
732 for i in 0..10 {
733 input.push_str(header);
734 input.push('\n');
735 input.push_str(&format!("Section {i} content\n"));
736 }
737 let result = clean_extracted_text(&input, 10_000);
738 let count = result.matches(header).count();
739 assert_eq!(count, 1, "header appeared {count} times, expected 1");
740 }
741
742 #[test]
743 fn short_non_repeated_lines_kept() {
744 let input = "Line A\nLine B\nLine C\n";
745 let result = clean_extracted_text(input, 1000);
746 assert!(result.contains("Line A"));
747 assert!(result.contains("Line B"));
748 assert!(result.contains("Line C"));
749 }
750
751 #[test]
755 fn blank_lines_not_removed_by_dedup() {
756 let input = "Section A content\n\nSection B content\n\nSection C content\n\nSection D content\n\nSection E content\n";
758 let result = clean_extracted_text(input, 10_000);
759 assert!(
760 result.contains("Section A content\n\nSection B content"),
761 "blank line separator between A and B was removed; result: {result:?}"
762 );
763 assert!(
764 result.contains("Section B content\n\nSection C content"),
765 "blank line separator between B and C was removed; result: {result:?}"
766 );
767 }
768
769 #[test]
775 fn short_repeated_values_preserved() {
776 let repeated = "該当区分なし";
777 let mut input = String::new();
778 for i in 0..8 {
779 input.push_str(&format!("危険有害性クラス{i}: {repeated}\n"));
780 }
781 let result = clean_extracted_text(&input, 10_000);
782 let count = result.matches(repeated).count();
783 assert_eq!(count, 8, "short repeated value appeared {count} times, expected 8");
784 }
785}