บทความเทคนิค

การส่งออกเวิร์กบุ๊ก Excel เป็น CSV, TSV, HTML และ RTF จาก Delphi ด้วย HotXLS

ลองนึกภาพงาน job ที่รันทุกคืนซึ่งสร้าง workbook ใบแจ้งหนี้ด้วยโค้ด แล้วเขียนออกมาเป็น CSV ให้ระบบปลายทางนำเข้า ตัวเลขดูถูกต้องใน Excel ไฟล์ CSV เปิดได้สะอาดใน text editor แล้วจู่ ๆ ตัวนำเข้าก็สำลักที่คอลัมน์ยอดรวม เพราะฟิลด์จำนวนเงินของแถวที่ 42 อ่านได้ =SUM(D2:D41) คือตัวสูตรในรูปข้อความดิบ ๆ ไม่ใช่ตัวเลขที่มันควรคำนวณออกมา ไม่มีอะไรพังเลย นี่คือพฤติกรรมที่มีเอกสารรองรับ และเป็นสิ่งแรกที่ต้องเข้าใจเกี่ยวกับการ export จาก HotXLS: ตัวเขียน serialize โมเดลเซลล์ตรงตามที่มันเป็นเป๊ะ ๆ และเซลล์สูตรที่ค่าของมันไม่เคยถูกคำนวณเลยก็มีแค่ข้อความสูตรของมันเท่านั้นที่จะส่งมอบให้

ทำไม CSV ของคุณถึงมีสูตรแทนที่จะเป็นตัวเลข

HotXLS เก็บข้อความสูตรกับค่าที่คำนวณแล้วเป็นสองสิ่งแยกจากกัน SaveAsCSV ไม่รันเครื่องคำนวณระหว่างทางออกไป โดยตั้งใจให้เป็นแบบนั้น: การ export ไม่ควรเปลี่ยนแปลง workbook และไม่ควรเสี่ยงค้างอยู่กับสูตรที่โยงกันแบบผิดปกติ ไฟล์ที่ Excel เองเป็นคนบันทึกจะพกผลลัพธ์ที่แคชไว้คู่กับสูตรเสมอ ดังนั้นการ export ไฟล์เหล่านั้นซ้ำจึงทำงานตามที่คุณคาดหวัง กับดักนี้เจาะจงเฉพาะ workbook ที่โค้ดของคุณเองสร้างขึ้น ที่ซึ่งสูตรถูกเขียนแต่ไม่เคย evaluate เลย วิธีแก้คือทำให้ค่ามีอยู่จริงก่อนที่คุณจะ export โดยใช้เครื่องคำนวณ Calculate ตัวเดียวกับที่ resolve reference ข้าม sheet และฟังก์ชันกำหนดเอง:

