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

การ Implement รูปแบบคลิปบอร์ด CF_HTML ใน Delphi

ลอง copy ช่วงหนึ่งจาก grid ใน Delphi แล้ววางลงใน Word การจัดรูปแบบมักจะหายไป เหลือแค่ข้อความธรรมดา ไม่มีหัวข้อตัวหนา ไม่มีเส้นขอบ ไม่มีสีเติม HotXLS ปิดช่องว่างนั้นด้วย TXLSRange.CopyToClipboard ซึ่งวาง payload คลิปบอร์ด CF_HTML ซึ่งเป็นรูปแบบของ Windows สำหรับ HTML ที่มีสไตล์พร้อม fragment marker ที่แม่นยำระดับไบต์ ไว้บนคลิปบอร์ดควบคู่ไปกับข้อความ Unicode ธรรมดา

ฟังดูง่ายจนกว่าคุณจะดูว่า payload CF_HTML ต้องการอะไรจริงๆ รูปแบบนี้ต้องการ header ข้อความสั้นๆ ที่ระบุตำแหน่งที่ fragment เริ่มต้นและสิ้นสุดภายใน buffer คลิปบอร์ดที่ใหญ่กว่าอย่างแม่นยำ และตำแหน่งเหล่านั้นเป็น byte offset นับผ่าน encoding แบบ multi-byte ใดก็ตามที่ HTML ลงเอย คำนวณผิดแม้แค่หนึ่งไบต์ แอปพลิเคชันเป้าหมายก็จะคว้าส่วนของ markup ที่ผิด หรือไม่ก็ยอมแพ้และ fallback ไปเป็นข้อความธรรมดา และไม่มีความล้มเหลวไหนเลยที่ดูเหมือนบั๊กในโค้ดของคุณ มันดูเหมือน Word กำลังทำตัวเป็น Word เท่านั้นเอง

ทำไม copy-paste จาก Delphi grid ถึงมักสูญเสียการจัดรูปแบบ

การเรียกคลิปบอร์ดเริ่มต้นของ Windows ที่โค้ด Delphi ส่วนใหญ่ใช้ คือ SetClipboardData ด้วย CF_TEXT หรือ CF_UNICODETEXT พกแค่ตัวอักษรธรรมดาเท่านั้น ดังนั้นสไตล์ใดๆ ที่ใช้กับ grid ต้นทางจึงไม่มีที่ไป Word, Outlook และเบราว์เซอร์ที่อิง Chromium ทุกตัวมองหารูปแบบที่สมบูรณ์กว่าเมื่อคุณวาง คือการแสดง HTML ของสิ่งที่เลือก พร้อมสไตล์แบบ inline โครงสร้างตาราง และลิงก์ Excel เองก็พึ่งพากลเม็ดนี้พอดี copy ช่วงใน Excel แล้วคลิปบอร์ดจะได้รับหลายรูปแบบพร้อมกันอย่างเงียบๆ รวมถึง HTML ด้วย ดังนั้นแอปพลิเคชันใดก็ตามที่คุณวางลงไปจะเลือกรูปแบบที่สมบูรณ์ที่สุดที่มันเข้าใจ คอมโพเนนต์ที่เขียนแค่ CF_UNICODETEXT เท่านั้นจะส่งอะไรให้ consumer ที่สมบูรณ์กว่าเหล่านั้นทำงานด้วยไม่ได้เลย และความสมบูรณ์ทางภาพที่ผู้ใช้เพิ่ง copy มาก็ไม่มีให้วางเลย

รูปแบบคลิปบอร์ด CF_HTML คืออะไรกันแน่

CF_HTML ไม่ใช่รูปแบบคลิปบอร์ดระบบคงที่แบบ CF_TEXT มันเป็นรูปแบบที่ลงทะเบียนแบบ dynamic ขอด้วยชื่อผ่าน RegisterClipboardFormat('HTML Format') และ payload ของมันเป็น header แบบ ASCII สั้นๆ ตามด้วยเอกสารหรือ fragment HTML header พกฟิลด์ห้าตัว คือ Version, StartHTML, EndHTML, StartFragment, EndFragment โดยที่ Version เป็น 0.9 เสมอ และอีกสี่ตัวเป็นตัวเลขฐานสิบเขียนออกมาเป็นเลข ASCII StartHTML และ EndHTML ครอบเอกสารทั้งหมดตามที่แอปพลิเคชันที่รับควร parse เพื่อบริบท รวมถึงฟอนต์และสไตล์ ในขณะที่ StartFragment และ EndFragment ครอบส่วนที่แคบกว่าซึ่งลงเอยที่เคอร์เซอร์จริงๆ โดยทั่วไปทำเครื่องหมายไว้ใน markup เองด้วย comment <!--StartFragment--> และ <!--EndFragment--> เพื่อให้ขอบเขตอยู่รอดผ่านการ re-serialize แบบไร้เดียงสา

