// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\docs\service; /** * 文档导入服务 * * 支持三种来源: * 1. .docx 优先使用 phpoffice/phpword,未安装时退回内置 ZIP + XML 解析 * 2. .md 内置轻量 Markdown 转 HTML * 3. .html 直接清洗后入库 * * @author ywxapp */ class DocImportService { /** * 允许导入的扩展名 */ const ALLOW_EXT = ['docx', 'md', 'markdown', 'html', 'htm', 'txt']; /** * 解析上传文件为文档内容 * * @param string $filePath 文件绝对路径 * @param string $ext 扩展名(小写) * @return array{title: string, content: string, content_md: string} * @throws \Exception 解析失败时抛出 */ public static function parse(string $filePath, string $ext): array { if (!is_file($filePath)) { throw new \Exception('导入文件不存在'); } $ext = strtolower($ext); if (!in_array($ext, self::ALLOW_EXT, true)) { throw new \Exception('不支持的文件类型:' . $ext); } switch ($ext) { case 'docx': return self::parseDocx($filePath); case 'md': case 'markdown': return self::parseMarkdown($filePath); default: return self::parseHtml($filePath); } } /** * 解析 docx * * @param string $filePath 文件路径 * @return array * @throws \Exception */ protected static function parseDocx(string $filePath): array { if (class_exists('\PhpOffice\PhpWord\IOFactory')) { try { return self::parseDocxByPhpWord($filePath); } catch (\Throwable $e) { // PhpWord 解析异常时退回内置解析,保证功能可用 } } return self::parseDocxByZip($filePath); } /** * 使用 phpoffice/phpword 解析 docx * * @param string $filePath 文件路径 * @return array * @throws \Exception */ protected static function parseDocxByPhpWord(string $filePath): array { $reader = \PhpOffice\PhpWord\IOFactory::createReader('Word2007'); $document = $reader->load($filePath); $writer = \PhpOffice\PhpWord\IOFactory::createWriter($document, 'HTML'); ob_start(); $writer->save('php://output'); $html = (string) ob_get_clean(); // PhpWord 输出整页 HTML,仅保留 body 内部 if (preg_match('#]*>(.*?)#is', $html, $m)) { $html = $m[1]; } $html = self::sanitize($html); $title = self::guessTitle($html, $filePath); return ['title' => $title, 'content' => $html, 'content_md' => '']; } /** * 内置 docx 解析:解压 word/document.xml 后提取段落 * * 不依赖任何扩展包,能还原段落、标题层级与加粗,适用于纯文字文档。 * * @param string $filePath 文件路径 * @return array * @throws \Exception */ protected static function parseDocxByZip(string $filePath): array { if (!class_exists('\ZipArchive')) { throw new \Exception('解析 docx 需要 PHP 开启 zip 扩展,或安装 phpoffice/phpword'); } $zip = new \ZipArchive(); if ($zip->open($filePath) !== true) { throw new \Exception('无法打开 docx 文件,请确认文件未损坏'); } $xml = $zip->getFromName('word/document.xml'); $zip->close(); if ($xml === false || $xml === '') { throw new \Exception('docx 内容为空或格式不正确'); } $prev = libxml_use_internal_errors(true); $dom = new \DOMDocument(); $dom->loadXML($xml, LIBXML_NOCDATA | LIBXML_NONET); libxml_clear_errors(); libxml_use_internal_errors($prev); $xpath = new \DOMXPath($dom); $xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'); $paragraphs = $xpath->query('//w:body/w:p'); $htmlParts = []; if ($paragraphs !== false) { foreach ($paragraphs as $p) { $text = ''; $runs = $xpath->query('.//w:t', $p); if ($runs !== false) { foreach ($runs as $t) { $text .= $t->textContent; } } $text = trim($text); if ($text === '') { continue; } // 读取段落样式名判断标题层级,如 Heading1 / 标题 1 $level = 0; $styleNode = $xpath->query('.//w:pStyle/@w:val', $p); if ($styleNode !== false && $styleNode->length > 0) { $style = (string) $styleNode->item(0)->nodeValue; if (preg_match('/(?:heading|标题)\s*([1-6])/i', $style, $m)) { $level = (int) $m[1]; } } $safe = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); if ($level >= 1 && $level <= 6) { $htmlParts[] = '' . $safe . ''; } else { $htmlParts[] = '

' . $safe . '