แผนภาพแสดงเซลล์เวิร์กบุ๊ก HotXLS ใน Delphi เก็บเพียงข้อความสูตร จนกระทั่ง Book.Calculate คำนวณค่า การ export CSV จึงปล่อยตัวเลขออกมา แทนข้อความ =SUM
SaveAsCSV serialize โมเดลเซลล์ตามที่เป็นอยู่ — หากไม่รัน Calculate ฟิลด์จำนวนเงินจะแบกข้อความสูตรตรงตัว และตัวนำเข้าจะปฏิเสธมัน
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  R: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('invoice-run.xlsx');
    Sheet := Book.Sheets[0];

    // ทำให้ผลลัพธ์ของสูตรเป็นค่าจริง เพื่อให้ CSV พกตัวเลข ไม่ใช่ข้อความ '=...'
    for R := 2 to 41 do
      if Sheet.Cells[R, 4].Formula <> '' then
        Sheet.Cells[R, 4].Value := Book.Calculate(Sheet.Cells[R, 4].Formula);

    Book.SaveAsCSV('feed.csv', 0, ',');    // sheet 0, comma
    Book.SaveAsCSV('feed.tsv', 0, #9);     // sheet เดียวกัน แต่เป็น TSV
  finally
    Book.Free;
  end;
end;

สังเกตว่า loop นี้ทำอะไรจริง ๆ: มันเขียนทับเซลล์สูตรด้วยค่าที่คำนวณแล้วของมัน นั่นถูกต้องเป๊ะสำหรับรอบ export แบบใช้แล้วทิ้ง แต่ผิดถ้าคุณตั้งใจจะ save workbook เป็น .xlsx อีกครั้งภายหลัง เพราะคุณเพิ่งแทนที่สูตรที่ยังมีชีวิตด้วยตัวเลขที่แช่แข็งไปแล้ว export จากสำเนาแทน หรือจำกัดขอบเขตการเขียนกลับให้แตะแค่รอบ export เท่านั้น เครื่องคำนวณที่อยู่เบื้องหลัง Calculate ทำได้มากกว่านี้อีก รวมถึงการลงทะเบียนฟังก์ชันของคุณเอง ซึ่งเป็นหัวข้อของ เครื่องคำนวณสูตรและฟังก์ชันกำหนดเองของ HotXLS

สิ่งที่ตัวเขียนแบบมีตัวคั่นรับประกันให้

เส้นทาง CSV ผลิต UTF-8 พร้อม byte order mark, ตัวจบบรรทัดแบบ CRLF และการใส่เครื่องหมายคำพูดตาม RFC 4180 ฟิลด์ใดก็ตามที่มีตัวคั่น เครื่องหมายคำพูด หรือตัวขึ้นบรรทัดใหม่จะถูกห่อไว้ และเครื่องหมายคำพูดที่ฝังอยู่ข้างในจะถูกใส่ซ้ำสองตัว วันที่จะ render เป็น yyyy-mm-dd hh:nn:ss ไม่ว่ารูปแบบการแสดงผลของเซลล์จะเป็นอย่างไร นั่นคือทางเลือกที่ถูกต้องสำหรับผู้บริโภคที่เป็นเครื่อง แม้ว่ามันจะทำให้ใครก็ตามที่คาดหวังว่ารูปแบบบนหน้าจอจะติดตามมาด้วยต้องแปลกใจ เซลล์ rich text จะถูกทำให้แบนราบด้วยการต่อ run ของมันเข้าด้วยกัน

แผนภาพตัวเขียนไฟล์คั่นตัวคั่นตัวเดียวของ HotXLS ใน Delphi ที่ผลิต CSV ด้วยเครื่องหมายจุลภาคและ TSV ด้วย #9 ขณะที่ผลลัพธ์ทั้งสองใช้ UTF-8 BOM, การจบบรรทัดแบบ CRLF และการอ้างอิงตาม RFC 4180 ร่วมกัน
CSV และ TSV มาจากตัวเขียนตัวเดียวกัน การใส่ BOM แบบ UTF-8 การจบบรรทัด CRLF และการอ้างอิงตาม RFC 4180 จึงมีผลกับทั้งคู่โดยไม่เปลี่ยนแปลง

ค่าเริ่มต้นเหล่านั้นยุติข้อโต้แย้งส่วนใหญ่กับตัวนำเข้าไปได้ก่อนที่มันจะเริ่มขึ้นด้วยซ้ำ แต่สองอย่างในนั้นควรอยู่ใน contract ของ interface คุณอยู่ดี อย่างแรกคือ BOM มันคือสิ่งที่ทำให้ Excel เปิดไฟล์ที่มีอักขระเน้นเสียงได้ครบถ้วน แต่ parser เข้มงวดบางตัวกลับมองสามไบต์นั้นเป็นข้อมูล ถ้าของคุณเป็นตัวหนึ่งในนั้น ให้ตัดมันทิ้งตอนส่งมอบ อย่างที่สองคือ TSV มันไม่ใช่ฟีเจอร์แยกต่างหากเลย เป็นแค่ตัวเขียนตัวเดียวกันที่ถูกเรียกด้วย #9 เป็นตัวคั่น ดังนั้นทุกอย่างข้างต้นก็ใช้ได้กับมันโดยไม่เปลี่ยนแปลง sheet ที่จะ export ถูกเลือกด้วย index แบบเริ่มนับที่ 0 ใน overload หลาย argument ในขณะที่รูปแบบย่อ SaveAsCSV(FileName) ที่รับ argument เดียวจะใช้ sheet ที่ active อยู่

การ export เป็น HTML คือภาพนิ่ง ไม่ใช่รูปแบบสำหรับแลกเปลี่ยน

ในขณะที่ CSV ทิ้งทุกอย่างไปหมดยกเว้นค่า SaveAsHTML พยายามรักษารูปลักษณ์ไว้: หนึ่ง <table> ต่อหนึ่ง sheet พื้นที่ที่ merge ไว้แสดงเป็น colspan และ rowspan การจัดสไตล์เซลล์พื้นฐานฝังเป็น CSS แบบ inline สีที่อิง theme จะถูกข้ามไปแทนที่จะ resolve ออกมา ดังนั้น template ที่พึ่งพา theme slot จะออกมาเรียบกว่าที่มันดูใน Excel ตั้งสี RGB ตรง ๆ บนอะไรก็ตามที่ต้องรอดจากการเดินทางนี้ให้ได้ object ตัวเลือกควบคุมเปลือกนอก:

var
  Opts: TXLSXHtmlExportOptions;
begin
  Opts := TXLSXHtmlExportOptions.Create;
  try
    Opts.Title := 'Weekly settlement';
    Opts.TableClass := 'report-grid';     // จุดเกี่ยวสำหรับ stylesheet ของหน้าโฮสต์
    Opts.WriteDocument := True;           // หน้าเต็ม ไม่ใช่ fragment
    if Book.SaveAsHTML('settlement.html', 0, Opts) <> 0 then
      raise Exception.Create('Sheet index out of range');
  finally
    Opts.Free;
  end;
end;

รายละเอียดสองอย่างในตัวอย่างนั้นคุ้มค่าที่จะใส่ใจ พลิก WriteDocument เป็น False แล้ว output จะกลายเป็นแค่ table fragment เปล่า ๆ แทนที่จะเป็นหน้าเต็ม ซึ่งเป็นสิ่งที่คุณต้องการเมื่อฉีด preview เข้าไปใน layout ที่มีอยู่แล้ว: ตั้ง TableClass แล้วปล่อยให้ stylesheet ของโฮสต์จัดการเรื่อง theme เอง ธรรมเนียมค่าที่คืนกลับก็สวนทางกับ call ส่วนใหญ่ของ HotXLS เช่นกัน SaveAsHTML คืนค่า 0 เมื่อสำเร็จ และ -1 เมื่อ sheet index ไม่ถูกต้อง ดังนั้นการตรวจสอบตามความเคยชินด้วย = 1 จะรายงานว่า export ที่สำเร็จทุกครั้งเป็นความล้มเหลว เมื่อคุณต้องการแค่บางพื้นที่แทนที่จะเป็นทั้ง sheet บางทีอาจจะเพื่อส่งอีเมลหรือฝังบล็อกเดียว TXLSXRange.SaveAsHTML จะ export range สี่เหลี่ยมใด ๆ ตามกฎการ render ชุดเดียวกัน

output แบบ RTF และที่ที่มันยังคุ้มค่าอยู่

เป้าหมายที่สี่เขียนตาราง RTF 1.6 หนึ่ง sheet ต่อหนึ่งการเรียกผ่าน SaveAsRTF ความกว้างคอลัมน์ถูกประมาณไว้ที่ราว 96 twip ต่อหนึ่งอักขระของความกว้างคอลัมน์ ข้อจำกัดเชิงโครงสร้างที่ควรรู้ไว้คือเซลล์ที่ merge จะไม่ครอบคลุมพื้นที่ต่อกันใน output: มีแค่เซลล์ anchor เท่านั้นที่พกเนื้อหาของมัน ส่วนเซลล์ที่ถูกครอบไว้จะออกมาเป็นช่องว่างเปล่า สิ่งนี้ตัด RTF ออกจากตัวเลือกสำหรับ template ที่หนักไปทาง layout มันยังคงคุ้มค่าอยู่ในฐานะเส้นทางที่ต้านทานน้อยที่สุดสำหรับการวางผลลัพธ์แบบตารางลงใน word processor หรือลงในระบบจัดการเอกสารรุ่นเก่าที่มีมาก่อนยุคที่รับ HTML ได้

การ round-trip: การนำเข้า CSV ถูกออกแบบให้ทำลายข้อมูลเดิม

การอ่าน CSV กลับเข้ามามี contract ของตัวเอง OpenCSV ล้าง workbook ทั้งหมดแล้วสร้างมันขึ้นใหม่เป็น sheet เดียวชื่อ Sheet1 มันเป็น constructor ในเชิงจิตวิญญาณ ไม่ใช่การ merge ดังนั้นห้ามเรียกมันบน workbook ที่ยังพกเนื้อหาที่ยังไม่ได้ save อยู่เด็ดขาด การส่ง #0 เป็นตัวคั่นจะกระตุ้นให้ตรวจจับตัวคั่นแบบอัตโนมัติ flag ADetectTypes ควบคุมการเลื่อนระดับชนิดข้อมูล: เมื่อเปิดไว้ สตริงตัวเลขจะกลายเป็นตัวเลข สตริง ISO-8601 จะกลายเป็นวันที่ และ true/false จะกลายเป็น boolean ปิดมันเมื่อ feed พก identifier ที่มีเลขศูนย์นำหน้า รหัสไปรษณีย์ หรือรหัสสินค้า ซึ่งทั้งหมดนี้การเลื่อนระดับจะบิดเบือนให้กลายเป็นตัวเลขอย่างเงียบ ๆ (เลขศูนย์นำหน้าจะหายไปเฉยเลยทันทีที่ 00123 กลายเป็น 123) ทั้งสองส่วนหน้าเปิดการนำเข้าแบบเดียวกันนี้ออกมา จับคู่มันกับ call สำหรับ export ข้างต้น แล้วคุณก็จะได้สะพานเชื่อม format ที่ไม่ต้องติดตั้ง Excel เลยสักตัวในทั้ง pipeline ซึ่งเป็นสถานการณ์ที่ครอบคลุมอยู่ใน การสร้างรายงาน Excel จากฐานข้อมูลด้วย HotXLS

การ export ตรงเข้าไปยัง stream

ตัวเขียนทุกตัวตรงนี้มี overload แบบ stream วางอยู่ข้าง ๆ เวอร์ชันที่ใช้ชื่อไฟล์: CSV, HTML, RTF และรูปแบบ workbook เองก็เช่นกัน ในโค้ดฝั่งเซิร์ฟเวอร์ overload เหล่านั้นคือสิ่งที่ควรหยิบใช้ endpoint บนเว็บที่ให้ดาวน์โหลด CSV สามารถเขียนลงใน TMemoryStream แล้วส่งมันตรงไปยัง response object ได้เลย โดยไม่มีไฟล์ชั่วคราว ไม่มีงาน cleanup และไม่มีการชนกันระหว่างสอง request ที่บังเอิญเลือกชื่อที่สร้างขึ้นซ้ำกัน หลักการเดียวกันนี้ก็ใช้ได้กับการดัน export เข้าไปใน blob storage หรือแนบมันเข้ากับอีเมลขาออก ระบบไฟล์หลุดออกจากภาพไปเลยทั้งหมด

รูปแบบนี้ยิ่งทวีคูณด้วยวิธีที่ไลบรารีถูก deploy ทั้งสองส่วนหน้าเป็นตัวอ่านและเขียน Object Pascal แบบ native ดังนั้นจึงไม่มีการติดตั้ง Excel ไม่มี COM automation และไม่มี bottleneck ต่อ process ที่ serialize request บนเซิร์ฟเวอร์ แต่ละ request สามารถเป็นเจ้าของ workbook object ของตัวเอง รันการเขียนกลับของการคำนวณจากส่วนแรก และ stream การ export ของมันไปพร้อม ๆ กับ request ข้างเคียงได้ ทรัพยากรอย่างเดียวที่ต้องจับตาดูคือหน่วยความจำ โมเดล workbook อาศัยอยู่ใน RAM ตลอดช่วงการ export ดังนั้น service ที่เปิดไฟล์ขนาดใหญ่มาก ๆ แค่เพื่อปล่อยมันออกมาใหม่เป็น CSV ควรจำกัดจำนวนงานที่รันพร้อมกัน หรือเข้าคิวงานที่ใหญ่เกินไป แทนที่จะปล่อยให้ traffic ที่พุ่งขึ้นเป็นตัวตัดสิน working set

ปุ่มปรับเล็ก ๆ อีกอันหนึ่ง: ตั้ง IncludeBOM บนตัวเลือก HTML เมื่อ fragment นั้นจะถูก save เป็นไฟล์แยกต่างหากที่เครื่องมือปลายทางบางตัวดมกลิ่นหา encoding เมื่อคุณเสิร์ฟ HTML ตรงผ่าน HTTP ให้ปล่อยการประกาศ charset ไว้ให้ header ของ response แทน

เมื่อไบต์ยังคงออกมาผิดอยู่ดี

คำถามสนับสนุนที่พบบ่อยที่สุดเกี่ยวกับการ export CSV คือปัญหาเดียวกับตัวอย่างเปิดเรื่องแต่สวมเสื้อผ้าคนละชุด: Excel แสดง mojibake แทนที่จะเป็นอักขระเน้นเสียง สัญชาตญาณคือโทษตัวเขียน แต่มันปล่อย UTF-8 BOM ออกมาด้วยเหตุผลนี้เป๊ะ ๆ และไฟล์แทบจะถูกต้องเสมอตอนที่มันออกจากโค้ดของคุณ มีบางอย่างระหว่างตรงนั้นกับ Excel ที่กินทิ้ง BOM ไป การโอนย้ายผ่าน FTP ในโหมดข้อความ การคัดลอก stream ที่ข้ามสามไบต์แรกไป proxy ที่ re-encode ระหว่างทาง: อย่างใดอย่างหนึ่งในนี้จะลอกเครื่องหมายนั้นออกและปล่อยให้ Excel เดา encoding เอง ซึ่งมันทำได้แย่มาก วินิจฉัยปัญหานี้ที่ขอบเขต ไม่ใช่ที่ตัว call สำหรับ export เปิดไฟล์ที่ส่งมอบแล้วด้วย hex viewer แล้วยืนยันว่า EF BB BF ยังคงเป็นสิ่งแรกที่อยู่ในนั้น

แผนภาพตามรอยว่า UTF-8 BOM ที่ถูกต้องซึ่งการ export CSV ของ HotXLS ใน Delphi เขียนไว้ ถูกตัดทิ้งโดยการถ่ายโอนแบบ text mode ผ่าน FTP หรือพร็อกซีที่เข้ารหัสใหม่ ทำให้ Excel แสดงอักษรเพี้ยนแบบ mojibake
ตัวเขียนปล่อย EF BB BF ออกมาถูกต้อง — mojibake ปรากฏหลังจากที่ขนส่งผ่านชั้นหนึ่งม้วนมาร์กเกอร์ทิ้งเท่านั้น จึงต้องวินิจฉัยไบต์ที่ส่งถึงในเครื่องมือดูเลขฐานสิบหก

นั่นคือเส้นเรื่องที่ร้อยทั้งสี่รูปแบบเข้าด้วยกัน call สำหรับ export คือส่วนที่ง่าย และ HotXLS เลือกทางที่อธิบายได้ในทุกจุดตัดสินใจที่ตัวเขียนต้องเผชิญ ความล้มเหลวอาศัยอยู่ที่รอยต่อ ตรงที่ข้อความสูตรมาเจอกับ parser ที่ต้องการตัวเลข ตรงที่ BOM มาเจอกับการขนส่งที่ไม่รักษามันไว้ ตรงที่เซลล์ merge มาเจอกับโมเดลตารางแบนของ RTF แต่ละอย่างนั้นคือข้อเท็จจริงที่ควรเขียนลงใน contract ระหว่างตัว exporter ของคุณกับสิ่งที่จะบริโภคมัน เพราะผู้บริโภคอ่านความตั้งใจของคุณออกมาจากไบต์ไม่ได้ สำหรับรายการเมธอดเต็มรูปแบบข้ามทั้งสองส่วนหน้าของ workbook หน้าผลิตภัณฑ์ HotXLS Delphi Component มีเอกสารอ้างอิงฉบับเต็ม