วิธีทำไฟล์ CSV ภาษาไทยให้เปิดใน Excel แล้วอ่านออก

Excel บน Windows จะอ่าน CSV เป็น UTF-8 ก็ต่อเมื่อไฟล์ขึ้นต้นด้วย BOM สามไบต์ ใส่ BOM ให้ไฟล์ที่คนเปิดใน Excel ไม่ต้องใส่ให้ไฟล์ที่โปรแกรมอ่าน และแปลงเป็น TIS-620 ด้วย iconv เมื่อต้องส่งให้ระบบเก่า

เผยแพร่
เวลาอ่าน
6 นาที
ในโพสต์นี้ 6 หัวข้อ
  1. Excel อ่าน CSV ด้วยรหัสตามภาษาของเครื่อง
  2. ใส่ BOM ให้ไฟล์ที่คนจะเปิดใน Excel
  3. ไฟล์ที่โปรแกรมอ่านไม่ต้องมี BOM
  4. ระบบเก่าที่ต้องการ TIS-620 ให้แปลงด้วย iconv
  5. เลือก encoding ตามคนที่เปิดไฟล์
  6. ไฟล์ CSV ที่ส่งไปมาระหว่างคนกับระบบ

ปุ่ม "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ได้ครับ

ตอนถัดไปของซีรีส์ Export Excel และ CSV จาก Laravel

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →