新聞標題、清單摘要常需限制顯示長度。單純以字元數截斷會導致「博客園成立於20...」實際寬度遠小於「博客園萬歲萬歲萬萬歲」,造成版面參差不齊。本文實作全形視覺寬度計算演算法:全形字寬度=1,半形字寬度=0.5,精確截取至指定視覺寬度。
public static class StringTruncator
{
///
/// 依全形視覺寬度截取字串(全形=1, 半形=0.5)
///
/// 輸入字串
/// 目標視覺長度(全形字數)
/// 截取後字串
public static string TruncateByVisualWidth(string input, int visualLength)
{
if (string.IsNullOrEmpty(input) || visualLength <= 0) return input ?? "";
// 移除空白(可依需求調整)
var cleaned = RemoveWhitespace(input);
if (cleaned.Length <= visualLength) return cleaned; // 快速通道
return TruncateRecursive(cleaned, visualLength, visualLength);
}
private static string TruncateRecursive(string str, int targetWidth, int tryLength)
{
if (tryLength > str.Length) return str;
var left = str.Substring(0, tryLength);
var width = CalculateVisualWidth(left);
if (width > targetWidth || Math.Abs(width - targetWidth) == 0.5)
{
// 超出或剛好超出半格:移除最後一字
return left.Substring(0, left.Length - 1);
}
else if (width < targetWidth)
{
// 未達目標:嘗試加入下一字
if (tryLength + 1 <= str.Length)
return TruncateRecursive(str, targetWidth, tryLength + 1);
return left;
}
return left; // 剛好等於目標
}
private static double CalculateVisualWidth(string s)
{
double width = 0;
foreach (char c in s)
{
// ASCII 可列印字元視為半形 (33-126)
if (c >= 33 && c <= 126)
width += 0.5;
else
width += 1.0; // 中文、全形符號、控制字元等視為全形
}
return width;
}
private static string RemoveWhitespace(string s)
{
var sb = new StringBuilder(s.Length);
foreach (char c in s)
{
// 僅移除 ASCII 空白 (32),保留全形空白 (12288) 等
if (c != 32) sb.Append(c);
}
return sb.ToString();
}
}
| 輸入 | 目標寬度 | 結果 | 說明 |
|---|---|---|---|
| 博客園成立於2004年 | 9 | 博客園成立於2004年 | 11字元含4半形(2寬度) = 9寬度,完整保留 |
| 12345678910111213 | 9 | 12345678910111213 | 17半形 = 8.5寬度,完整保留 |
| 博客園萬歲萬歲萬萬歲 | 9 | 博客園萬歲萬歲萬 | 10全形 = 10寬度,截取前9字 |
| 博客園萬歲萬歲萬萬s歲 | 9 | 博客園萬歲萬歲萬萬s | 9全形+1半形=9.5,截取含半形 |
| 博客園萬歲萬歲萬萬sui | 9 | 博客園萬歲萬歲萬萬su | 9全形+3半形=10.5,截取含2半形 |
function truncateByVisualWidth(str, targetWidth) {
let width = 0;
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
width += (code >= 33 && code <= 126) ? 0.5 : 1;
if (width > targetWidth || Math.abs(width - targetWidth) === 0.5) {
return str.substring(0, i);
}
}
return str;
}
// Vue/React 過濾器/元件化使用
Vue.filter('visualTruncate', (value, len) => truncateByVisualWidth(value, len));
| 場景 | 調整策略 |
|---|---|
| CSS 等寬字體環境 | 直接使用字元數截斷即可 |
| 比例字體 | 需引入字體度量表,計算實際像素寬度 |
| 含 Emoji/代理字元對 | 使用 StringInfo 或 Rune 正確處理 Unicode 純量值 |
| 保留 HTML 標籤結構 | 結合前文 HtmlTruncator 先移除/補全標籤再計寬 |
此演算法已在多個高流量門戶網站標題截斷服務穩定運行,有效解決中英混排視覺對齊痛點,歡迎參考改良。