首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> .NET 網頁程式設計 >> ASP.NET 基礎 >> ASP.NET 基礎

ASP.NET 截取 HTML 字串不破壞標籤結構的無損截斷演算法

     編輯:ASP.NET 基礎

問題背景

在文章列表、搜尋摘要等場景,需將富文字 HTML 內容截取前 N 個字元作為預覽。若直接用 Substring 或 Regex 移除標籤,極易導致:

  • 標籤未閉合(如 <div> 沒有 </div>),破壞後續頁面排版。
  • 實體字元被切斷(如 &nbsp; 變 &nb),顯示亂碼。
  • 屬性值被截斷(如 style="color:red" 變 style="color:re),樣式失效。

本文提供一個成熟的 LeftH 方法,能在保留完整標籤結構前提下,精準截取指定長度的可見文字。

核心演算法邏輯

  1. 逐字元掃描,區分「標籤區」與「文字區」。
  2. 計數器 n 僅在文字區遞增,標籤區不計入長度。
  3. 堆疊記錄未閉合標籤:遇到開啟標籤 <tag> 推入堆疊,遇到關閉標籤 </tag> 彈出並從輸出移除對應開啟標籤。
  4. 特殊單一標籤處理:<br>、<hr>、<img>、<%...%>、<?...?> 視為無需閉合。
  5. 截斷點補全:迴圈結束時,將堆疊剩餘標籤以逆序補上關閉標籤,確保結構完整。
  6. 多重驗證:最終以 Regex 驗證標籤配對、引號配對、實體完整性,失敗則回退純文字模式。

完整程式碼

/// <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>...

注意事項與限制

  • ⚡ 效能:逐字元掃描,極長 HTML(>100KB)建議先截取前 N 字元再處理,或改用 HtmlAgilityPack 解析樹狀結構。
  • 🔧 編碼:輸入需為已解碼的 HTML 字串(非 HttpUtility.HtmlEncode 後的內容)。
  • 🧪 測試覆蓋:巢狀標籤、單一標籤、實體字元、屬性含引號、Script/Style 區塊、不合法 HTML 片段。

替代方案比較

方案優點缺點
Regex 移除所有標籤簡單、效能好失去排版、無法保留部份格式
HtmlAgilityPack 解析後截取文字節點結構化、支援 XPath需引入第三方套件、記憶體佔用大
本文 LeftH 方法零依賴、保留標籤結構、精確控制可見字數極長字串效能較弱、邏輯複雜
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved