處理使用者輸入的 HTML 片段 (如富文本編輯器內容、RSS 摘要、評論截斷) 時,常遇到標籤未閉合導致版面崩壞。本文提供生產級 fixHtmlTag() 函數,支援兩種閉合策略、指定標籤就近閉合、XHTML 自閉合標籤修正、長度截斷與標籤小寫化。
<div><p>內容 → <div><p>內容</p></div>,符合 HTML 規範樹狀結構。<p>段1<p>段2 → <p>段1</p><p>段2</p>,適合段落類標籤。p, li, dt) 就近閉合。<br> → <br />,<img>、<input> 等自動識別。mb_strlen、mb_substr 正確處理多位元組字元。<?php
/**
* HTML 標籤修復主函數
*
* @param array $params 參數陣列:
* - html (string) 必填,待修復 HTML 字串
* - options (array) 選項:
* - tagArray (string[]) 就近閉合標籤清單 (巢狀模式下生效)
* - type (string) 'NEST'|'CLOSE' 閉合模式
* - length (int|null) 截斷字元數
* - lowerTag (bool) 標籤轉小寫 (預設 true)
* - XHtmlFix (bool) 自閉合標籤修正 (預設 true)
* @return string 修復後 HTML
*/
function fixHtmlTag(array $params = []): string {
// 1. 參數預設值與解構
$html = $params['html'] ?? '';
$options = $params['options'] ?? [];
$tagArray = array_map('strtolower', $options['tagArray'] ?? []);
$type = strtoupper($options['type'] ?? 'NEST');
$length = $options['length'] ?? null;
$lowerTag = $options['lowerTag'] ?? true;
$xhtmlFix = $options['XHtmlFix'] ?? true;
if (!in_array($type, ['NEST', 'CLOSE'])) $type = 'NEST';
// 2. 自閉合標籤白名單 (無結束標籤)
$voidTags = ['meta', 'link', 'base', 'br', 'hr', 'input', 'img', 'area', 'col', 'embed', 'param', 'source', 'track', 'wbr'];
// 3. 以 <...> 為分隔符切割,保留標籤本身
$tokens = preg_split('/(<[^>]+>)/si', $html, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
$result = '';
$tagStack = []; // 標籤堆疊
$currentLen = 0; // 目前輸出長度
$isClosed = true; // 就近閉合狀態旗標
foreach ($tokens as $token) {
if (trim($token) === '') { $result .= $token; continue; }
// 3.1 註解標籤直接輸出
if (preg_match('/^$/si', $token)) { $result .= $token; continue; }
// 3.2 已正確自閉合的標籤
if (preg_match('/^<(\w+)[^>]*?\/>$/si', $token)) { $result .= $token; continue; }
// 3.3 開始標籤
if (preg_match('/^<(\w+)[^>]*>$/si', $token, $m)) {
$tagName = $lowerTag ? strtolower($m[1]) : $m[1];
$token = $lowerTag ? '<' . $tagName . substr($token, 1 + strlen($m[1])) : $token;
// 處理上一個就近閉合標籤
if (!$isClosed) {
if ($type === 'CLOSE' || in_array(end($tagStack), $tagArray)) {
$result .= '' . array_pop($tagStack) . '>';
}
}
$result .= $token;
array_push($tagStack, $tagName);
// 判斷是否為 void tag
$isVoid = in_array($tagName, $voidTags);
if ($isVoid) {
if ($xhtmlFix && !preg_match('/\/>$/i', $token)) {
$result = substr_replace($result, ' />', -1); // 修正為
}
array_pop($tagStack); // void tag 不入堆疊
}
// 更新就近閉合狀態
$isClosed = !($type === 'CLOSE' || in_array($tagName, $tagArray));
continue;
}
// 3.4 結束標籤
if (preg_match('/^<\/(\w+)[^>]*>$/si', $token, $m)) {
$tagName = $lowerTag ? strtolower($m[1]) : $m[1];
$token = $lowerTag ? '' . $tagName . '>' : $token;
if (!empty($tagStack) && end($tagStack) === $tagName) {
$result .= $token;
array_pop($tagStack);
$isClosed = true;
} else {
// 孤立結束標籤,直接輸出 (或可選丟棄)
$result .= $token;
}
continue;
}
// 3.5 純文字內容
if ($length !== null && $currentLen + mb_strlen($token) >= $length) {
$token = mb_substr($token, 0, $length - $currentLen);
$result .= $token;
break;
}
$result .= $token;
$currentLen += mb_strlen($token);
}
// 4. 結束時補全堆疊剩餘標籤 (巢狀閉合)
while (!empty($tagStack)) {
$result .= '' . array_pop($tagStack) . '>';
}
return $result;
}
?>
<?php
$html = '第一段
第二段
- 項目1
- 項目2
結尾';
// 1. 預設巢狀模式
echo fixHtmlTag(['html' => $html]);
/* 輸出:
第一段
第二段
- 項目1
- 項目2
結尾
*/
// 2. 指定 p, li 就近閉合 (混合模式)
echo fixHtmlTag([
'html' => $html,
'options' => ['tagArray' => ['p', 'li'], 'type' => 'NEST']
]);
/* 輸出:
第一段
第二段
- 項目1
- 項目2
結尾
*/
// 3. 全域就近閉合模式
echo fixHtmlTag([
'html' => $html,
'options' => ['type' => 'CLOSE']
]);
/* 輸出:
第一段
第二段
- 項目1
- 項目2
結尾
*/
// 4. 截斷 30 字元並修復
echo fixHtmlTag([
'html' => '這是一段很長的測試內容用來驗證截斷功能
',
'options' => ['length' => 30]
]);
/* 輸出:這是一段很長的測試內容用來驗證截
*/
?>
架構設計重點
模組 說明
Token 化 preg_split 保留分隔符,單次遍歷 O(n)
堆疊機制 陣列模擬 Stack,array_push/pop O(1)
狀態機 $isClosed 追蹤就近閉合狀態
多位元組安全 全程使用 mb_strlen/substr
XHTML 修正 void tag 自動補 />
局限性與替代方案
- 不處理屬性值中的
>:極少見但可能誤判。嚴格場景請用 DOMDocument::loadHTML。
- 不修正屬性值未加引號:
<img src=test.jpg> 保持原樣。
- 效能:百 KB 級片段 < 1ms;大文件 (MB 級) 建議用
HTMLPurifier 或 masterminds/html5-php (HTML5 解析器)。
- 安全性:此函數**不防 XSS**,輸出前仍需配合 CSP、輸出編碼。