在文章列表、搜尋摘要等場景,需將富文字 HTML 內容截取前 N 個字元作為預覽。若直接用 Substring 或 Regex 移除標籤,極易導致:
<div> 沒有 </div>),破壞後續頁面排版。 變 &nb),顯示亂碼。style="color:red" 變 style="color:re),樣式失效。本文提供一個成熟的 LeftH 方法,能在保留完整標籤結構前提下,精準截取指定長度的可見文字。
n 僅在文字區遞增,標籤區不計入長度。<tag> 推入堆疊,遇到關閉標籤 </tag> 彈出並從輸出移除對應開啟標籤。<br>、<hr>、<img>、<%...%>、<?...?> 視為無需閉合。/// <summary>
/// 無損截取 HTML 字串,保留完整標籤結構
/// </summary>
/// <param name="str">原始 HTML</param>
/// <param name="l">欲保留的可見文字長度</param>
/// <returns>截取後的 HTML 片段</returns>
public static string LeftH(string str, int l)
{
if (string.IsNullOrEmpty(str) || l >= str.Length) return str;
string original = str;
bool inTag = false;
int maxLen = str.Length;
int n = 0, i = 0, tagStart = 0;
string currentChar, nextTwoChars, output = "", closingTags = "";
try
{
while (n < l && i < maxLen)
{
i++;
currentChar = str.Substring(i - 1, 1);
// 取得後續兩字元供判斷
nextTwoChars = (i < maxLen) ? str.Substring(i - 1, 2) : "";
// 1. 偵測進入標籤區
if (currentChar == "<" && nextTwoChars != "</")
{
inTag = true;
tagStart = i; // 記錄標籤起始位置
// 單一標籤判斷
string tagPeek = (i < maxLen) ? str.Substring(i, 2).ToLower() : "";
if (tagPeek == "br" || tagPeek == "hr" ||
(i < maxLen && (str.Substring(i, 1) == "%" || str.Substring(i, 1) == "?")))
{
inTag = false; // 單一標籤,不進入標籤區
}
}
// 2. 偵測關閉標籤
if (currentChar == "<" && nextTwoChars == "</")
{
int endPos = str.IndexOf(">", i - 1);
if (endPos > 0)
{
string closeTag = str.Substring(i - 1, endPos - i + 2);
// 從待補全清單移除對應開啟標籤
if (closingTags.StartsWith(closeTag))
closingTags = closeTag + closingTags.Substring(closeTag.Length);
}
}
output += currentChar; // 輸出當前字元
// 3. 文字區計數
if (!inTag)
{
// 跳過單一標籤內部字元
string peek = (i < maxLen) ? str.Substring(i, 2).ToLower() : "";
if (peek != "br" && peek != "hr")
n++;
}
else
{
// 4. 標籤區結束,產生對應關閉標籤
if (currentChar == ">" && inTag)
{
string fullTag = str.Substring(tagStart - 1, i - tagStart + 1);
int spaceIdx = fullTag.IndexOf(" ");
string tagName = (spaceIdx > 0)
? fullTag.Substring(1, spaceIdx - 1)
: fullTag.Substring(1, fullTag.Length - 2);
closingTags = "</" + tagName + ">" + closingTags;
inTag = false;
}
}
// 5. 接近長度限制時,檢查標籤是否配對
if (n + 1 >= l)
{
int ltCount = CountOccurrences(output, "<");
int gtCount = CountOccurrences(output, ">");
if (ltCount != gtCount) n--; // 還有未閉合標籤,退一步繼續掃描
}
}
}
catch
{
// 任何異常回退純文字模式
return FallbackPlainText(original, l);
}
string result = output + closingTags + "...";
// 最終驗證
if (!ValidateHtmlFragment(result))
return FallbackPlainText(original, l);
return result;
}
// 輔助方法
private static int CountOccurrences(string haystack, string needle)
{
int count = 0, idx = 0;
while ((idx = haystack.IndexOf(needle, idx)) != -1) { count++; idx += needle.Length; }
return count;
}
private static bool ValidateHtmlFragment(string html)
{
// 1. 標籤配對
if (CountOccurrences(html, "<") != CountOccurrences(html, ">")) return false;
// 2. 引號配對
if (CountOccurrences(html.Replace(""", "\""), "\"") % 2 == 1) return false;
// 3. 開啟/關閉標籤數量一致
var openTags = System.Text.RegularExpressions.Regex.Matches(html, "<.[^>/]+>");
var closeTags = System.Text.RegularExpressions.Regex.Matches(html, @"<\s*/[a-z]\s*>");
if (openTags.Count != closeTags.Count) return false;
return true;
}
private static string FallbackPlainText(string html, int len)
{
string plain = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>|</[^>]+>", "");
plain = plain.Replace("<", "<").Replace(">", ">");
if (len > plain.Length) len = plain.Length;
return plain.Substring(0, len) + "...";
}
string html = "<div><p>這是一段<strong>包含粗體</strong>的測試內容</p></div>";
string summary = LeftH(html, 10);
// 輸出:<div><p>這是一段<strong>包含粗</strong></p></div>...
| 方案 | 優點 | 缺點 |
|---|---|---|
| Regex 移除所有標籤 | 簡單、效能好 | 失去排版、無法保留部份格式 |
| HtmlAgilityPack 解析後截取文字節點 | 結構化、支援 XPath | 需引入第三方套件、記憶體佔用大 |
| 本文 LeftH 方法 | 零依賴、保留標籤結構、精確控制可見字數 | 極長字串效能較弱、邏輯複雜 |