CsvHelper.cs 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. using System.Text;
  2. namespace SharedKernel.Helpers;
  3. /// <summary>
  4. /// RFC 4180 호환 CSV 직렬화/역직렬화. 외부 라이브러리 미사용 (antooza 코드 컨벤션).
  5. /// 직렬화는 UTF-8 BOM 포함 (Excel 호환).
  6. /// </summary>
  7. public static class CsvHelper
  8. {
  9. /// <summary>
  10. /// 행 시퀀스를 RFC 4180 CSV byte[] 로 직렬화. UTF-8 BOM 포함.
  11. /// </summary>
  12. public static byte[] WriteUtf8Bom(IEnumerable<string[]> rows)
  13. {
  14. var sb = new StringBuilder();
  15. foreach (var row in rows)
  16. {
  17. for (var i = 0; i < row.Length; i++)
  18. {
  19. if (i > 0)
  20. {
  21. sb.Append(',');
  22. }
  23. sb.Append(EscapeCell(row[i]));
  24. }
  25. sb.Append("\r\n");
  26. }
  27. var data = Encoding.UTF8.GetBytes(sb.ToString());
  28. var bom = new byte[] { 0xEF, 0xBB, 0xBF };
  29. var result = new byte[bom.Length + data.Length];
  30. Buffer.BlockCopy(bom, 0, result, 0, bom.Length);
  31. Buffer.BlockCopy(data, 0, result, bom.Length, data.Length);
  32. return result;
  33. }
  34. /// <summary>RFC 4180 escape: 쉼표/따옴표/개행 포함 시 "..." 로 감싸고 내부 따옴표는 "" 로.</summary>
  35. public static string EscapeCell(string? cell)
  36. {
  37. cell ??= "";
  38. if (cell.Contains(',') || cell.Contains('"') || cell.Contains('\n') || cell.Contains('\r'))
  39. {
  40. return "\"" + cell.Replace("\"", "\"\"") + "\"";
  41. }
  42. return cell;
  43. }
  44. /// <summary>
  45. /// TextReader 에서 RFC 4180 CSV 행을 lazy 하게 읽는다.
  46. /// UTF-8 BOM 이 앞에 있으면 자동으로 건너뜀 (StreamReader 가 처리해 주거나, 여기서 fallback).
  47. /// </summary>
  48. public static IEnumerable<string[]> Read(TextReader reader)
  49. {
  50. var currentRow = new List<string>();
  51. var currentField = new StringBuilder();
  52. var inQuotes = false;
  53. var hasContent = false;
  54. var atStart = true;
  55. int ch;
  56. while ((ch = reader.Read()) >= 0)
  57. {
  58. var c = (char)ch;
  59. // 파일 첫머리 BOM (U+FEFF) 건너뜀 (StreamReader 가 못 처리한 경우 대비)
  60. if (atStart)
  61. {
  62. atStart = false;
  63. if (c == '')
  64. {
  65. continue;
  66. }
  67. }
  68. if (inQuotes)
  69. {
  70. if (c == '"')
  71. {
  72. var next = reader.Peek();
  73. if (next == '"')
  74. {
  75. currentField.Append('"');
  76. reader.Read();
  77. }
  78. else
  79. {
  80. inQuotes = false;
  81. }
  82. }
  83. else
  84. {
  85. currentField.Append(c);
  86. }
  87. hasContent = true;
  88. }
  89. else
  90. {
  91. if (c == '"')
  92. {
  93. inQuotes = true;
  94. hasContent = true;
  95. }
  96. else if (c == ',')
  97. {
  98. currentRow.Add(currentField.ToString());
  99. currentField.Clear();
  100. hasContent = true;
  101. }
  102. else if (c == '\r')
  103. {
  104. // \r\n 정상 처리: \n 이 뒤에 오면 그때 행 종료
  105. }
  106. else if (c == '\n')
  107. {
  108. if (hasContent || currentRow.Count > 0)
  109. {
  110. currentRow.Add(currentField.ToString());
  111. currentField.Clear();
  112. yield return currentRow.ToArray();
  113. currentRow = new List<string>();
  114. hasContent = false;
  115. }
  116. }
  117. else
  118. {
  119. currentField.Append(c);
  120. hasContent = true;
  121. }
  122. }
  123. }
  124. // 마지막 행: 개행 없이 끝난 경우
  125. if (hasContent || currentRow.Count > 0)
  126. {
  127. currentRow.Add(currentField.ToString());
  128. yield return currentRow.ToArray();
  129. }
  130. }
  131. }