Technical Article

การส่งออกสเปรดชีตแบบปลอดภัยสำหรับยูนิโค้ดใน Delphi: RTF และ HTML

สเปรดชีตหนึ่งเก็บคอลัมน์รายชื่อลูกค้า บางชื่อเป็นภาษาจีน บางชื่อเป็นอักษรซีริลลิก และบางชื่อมีเครื่องหมายการออกเสียงภาษาเยอรมันหรือฝรั่งเศส คุณส่งออกข้อมูลนั้นเป็นไฟล์ CSV และเปิดผลลัพธ์ดู และพบว่าทุกตัวอักษรยังคงความสมบูรณ์ แต่เมื่อคุณส่งออกสมุดงานเดียวกันเป็นไฟล์ RTF สำหรับเป็นเทมเพลตจดหมายเวียน และเปิดดูในโปรแกรมประมวลผลคำ รายชื่อที่ไม่ใช่อักษร ASCII กลับกลายเป็นแถวของเครื่องหมายคำถามไปทั้งหมด ข้อมูลไม่ได้เปลี่ยนแปลง สิ่งที่เปลี่ยนแปลงคือข้อตกลงในการเข้ารหัสของรูปแบบไฟล์ที่คุณเขียน และแต่ละเส้นทางการส่งออกก็มีเกณฑ์การเข้ารหัสที่แตกต่างกันออกไป

นี่คือกัปดักที่คอยดักเล่นงานไลบรารีที่ดูเหมือนว่าจะรองรับยูนิโค้ดอย่างครบถ้วนที่ลักษณะภายนอก ข้อความเซลล์จะถูกเก็บไว้ภายในเป็นประเภท WideString ดังนั้นโมเดลการประมวลผลจึงไม่เคยทำตัวอักษรใดๆ หล่นหาย ความสูญเสียจะเกิดขึ้นที่บริเวณขอบเขต ในขั้นตอนของตัวเขียนซึ่งทำหน้าที่แปลงข้อความเหล่านั้นให้อยู่ในรูปแบบที่มีกฎระเบียบของตัวเองว่าไบต์ใดบ้างที่ถูกต้อง และองค์ประกอบใดๆ ที่อยู่นอกเกณฑ์จะต้องถูกเข้ารหัสอย่างไร พัฒนาตัวเขียนตัวหนึ่งให้ถูกต้อง แต่คุณก็ยังอาจจัดส่งตัวเขียนอีกตัวที่ทำให้ข้อความเดียวกันเกิดความเสียหายได้ วิธีแก้ไขไม่ใช่การปรับเปลี่ยนสวิตช์ระดับสากล แต่มันคือการตัดสินใจแบบแยกส่วนและถูกต้องในทุกๆ เส้นทางการทำงาน

RTF เป็นรูปแบบไฟล์ที่ปลอดภัยสำหรับ 7 บิตโดยการออกแบบ

Rich Text Format (RTF) ถูกคิดค้นขึ้นมาก่อนยุคของยูนิโค้ด และได้รับการกำหนดขึ้นเพื่อให้สามารถรอดพ้นระบบรับส่งข้อมูลที่อนุญาตผ่านเฉพาะตัวอักษร ASCII ที่พิมพ์ได้เท่านั้น เอกสาร RTF จะประกาศรหัสหน้า (code page) ไว้ในส่วนหัว และอักขระใดๆ ที่ตัวเขียนไม่สามารถนำเสนอในรหัสหน้านั้นได้ จะต้องถูกส่งออกมาในรูปแบบของรหัสควบคุม (escape) แทนที่จะเป็นไบต์ดิบ รหัสควบคุมที่เกี่ยวข้องคือ \u ซึ่งจะทำหน้าที่เก็บหน่วยรหัสขนาด 16 บิตแบบมีเครื่องหมาย และตามด้วยตัวอักษร ASCII สำรองสำหรับโปรแกรมอ่านที่มีอายุมากเกินกว่าจะทำความเข้าใจรหัสควบคุมนี้ได้