'; } } } if (empty($htmlParts)) { throw new \Exception('未能从 docx 中提取到文本内容'); } $html = implode("\n", $htmlParts); $title = self::guessTitle($html, $filePath); return ['title' => $title, 'content' => $html, 'content_md' => '']; } /** * 解析 Markdown 文件 * * @param string $filePath 文件路径 * @return array */ protected static function parseMarkdown(string $filePath): array { $md = (string) file_get_contents($filePath); $md = self::normalizeEncoding($md); $html = self::markdownToHtml($md); $title = ''; // 优先取首个一级标题作为文档标题 if (preg_match('/^\s*#\s+(.+)$/m', $md, $m)) { $title = trim($m[1]); } if ($title === '') { $title = self::guessTitle($html, $filePath); } return ['title' => $title, 'content' => $html, 'content_md' => $md]; } /** * 解析 HTML / 纯文本文件 * * @param string $filePath 文件路径 * @return array */ protected static function parseHtml(string $filePath): array { $raw = (string) file_get_contents($filePath); $raw = self::normalizeEncoding($raw); if (preg_match('#]*>(.*?)#is', $raw, $m)) { $raw = $m[1]; } // 纯文本按行包 p 标签 if (strip_tags($raw) === $raw) { $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; $parts = []; foreach ($lines as $line) { $line = trim($line); if ($line !== '') { $parts[] = '

' . htmlspecialchars($line, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '

