ปุ่ม "Export CSV" เป็นปุ่มที่ระบบหลังร้านแทบทุกระบบมี เพราะทำง่าย ไฟล์เล็ก และเปิดได้ทุกโปรแกรม แต่ไฟล์แรกที่ส่งให้ฝ่ายบัญชีมักกลับมาพร้อมคำถามว่า "ทำไมชื่อลูกค้าเป็นภาษาต่างดาว" ทั้งที่ไฟล์เดียวกันเปิดใน VS Code หรือ Google Sheets แล้วภาษาไทยขึ้นครบ
ไฟล์ไม่ได้เสีย ตัวอักษรอยู่ครบทุกตัว แค่ Excel อ่านไฟล์ด้วยรหัสคนละแบบกับที่เราเขียน
Excel อ่าน CSV ด้วยรหัสตามภาษาของเครื่อง
CSV เป็นไฟล์ข้อความเปล่า ๆ ไม่มีที่ให้บอกว่าตัวอักษรเข้ารหัสแบบไหน โปรแกรมที่เปิดไฟล์จึงต้องเดาเอง Excel บน Windows เดาด้วยรหัสตามภาษาที่ตั้งไว้ในเครื่อง (code page) เครื่องที่ตั้งภาษาไทยจะอ่านเป็น Windows-874 เครื่องที่ตั้งภาษาอังกฤษจะอ่านเป็น Windows-1252
ภาษาไทยใน UTF-8 ใช้ตัวละสามไบต์ แต่ Windows-874 กับ Windows-1252 อ่านทีละไบต์ อักษรไทยหนึ่งตัวจึงกลายเป็นสามตัวที่ไม่เกี่ยวกัน คำว่า "สาขา" บนเครื่องภาษาอังกฤษจะออกมาเป็น สาขา
ทางที่ Excel ใช้แยกไฟล์ UTF-8 คือ BOM (byte order mark) สามไบต์ EF BB BF ที่วางไว้หน้าไฟล์ Microsoft เขียนไว้ตรง ๆ ว่าไฟล์ CSV UTF-8 ที่บันทึกพร้อม BOM จะเปิดได้ตามปกติ ถ้าไม่มี BOM ให้ผู้ใช้นำเข้าผ่าน Data › Get Data › From Text/CSV แล้วเลือก UTF-8 เอง
ทางนำเข้านี้ใช้ได้ แต่ต้องสอนผู้ใช้ทุกคน และทุกคนก็ยังดับเบิลคลิกไฟล์อยู่ดี ใส่ BOM ไว้ตั้งแต่ตอนสร้างไฟล์จึงง่ายกว่าเยอะ
ใส่ BOM ให้ไฟล์ที่คนจะเปิดใน Excel
BOM เป็นแค่สามไบต์ที่เขียนก่อนแถวแรก ใน PHP เขียนเป็น "\xEF\xBB\xBF" หรือ "\u{FEFF}" ก็ได้ แต่ละ package ใส่ให้ไม่เหมือนกัน
| เขียนด้วย | ใส่ BOM ให้เองไหม | ทำยังไง |
|---|---|---|
fputcsv ของ PHP |
ไม่ใส่ | fwrite($handle, "\xEF\xBB\xBF") ก่อนแถวแรก |
| league/csv | ใส่เฉพาะตอนส่งออกเป็นข้อความหรือดาวน์โหลด | เขียน Bom::Utf8->value เองเมื่อเขียนลงไฟล์ |
| Laravel Excel (maatwebsite) | ไม่ใส่ (use_bom ค่าเริ่มต้นเป็น false) |
ตั้ง exports.csv.use_bom เป็น true ใน config/excel.php |
| OpenSpout CSV writer | ใส่ (SHOULD_ADD_BOM เปิดไว้) |
ไม่ต้องทำอะไร |
| spatie/simple-excel | ใส่ | ไม่ต้องทำอะไร ปิดได้ด้วย createWithoutBom() |
league/csv เป็นตัวที่ต้องระวังที่สุด เพราะชื่อเมธอด setOutputBOM() ชวนให้คิดว่าตั้งแล้วจบ เอกสารของ league/csv บอกว่า BOM ที่ตั้งไว้จะออกเฉพาะตอนเรียก output(), toString() หรือดาวน์โหลด ไม่ได้บันทึกลงไฟล์ เราลองเปิด writer ลงไฟล์ ตั้ง setOutputBOM(Bom::Utf8) แล้วเขียนแถว ไฟล์ที่ได้ไม่มี BOM จริง ถ้าเขียนลงไฟล์หรือ stream เองจึงต้องเขียน BOM ไว้ก่อน
use League\Csv\Bom;
use League\Csv\Writer;
$file = fopen($path, 'w');
fwrite($file, Bom::Utf8->value); // เขียน BOM ลงไฟล์เองก่อนแถวแรก
$csv = Writer::from($file);
$csv->insertOne(['สาขา', 'สินค้า', 'ยอดขาย']);
ถ้าเขียนด้วย fputcsv เอง เช่นใน response()->streamDownload() ให้ส่ง escape: '' ไปด้วยทุกครั้ง ตั้งแต่ PHP 8.4 การไม่ส่ง $escape จะเตือน deprecation เพราะค่าเริ่มต้นจะเปลี่ยน และ escape: '' ทำให้ไฟล์เข้ารหัสเครื่องหมายคำพูดแบบเดียวกับที่ Excel อ่าน คือเบิ้ลเป็น "" อย่างเดียว
return response()->streamDownload(function () use ($orders): void {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['เลขที่', 'ลูกค้า', 'ยอดรวม'], escape: '');
foreach ($orders as $order) {
fputcsv($out, [$order->number, $order->customer_name, $order->total], escape: '');
}
}, 'orders.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
ไฟล์ที่โปรแกรมอ่านไม่ต้องมี BOM
BOM มีไว้ให้ Excel เดาถูก ไฟล์ที่ไม่มีคนเปิดใน Excel ไม่ได้อะไรจาก BOM และบางครั้งยังทำให้ฝั่งรับอ่านผิด
- feed และ API เช่นไฟล์ conversion ที่ส่งให้ Google Ads หรือ feed สินค้า ระบบปลายทางรู้อยู่แล้วว่าเป็น UTF-8
- ชุดข้อมูลที่แจกให้นักพัฒนา เช่นรายชื่อตำบลและรหัสไปรษณีย์ ถ้าอยากให้คนทั่วไปเปิดใน Excel ได้ ให้มีไฟล์ .xlsx คู่กันแทน
- ไฟล์ที่ระบบอื่น import ต่อ โปรแกรมหลายตัวไม่ตัด BOM ให้ สามไบต์นั้นจะไปติดอยู่หน้าชื่อคอลัมน์แรก หัวคอลัมน์
skuกลายเป็น\u{FEFF}skuที่ดูด้วยตาเหมือนกันทุกอย่าง แต่โค้ดหาคอลัมน์skuไม่เจอ
ข้อสุดท้ายย้อนกลับมาที่ฝั่งเราด้วย ถ้าระบบรับไฟล์ CSV ที่ผู้ใช้ save มาจาก Excel ("CSV UTF-8" ของ Excel ใส่ BOM ให้) ให้ตัด BOM ออกก่อนอ่านหัวคอลัมน์ทุกครั้ง Reader ของ league/csv ตัด BOM ขาเข้าให้เป็นค่าเริ่มต้น ถ้าอ่านเองด้วย fgetcsv ให้ตัดสามไบต์นั้นออกจากบรรทัดแรกเอง
ระบบเก่าที่ต้องการ TIS-620 ให้แปลงด้วย iconv
บางปลายทางยังไม่รับ UTF-8 เช่นไฟล์ batch ที่ส่งเข้า SAP รุ่นเก่า ไฟล์ที่ธนาคารส่งกลับ หรือรายงานจากโปรแกรมบัญชีรุ่นเก่า ไฟล์พวกนี้มักเป็น TIS-620 หรือ Windows-874 (เรื่องไฟล์ที่ธนาคารส่งกลับเป็น TIS-620 อยู่ในโพสต์เรื่องไฟล์โอนเงิน)
สองชื่อนี้เกือบเป็นตัวเดียวกัน Windows-874 คือ TIS-620 ที่ Microsoft เพิ่มตัวอักษรเข้าไป เช่น €, … และเครื่องหมายคำพูดแบบโค้ง ข้อความที่ผู้ใช้ copy มาจาก Word มักมีเครื่องหมายคำพูดแบบโค้งติดมา ถ้าปลายทางบอกว่ารับ Windows-874 ก็ใช้ Windows-874 ได้เลย ถ้าบอก TIS-620 ต้องเตรียมรับมือกับตัวที่แปลงไม่ได้
PHP มีสองทางที่ใช้แปลง encoding และสองทางนี้รู้จักชื่อไม่เท่ากัน
| ทาง | TIS-620 | Windows-874 / CP874 |
|---|---|---|
mb_convert_encoding (mbstring) |
ไม่รู้จัก | ไม่รู้จัก (ValueError) |
iconv |
ได้ | ได้ |
CharsetConverter ของ league/csv |
ไม่ได้ (ใช้ mbstring อยู่ข้างใน) | ไม่ได้ |
CharsetConverter ของ league/csv ใช้ mbstring อยู่ข้างใน จึงโยน exception เมื่อเจอชื่อ encoding ที่ mbstring ไม่รู้จัก เอกสารของ league/csv เองก็แนะนำให้ใช้ stream filter ของ iconv แทนในกรณีนี้ ทางที่ใช้ได้แน่นอนคือเขียนไฟล์เป็น UTF-8 ก่อน แล้วแปลงทั้งไฟล์ด้วย iconv ตอนจบ
$utf8 = Storage::get('sap/payments.txt');
Storage::put('sap/payments-tis620.txt', iconv('UTF-8', 'TIS-620//IGNORE', $utf8));
//IGNORE ต้องต่อท้าย encoding ปลายทาง เสมอ ตามคู่มือ PHP iconv จะทิ้งตัวที่ TIS-620 ไม่มี เช่น € ไปเงียบ ๆ ถ้าไม่ใส่ //IGNORE หรือไปใส่ผิดที่เป็น 'UTF-8//IGNORE' ฝั่งต้นทาง iconv จะเจอตัวที่แปลงไม่ได้แล้วคืนค่า false ไฟล์ที่ได้จะเป็นไฟล์ว่าง เราลองแปลง "ราคา €5" แบบไม่มี //IGNORE ได้ false กลับมาจริง ส่วนแบบมี //IGNORE ได้ "ราคา 5" ถ้าเปลี่ยนปลายทางเป็น CP874 แทน € จะอยู่ครบ
ขาเข้าก็ทำกลับกัน ไฟล์ที่ไม่ใช่ UTF-8 ที่ถูกต้อง (mb_check_encoding($text, 'UTF-8') เป็น false) ให้แปลงด้วย iconv('TIS-620', 'UTF-8//IGNORE', $text) ก่อนบันทึกลงฐานข้อมูล PostgreSQL จะปฏิเสธข้อความที่ไม่ใช่ UTF-8 ทันที MySQL ในโหมด strict ก็ปฏิเสธเหมือนกันด้วย error Incorrect string value
เลือก encoding ตามคนที่เปิดไฟล์
| ใครเปิดไฟล์ | encoding | BOM |
|---|---|---|
| ผู้ใช้ดับเบิลคลิกเปิดใน Excel | UTF-8 | ใส่ |
| Google Sheets, Numbers, LibreOffice | UTF-8 | ใส่ได้ ไม่เสียหาย |
| โปรแกรมหรือ API ของระบบอื่น | UTF-8 | ไม่ใส่ |
| ระบบเก่า ธนาคาร SAP รุ่นเก่า | ตามสเปกของปลายทาง (TIS-620 / Windows-874) | ไม่ใส่ |
ถ้าไฟล์ที่ส่งให้ผู้ใช้ต้องจัดหน้าตา มีหลายชีท หรือมีวันที่และตัวเลขที่ต้องคำนวณต่อ ไฟล์ .xlsx จะไม่มีปัญหาเรื่อง encoding เลย เพราะเก็บข้อความเป็น UTF-8 เสมอ CSV เหมาะกับข้อมูลดิบที่ต้องการไฟล์เล็กและเปิดได้ทุกที่
ไฟล์ CSV ที่ส่งไปมาระหว่างคนกับระบบ
- ในโปรเจกต์เดียวมี CSV สองแบบ อย่างในระบบรายงานจาก SAP ที่เราทำให้ลูกค้า ปุ่ม export ในหน้าเว็บใส่ BOM ไว้แล้ว แต่หน้าจอ export ของผู้ดูแลระบบอีกหน้ายังเขียนด้วย
fputcsvเปล่า ๆ ไฟล์จากสองปุ่มจึงเปิดใน Excel ได้ไม่เหมือนกัน ทางที่กันได้คือรวมการเขียน CSV ไว้ที่เดียวในโปรเจกต์ - ผู้ใช้ save ไฟล์จาก Excel กลับมา import แล้วหัวคอลัมน์แรกหาไม่เจอ ให้ตัด BOM ขาเข้าเสมอ
- ไฟล์ส่งระบบเก่าว่างเปล่า เพราะมีตัวอักษรเดียวที่ TIS-620 ไม่มี แล้ว
iconvคืนค่าfalseให้ใส่//IGNOREฝั่งปลายทาง หรือใช้ CP874 ถ้าปลายทางรับ - Laravel Excel export เป็น CSV แล้วภาษาไทยเพี้ยน เพราะ
use_bomปิดไว้เป็นค่าเริ่มต้น เปิดในconfig/excel.php
ถ้ารายงานต้องมีวันที่เป็น พ.ศ. ที่ยัง sort ได้ ลองอ่านต่อที่วิธีใส่วันที่ พ.ศ. ใน Excelได้ครับ






