Files
chat/backend/app/service/DocumentTextService.php
2026-07-22 10:18:59 +08:00

208 lines
6.3 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}