Byte offset ไม่ใช่จำนวนตัวอักษร: กับดักคลาสสิกของ CF_HTML

ฟิลด์ตัวเลขทั้งสี่ของ header ของ CF_HTML เป็น byte offset เข้าไปในลำดับไบต์ที่แน่นอนที่นั่งอยู่บนคลิปบอร์ด นับจากตัวอักษรตัวแรกสุดของ header เอง ไม่ใช่จำนวนตัวอักษร ไม่ใช่ Unicode code point และไม่ใช่ offset ที่สัมพัทธ์กับ fragment หรือ tag <body> ความแตกต่างนั้นคือจุดที่การ implement CF_HTML ด้วยมือมักผิดพลาดอย่างเงียบๆ Length ของ UnicodeString ใน Delphi รายงานหน่วย UTF-16 ซึ่งบังเอิญเท่ากับจำนวนไบต์สำหรับข้อความ ASCII ธรรมดา ดังนั้นบั๊กจึงหลุดผ่านการทดสอบใดๆ ที่เขียนด้วยข้อมูลตัวอย่างภาษาอังกฤษไปได้อย่างสะอาด และปรากฏขึ้นก็ต่อเมื่อเซลล์ที่ copy มามีขีดกลางยาว สัญลักษณ์สกุลเงิน หรือตัวอักษรมี accent เท่านั้น เครื่องหมายยูโรเป็นหนึ่งหน่วย UTF-16 แต่เป็นสามไบต์ใน UTF-8 และทุก offset ที่คำนวณหลังจุดนั้นจะเลื่อนไปตามจำนวนไบต์พิเศษที่ encoding เพิ่มเข้ามา ความล้มเหลวที่ตามมาไม่ใช่การ crash มันคือแอปพลิเคชันที่รับข้อมูลยึดช่วงไบต์ที่แน่นอนที่ header ชี้ไป พบส่วนของ markup ที่เริ่มต้นหรือสิ้นสุดกลาง tag แล้วก็ render ขยะออกมาหรือยอมแพ้และ fallback ไปเป็นข้อความธรรมดาใดก็ตามที่อยู่ข้างๆ บนคลิปบอร์ด อย่างเงียบๆ โดยไม่มีอะไรในโค้ดของคุณอธิบายว่าทำไม นี่คือรูปร่างของโค้ดที่สร้างความล้มเหลวแบบนั้นพอดี

// Fragile: Length() on a UnicodeString counts UTF-16 code units, not bytes
var
  Header: string;
  Fragment: string;
  StartFragmentOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 + 'StartHTML:0000000000'#13#10 + '...';
  StartFragmentOfs := Length(Header) + Pos('<!--StartFragment-->', Fragment);
  // A currency symbol, an em dash, or any accented character placed
  // before this point costs one character here but two or three bytes
  // once the document is UTF-8 encoded, so StartFragmentOfs now points
  // short of where the fragment actually begins on the real clipboard
end;

HotXLS รักษาความแม่นยำระดับไบต์ของ header ได้อย่างไร

HotXLS หลีกเลี่ยงบั๊กประเภทนี้เชิงโครงสร้าง TXLSRange.CopyToClipboard และ unit lxClipboard ที่อยู่ข้างใต้มันสร้างเอกสาร CF_HTML และ header ของมันทั้งหมดเป็น AnsiString ซึ่งเป็น byte-string type ของ Delphi ดังนั้น Length และ Pos จึงคืนตำแหน่งไบต์อยู่แล้วทุกที่ในการคำนวณ ไม่มีขั้นตอนแยกต่างหาก และดังนั้นจึงไม่มีขั้นตอนที่ลืมได้ ที่จำนวนตัวอักษร Unicode ต้องแปลงเป็นจำนวนไบต์ก่อนที่จะเข้าไปใน header

