This commit is contained in:
Your Name
2026-07-22 10:18:59 +08:00
parent 2530ddada6
commit 0fb03d0bca
618 changed files with 19445 additions and 3 deletions
+207
View File
@@ -0,0 +1,207 @@
<?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-utilspdftotext';
}
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);
}
}