HotXLS เขียนไฟล์ RTF ด้วยวิธีนี้ ส่วนหัวของเอกสารจะเปิดใช้งานโดยการประกาศรหัสหน้าในรูป \ansi\ansicpg1252\uc1 และตัวเขียนในหน่วยการทำงาน lxRTF จะดำเนินการตรวจสอบทุกสตริงและส่งออกอักขระใดๆ ที่สูงกว่า ASCII ปกติให้อยู่ในรูปของรหัสควบคุม \u เพื่อให้กระแสข้อมูลไบต์อยู่ในรูป 7 บิตอย่างสะอาดเรียบร้อยโดยไม่ต้องคำนึงถึงข้อมูลที่รหัสหน้าที่ประกาศสามารถบันทึกได้ จุดรหัสเช่น U+4E2D จะกลายเป็นลำดับอักษร \u20013? แทนที่จะเป็นไบต์ดิบซึ่งโปรแกรมแสดงผลอาจตีความผ่านรหัสหน้าใดๆ ที่มันคาดเดาขึ้นมาเอง หากไม่มีข้อกำหนดนี้ ข้อมูลใดๆ ที่อยู่นอกรหัสหน้าที่ประกาศจะไม่มีรูปแบบไบต์ที่ถูกต้องตามกฎหมาย และตัวเขียนที่ส่งออกค่าดิบจะผลิตเครื่องหมายคำถามซึ่งเป็นประเด็นหลักของบทความนี้

รายละเอียดที่ต้องระลึกไว้เสมอคือรหัสหน้าที่ประกาศและรหัสควบคุมคือสองซีกของข้อตกลงเดียวกัน การประกาศรหัสหน้าเพียงอย่างเดียวจะไม่สามารถช่วยเหลือข้อความที่อยู่นอกเหนือขอบเขตของมันได้ และการส่งออกรหัสควบคุมโดยไม่มีรหัสหน้าที่ประกาศจะทำให้ตัวอักษรสำรองเกิดความไม่ชัดเจน ทั้งสองส่วนจะต้องมีความถูกต้องร่วมกัน ซึ่งเป็นเหตุผลที่ตัวเขียนที่จัดการเพียงส่วนใดส่วนหนึ่งยังคงล้มเหลวในการทำงานกับสมุดงานแบบหลายภาษา

HTML escaping is about more than angle brackets

การส่งออก HTML จะผลิตเอกสารแบบหลายแผ่นงานซึ่งเฟรมนำทางจะมีชื่อแผ่นงานเป็นข้อความที่แสดงผล ชื่อเหล่านั้นเป็นสตริงที่ผู้ใช้กำหนดซึ่งสามารถเก็บตัวอักษรใดๆ ได้ รวมถึงตัวอักษรที่มีความหมายสำคัญต่อการเขียนโค้ดมาร์กอัปด้วย แผ่นงานที่ตั้งชื่อในรูป Q1 & Q2 <draft> จะต้องถูกแปลงให้เป็นเอนทิตีหลีกเลี่ยง (escaped entities) เมื่อส่งมาที่หน้ากระดาษ มิฉะนั้นวงเล็บมุมจะเปิดแท็กจำลองและเครื่องหมายแอมเพอร์แซนด์จะเริ่มอ้างอิงเอนทิตีโดยไม่ได้ตั้งใจ นี่คือการหลีกเลี่ยงอักขระ HTML ตามปกติ และการละเลยขั้นตอนนี้บนป้ายกำกับเฟรมก็มักจะเป็นจุดตกหล่นที่รอดพ้นการทดสอบผ่านเฉพาะชื่อแผ่นงานที่เป็นภาษาอังกฤษ ASCII

ประเด็นเรื่องการเข้ารหัสจะอยู่ลึกลงไปอีกหนึ่งชั้น เมื่ออักขระที่ไม่ใช่ ASCII ตกอยู่ในบริบทที่ไม่มีการรับประกันว่าจะถูกจัดส่งในรูปแบบ UTF-8 รูปแบบการแสดงผลที่ปลอดภัยคือการใช้อ้างอิงอักขระตัวเลข (numeric character reference) ดังนั้น U+00E9 จึงควรเขียนเป็น &#233; แทนที่จะเป็นไบต์ดิบซึ่งความหมายจะขึ้นอยู่กับชุดอักขระตอบกลับ (response charset) ที่คาดเดา ภาพสะท้อนของกฎข้อนี้จะนำมาใช้ในฝั่งขาเข้า สมุดงานที่อ่านกลับมาจาก XLSX จะเก็บสตริงร่วมกันซึ่งตัวอักษรบางตัวอาจถูกจัดเก็บเป็นเอนทิตี XML แบบตัวเลขไปแล้ว และเอนทิตีนั้นจะต้องได้รับการถอดรหัสให้เป็นตัวอักษรที่สมบูรณ์หนึ่งตัวก่อนที่จะเข้าสู่โมเดลของเซลล์ หากถอดรหัสอย่างไร้ความระมัดระวังโดยการแยกจุดรหัสออกเป็นไบต์ย่อย ตัวอักษรเดี่ยวจะกลายเป็นอักษรขยะ (mojibake) สองชิ้นที่การส่งออกในภายหลังไม่สามารถกู้คืนกลับมาได้อีกเลย

