package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.tool.guard.WorkspacePathGuard;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* Document text extraction tool.
* Supports PDF, DOCX, XLSX, PPTX, HTML with format-specific fallback chains.
*
* Strategy by format:
* - PDF: pdftotext -> pdfplumber/pypdf -> pdfbox -> OCR (scanned) -> Tika
* - DOCX: textutil / pandoc / libreoffice -> ZIP+XML -> Tika
* - XLSX/PPTX: Tika directly (POI-based; correctly resolves the shared-strings
* indirection table and walks SmartArt / chart / grouped-shape
* text that a naive ZIP+XML scan misses).
* - HTML: jsoup parse -> drop script/style/nav/footer noise -> keep
* heading hierarchy as Markdown ATX lines.
*/
@Slf4j
@Component
public class DocumentExtractTool {
private static final int COMMAND_TIMEOUT_SECONDS = 30;
private static final int MAX_OUTPUT_LENGTH = 500000; // 500KB — CLOB column has no size limit
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("win");
@Tool(description = """
从 Office/PDF 文档中提取文本内容。
支持的格式:
- PDF (.pdf)
- Word (.docx, .doc)
- Excel (.xlsx, .xls) - 提取为文本表格
- PowerPoint (.pptx, .ppt)
- HTML (.html, .htm) - jsoup 清洗后提取正文
提取策略(按格式分链):
- PDF: pdftotext → pdfplumber/pypdf → pdfbox → OCR(扫描版) → Tika
- DOCX: textutil / pandoc / libreoffice → ZIP-XML → Tika
- XLSX/PPTX: 直接走 Tika(基于 POI,正确解析 sharedStrings 表与 SmartArt / 图表文本)
- HTML: jsoup 解析 → 去除 script/style/nav/footer 等噪音 → 保留标题层级
- 返回详细的提取过程和元数据
参数 options 可包含:
- pages: 指定页码范围(如 "1-5" 或 "1,3,5")
- preserveLayout: 是否保留布局(默认 true)
- method: 强制指定提取器,跳过自动 fallback 链。当前支持:
* "auto"(默认)—— 走完整 fallback 链
* "tika" —— 直接用 Apache Tika 抽取,适合 Windows 上没装
Poppler/Python 的环境,或验证 Tika 单独是否能解开
如果提取失败,会返回详细的尝试过程和错误信息
""")
public String extract_document_text(
@ToolParam(description = "文件的绝对路径或相对路径") String filePath,
@ToolParam(description = "可选参数 JSON,如 {\"pages\": \"1-5\", \"method\": \"tika\"}", required = false) String options,
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. Carries the
// ChatOrigin so the workspace boundary check honors per-agent basePath.
@Nullable ToolContext ctx) {
Path path;
try {
path = WorkspacePathGuard.validatePath(filePath, ctx);
} catch (IllegalArgumentException e) {
// Sandbox rejected the literal path. Try chat-upload basename
// resolution before surfacing the boundary error.
Path attachment = ChatUploadResolver.resolve(filePath);
if (attachment == null) {
return errorResult(filePath, e.getMessage(), new ArrayList<>());
}
path = attachment;
}
return extractResolved(filePath, path, options);
}
/**
* Internal, sandbox-exempt extraction for server-managed file paths.
*
* The wiki ingest pipeline stages an uploaded raw material under its own
* upload directory and feeds that stored path straight back here. The path
* is produced by the server, never by the model, so the workspace boundary
* guard — whose job is to stop the LLM reading arbitrary disk locations —
* must not apply: the wiki upload dir is a sibling of the global sandbox root
* and would otherwise be rejected as "outside workspace boundary", surfacing
* to the user as "No text content available". Callers must pass a path the
* server itself produced, not anything derived from model output.
*
* @param filePath absolute, server-controlled path to the staged document
* @param options same options JSON accepted by {@link #extract_document_text}
*/
public String extractTrustedDocument(String filePath, String options) {
Path path = Paths.get(filePath).toAbsolutePath().normalize();
return extractResolved(filePath, path, options);
}
/**
* Shared extraction body running on an already-resolved {@link Path}: detects
* the document type and drives the per-format extractor chain. Both the
* sandbox-guarded {@link #extract_document_text} tool entry and the trusted
* {@link #extractTrustedDocument} internal entry funnel through here so the
* extraction logic stays in one place.
*/
private String extractResolved(String filePath, Path path, String options) {
JSONObject result = new JSONObject();
result.set("filePath", filePath);
List attempts = new ArrayList<>();
try {
if (!Files.exists(path)) {
// The user-uploaded chat attachment is rendered to the LLM as
// "[附件] foo.docx" without its stored path, and Chinese / non-ASCII
// filenames are sanitized at upload time (see ChatController#upload),
// so the LLM-supplied path won't match anything on disk. Fall back to
// basename matching inside the conversation's chat-upload directory.
Path attachment = ChatUploadResolver.resolve(filePath);
if (attachment == null) {
return errorResult(filePath, "文件不存在: " + path, attempts);
}
log.info("[DocumentExtract] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment);
path = attachment;
}
// 解析文件类型
String mimeType = detectMimeType(path);
result.set("mimeType", mimeType);
// RFC-051: method=tika 短路 —— 跳过整条 fallback 链,直接调 Tika。
// 用于:1) 测试 Tika 集成是否健康;2) 用户明知系统命令不可用、想免去
// 那一长串失败日志的场景。结果里仍然带 attempts 数组,告知"应用户要求跳过自动链"。
String forcedMethod = extractOption(options, "method");
if ("tika".equalsIgnoreCase(forcedMethod)) {
long t = System.currentTimeMillis();
String text = TikaExtractor.extract(path);
attempts.add("user-forced method=tika: skipped automatic fallback chain");
if (text == null || text.isBlank()) {
attempts.add("tika: 失败或不可用 (" + (System.currentTimeMillis() - t) + "ms)");
return errorResult(filePath, "Tika 抽取无文本(可能格式不支持或文件损坏)", attempts);
}
attempts.add("tika: 成功 (" + (System.currentTimeMillis() - t) + "ms)");
String capped = text;
boolean trunc = false;
if (capped.length() > MAX_OUTPUT_LENGTH) {
capped = capped.substring(0, MAX_OUTPUT_LENGTH)
+ "\n\n... [内容已截断,总长度: " + text.length() + " 字符]";
trunc = true;
}
result.set("text", capped);
result.set("method", "tika");
result.set("pages", estimatePages(text));
result.set("attempts", attempts);
result.set("truncated", trunc);
result.set("success", true);
log.info("[DocumentExtract] {} 使用 method=tika 强制提取成功,{} 字符",
filePath, text.length());
return JSONUtil.toJsonPrettyStr(result);
}
// 根据类型选择提取器
ExtractedContent content;
if (mimeType.contains("pdf")) {
content = extractPdf(path, options, attempts);
} else if (mimeType.contains("wordprocessingml") || mimeType.contains("msword")) {
content = extractDocx(path, options, attempts);
} else if (mimeType.contains("spreadsheetml") || mimeType.contains("excel")) {
content = extractXlsx(path, options, attempts);
} else if (mimeType.contains("presentationml") || mimeType.contains("powerpoint")) {
content = extractPptx(path, options, attempts);
} else if (mimeType.contains("html")) {
content = extractHtml(path, attempts);
} else {
return errorResult(filePath, "不支持的文档类型: " + mimeType, attempts);
}
// 截断过长的输出
String text = content.text();
boolean truncated = false;
if (text.length() > MAX_OUTPUT_LENGTH) {
text = text.substring(0, MAX_OUTPUT_LENGTH) + "\n\n... [内容已截断,总长度: " + content.text().length() + " 字符]";
truncated = true;
}
result.set("text", text);
result.set("method", content.method());
result.set("pages", content.pages());
result.set("attempts", attempts);
result.set("truncated", truncated);
result.set("success", true);
log.info("[DocumentExtract] {} 使用 {} 提取成功,{} 页,{} 字符",
filePath, content.method(), content.pages(), content.text().length());
} catch (Exception e) {
log.error("[DocumentExtract] 提取失败: {}", e.getMessage(), e);
return errorResult(filePath, "提取失败: " + e.getMessage(), attempts);
}
return JSONUtil.toJsonPrettyStr(result);
}
@Tool(description = """
专门用于提取 PDF 文件文本(extract_document_text 的快捷方式)。
提取链(按优先级):
1. pdftotext (poppler-utils) - 质量最好
2. pdfimages + OCR - 用于扫描版 PDF
3. Java 实现的 PDF 解析 - 无需外部依赖
参数 pages 支持:
- "1-5" - 提取 1-5 页
- "1,3,5" - 提取指定页
- 不传则提取全部
""")
public String extract_pdf_text(
@ToolParam(description = "PDF 文件的绝对路径或相对路径") String filePath,
@ToolParam(description = "页码范围,如 \"1-5\" 或 \"1,3,5\"", required = false) String pages,
@Nullable ToolContext ctx) {
String options = pages != null ? "{\"pages\": \"" + pages + "\"}" : null;
return extract_document_text(filePath, options, ctx);
}
@Tool(description = """
专门用于提取 Word 文档文本(extract_document_text 的快捷方式)。
提取链(按优先级):
1. textutil (macOS) / pandoc - 保留格式最好
2. unzip + 解析 document.xml - 纯 Java 实现
支持 .docx 和 .doc 格式
""")
public String extract_docx_text(
@ToolParam(description = "Word 文档的绝对路径或相对路径") String filePath,
@Nullable ToolContext ctx) {
return extract_document_text(filePath, null, ctx);
}
// ==================== PDF 提取链 ====================
private ExtractedContent extractPdf(Path path, String options, List attempts) throws Exception {
// 先获取 PDF 真实页数(不依赖提取出的文本长度反推)
int realPageCount = getPdfPageCount(path);
log.info("[DocumentExtract] PDF 真实页数: {} ({})", realPageCount, path.getFileName());
String bestContent = null;
String bestMethod = null;
// 1. 尝试 pdftotext
long t0 = System.currentTimeMillis();
String content = tryPdftotext(path, options);
if (content != null && !content.isBlank()) {
ExtractionQuality q = classifyExtraction(content, realPageCount);
if (!q.needsOcr()) {
attempts.add("pdftotext: 成功 (" + (System.currentTimeMillis() - t0) + "ms)");
return new ExtractedContent(content, "pdftotext", realPageCount > 0 ? realPageCount : estimatePages(content));
}
attempts.add("pdftotext: 触发 OCR (" + describeTrigger(q, content.strip().length(), realPageCount) + ")");
bestContent = content;
bestMethod = "pdftotext";
} else {
attempts.add("pdftotext: 失败或不可用");
}
// 2. 尝试 Python pdfplumber/pypdf
long t1 = System.currentTimeMillis();
content = tryPythonPdfExtractor(path, options);
if (content != null && !content.isBlank()) {
ExtractionQuality q = classifyExtraction(content, realPageCount);
if (!q.needsOcr()) {
attempts.add("python_pdf: 成功 (" + (System.currentTimeMillis() - t1) + "ms)");
return new ExtractedContent(content, "python_pdfplumber", realPageCount > 0 ? realPageCount : estimatePages(content));
}
attempts.add("python_pdf: 触发 OCR (" + describeTrigger(q, content.strip().length(), realPageCount) + ")");
if (bestContent == null || content.strip().length() > bestContent.strip().length()) {
bestContent = content;
bestMethod = "python_pdfplumber";
}
} else {
attempts.add("python_pdf: 失败或不可用");
}
// 3. Java 实现
long t2 = System.currentTimeMillis();
content = extractPdfWithJava(path);
if (content != null && !content.isBlank()) {
ExtractionQuality q = classifyExtraction(content, realPageCount);
if (!q.needsOcr()) {
attempts.add("java_pdf: 成功 (" + (System.currentTimeMillis() - t2) + "ms)");
return new ExtractedContent(content, "java_pdfbox", realPageCount > 0 ? realPageCount : estimatePages(content));
}
attempts.add("java_pdf: 触发 OCR (" + describeTrigger(q, content.strip().length(), realPageCount) + ")");
if (bestContent == null || content.strip().length() > bestContent.strip().length()) {
bestContent = content;
bestMethod = "java_pdfbox";
}
} else {
attempts.add("java_pdf: 失败");
}
// 4. OCR fallback(扫描版/照片型 PDF)
log.info("[DocumentExtract] 文本提取不足,尝试 OCR: {}", path.getFileName());
long t3 = System.currentTimeMillis();
OcrResult ocrResult = tryOcrExtract(path, attempts);
if (ocrResult != null && ocrResult.text != null && !ocrResult.text.isBlank()) {
attempts.add("ocr_tesseract: 成功 (" + (System.currentTimeMillis() - t3) + "ms, "
+ ocrResult.successPages + "/" + ocrResult.totalPages + " 页识别成功)");
return new ExtractedContent(ocrResult.text, "ocr_tesseract", ocrResult.totalPages);
}
// attempts 已由 tryOcrExtract 内部记录失败原因
// 5. Tika 兜底(RFC-051 §5.2):所有命令行 / Python / PDFBox / OCR 都失败时
// 用 Java 内置的 Tika 再试一次。主要服务于 Windows 没装 Poppler / Python 的桌面用户。
long t4 = System.currentTimeMillis();
content = TikaExtractor.extract(path);
if (content != null && !content.isBlank()) {
attempts.add("tika: 成功 (" + (System.currentTimeMillis() - t4) + "ms)");
int pages = realPageCount > 0 ? realPageCount : estimatePages(content);
return new ExtractedContent(content, "tika", pages);
}
attempts.add("tika: 失败或不可用");
// 返回之前级别的部分结果(如果有)
if (bestContent != null) {
log.warn("[DocumentExtract] OCR/Tika 不可用,返回部分文本结果: method={}, length={}",
bestMethod, bestContent.strip().length());
int pages = realPageCount > 0 ? realPageCount : estimatePages(bestContent);
return new ExtractedContent(bestContent, bestMethod + "_partial", pages);
}
throw new Exception("所有 PDF 提取方法都失败(包括 OCR 与 Tika)");
}
/**
* 获取 PDF 真实页数。优先用 pdfinfo(Poppler 自带),失败则用 Python pypdf。
* 不依赖提取出的文本长度反推,避免扫描版 PDF 误判页数。
*/
private int getPdfPageCount(Path path) {
// 方法 1: pdfinfo(Poppler 附带工具)
try {
String output = executeCommand(List.of("pdfinfo", path.toString()));
if (output != null) {
for (String line : output.split("\n")) {
if (line.toLowerCase().startsWith("pages:")) {
String num = line.substring(6).strip();
return Integer.parseInt(num);
}
}
}
} catch (Exception e) {
log.debug("[DocumentExtract] pdfinfo 获取页数失败: {}", e.getMessage());
}
// 方法 2: Python pypdf
try {
String script = "import sys; from pypdf import PdfReader; print(len(PdfReader(sys.argv[1]).pages))";
String result = tryPythonScript(script, path.toString());
if (result != null && !result.isBlank()) {
return Integer.parseInt(result.strip());
}
} catch (Exception e) {
log.debug("[DocumentExtract] pypdf 获取页数失败: {}", e.getMessage());
}
return 0; // 未知页数
}
/** Fraction below which extracted text is judged unreadable and an OCR pass is forced. */
static final double READABLE_RATIO_THRESHOLD = 0.5;
/** Outcome of {@link #classifyExtraction}; {@link #trigger()} is {@code null} when usable. */
record ExtractionQuality(String trigger, double readableRatio, double charsPerPage) {
boolean needsOcr() { return trigger != null; }
}
/**
* Classify the quality of a text extraction pass.
*
* Three failure modes can fire an OCR retry:
*
* - {@code empty} / {@code too_short}: nothing extracted, typical of image-only PDFs.
* - {@code low_readable_ratio}: extractor returned plenty of characters but most of
* them are control bytes / high-Latin junk — typical of CID-encoded fonts without
* a {@code ToUnicode} CMap, where the engine dumps glyph indices as bytes.
* - {@code low_char_density}: per-page char count is far below what a real text PDF
* would yield, typical of scanned PDFs with a thin OCR layer applied upstream.
*
*/
static ExtractionQuality classifyExtraction(String text, int realPageCount) {
if (text == null || text.isBlank()) {
return new ExtractionQuality("empty", 0.0, 0.0);
}
String stripped = text.strip();
if (stripped.length() < 20) {
return new ExtractionQuality("too_short", 0.0, 0.0);
}
double ratio = readableRatio(stripped);
double perPage = realPageCount > 0
? (double) stripped.length() / realPageCount
: stripped.length();
if (ratio < READABLE_RATIO_THRESHOLD) {
return new ExtractionQuality("low_readable_ratio", ratio, perPage);
}
if (realPageCount <= 0) {
// Page count unknown — fall back to a conservative total-length cutoff.
if (stripped.length() < 100) {
return new ExtractionQuality("too_short", ratio, perPage);
}
} else if (perPage < 30) {
return new ExtractionQuality("low_char_density", ratio, perPage);
}
return new ExtractionQuality(null, ratio, perPage);
}
/**
* Fraction of code points that are obviously readable: ASCII printable, tab/newline,
* CJK Unified Ideographs (+ ext A), CJK punctuation, halfwidth/fullwidth forms,
* hiragana/katakana, hangul syllables. Returns 0 for empty input.
*
* The threshold {@link #READABLE_RATIO_THRESHOLD} separates real-world noisy
* extraction (well above 0.7 even with OCR errors) from font-encoding garbage,
* which typically lands below 0.1 because the bytes fall outside every script range.
*/
static double readableRatio(String text) {
if (text == null || text.isEmpty()) return 0.0;
int total = 0, good = 0;
for (int i = 0; i < text.length(); ) {
int cp = text.codePointAt(i);
i += Character.charCount(cp);
total++;
if (isReadable(cp)) good++;
}
return total == 0 ? 0.0 : (double) good / total;
}
/** Compact one-line summary of why an extraction was rejected, for the attempts log. */
private static String describeTrigger(ExtractionQuality q, int totalChars, int realPageCount) {
return switch (q.trigger()) {
case "low_readable_ratio" -> String.format(
"readable=%.2f<%.2f, %d 字符多为非可读字节,可能是字体编码异常",
q.readableRatio(), READABLE_RATIO_THRESHOLD, totalChars);
case "low_char_density" -> String.format(
"每页 %.0f 字符(总 %d, %d 页),可能是扫描版",
q.charsPerPage(), totalChars, realPageCount);
case "too_short" -> "总 " + totalChars + " 字符,文本过少";
case "empty" -> "提取结果为空";
default -> "trigger=" + q.trigger();
};
}
private static boolean isReadable(int cp) {
if (cp == 9 || cp == 10 || cp == 13) return true;
if (cp >= 0x20 && cp <= 0x7E) return true; // ASCII printable
if (cp >= 0x3000 && cp <= 0x303F) return true; // CJK punctuation
if (cp >= 0x3040 && cp <= 0x30FF) return true; // hiragana / katakana
if (cp >= 0x3400 && cp <= 0x4DBF) return true; // CJK ext A
if (cp >= 0x4E00 && cp <= 0x9FFF) return true; // CJK unified
if (cp >= 0xAC00 && cp <= 0xD7AF) return true; // hangul syllables
if (cp >= 0xFF00 && cp <= 0xFFEF) return true; // halfwidth / fullwidth
return false;
}
/** OCR 结果(含成功/失败页数统计) */
private record OcrResult(String text, int totalPages, int successPages) {}
/**
* OCR 提取:pdftoppm 转图片 + tesseract 识别文字。
* 仅依赖 Poppler(pdftoppm)和 tesseract 系统命令,不引入新 Python 依赖。
* 只有至少一页真正识别到文本时才返回有效结果,全部失败返回 null。
*/
private OcrResult tryOcrExtract(Path pdfPath, List attempts) {
Path tempDir = null;
try {
long startTime = System.currentTimeMillis();
tempDir = Files.createTempDirectory("mc_ocr_");
// Step 1: 检查 pdftoppm 可用性
try {
executeCommand(List.of("pdftoppm", "-v"));
} catch (Exception e) {
attempts.add("ocr: pdftoppm 不可用,无法将 PDF 转为图片");
log.warn("[DocumentExtract] OCR: pdftoppm 不可用 - {}", e.getMessage());
return null;
}
// Step 2: PDF → PNG(200dpi,足够 OCR 但不过大)
long t1 = System.currentTimeMillis();
String pagePrefix = tempDir.resolve("page").toString();
executeCommand(List.of("pdftoppm", "-png", "-r", "200",
pdfPath.toString(), pagePrefix));
log.info("[DocumentExtract] OCR: pdftoppm 耗时 {}ms", System.currentTimeMillis() - t1);
File[] pageFiles = tempDir.toFile().listFiles((dir, name) -> name.endsWith(".png"));
if (pageFiles == null || pageFiles.length == 0) {
attempts.add("ocr: pdftoppm 未生成图片");
return null;
}
java.util.Arrays.sort(pageFiles);
// Step 3: 检查 tesseract 可用性并探测语言包
String langParam;
try {
String langOutput = executeCommand(List.of("tesseract", "--list-langs"));
langParam = buildTesseractLangParam(langOutput);
log.info("[DocumentExtract] OCR: tesseract 可用,语言参数: {}", langParam);
} catch (Exception e) {
attempts.add("ocr: tesseract 未安装");
log.warn("[DocumentExtract] OCR: tesseract 不可用 - {}", e.getMessage());
return null;
}
// Step 4: 对每页执行 OCR,统计成功/失败页数
long t2 = System.currentTimeMillis();
StringBuilder ocrText = new StringBuilder();
int successPages = 0;
int failedPages = 0;
for (int i = 0; i < pageFiles.length; i++) {
ocrText.append("--- Page ").append(i + 1).append(" ---\n");
try {
List cmd = new ArrayList<>();
cmd.add("tesseract");
cmd.add(pageFiles[i].getAbsolutePath());
cmd.add("stdout");
if (langParam != null) {
cmd.add("-l");
cmd.add(langParam);
}
String pageText = executeCommand(cmd);
if (pageText != null && !pageText.isBlank()) {
ocrText.append(pageText.trim()).append("\n\n");
successPages++;
} else {
ocrText.append("[空白页]\n\n");
failedPages++;
}
} catch (Exception e) {
log.warn("[DocumentExtract] OCR: 页面 {} tesseract 失败: {}", i + 1, e.getMessage());
failedPages++;
// 不写 [OCR 失败] 占位,避免被上层误判为有效文本
}
}
log.info("[DocumentExtract] OCR: tesseract 处理 {} 页 (成功 {}, 失败 {}), 耗时 {}ms, 总耗时 {}ms",
pageFiles.length, successPages, failedPages,
System.currentTimeMillis() - t2, System.currentTimeMillis() - startTime);
// 只有至少一页真正识别到文本时才算成功
if (successPages == 0) {
attempts.add("ocr: tesseract 运行但所有 " + pageFiles.length + " 页均未识别到文本");
return null;
}
return new OcrResult(ocrText.toString().trim(), pageFiles.length, successPages);
} catch (Exception e) {
log.warn("[DocumentExtract] OCR 失败: {}", e.getMessage());
attempts.add("ocr: 异常 - " + e.getMessage());
return null;
} finally {
if (tempDir != null) {
try {
Files.walk(tempDir)
.sorted(java.util.Comparator.reverseOrder())
.forEach(f -> { try { Files.delete(f); } catch (IOException ignored) {} });
} catch (IOException ignored) {}
}
}
}
/**
* 从 tesseract --list-langs 输出中构建最优语言参数。
* 优先 eng+chi_sim,缺中文则只用 eng,都缺则返回 null(用 tesseract 默认)。
*/
private String buildTesseractLangParam(String listLangsOutput) {
if (listLangsOutput == null) return null;
boolean hasEng = listLangsOutput.contains("eng");
boolean hasChiSim = listLangsOutput.contains("chi_sim");
if (hasEng && hasChiSim) return "eng+chi_sim";
if (hasEng) return "eng";
if (hasChiSim) return "chi_sim";
return null; // 用 tesseract 默认语言
}
private String tryPdftotext(Path path, String options) {
try {
List command = new ArrayList<>();
command.add("pdftotext");
command.add("-layout"); // 保留布局
// 解析页码选项
if (options != null && options.contains("pages")) {
// 简单解析,实际项目中可以更完善
String pages = extractOption(options, "pages");
if (pages != null && pages.contains("-")) {
String[] parts = pages.split("-");
command.add("-f");
command.add(parts[0].trim());
command.add("-l");
command.add(parts[1].trim());
}
}
command.add(path.toString());
command.add("-"); // 输出到 stdout
return executeCommand(command);
} catch (Exception e) {
log.debug("pdftotext 失败: {}", e.getMessage());
return null;
}
}
private String tryPythonPdfExtractor(Path path, String options) {
// 尝试 pdfplumber
String script = """
import sys
try:
import pdfplumber
with pdfplumber.open(sys.argv[1]) as pdf:
text = []
for i, page in enumerate(pdf.pages, 1):
text.append(f"--- Page {i} ---")
text.append(page.extract_text() or "")
print("\\n".join(text))
except Exception as e:
sys.exit(1)
""";
String result = tryPythonScript(script, path.toString());
if (result != null && !result.isBlank()) return result;
// 尝试 pypdf
script = """
import sys
try:
from pypdf import PdfReader
reader = PdfReader(sys.argv[1])
text = []
for i, page in enumerate(reader.pages, 1):
text.append(f"--- Page {i} ---")
text.append(page.extract_text() or "")
print("\\n".join(text))
except Exception as e:
sys.exit(1)
""";
return tryPythonScript(script, path.toString());
}
private String extractPdfWithJava(Path path) {
// 这里使用纯 Java 实现的 PDF 文本提取
// 由于 PDFBox 依赖较重,我们使用简化的实现
// 实际项目中可以引入 org.apache.pdfbox:pdfbox 依赖
try {
return extractPdfBasic(path);
} catch (Exception e) {
log.debug("Java PDF 提取失败: {}", e.getMessage());
return null;
}
}
/**
* 基础 PDF 文本提取(简化实现)
* 实际项目中建议使用 Apache PDFBox
*/
private String extractPdfBasic(Path path) throws IOException {
StringBuilder text = new StringBuilder();
try (InputStream is = Files.newInputStream(path)) {
byte[] content = is.readAllBytes();
String pdfContent = new String(content, java.nio.charset.StandardCharsets.ISO_8859_1);
// 简单的文本提取:查找 () 中的文本内容
// 这是简化实现,仅作为 fallback
int pageNum = 1;
text.append("--- Page ").append(pageNum).append(" ---\n");
// 提取 BT...ET 块中的文本
int start = 0;
while ((start = pdfContent.indexOf("BT", start)) != -1) {
int end = pdfContent.indexOf("ET", start);
if (end == -1) break;
String block = pdfContent.substring(start, end);
// 提取 (text) 中的文本
int parenStart = 0;
while ((parenStart = block.indexOf('(', parenStart)) != -1) {
int parenEnd = block.indexOf(')', parenStart);
if (parenEnd == -1) break;
String txt = block.substring(parenStart + 1, parenEnd);
// 处理转义
txt = txt.replace("\\(", "(").replace("\\)", ")")
.replace("\\\\", "\\");
if (!txt.trim().isEmpty()) {
text.append(txt).append(" ");
}
parenStart = parenEnd + 1;
}
start = end + 2;
}
}
return text.toString().trim();
}
// ==================== DOCX 提取链 ====================
private ExtractedContent extractDocx(Path path, String options, List attempts) throws Exception {
// 1. 尝试 textutil (macOS)
String content = tryTextutil(path);
if (content != null && !content.isBlank()) {
attempts.add("textutil: 成功");
return new ExtractedContent(content, "textutil", 0);
}
attempts.add("textutil: 失败或不可用");
// 2. 尝试 pandoc
content = tryPandoc(path);
if (content != null && !content.isBlank()) {
attempts.add("pandoc: 成功");
return new ExtractedContent(content, "pandoc", 0);
}
attempts.add("pandoc: 失败或不可用");
// 3. 尝试 LibreOffice
content = tryLibreOffice(path);
if (content != null && !content.isBlank()) {
attempts.add("libreoffice: 成功");
return new ExtractedContent(content, "libreoffice", 0);
}
attempts.add("libreoffice: 失败或不可用");
// 4. Java ZIP XML 解析
content = extractDocxWithJava(path);
if (content != null && !content.isBlank()) {
attempts.add("java_zip_xml: 成功");
return new ExtractedContent(content, "java_zip_xml", 0);
}
attempts.add("java_zip_xml: 失败");
// 5. Tika 兜底(RFC-051 §5.2)—— 当 textutil/pandoc/libreoffice/ZIP-XML 全失败时。
// Tika 的 Microsoft 模块覆盖到 .docx 内嵌 SmartArt、批注、复杂表格等场景,正好填补
// 我们手写的 ZIP XML 解析器的盲区。
content = TikaExtractor.extract(path);
if (content != null && !content.isBlank()) {
attempts.add("tika: 成功");
return new ExtractedContent(content, "tika", 0);
}
attempts.add("tika: 失败或不可用");
throw new Exception("所有 DOCX 提取方法都失败(包括 Tika)");
}
private String tryTextutil(Path path) {
if (!System.getProperty("os.name").toLowerCase().contains("mac")) {
return null; // textutil 只在 macOS 上可用
}
try {
// textutil 只能输出到文件
Path tempOutput = Files.createTempFile("extract", ".txt");
List command = List.of(
"textutil",
"-convert", "txt",
"-output", tempOutput.toString(),
path.toString()
);
executeCommand(command);
String content = Files.readString(tempOutput);
Files.deleteIfExists(tempOutput);
return content;
} catch (Exception e) {
log.debug("textutil 失败: {}", e.getMessage());
return null;
}
}
private String tryPandoc(Path path) {
try {
List command = List.of(
"pandoc",
path.toString(),
"-t", "plain",
"--wrap=none"
);
return executeCommand(command);
} catch (Exception e) {
log.debug("pandoc 失败: {}", e.getMessage());
return null;
}
}
private String tryLibreOffice(Path path) {
try {
Path tempDir = Files.createTempDirectory("libreoffice");
List command = List.of(
"soffice",
"--headless",
"--convert-to", "txt",
"--outdir", tempDir.toString(),
path.toString()
);
executeCommand(command);
// 查找生成的 txt 文件
String baseName = path.getFileName().toString().replaceAll("\\.[^.]+$", "");
Path outputFile = tempDir.resolve(baseName + ".txt");
if (Files.exists(outputFile)) {
String content = Files.readString(outputFile);
// 清理
Files.walk(tempDir).forEach(f -> {
try { Files.delete(f); } catch (IOException ignored) {}
});
return content;
}
return null;
} catch (Exception e) {
log.debug("libreoffice 失败: {}", e.getMessage());
return null;
}
}
private String extractDocxWithJava(Path path) throws Exception {
StringBuilder text = new StringBuilder();
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(path))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.getName().equals("word/document.xml")) {
String xml = new String(zis.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
text.append(extractTextFromDocxXml(xml));
}
}
}
return text.toString().trim();
}
private String extractTextFromDocxXml(String xml) {
StringBuilder text = new StringBuilder();
// 简单解析 标签内容
int start = 0;
while ((start = xml.indexOf("", start);
int closeTag = xml.indexOf("", tagEnd);
if (closeTag == -1) break;
String txt = xml.substring(tagEnd + 1, closeTag);
// 处理 XML 实体
txt = txt.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"");
text.append(txt);
// 检查是否是段落结束
int nextTag = xml.indexOf("<", closeTag);
if (nextTag != -1 && xml.substring(nextTag, Math.min(nextTag + 6, xml.length())).equals("")) {
text.append("\n");
}
start = closeTag + 6;
}
return text.toString();
}
// ==================== XLSX 提取 ====================
private ExtractedContent extractXlsx(Path path, String options, List attempts) throws Exception {
long t = System.currentTimeMillis();
String text = TikaExtractor.extract(path);
long elapsed = System.currentTimeMillis() - t;
if (text != null && !text.isBlank()) {
attempts.add("tika: 成功 (" + elapsed + "ms)");
return new ExtractedContent(text, "tika", 0);
}
attempts.add("tika: 失败或不可用 (" + elapsed + "ms)");
throw new Exception("XLSX 提取失败:Tika 无法解析(文件可能损坏、加密或非标准格式)");
}
// ==================== PPTX 提取 ====================
private ExtractedContent extractPptx(Path path, String options, List attempts) throws Exception {
long t = System.currentTimeMillis();
String text = TikaExtractor.extract(path);
long elapsed = System.currentTimeMillis() - t;
if (text != null && !text.isBlank()) {
attempts.add("tika: 成功 (" + elapsed + "ms)");
int slides = countPptxSlides(path);
return new ExtractedContent(text, "tika", slides);
}
attempts.add("tika: 失败或不可用 (" + elapsed + "ms)");
throw new Exception("PPTX 提取失败:Tika 无法解析(文件可能损坏、加密或非标准格式)");
}
/**
* Cheap slide count for the result metadata. Counts {@code ppt/slides/slideN.xml}
* entries in the OOXML zip without parsing the slide content. Returns 0 if the
* file isn't a readable zip.
*/
private int countPptxSlides(Path path) {
int count = 0;
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(path))) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
String name = e.getName();
if (name.startsWith("ppt/slides/slide") && name.endsWith(".xml")) {
count++;
}
}
} catch (IOException ignored) {
// Slide count is best-effort metadata; never fail the extract on this.
}
return count;
}
// ==================== HTML 提取 ====================
/**
* Extract readable text from an HTML file with jsoup.
*
* Drops structural noise (script / style / nav / header / footer / aside /
* form / iframe), then walks the surviving elements emitting headings as
* Markdown ATX lines ({@code # }, {@code ## } …) so the wiki preprocessor
* can still detect the document's heading hierarchy. The charset is
* auto-detected from the BOM / {@code } declaration.
*/
private ExtractedContent extractHtml(Path path, List attempts) throws Exception {
long t = System.currentTimeMillis();
org.jsoup.nodes.Document doc;
try {
// charsetName = null lets jsoup sniff the encoding from BOM / meta tag.
doc = org.jsoup.Jsoup.parse(path.toFile(), null);
} catch (IOException e) {
attempts.add("jsoup: 读取失败 - " + e.getMessage());
throw new Exception("HTML 文件读取失败: " + e.getMessage());
}
doc.select("script, style, noscript, nav, header, footer, aside, form, iframe").remove();
StringBuilder sb = new StringBuilder();
org.jsoup.nodes.Element root = doc.body() != null ? doc.body() : doc;
for (org.jsoup.nodes.Element el : root.getAllElements()) {
String text = el.ownText();
if (text.isBlank()) continue;
String tag = el.tagName();
if (tag.length() == 2 && tag.charAt(0) == 'h' && tag.charAt(1) >= '1' && tag.charAt(1) <= '6') {
int level = tag.charAt(1) - '0';
sb.append('\n').append("#".repeat(level)).append(' ').append(text.trim()).append('\n');
} else {
sb.append(text.trim()).append('\n');
}
}
String out = sb.toString().strip();
if (out.isBlank()) {
attempts.add("jsoup: 解析成功但无可读文本 (" + (System.currentTimeMillis() - t) + "ms)");
throw new Exception("HTML 提取无文本(页面可能仅含脚本 / 样式)");
}
attempts.add("jsoup: 成功 (" + (System.currentTimeMillis() - t) + "ms)");
return new ExtractedContent(out, "jsoup", 0);
}
// ==================== 工具方法 ====================
/**
* 执行外部命令并返回 stdout 输出。
* 使用临时文件重定向代替管道,避免以下问题:
* - 输出超过管道缓冲区(Linux ~64KB, Windows ~4KB)时进程写阻塞 + waitFor 死锁
* - Windows 子进程继承 pipe handle 导致读取永远不到 EOF
*/
private String executeCommand(List command) throws Exception {
Path outputFile = null;
try {
outputFile = Files.createTempFile("mc_extract_", ".tmp");
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
pb.redirectOutput(outputFile.toFile());
Process process = pb.start();
boolean finished = process.waitFor(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new RuntimeException("命令执行超时");
}
if (process.exitValue() != 0) {
String output = Files.readString(outputFile);
throw new RuntimeException("命令执行失败: " + output);
}
return Files.readString(outputFile);
} finally {
if (outputFile != null) {
try { Files.deleteIfExists(outputFile); } catch (IOException ignored) {}
}
}
}
private String tryPythonScript(String script, String... args) {
Path tempScript = null;
try {
tempScript = Files.createTempFile("extract", ".py");
Files.writeString(tempScript, script);
// Windows 通常只有 python,没有 python3
String pythonCmd = IS_WINDOWS ? "python" : "python3";
List command = new ArrayList<>();
command.add(pythonCmd);
command.add(tempScript.toString());
for (String arg : args) command.add(arg);
return executeCommand(command);
} catch (Exception e) {
log.debug("Python 脚本失败: {}", e.getMessage());
return null;
} finally {
if (tempScript != null) {
try { Files.deleteIfExists(tempScript); } catch (IOException ignored) {}
}
}
}
private String detectMimeType(Path path) {
// 复用 FileTypeDetectorTool 的逻辑
String fileName = path.getFileName().toString().toLowerCase();
if (fileName.endsWith(".pdf")) return "application/pdf";
if (fileName.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
if (fileName.endsWith(".doc")) return "application/msword";
if (fileName.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
if (fileName.endsWith(".xls")) return "application/vnd.ms-excel";
if (fileName.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
if (fileName.endsWith(".ppt")) return "application/vnd.ms-powerpoint";
if (fileName.endsWith(".html") || fileName.endsWith(".htm")) return "text/html";
return "application/octet-stream";
}
private String extractOption(String options, String key) {
// 简单 JSON 解析
try {
JSONObject json = JSONUtil.parseObj(options);
return json.getStr(key);
} catch (Exception e) {
return null;
}
}
private int estimatePages(String text) {
// 粗略估计:每页约 3000 字符
return Math.max(1, text.length() / 3000);
}
private String errorResult(String filePath, String message, List attempts) {
JSONObject result = new JSONObject();
result.set("filePath", filePath);
result.set("error", true);
result.set("message", message);
result.set("attempts", attempts);
result.set("success", false);
return JSONUtil.toJsonPrettyStr(result);
}
// 记录类
private record ExtractedContent(String text, String method, int pages) {}
}