'; } } $raw = implode("\n", $parts); } $html = self::sanitize($raw); $title = self::guessTitle($html, $filePath); return ['title' => $title, 'content' => $html, 'content_md' => '']; } /** * 轻量 Markdown 转 HTML * * 覆盖文档站常用语法:标题、围栏代码块、行内代码、粗斜体、 * 链接、图片、无序 / 有序列表、引用、分隔线、表格。 * 若项目已安装 erusev/parsedown 则优先使用。 * * @param string $md Markdown 源码 * @return string */ public static function markdownToHtml(string $md): string { if (class_exists('\Parsedown')) { try { $parser = new \Parsedown(); if (method_exists($parser, 'setSafeMode')) { $parser->setSafeMode(true); } return (string) $parser->text($md); } catch (\Throwable $e) { // 解析失败退回内置实现 } } $md = str_replace(["\r\n", "\r"], "\n", $md); // 先抽出围栏代码块,避免其内部符号被后续规则误处理 $blocks = []; $md = preg_replace_callback('/```([a-zA-Z0-9#+\-]*)\n(.*?)```/s', function ($m) use (&$blocks) { $lang = $m[1] !== '' ? ' class="language-' . htmlspecialchars($m[1], ENT_QUOTES) . '"' : ''; $code = htmlspecialchars($m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'); $token = '@@DOCS_CODE_' . count($blocks) . '@@'; $blocks[$token] = '
' . $code . '
'; return "\n" . $token . "\n"; }, $md) ?? $md; $lines = explode("\n", $md); $out = []; $listType = ''; // ul / ol / 空 $inQuote = false; $inTable = false; $closeList = function () use (&$listType, &$out) { if ($listType !== '') { $out[] = ''; $listType = ''; } }; $closeQuote = function () use (&$inQuote, &$out) { if ($inQuote) { $out[] = ''; $inQuote = false; } }; $closeTable = function () use (&$inTable, &$out) { if ($inTable) { $out[] = ''; $inTable = false; } }; foreach ($lines as $i => $line) { $trim = trim($line); // 代码块占位符原样输出 if (isset($blocks[$trim])) { $closeList(); $closeQuote(); $closeTable(); $out[] = $trim; continue; } if ($trim === '') { $closeList(); $closeQuote(); $closeTable(); continue; } // 分隔线 if (preg_match('/^(\*{3,}|-{3,}|_{3,})$/', $trim)) { $closeList(); $closeQuote(); $closeTable(); $out[] = '
'; continue; } // 标题 if (preg_match('/^(#{1,6})\s+(.*)$/', $trim, $m)) { $closeList(); $closeQuote(); $closeTable(); $level = strlen($m[1]); $out[] = '' . self::inline($m[2]) . ''; continue; } // 表格:当前行是 | a | b |,下一行是分隔行 if (!$inTable && strpos($trim, '|') !== false && isset($lines[$i + 1]) && preg_match('/^\s*\|?[\s:\-\|]+\|[\s:\-\|]*$/', $lines[$i + 1])) { $closeList(); $closeQuote(); $cells = self::tableCells($trim); $out[] = ''; foreach ($cells as $cell) { $out[] = ''; } $out[] = ''; $inTable = true; continue; } if ($inTable) { // 跳过分隔行 if (preg_match('/^\s*\|?[\s:\-\|]+\|[\s:\-\|]*$/', $trim)) { continue; } if (strpos($trim, '|') !== false) { $cells = self::tableCells($trim); $out[] = ''; foreach ($cells as $cell) { $out[] = ''; } $out[] = ''; continue; } $closeTable(); } // 引用 if (preg_match('/^>\s?(.*)$/', $trim, $m)) { $closeList(); if (!$inQuote) { $out[] = '
'; $inQuote = true; } $out[] = '

' . self::inline($m[1]) . '

'; continue; } $closeQuote(); // 无序列表 if (preg_match('/^[\*\-\+]\s+(.*)$/', $trim, $m)) { if ($listType !== 'ul') { $closeList(); $out[] = '
    '; $listType = 'ul'; } $out[] = '
  • ' . self::inline($m[1]) . '
  • '; continue; } // 有序列表 if (preg_match('/^\d+\.\s+(.*)$/', $trim, $m)) { if ($listType !== 'ol') { $closeList(); $out[] = '
      '; $listType = 'ol'; } $out[] = '
    1. ' . self::inline($m[1]) . '
    2. '; continue; } $closeList(); $out[] = '

      ' . self::inline($trim) . '

      '; } $closeList(); $closeQuote(); $closeTable(); $html = implode("\n", $out); // 还原代码块 if (!empty($blocks)) { $html = strtr($html, $blocks); } return $html; } /** * 拆分表格行单元格 * * @param string $line 表格行 * @return array */ protected static function tableCells(string $line): array { $line = trim($line, "| \t"); $cells = explode('|', $line); return array_map('trim', $cells); } /** * 处理行内 Markdown 语法 * * @param string $text 行内文本 * @return string */ protected static function inline(string $text): string { $text = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // 行内代码优先,避免其中的星号被当作强调 $codes = []; $text = preg_replace_callback('/`([^`]+)`/', function ($m) use (&$codes) { $token = '@@DOCS_IC_' . count($codes) . '@@'; $codes[$token] = '' . $m[1] . ''; return $token; }, $text) ?? $text; // 图片 $text = preg_replace('/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/', '$1', $text) ?? $text; // 链接 $text = preg_replace('/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/', '$1', $text) ?? $text; // 加粗 $text = preg_replace('/\*\*([^*]+)\*\*/', '$1', $text) ?? $text; $text = preg_replace('/__([^_]+)__/', '$1', $text) ?? $text; // 斜体 $text = preg_replace('/\*([^*]+)\*/', '$1', $text) ?? $text; // 删除线 $text = preg_replace('/~~([^~]+)~~/', '$1', $text) ?? $text; if (!empty($codes)) { $text = strtr($text, $codes); } return $text; } /** * 清洗 HTML,移除脚本与事件属性 * * @param string $html 原始 HTML * @return string */ public static function sanitize(string $html): string { if ($html === '') { return ''; } $html = preg_replace('#<(script|style|iframe|object|embed)\b[^>]*>.*?#is', '', $html) ?? $html; $html = preg_replace('#<\?php.*?\?>#is', '', $html) ?? $html; // 移除 onclick 等事件属性 $html = preg_replace('/\son[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $html) ?? $html; // 移除 javascript: 协议 $html = preg_replace('/(href|src)\s*=\s*("|\')\s*javascript:[^"\']*\2/i', '$1="#"', $html) ?? $html; return trim($html); } /** * 推测标题:优先取首个标题标签,其次取文件名 * * @param string $html 内容 * @param string $filePath 文件路径 * @return string */ protected static function guessTitle(string $html, string $filePath): string { if (preg_match('#]*>(.*?)#is', $html, $m)) { $title = trim(strip_tags($m[1])); if ($title !== '') { return mb_substr($title, 0, 200, 'UTF-8'); } } $base = pathinfo($filePath, PATHINFO_FILENAME); return $base !== '' ? mb_substr($base, 0, 200, 'UTF-8') : '未命名文档'; } /** * 统一转 UTF-8,兼容 GBK 编码的文本文件 * * @param string $text 原始文本 * @return string */ protected static function normalizeEncoding(string $text): string { if ($text === '' || !function_exists('mb_detect_encoding')) { return $text; } // 去除 UTF-8 BOM $text = preg_replace('/^\xEF\xBB\xBF/', '', $text) ?? $text; $encoding = mb_detect_encoding($text, ['UTF-8', 'GB18030', 'GBK', 'BIG5'], true); if ($encoding && strtoupper($encoding) !== 'UTF-8') { $converted = @mb_convert_encoding($text, 'UTF-8', $encoding); if ($converted !== false) { return $converted; } } return $text; } /** * 由标题生成 URL 标识 * * @param string $title 标题 * @return string */ public static function slugify(string $title): string { $slug = mb_strtolower(trim($title), 'UTF-8'); // 仅保留字母数字与中划线,中文标题回退为拼音无关的哈希短码 $ascii = preg_replace('/[^a-z0-9]+/u', '-', $slug) ?? ''; $ascii = trim($ascii, '-'); if ($ascii === '' || !preg_match('/[a-z0-9]/', $ascii)) { return 'doc-' . substr(md5($title . microtime(true)), 0, 8); } return mb_substr($ascii, 0, 80, 'UTF-8'); } }
' . self::inline($cell) . '
' . self::inline($cell) . '