列表頁標題、摘要、搜尋結果常需截取固定寬度字串。中英混排下單純 Substring 會導致中文截半、寬度不一。本文提供精確計算字元顯示寬度的截取方法,含 HTML 標籤過濾、尾碼附加、效能優化。
public static class StringTruncate
{
///
/// 取得字元顯示寬度:中文/全形=2,英文/半形=1
///
public static int GetCharWidth(char c)
{
// Unicode 區塊判斷比單純 > 255 更精確
return c switch
{
// CJK 統一表意文字
>= '\u4E00' and <= '\u9FFF' => 2,
// CJK 符號和標點
>= '\u3000' and <= '\u303F' => 2,
// 全形英文/數字/符號
>= '\uFF01' and <= '\uFF5E' => 2,
// 半形片假名
>= '\uFF61' and <= '\uFF9F' => 1,
// 控制字元、格式字元視為 0
_ when char.IsControl(c) || char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.Format => 0,
_ => 1 // ASCII 可列印字元、其他視為半形
};
}
///
/// 精確截取至指定顯示寬度
///
public static string TruncateByWidth(string input, int maxWidth, string suffix = "...")
{
if (string.IsNullOrEmpty(input) || maxWidth <= 0) return string.Empty;
int suffixWidth = suffix.Sum(GetCharWidth);
if (suffixWidth >= maxWidth) return string.Empty; // 寬度不足放尾碼
int availableWidth = maxWidth - suffixWidth;
int currentWidth = 0;
int lastValidIndex = -1;
for (int i = 0; i < input.Length; i++)
{
int charWidth = GetCharWidth(input[i]);
if (currentWidth + charWidth > availableWidth)
break;
currentWidth += charWidth;
lastValidIndex = i;
}
if (lastValidIndex == -1) return suffix; // 連第一個字都放不下
if (lastValidIndex == input.Length - 1) return input; // 無需截取
return input.Substring(0, lastValidIndex + 1) + suffix;
}
}
public static class HtmlSanitizer
{
// 簡易版:移除所有標籤,保留文字內容
private static readonly System.Text.RegularExpressions.Regex _tagRegex =
new(@"<[^>]*>", System.Text.RegularExpressions.RegexOptions.Compiled);
public static string StripTags(string html)
{
if (string.IsNullOrEmpty(html)) return string.Empty;
return _tagRegex.Replace(html, string.Empty);
}
// 進階版:保留安全標籤 (b, i, u, span 等),移除 script/style/iframe
public static string Sanitize(string html, string[] allowedTags = null)
{
// 建議使用 HtmlSanitizer 套件 (NuGet: Ganss.XSS)
// 此處略過複雜實作
return StripTags(html);
}
}
public static class SummaryGenerator
{
public static string GenerateSummary(string htmlContent, int maxWidth = 80, string suffix = "...")
{
// 1. 移除 HTML 標籤
string plainText = HtmlSanitizer.StripTags(htmlContent);
// 2. 合併連續空白字元
plainText = System.Text.RegularExpressions.Regex.Replace(plainText, @"\s+", " ");
// 3. 精確截取
return StringTruncate.TruncateByWidth(plainText.Trim(), maxWidth, suffix);
}
}
// 使用
string html = "這是一個測試內容,包含 English text。
";
string summary = SummaryGenerator.GenerateSummary(html, 40);
// 結果: "這是一個測試內容,包含 English..."
public static class FastTruncate
{
// 預計算 ASCII 字元寬度查找表
private static readonly byte[] _asciiWidth = new byte[128];
static FastTruncate()
{
for (int i = 0; i < 128; i++)
{
char c = (char)i;
_asciiWidth[i] = (byte)(char.IsControl(c) ? 0 : 1);
}
}
public static string Truncate(string input, int maxWidth, string suffix = "...")
{
if (string.IsNullOrEmpty(input) || maxWidth <= 0) return string.Empty;
int suffixWidth = 0;
foreach (char c in suffix) suffixWidth += GetWidth(c);
if (suffixWidth >= maxWidth) return string.Empty;
int avail = maxWidth - suffixWidth;
int width = 0;
int end = 0;
for (int i = 0; i < input.Length; i++)
{
char c = input[i];
int w = c < 128 ? _asciiWidth[c] : GetWidth(c);
if (width + w > avail) break;
width += w;
end = i + 1;
}
if (end == 0) return suffix;
if (end >= input.Length) return input;
return input.Substring(0, end) + suffix;
}
private static int GetWidth(char c)
{
// 同 StringTruncate.GetCharWidth 邏輯
if (c >= '\u4E00' && c <= '\u9FFF') return 2;
if (c >= '\u3000' && c <= '\u303F') return 2;
if (c >= '\uFF01' && c <= '\uFF5E') return 2;
if (c >= '\uFF61' && c <= '\uFF9F') return 1;
if (char.IsControl(c)) return 0;
return 1;
}
}
[TestClass]
public class TruncateTests
{
[TestMethod]
public void PureChinese()
{
Assert.AreEqual("中文...", StringTruncate.TruncateByWidth("中文字符串", 6)); // 3字*2=6
}
[TestMethod]
public void PureEnglish()
{
Assert.AreEqual("Hello...", StringTruncate.TruncateByWidth("Hello World", 7)); // 5字*1+3=8? Wait: "Hello"=5, "..."=3, total=8. maxWidth=7 -> "Hel..."=3+3=6
}
[TestMethod]
public void Mixed()
{
// "中English" = 2+1+1+1+1+1+1+1 = 9
// maxWidth=10, suffix=3 => avail=7 => "中Engl" (2+1+1+1+1+1=7) + "..."
Assert.AreEqual("中Engl...", StringTruncate.TruncateByWidth("中English", 10));
}
[TestMethod]
public void HtmlStripped()
{
string html = "內容
";
Assert.AreEqual("內容...", SummaryGenerator.GenerateSummary(html, 6));
}
[TestMethod]
public void EmojiAndSpecial()
{
// Emoji 通常佔 2 字元位置 (surrogate pair)
string s = "😀Hello"; // 😀 = 2 char (surrogate), 視為寬度 2
// 實際寬度計算需處理 surrogate pair
}
}
public static int GetTextElementWidth(string s, int index)
{
// 使用 StringInfo 處理字素簇
var info = new System.Globalization.StringInfo(s);
int textElementCount = info.LengthInTextElements;
// 簡化:取得指定索引的文字元素
string element = info.SubstringByTextElements(index, 1);
// 判斷寬度:EastAsianWidth 屬性
// 需 NuGet: NodaTime 或自建查表
// 此處略過複雜實作
return element.Length switch
{
1 => GetCharWidth(element[0]),
2 => 2, // Surrogate pair (Emoji 等) 通常顯示寬度 2
_ => element.Length
};
}
using System.Globalization;
public static string TruncateByTextElements(string input, int maxElements, string suffix = "...")
{
var enumerator = StringInfo.GetTextElementEnumerator(input);
var elements = new List();
while (enumerator.MoveNext())
elements.Add(enumerator.GetTextElement());
if (elements.Count <= maxElements) return input;
return string.Join("", elements.Take(maxElements)) + suffix;
}
精確字串截取需考慮:字元顯示寬度 (半形/全形)、HTML 標籤、Surrogate Pair (Emoji)、字素簇。生產代碼建議:
HtmlSanitizer.StripTags 移除標籤GetCharWidth 計算寬度FastTruncate 避免 LINQ/配置NodaTime 或 SkiaSharp 文字度量