คอนเทนเนอร์ XLSX คือไฟล์ ZIP และ ZIP ก็มีการเข้ารหัสชื่อของตัวเอง

ไฟล์ XLSX คือคลังเก็บ ZIP และคลังเก็บจะบันทึกชื่อสำหรับสมาชิกทุกตัวที่มันเก็บไว้ รูปแบบ ZIP นั้นเก่าแก่มากพอที่ข้อกำหนดดั้งเดิมไม่ได้ระบุสิ่งใดเกี่ยวกับการเข้ารหัสชื่อเหล่านั้นไว้ ดังนั้นโปรแกรมอ่านที่ไม่พบสัญญาณจะสมมติใช้รหัสหน้าท้องถิ่นของคลังเก็บนั้น การคาดเดานั้นจะผิดพลาดทันทีที่ชื่อสมาชิกมีตัวอักษรที่ไม่ใช่ ASCII ซึ่งเกิดขึ้นกับชื่อส่วนแผ่นงานที่ผ่านการแปลท้องถิ่นและกับสื่อบันทึกที่ฝังไว้ซึ่งชื่อไฟล์มีเครื่องหมายการออกเสียงหรือตัวอักษรที่ไม่ใช่อักษรละติน

วิธีแก้ไขคือการใช้บิตเดี่ยว บิตวัตถุประสงค์ทั่วไปตำแหน่งที่ 11 (General-purpose bit 11) ในส่วนหัวไฟล์ท้องถิ่นแต่ละตัวจะประกาศว่าชื่อสมาชิกได้รับการเข้ารหัสเป็น UTF-8 ฟังก์ชันของ HotXLS จะตรวจสอบบิตนี้อย่างแม่นยำเมื่ออ่านคลังเก็บ โดยทดสอบแฟล็กวัตถุประสงค์ทั่วไปกับมาสก์ $0800 และโปรแกรมอ่านหรือโปรแกรมเขียนที่ละเลยข้อกำหนดนี้จะอ่านชื่อที่โปรแกรมเวอร์ชันถูกต้องจัดเก็บเป็น UTF-8 ผิดพลาดไป บิตนี้มีต้นทุนการตั้งค่าต่ำและประมวลผลได้ง่าย และเป็นส่วนแบ่งระหว่างชื่อสมาชิกที่รอดพ้นการเดินทางไปกลับกับชื่อสมาชิกที่เสียหายตั้งแต่ก่อนที่เนื้อหาของสเปรดชีตจะได้รับการวิเคราะห์ไวยากรณ์ด้วยซ้ำ

การเปลี่ยนรูปอักษร (Case folding) และการสแกนตัวเลขก็ซ่อนความเสี่ยงแบบเดียวกันไว้