มีกลเม็ดที่สองที่เล็กกว่าซึ่งควรรู้ไว้ถ้าคุณเคยสร้าง header CF_HTML ด้วยมือ header ถูกเขียนสองครั้ง ครั้งแรกด้วยเลขศูนย์สิบตัวแทนแต่ละ offset ทั้งสี่ตัว เพื่อให้วัดความยาวไบต์ของตัวมันเองได้ และอีกครั้งพร้อม offset จริงที่แพตช์เข้าไป เพราะ offset จริงทุกตัวถูกจัดรูปแบบให้มีความกว้างสิบหลักคงที่เดียวกัน header ครั้งที่สองจึงออกมามีความยาวเท่ากับ header แบบ placeholder ทุกไบต์ ซึ่งเป็นเหตุผลที่การวัดก่อนหน้ายังคงถูกต้องหลังการเขียนใหม่ ข้ามความกว้างคงที่นั้นไป จัดรูปแบบตัวเลขด้วย IntToStr ธรรมดาแทน แล้ว header จะหดหรือขยายไปหนึ่งหลักระหว่างสองรอบ ทำให้ offset ทุกตัวที่ตามมาไม่ถูกต้องอย่างเงียบๆ

const
  Placeholder = '0000000000';   // 10 ASCII digits: fixed width in, fixed width out
var
  Header: AnsiString;           // AnsiString.Length is a byte count, not a char count
  StartHtmlOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 +
    'StartHTML:' + Placeholder + #13#10 +
    'EndHTML:' + Placeholder + #13#10 +
    'StartFragment:' + Placeholder + #13#10 +
    'EndFragment:' + Placeholder + #13#10;
  StartHtmlOfs := Length(Header);   // safe to measure once, up front
  // ...compute the real offsets against the AnsiString document...
  // then rebuild Header with the real numbers formatted to the same
  // 10-digit width, so its byte length -- and therefore StartHtmlOfs --
  // never moves between the placeholder pass and the final one
end;

ทำไม payload ข้อความธรรมดายังต้องพ่วงไปด้วย

TXLSRange.CopyToClipboard ไม่เคยวาง CF_HTML บนคลิปบอร์ดเพียงลำพัง มันเขียน CF_UNICODETEXT ในการเรียกเดียวกันเสมอ เพราะ CF_HTML เป็นรูปแบบที่ลงทะเบียนแทนที่จะเป็นหนึ่งใน CF_* คงที่ที่ทุกแอปพลิเคชัน Windows รู้อยู่แล้วว่าต้องมองหา ตัวแก้ไขข้อความธรรมดา, grid รุ่นเก่า หรืออะไรก็ตามที่ไม่เคยตรวจสอบ 'HTML Format' จะไม่เห็นมันเลย และช่วงที่คุณ copy มาก็จะมาถึงเป็นข้อความคั่นด้วย tab หรือไม่ก็ไม่มาถึงเลย ข้อความคั่นด้วย tab นั้นก็ไม่ใช่การประมาณคร่าวๆ ด้วย เซลล์สูตร copy เป็น string สูตรของมันพร้อมเครื่องหมาย = นำหน้าที่ถูกคืนกลับมาถ้าข้อความที่เก็บไว้ทิ้งมันไป ตรงกับพฤติกรรมข้อความคลิปบอร์ดของ Excel เอง เซลล์ปกติ copy FormattedText ของมัน ซึ่งเป็น string ตามที่แสดง ดังนั้นเซลล์สกุลเงิน copy เป็น $1,234.56 ไม่ใช่ 1234.56 ที่อยู่ข้างใต้ และฟิลด์ใดก็ตามที่มี tab, quote หรือ line break จะถูกใส่ quote ครอบโดยเครื่องหมาย quote ภายในถูกเพิ่มเป็นสองตัว ตามข้อตกลงเดียวกับที่ CSV ใช้

SaveAsHTML ไม่ใช่เส้นทางการ render แยกต่างหากที่ผูกเข้ามาแค่สำหรับกรณีคลิปบอร์ด CopyToClipboard เรียกตัวเขียน HTML ตัวเดียวกันเป๊ะที่อธิบายไว้ในการ export CSV, TSV และ HTML ของ HotXLS แล้วห่อสิ่งที่ตัวเขียนนั้นสร้างขึ้นด้วย envelope CF_HTML แทนที่จะบันทึกเป็นไฟล์แยกต่างหาก ดังนั้นอะไรก็ตามที่เป็นจริงเกี่ยวกับ HTML นั้นจะส่งผ่านตรงไปยังสิ่งที่ลงเอยบนคลิปบอร์ด การดึงช่วงเวิร์กชีตมารวมกันเป็นทั้งสองรูปแบบในการเรียกเดียวมีหน้าตาแบบนี้

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-report.xlsx');
    // Classic TXLSWorkbook ranges expose the identical method as
    // Workbook.Sheets[1].Range['A1', 'F40'].CopyToClipboard
    if Book.Sheets[1].Range['A1:F40'].CopyToClipboard then
      ShowMessage('Range copied - press Ctrl+V in Word or a browser')
    else
      ShowMessage('Clipboard was busy; see the retry pattern below');
  finally
    Book.Free;
  end;
