208 lines
6.3 KiB
PHP
208 lines
6.3 KiB
PHP
<?php
|
||
|
||
namespace app\service;
|
||
|
||
/**
|
||
* 从常见文档格式提取纯文本,供 Dify 等不支持 file 附件的接口使用。
|
||
*/
|
||
class DocumentTextService
|
||
{
|
||
private const MAX_CHARS = 12000;
|
||
|
||
/** @var string|null 最近一次 extract 失败原因(供上层展示) */
|
||
private static ?string $lastFailure = null;
|
||
|
||
public static function extract(string $path, string $mime = '', string $filename = ''): ?string
|
||
{
|
||
self::$lastFailure = null;
|
||
|
||
if (!is_file($path) || !is_readable($path)) {
|
||
self::$lastFailure = 'file_unreadable';
|
||
return null;
|
||
}
|
||
|
||
$ext = strtolower(pathinfo($filename ?: $path, PATHINFO_EXTENSION));
|
||
$mime = strtolower($mime);
|
||
|
||
$text = match (true) {
|
||
in_array($ext, ['txt', 'md'], true) || str_starts_with($mime, 'text/') => self::readPlainText($path),
|
||
$ext === 'docx' || str_contains($mime, 'wordprocessingml') => self::extractDocx($path),
|
||
$ext === 'pdf' || str_contains($mime, 'pdf') => self::extractPdf($path),
|
||
default => null,
|
||
};
|
||
|
||
if ($text === null) {
|
||
return null;
|
||
}
|
||
|
||
$text = self::normalize($text);
|
||
|
||
if ($text === '') {
|
||
if (self::$lastFailure === null) {
|
||
self::$lastFailure = 'empty_content';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
return mb_substr($text, 0, self::MAX_CHARS);
|
||
}
|
||
|
||
public static function unsupportedReason(string $filename, string $mime = ''): string
|
||
{
|
||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||
|
||
if (self::$lastFailure === 'exec_disabled') {
|
||
return 'PHP 禁用了 exec 函数,无法调用 pdftotext。请在 php.ini 的 disable_functions 中移除 exec';
|
||
}
|
||
|
||
if (self::$lastFailure === 'pdftotext_missing') {
|
||
return '未找到 pdftotext 命令。请在**运行 PHP 的服务器**(不是 Dify 容器)执行:apt install poppler-utils';
|
||
}
|
||
|
||
if (self::$lastFailure === 'pdftotext_failed') {
|
||
return 'pdftotext 执行失败,请检查 PDF 是否损坏或 uploads 目录是否可读';
|
||
}
|
||
|
||
if (self::$lastFailure === 'empty_content' && ($ext === 'pdf' || str_contains($mime, 'pdf'))) {
|
||
return 'PDF 未提取到文字,可能是扫描版图片 PDF,请改用可复制文字的 PDF 或粘贴文字';
|
||
}
|
||
|
||
if ($ext === 'doc' || str_contains($mime, 'msword')) {
|
||
return '旧版 .doc 暂不支持自动解析,请另存为 .docx 或复制文字发送';
|
||
}
|
||
|
||
if ($ext === 'pdf' || str_contains($mime, 'pdf')) {
|
||
return 'PDF 文字提取失败,请在运行 PHP 的服务器安装 poppler-utils(pdftotext)';
|
||
}
|
||
|
||
if (!class_exists(\ZipArchive::class) && ($ext === 'docx' || str_contains($mime, 'wordprocessingml'))) {
|
||
return 'PHP 未启用 zip 扩展,无法解析 .docx,请安装 php-zip';
|
||
}
|
||
|
||
return '未能提取文档文字,请改用 .txt / .docx 或粘贴文字内容';
|
||
}
|
||
|
||
private static function readPlainText(string $path): ?string
|
||
{
|
||
$content = @file_get_contents($path);
|
||
if ($content === false) {
|
||
return null;
|
||
}
|
||
|
||
if (!mb_check_encoding($content, 'UTF-8')) {
|
||
$content = mb_convert_encoding($content, 'UTF-8', 'GB18030,UTF-8,ASCII');
|
||
}
|
||
|
||
return $content;
|
||
}
|
||
|
||
private static function extractDocx(string $path): ?string
|
||
{
|
||
if (!class_exists(\ZipArchive::class)) {
|
||
return null;
|
||
}
|
||
|
||
$zip = new \ZipArchive();
|
||
if ($zip->open($path) !== true) {
|
||
return null;
|
||
}
|
||
|
||
$xml = $zip->getFromName('word/document.xml');
|
||
$zip->close();
|
||
|
||
if (!$xml) {
|
||
return null;
|
||
}
|
||
|
||
$xml = preg_replace('/<w:tab[^>]*\/>/', "\t", $xml);
|
||
$xml = preg_replace('/<\/w:p>/', "\n", $xml);
|
||
$xml = preg_replace('/<\/w:tr>/', "\n", $xml);
|
||
|
||
$text = strip_tags($xml);
|
||
|
||
return html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||
}
|
||
|
||
private static function extractPdf(string $path): ?string
|
||
{
|
||
if (!function_exists('exec')) {
|
||
self::$lastFailure = 'exec_disabled';
|
||
return null;
|
||
}
|
||
|
||
$binary = self::resolvePdftotextBinary();
|
||
if ($binary === null) {
|
||
self::$lastFailure = 'pdftotext_missing';
|
||
return null;
|
||
}
|
||
|
||
$out = tempnam(sys_get_temp_dir(), 'pdftxt_');
|
||
if (!$out) {
|
||
self::$lastFailure = 'pdftotext_failed';
|
||
return null;
|
||
}
|
||
|
||
$cmd = escapeshellarg($binary) . ' -enc UTF-8 -layout '
|
||
. escapeshellarg($path) . ' ' . escapeshellarg($out) . ' 2>&1';
|
||
exec($cmd, $_, $code);
|
||
|
||
$text = ($code === 0 && is_file($out)) ? @file_get_contents($out) : false;
|
||
@unlink($out);
|
||
|
||
if ($text === false) {
|
||
self::$lastFailure = 'pdftotext_failed';
|
||
return null;
|
||
}
|
||
|
||
return $text;
|
||
}
|
||
|
||
/**
|
||
* PHP-FPM 进程的 PATH 常不含 /usr/bin,需显式探测可执行文件路径。
|
||
*/
|
||
private static function resolvePdftotextBinary(): ?string
|
||
{
|
||
$candidates = [
|
||
'/usr/bin/pdftotext',
|
||
'/usr/local/bin/pdftotext',
|
||
'pdftotext',
|
||
];
|
||
|
||
foreach ($candidates as $bin) {
|
||
if (str_starts_with($bin, '/')) {
|
||
if (is_executable($bin)) {
|
||
return $bin;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (self::commandExists($bin)) {
|
||
return $bin;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static function commandExists(string $command): bool
|
||
{
|
||
if (!function_exists('exec')) {
|
||
return false;
|
||
}
|
||
|
||
$check = stripos(PHP_OS, 'WIN') === 0 ? 'where' : 'command -v';
|
||
exec($check . ' ' . escapeshellarg($command) . ' 2>/dev/null', $output, $code);
|
||
|
||
return $code === 0 && !empty($output);
|
||
}
|
||
|
||
private static function normalize(string $text): string
|
||
{
|
||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||
$text = preg_replace("/[ \t]+\n/", "\n", $text);
|
||
$text = preg_replace("/\n{3,}/", "\n\n", $text);
|
||
|
||
return trim($text);
|
||
}
|
||
}
|