การประเมินผลสูตรคือจุดที่ความปลอดภัยสำหรับยูนิโค้ดเปลี่ยนจากการแปลงรูปแบบข้อมูลไปเป็นเรื่องของการเปรียบเทียบ ฟังก์ชัน SEARCH จะไม่ไวต่ออักษรพิมพ์เล็กพิมพ์ใหญ่ ซึ่งหมายความว่ามันจะต้องแปลงรูปอักษรก่อนที่จะค้นหาข้อความย่อย วิธีการแปลงที่ผิดคือการประมวลผลผ่านรหัสหน้า ANSI เนื่องจากตัวอักษรพิมพ์ใหญ่ของข้อความที่ไม่ใช่ ASCII ด้วยวิธีนั้นจะส่งอักขระผ่านรหัสหน้าที่มีขนาดแคบและทำความเสียหายแก่สิ่งใดก็ตามที่อยู่นอกเกณฑ์ วิธีการที่ถูกต้องคือการทำให้อักษรตัวพิมพ์ใหญ่ของสตริงแบบกว้าง (wide-string) ซึ่งจะคงรูปช่วง UTF-16 ทั้งหมดไว้ได้อย่างครบถ้วน HotXLS แปลงสไตล์ด้วย WideUpperCase ด้วยเหตุผลนี้ ดังนั้นการค้นหาข้อความที่มีเครื่องหมายออกเสียงหรืออักษรที่ไม่ใช่ละตินจะยังคงตรงกับตัวอักษรเดิมที่ส่งเข้าไป แทนที่จะเป็นค่าที่รหัสหน้าบิดเบือนไป

ตัวแบ่งโทเค็นของสูตร (formula tokenizer) มีหน้าที่เกี่ยวข้องที่ไม่ได้เกี่ยวข้องกับตัวอักษรเลย แต่เกี่ยวข้องกับตำแหน่งสิ้นสุดของโทเค็น รูปแบบทางวิทยาศาสตร์เช่น 1E3 หรือ 2.5E-3 เป็นค่าคงที่ตัวเลขเดียว และตัวสแกนจะต้องจดจำอักษร E เครื่องหมายที่ระบุ และหลักตัวเลขถัดไปให้เป็นส่วนหนึ่งของตัวเลข แทนที่จะหั่นอักขระอินพุตออกเป็นชื่อและตามด้วยตัวเลขที่แยกต่างหาก ตัวสแกนที่จัดการเรื่องนี้ผิดพลาดจะเปลี่ยนค่าคงที่ที่ถูกต้องสมบูรณ์ให้กลายเป็นข้อผิดพลาดของการวิเคราะห์ หรือที่แย่กว่านั้นคือได้นิพจน์ที่ให้ผลลัพธ์ผิดพลาดอย่างเงียบๆ ประเด็นนี้จัดอยู่ในกลุ่มหัวข้อเดียวกันเพราะทั้งสองกรณีต่างเป็นเรื่องของตัวอ่านที่ต้องทำการตัดสินใจในระดับอักขระอย่างถูกต้อง: กรณีหนึ่งเกี่ยวกับวิธีการแปลงอักขระเพื่อนำมาเปรียบเทียบ อีกกรณีเกี่ยวกับอักขระนั้นว่าทำหน้าที่ต่อยอดโทเค็นปัจจุบันหรือไม่

การสร้างและการส่งออกสมุดงานหลายภาษา

API สาธารณะไม่ได้ขอให้คุณต้องกังวลเกี่ยวกับเรื่องเหล่านี้เลย คุณสร้างสมุดงานขึ้นมาจากค่าของเซลล์ที่เป็น WideString และเรียกใช้จุดเข้าส่งออกตามที่คุณต้องการ การตัดสินใจเรื่องการเข้ารหัสเกิดขึ้นภายในตัวเขียนแต่ละตัว ตัวอย่างด้านล่างแสดงการป้อนแผ่นงานด้วยข้อความในหลายสคริปต์ จากนั้นเขียนทั้งไฟล์ RTF และไฟล์ HTML จากสมุดงานเดียวกัน เพื่อให้ทั้งสองเส้นทางทำงานเทียบกับอินพุตเดียวกัน

uses
  lxHandle;

procedure ExportMultilingualWorkbook;
var
  Book: IXLSWorkbook;
  Sheet: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Customers');

    Sheet.Cells[1, 1].Value := 'Name';
    Sheet.Cells[1, 2].Value := 'City';

    // Cell text is held as WideString, so every script survives the model.
    Sheet.Cells[2, 1].Value := '王伟';          // Chinese
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // German umlaut
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Cyrillic
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // French accents
    Sheet.Cells[5, 2].Value := 'Montréal';

    // RTF: the lxRTF writer declares the code page and emits every
    // non-ASCII character as a \u escape, keeping the file 7-bit clean.
    Book.SaveAsRTF('Customers.rtf');

    // HTML: sheet names are HTML-escaped and non-ASCII text is written
    // so it does not depend on a guessed response charset.
    Book.SaveAsHTML('Customers.html');
  finally
    Book := nil;
  end;