end;

ช่วงที่วางแล้วยังคงฟอนต์ สี และเซลล์ที่รวมไว้หรือไม่

ใช่ เพราะครึ่งหนึ่งของ payload ที่เป็น HTML คือการ render ช่วงนั้นแบบเต็มรูปแบบ ไม่ใช่การ dump ข้อมูลเปล่าๆ ฟอนต์, สีเติม, เส้นขอบ, รูปแบบตัวเลข และเซลล์ที่รวมไว้ล้วนส่งผ่านมาเป็นสไตล์แบบ inline และโครงสร้างตาราง ซึ่งเป็นกลไกการจัดสไตล์เดียวกันที่ครอบคลุมในคู่มือ conditional formatting และ rich text ของ HotXLS เพราะ rich text run ของเซลล์และผลลัพธ์ conditional formatting ทั้งคู่ป้อนเข้าการ render เดียวกันที่ CopyToClipboard อ่านมา สิ่งที่ไม่รอดผ่านการเดินทางนี้คือพฤติกรรมสูตรแบบ live เซลล์สูตรในรูปแบบข้อความธรรมดาพก string สูตรไว้ ดังนั้นเป้าหมายการวางที่รู้จักสเปรดชีตก็อาจคำนวณมันใหม่ได้ในทางทฤษฎี แต่รูปแบบ HTML พกแค่ผลลัพธ์ที่คำนวณล่าสุดเท่านั้น เพราะ HTML ไม่มีแนวคิดเรื่องสูตรให้เบราว์เซอร์หรือ word processor ประเมิน

การตรวจสอบการวาง และการจัดการคลิปบอร์ดที่ไม่ว่าง

สองนิสัยที่จับปัญหาคลิปบอร์ดส่วนใหญ่ได้ก่อนที่ลูกค้าจะเจอ วางลงใน Notepad ก่อนเพื่อยืนยันว่า fallback CF_UNICODETEXT เป็นข้อความคั่นด้วย tab ที่สมเหตุสมผล แล้ววาง copy เดียวกันนั้นลงใน Word หรือเบราว์เซอร์เพื่อยืนยันว่าเวอร์ชันที่มีสไตล์ปรากฏขึ้น payload ที่ดูถูกต้องในตัวหนึ่งแต่ผิดในอีกตัวมักหมายความว่า fragment marker ลงเอยที่ผิดที่ จากนั้นให้ปฏิบัติต่อผลลัพธ์ Boolean ที่ CopyToClipboard คืนกลับมาอย่างมีความหมาย ไม่ใช่แค่ของตกแต่ง OpenClipboard สามารถล้มเหลวได้เมื่อโปรเซสอื่นถือคลิปบอร์ดเปิดอยู่ พบได้บ่อยพอบน desktop ที่ยุ่งจนการเรียกที่ไม่ตรวจสอบครั้งหนึ่งในที่สุดวางอะไรไม่ได้เลยโดยไม่มี error อธิบายว่าทำไม ซึ่งเป็นสิ่งที่การ retry ด้านล่างนี้ป้องกันไว้

function TryCopyRangeToClipboard(Workbook: TXLSXWorkbook): Boolean;
var
  Attempt: Integer;
begin
  Result := False;
  for Attempt := 1 to 5 do
  begin
    Result := Workbook.Sheets[1].Range['A1:F40'].CopyToClipboard;
    if Result then
      Break;
    Sleep(50);   // give whichever app is holding the clipboard a moment
  end;
  if not Result then
    raise Exception.Create('Could not take ownership of the clipboard');
end;

ตัวรูปแบบเองไม่ได้แปลกประหลาดอะไรเลยเมื่อ header มีความแม่นยำระดับไบต์และ fallback ข้อความธรรมดาซื่อสัตย์เกี่ยวกับสิ่งที่มันมี มันมีอยู่โดยแทบไม่เปลี่ยนแปลงตั้งแต่ Internet Explorer นิยามมันขึ้นครั้งแรก และแอปพลิเคชัน Windows หลักทุกตัวยังคงอ่านมันแบบเดียวกัน CopyToClipboard อยู่ควบคู่กับ PasteFromClipboard ฝั่งอ่านของการแลกเปลี่ยนเดียวกันนี้ ในพื้นผิวคลิปบอร์ดและ export ที่กว้างกว่าซึ่งมีเอกสารไว้ที่หน้าผลิตภัณฑ์HotXLS Component