文章摘要、列表預覽常需截取 HTML 內容前 N 字元,若直接 mb_substr 會導致標籤未閉合、版面崩潰。本文提供三種成熟解法:堆疊法、正則分割法、DOMDocument 法。
<?php
/**
* 截取 HTML 並自動補全閉合標籤
* @param string $html 原始 HTML
* @param int $length 目標字元數(含標籤內文字)
* @param string $suffix 截斷後綴
* @return string
*/
function subHtml(string $html, int $length, string $suffix = '...'): string {
$result = '';
$tagStack = [];
$textLen = 0;
// 分割:保留標籤作為獨立元素
$tokens = preg_split('~(<[^>]+?>)~si', $html, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
foreach ($tokens as $token) {
if (trim($token) === '') continue;
// 自閉合標籤
等
if (preg_match('~<([a-z0-9]+)[^>]*?/>~si', $token)) {
$result .= $token;
continue;
}
// 結束標籤
if (preg_match('~</([a-z0-9]+)[^>]*?>~si', $token, $m)) {
if ($tagStack && end($tagStack) === $m[1]) {
array_pop($tagStack);
$result .= $token;
}
continue;
}
// 開始標籤
if (preg_match('~<([a-z0-9]+)[^>]*?>~si', $token, $m)) {
$tagStack[] = $m[1];
$result .= $token;
continue;
}
// 註解
if (preg_match('~<!--.*?-->~si', $token)) {
$result .= $token;
continue;
}
// 純文字
$tokenLen = mb_strlen($token, 'UTF-8');
if ($textLen + $tokenLen <= $length) {
$result .= $token;
$textLen += $tokenLen;
} else {
$remain = $length - $textLen;
if ($remain > 0) {
$result .= mb_substr($token, 0, $remain, 'UTF-8');
}
break;
}
}
// 補全未閉合標籤(逆序)
while ($tag = array_pop($tagStack)) {
$result .= "</$tag>";
}
return $result . ($textLen >= $length ? $suffix : '');
}
?>
<?php
function phpos_chsubstr_ahtml(string $str, int $num, bool $more = false): string {
if (mb_strlen($str, 'UTF-8') <= $num) return $str;
$stag = []; $etag = []; $sp = 0; $ep = 0;
$len = mb_strlen($str, 'UTF-8');
$word = 0; $i = 0;
while ($word < $num && $i < $len) {
$ch = $str[$i];
$ord = ord($ch);
if ($ord > 127) { // 多位元組字元(UTF-8 中文約 3 bytes)
$i += 3; $word++;
} elseif ($ch === '<') {
// 解析標籤
$isEnd = ($str[$i+1] ?? '') === '/';
$tag = '';
$j = $i + ($isEnd ? 2 : 1);
while ($j < $len && $str[$j] !== ' ' && $str[$j] !== '>') {
$tag .= $str[$j++];
}
if ($isEnd) $etag[$ep++] = $tag;
else $stag[$sp++] = $tag;
// 跳過完整標籤
while ($j < $len && $str[$j] !== '>') $j++;
$i = $j + 1;
} else {
$i++; $word++;
}
}
// 配對移除已閉合標籤
foreach ($etag as $tag) {
$key = array_search($tag, $stag);
if ($key !== false) unset($stag[$key]);
}
// 移除自閉合標籤
foreach ($stag as $k => $tag) {
if (in_array($tag, ['br', 'img', 'hr', 'input'])) unset($stag[$k]);
}
$stag = array_reverse($stag);
$endTags = '</' . implode('></', $stag) . '>';
return mb_substr($str, 0, $i, 'UTF-8') . $endTags . ($more ? '...' : '');
}
?>
<?php
function truncateHtmlDom(string $html, int $maxTextLen, string $suffix = '...'): string {
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML('<meta charset="utf-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
$textNodes = iterator_to_array($xpath->query('//text()'));
$total = 0;
$cutNode = null;
$cutOffset = 0;
foreach ($textNodes as $node) {
$len = mb_strlen($node->nodeValue, 'UTF-8');
if ($total + $len >= $maxTextLen) {
$cutNode = $node;
$cutOffset = $maxTextLen - $total;
break;
}
$total += $len;
}
if ($cutNode) {
// 截斷文字節點
$cutNode->nodeValue = mb_substr($cutNode->nodeValue, 0, $cutOffset, 'UTF-8');
// 移除後續所有節點
$next = $cutNode->nextSibling;
while ($next) {
$toRemove = $next;
$next = $next->nextSibling;
$toRemove->parentNode->removeChild($toRemove);
}
// 同時移除後續祖先的後續兄弟
$parent = $cutNode->parentNode;
while ($parent && $parent !== $dom) {
$next = $parent->nextSibling;
while ($next) {
$toRemove = $next;
$next = $next->nextSibling;
$toRemove->parentNode->removeChild($toRemove);
}
$parent = $parent->parentNode;
}
}
$result = $dom->saveHTML();
return $result . ($cutNode ? $suffix : '');
}
?>
| 維度 | 堆疊法 | 正則分割法 | DOMDocument 法 |
|---|---|---|---|
| 效能 | ★★★★★ | ★★★★☆ | ★★☆☆☆ |
| 容錯率(壞 HTML) | ★★★☆☆ | ★★☆☆☆ | ★★★★★ |
| 程式碼複雜度 | 中 | 高 | 低 |
| UTF-8 支援 | 原生 | 需手動處理 | 原生 |
| 推薦場景 | 高流量、純 PHP、HTML 相對乾淨 | 舊專案移植、無 DOM 擴充 | CMS 後台、使用者輸入不可控、需嚴格輸出 |
contenteditable 前端即時截取,後端僅做二次驗證。