end;

การเรียกใช้ทั้งสองแบบจะส่งคืนสถานะจำนวนเต็ม (Integer) และทั้งคู่ต่างใช้ข้อความเดียวกันในหน่วยความจำ ไม่มีส่วนใดในโค้ดที่เรียกใช้งานที่ประกาศรหัสหน้าหรือทำรหัสควบคุมอักขระ เนื่องจากหน้าที่รับผิดชอบอยู่ที่ตัวเขียนซึ่งเข้าใจรูปแบบไฟล์ของมันเอง ฟังก์ชันระดับสมุดงาน SaveAsCSV ก็ใช้รูปแบบเดียวกันหากคุณต้องการส่งออกข้อมูลแบบมีตัวคั่นจากแหล่งข้อมูลเดียวกัน

// Same workbook, a third export path with its own encoding rules.
Book.SaveAsCSV('Customers.csv');

ความปลอดภัยของยูนิโค้ดขึ้นอยู่กับเส้นทางการส่งออก ไม่ใช่ตัวไลบรารีทั้งหมด

บทความนี้มีคีย์เทรลเลอร์จากพจนานุกรมกระแสเป็นข้อความธรรมดา ขยายทุก /ObjStm ด้วยตัวถอดรหัส Flate ก่อนที่จะตรวจสอบเอกสาร และจัดการการถอดรหัสรายการอ้างอิงไขว้ไบนารีเป็นงานที่ใหญ่ขึ้นซึ่งเลือกทำภายหลังได้

บทเรียนที่ควรจำไว้คือไม่มีจุดใดจุดหนึ่งที่เป็นจุดทำให้ปลอดภัยสำหรับยูนิโค้ดได้ทั้งหมด RTF ต้องการรหัสหน้าที่ประกาศรวมถึงรหัสควบคุม \u HTML ต้องการการหลีกเลี่ยงเอนทิตีสำหรับอักขระที่มีความหมายสำคัญทางมาร์กอัปและการอ้างอิงตัวเลขเมื่อไม่มีการรับประกันชุดอักขระ ตลอดจนการถอดรหัสเอนทิตีที่ส่งมาในรูปของสตริงร่วมกันอย่างถูกต้อง ส่วน ZIP ต้องการการตั้งค่าบิตวัตถุประสงค์ทั่วไปตำแหน่งที่ 11 เพื่อให้อ่านชื่อสมาชิกที่เป็น UTF-8 ได้อย่างถูกต้อง การประเมินผลสูตรต้องการการจัดสไตล์ตัวอักษรแบบสตริงกว้างและตัวแบ่งโทเค็นที่ช่วยรักษาโครงสร้างวิทยาศาสตร์ไว้เป็นชิ้นเดียว แต่ละประเด็นเหล่านี้เป็นข้อตกลงที่แตกต่างกัน และไลบรารีอาจรองรับความต้องการหนึ่งในขณะที่ฝ่าฝืนอีกข้อตกลงหนึ่งอย่างเงียบๆ ได้ นั่นคือสาเหตุที่เครื่องมือที่ประมวลผลไฟล์ CSV ได้อย่างถูกต้องยังสามารถส่งมอบไฟล์ RTF ที่เต็มไปด้วยเครื่องหมายคำถามให้แก่คุณได้

หากการส่งออกของคุณพึ่งพารูปแบบแบบมีตัวคั่น ข้อแลกเปลี่ยนระหว่างกันจะมีอธิบายไว้ในคู่มือการส่งออก CSV, TSV และ HTML ของเรา และเมื่อแหล่งข้อมูลเป็นชุดผลลัพธ์แทนที่จะเป็นแผ่นงานที่สร้างขึ้นด้วยมือ รูปแบบในการส่งออกฐานข้อมูลสำหรับรายงาน Delphi จะทำงานร่วมกับกฎการเข้ารหัสที่อธิบายไว้ในที่นี้ได้อย่างลงตัว ทั้งหมดนี้จัดส่งมาในตัว HotXLS Component สำหรับ Delphi และ C++Builder พร้อมด้วย API การอ่าน สูตร และการจัดรูปแบบที่มีอธิบายในส่วนอื่นของบล็อกนี้