相較於 CSV (Comma-Separated Values),我更愛用 TSV (Tab-Separated Values) 格式,字串值包含 Tab 符號的機率遠低於逗號,通常用 Split('\t') 就夠了,不需加入例外解析邏輯。

Console.WriteLine() 輸出 TSV 時,理論上 Tab 應能發揮一定對齊效果,如果同一欄位的字元長度接近一致的話。但實務資料很少會這麼乖,欄位長短差異很大,中英文交差,因此通常輸出結果會如下圖般參差不齊:

我心中理想的輸出結果,應該要像表格,整整齊齊排列:

之前有處理固定欄寬資料檔的經驗,這個需求對 .NET 不算難事,這篇分享我的簡易版 TSV 表格化輸出函式。

不囉嗦,直接上 Code:

using System.Text;
var tsv = new string[] {
    "完全控制\tJeffrey\tAdmin,IT\t[AI 專案 擁有人]\t65,535",
    "讀取\tAlexander\tMgr\t[AI 專案 成員]\t9,999",
    "參與\tMay\tUser\t直接設定",
    "讀取\tCatherine\tMgr\t直接設定\t32,767",
};
Action<string> printTitle = (title) => {
    Console.WriteLine();
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine(title);
    Console.ResetColor();
};
printTitle("原始格式");
Console.WriteLine(string.Join("\n", tsv));

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
printTitle("表格顯示");
Console.WriteLine(string.Join("\n", PrintTsvAsTable(tsv)));    
printTitle("表格顯示(自訂標題)");
Console.WriteLine(string.Join("\n", 
    PrintTsvAsTable(tsv, headers: "權限,姓名,身分,來源,參數".Split(','))));
printTitle("表格顯示(第一列為標題)");
tsv = new List<string> { "權限\t姓名\t身分\t來源\t參數" }.Concat(tsv).ToArray();
Console.WriteLine(string.Join("\n", 
    PrintTsvAsTable(tsv, true)));

/// <summary>
/// 以表格方式列印 TSV
/// </summary>
/// <param name="tsv">Tab Separated Values</param>
/// <param name="firstRowAsHeader">使用第一列作為標題</param>
/// <param name="headers">自訂標題</param>
/// <returns></returns>
string[] PrintTsvAsTable(IEnumerable<string> tsv, bool firstRowAsHeader = false, string[] headers = null!)
{
    if (firstRowAsHeader)
    {
        headers = tsv.First().Split('\t');
        tsv = tsv.Skip(1);
    }
    var maxLens = new List<int>();
    var numFlags = new List<bool>();
    var data = new List<string[]>();
    // 用 Big5 簡易換算,非英數字元寬度算 2 
    // TODO: 此處忽略難字、非 Big5 Unicode 字元之寬度誤差,使用 Graphics.MeasureString 可更精準
    Func<string, int> calcLen = (s) => Encoding.GetEncoding(950).GetByteCount(s);
    Func<string, int, string> padLeft = (s, l) => new String(' ', Math.Max(0, l - calcLen(s))) + s;
    Func<string, int, string> padRight = (s, l) => s + new String(' ', Math.Max(0, l - calcLen(s)));
    foreach (var line in tsv)
    {
        var cells = line.Split('\t');
        for (int i = 0; i < cells.Length; i++)
        {
            if (maxLens.Count <= i) {
                maxLens.Add(0);
                numFlags.Add(true);
            }
            var val = cells[i];
            if (!float.TryParse(val.Replace(",", string.Empty), out _))
            {
                numFlags[i] = false;
            }
            maxLens[i] = Math.Max(maxLens[i], calcLen(val));
        }
        data.Add(cells);
    }
    var output = new List<string>();
    Func<int, int> getMaxLen = (i) => i < maxLens.Count ? maxLens[i] : 8;
    if (headers != null)
    {
        output.Add(string.Join(' ', 
        Enumerable.Range(0, headers.Length)
        .Select(i => padRight(headers[i], getMaxLen(i))).ToArray()));
        output.Add(string.Join(' ',
        Enumerable.Range(0, headers.Length)
        .Select(i => new string('-', getMaxLen(i))).ToArray()));
    }
    foreach (var cells in data)
    {
        output.Add(string.Join(' ',
        Enumerable.Range(0, cells.Length)
        .Select(i => numFlags[i] ? 
            padLeft(cells[i], getMaxLen(i)) : 
            padRight(cells[i], getMaxLen(i))).ToArray()));
    }
    return output.ToArray();
}

程式會掃瞄所有欄位值,統計各欄資料最大寬度,決定顯示時要補的空白字元數。我加了判斷欄位全部為數字靠右,否則靠左的邏輯。有個小挑戰是,中文字跟半型英數字的字元數都是 1,但顯示時中文寬度要算 2,會影響補空白的數量。這裡我是用字元轉 Big5 編碼後是兩個 Byte 判斷字元為中文字或英數字,寬度該算 2 還是 1,這個做法遇到難字或非 Big5 Unicode 字元時會失準,但優點是簡單,處理一般內容還堪用。若要更精準,可能從考慮用 Graphics.MeasureString(),複雜度較高,等真有需求再考慮。

Example C# code to print TSV in table format.


Comments

Be the first to post a comment

Post a comment