Bài viết kỹ thuật

Triển khai định dạng clipboard CF_HTML trong Delphi

Copy một vùng ra khỏi một grid Delphi rồi dán vào Word, và định dạng thường biến mất: văn bản thuần túy, không tiêu đề đậm, không viền, không màu nền. HotXLS lấp khoảng trống đó bằng TXLSRange.CopyToClipboard, đặt một payload clipboard CF_HTML — định dạng Windows cho HTML có định dạng với các dấu mốc đoạn (fragment) chính xác đến từng byte — lên clipboard cạnh văn bản Unicode thuần túy

Nghe có vẻ đơn giản cho đến khi bạn nhìn vào những gì một payload CF_HTML thực sự đòi hỏi. Định dạng này cần một header văn bản ngắn nêu chính xác đoạn bắt đầu và kết thúc ở đâu bên trong bộ đệm clipboard lớn hơn, và các vị trí đó là các offset byte, đếm qua bất kỳ mã hóa đa byte nào mà HTML cuối cùng dùng. Tính sai phép toán dù chỉ một byte và ứng dụng đích hoặc lấy nhầm lát markup, hoặc bỏ cuộc và rơi về văn bản thuần túy, và không lỗi nào trong hai lỗi đó trông giống một bug trong code của bạn — nó trông giống như Word đang là Word

Vì sao copy-paste từ một grid Delphi thường mất định dạng

Lệnh gọi clipboard Windows mặc định mà hầu hết code Delphi tìm đến, SetClipboardData với CF_TEXT hay CF_UNICODETEXT, chỉ bao giờ mang ký tự thuần túy, nên bất kỳ định dạng nào áp dụng trong grid nguồn không có nơi nào để đi. Word, Outlook, và mọi trình duyệt dựa trên Chromium tìm kiếm một định dạng phong phú hơn khi bạn dán: một biểu diễn HTML của vùng chọn, đầy đủ với style inline, cấu trúc bảng, và liên kết. Bản thân Excel dựa vào chính thủ thuật này — copy một vùng trong Excel và clipboard âm thầm nhận nhiều định dạng cùng lúc, HTML là một trong số đó, nên bất kể ứng dụng nào bạn dán vào sẽ chọn định dạng phong phú nhất nó hiểu được. Một component chỉ bao giờ ghi CF_UNICODETEXT đưa cho mỗi bên tiêu thụ phong phú hơn đó không có gì để làm việc, và sự phong phú thị giác mà người dùng vừa copy đơn giản là không có ở đó để dán

Định dạng clipboard CF_HTML chính xác là gì?

CF_HTML không phải một định dạng clipboard hệ thống cố định như CF_TEXT; nó là một định dạng được đăng ký động, được yêu cầu bằng tên thông qua RegisterClipboardFormat('HTML Format'), và payload của nó là một header ASCII ngắn theo sau bởi một tài liệu hoặc đoạn HTML. Header mang năm trường — Version, StartHTML, EndHTML, StartFragment, EndFragment — trong đó Version luôn là 0.9 và bốn trường còn lại là các số thập phân được viết ra dưới dạng chữ số ASCII. StartHTMLEndHTML đóng khung toàn bộ tài liệu như ứng dụng nhận nên phân tích nó để lấy ngữ cảnh, gồm cả font và style, trong khi StartFragmentEndFragment đóng khung lát hẹp hơn thực sự đáp xuống con trỏ, theo quy ước được đánh dấu ngay trong markup bằng comment <!--StartFragment--><!--EndFragment--> để ranh giới sống sót qua việc tái serialize ngây thơ

Offset byte, không phải số ký tự: cái bẫy kinh điển của CF_HTML

Bốn trường header dạng số của CF_HTML là các offset byte vào chính chuỗi byte nằm trên clipboard, đếm từ ký tự đầu tiên của chính header — không phải số ký tự, không phải code point Unicode, và không phải offset tương đối so với đoạn hay thẻ <body>. Sự phân biệt đó là nơi các triển khai CF_HTML viết tay âm thầm sai: Length của một UnicodeString Delphi báo cáo các code unit UTF-16, thứ tình cờ bằng số byte đối với văn bản ASCII thuần túy, nên lỗi này trót lọt qua bất kỳ test nào viết với dữ liệu mẫu tiếng Anh và chỉ lộ ra một khi một ô đã copy chứa một dấu gạch ngang dài, một ký hiệu tiền tệ, hay một ký tự có dấu — một ký hiệu euro là một code unit UTF-16 nhưng ba byte trong UTF-8, và mọi offset tính sau điểm đó trôi dạt theo bao nhiêu byte thêm vào mà mã hóa đã cộng thêm. Lỗi theo sau không phải một crash; đó là ứng dụng nhận nắm lấy đúng phạm vi byte mà header trỏ tới, tìm thấy một lát markup bắt đầu hoặc kết thúc giữa một thẻ, và hoặc render ra rác hoặc bỏ cuộc và rơi về bất cứ văn bản thuần túy nào nằm cạnh nó trên clipboard, âm thầm, không có gì trong code của bạn giải thích vì sao — đây là hình dạng của code tạo ra chính xác lỗi đó:

// 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 giữ header chính xác đến từng byte như thế nào

HotXLS tránh loại lỗi này về mặt cấu trúc: TXLSRange.CopyToClipboard và unit lxClipboard bên dưới nó dựng tài liệu CF_HTML và header của nó hoàn toàn dưới dạng AnsiString, kiểu chuỗi byte của Delphi, nên LengthPos đã trả về vị trí byte ở mọi nơi trong phép tính — không có bước riêng biệt nào, và do đó không có bước nào để quên, nơi một số ký tự Unicode cần chuyển đổi thành số byte trước khi đưa vào header

Có một thủ thuật thứ hai, nhỏ hơn, đáng biết nếu bạn từng tự dựng một header CF_HTML bằng tay. Header được ghi hai lần: một lần với mười chữ số không thay thế cho mỗi trong bốn offset, để độ dài byte của chính nó có thể được đo, và một lần nữa với các offset thật được vá vào. Vì mỗi offset thật được định dạng theo cùng chiều rộng cố định mười chữ số đó, header thứ hai cho ra đúng cùng độ dài từng byte với phiên bản placeholder, đó chính xác là lý do phép đo trước đó vẫn có giá trị sau khi viết lại. Bỏ qua chiều rộng cố định, định dạng một số bằng một IntToStr thuần túy thay vào đó, và header có thể co lại hay lớn lên một chữ số giữa hai lượt, âm thầm làm mất hiệu lực mọi offset theo sau nó:

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;

Vì sao payload văn bản thuần túy vẫn phải đi kèm

TXLSRange.CopyToClipboard không bao giờ đặt CF_HTML lên clipboard một mình; nó luôn ghi CF_UNICODETEXT trong cùng lệnh gọi, vì CF_HTML là một định dạng đã đăng ký thay vì một trong các hằng số CF_* cố định mà mọi ứng dụng Windows đã sẵn biết cách tìm — một trình soạn thảo văn bản thuần túy, một grid cũ, hay bất cứ thứ gì chưa từng kiểm tra 'HTML Format' sẽ hoàn toàn không thấy nó, và vùng bạn đã copy hoặc đến dưới dạng văn bản phân cách tab hoặc không đến. Văn bản phân cách tab đó cũng không phải một xấp xỉ thô: các ô công thức copy như chuỗi công thức của chúng với một dấu = đứng đầu được khôi phục nếu văn bản lưu trữ đã bỏ nó, khớp với cách văn bản clipboard của chính Excel hoạt động, các ô thông thường copy FormattedText của chúng — chuỗi như được hiển thị, nên một ô tiền tệ copy như $1,234.56, không phải 1234.56 bên dưới — và bất kỳ trường nào chứa một tab, một dấu ngoặc kép, hay một ngắt dòng đều được đặt trong ngoặc kép với các dấu ngoặc kép nhúng được nhân đôi, cùng quy ước CSV dùng

SaveAsHTML không phải một đường render riêng biệt được gắn thêm chỉ cho trường hợp clipboard. CopyToClipboard gọi chính bộ ghi HTML được mô tả trong xuất CSV, TSV, và HTML của HotXLS, sau đó bọc bất cứ thứ gì bộ ghi đó tạo ra trong vỏ bọc CF_HTML thay vì lưu nó như một file độc lập, nên bất cứ điều gì đúng với HTML đó đi thẳng vào những gì đáp xuống clipboard. Việc kéo một vùng worksheet lại thành cả hai định dạng trong một lệnh gọi trông như sau:

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;

Vùng đã dán có giữ font, màu sắc, và ô đã gộp không?

Có, vì nửa HTML của payload là một bản render đầy đủ của vùng đó, không phải một bản đổ dữ liệu trần trụi: font, màu nền, viền, định dạng số, và ô đã gộp đều đi qua dưới dạng style inline và cấu trúc bảng, cùng cỗ máy định dạng được nói đến trong hướng dẫn của HotXLS về định dạng có điều kiện và rich text, vì các đoạn rich text của một ô và kết quả định dạng có điều kiện đều nạp cùng bản render mà CopyToClipboard đọc từ đó. Điều không sống sót qua chuyến đi là hành vi công thức sống: dạng văn bản thuần túy của một ô công thức mang theo chuỗi công thức, nên một đích dán hiểu bảng tính về nguyên tắc có thể tính lại nó, nhưng dạng HTML chỉ bao giờ mang kết quả tính toán cuối cùng, vì HTML không có khái niệm về một công thức để một trình duyệt hay trình xử lý văn bản đánh giá

Xác minh việc dán, và xử lý một clipboard đang bận

Hai thói quen bắt được hầu hết các vấn đề clipboard trước khi một khách hàng làm điều đó. Dán vào Notepad trước để xác nhận phương án dự phòng CF_UNICODETEXT là văn bản phân cách tab hợp lý, sau đó dán cùng bản copy đó vào Word hay một trình duyệt để xác nhận phiên bản có định dạng xuất hiện — một payload trông đúng ở nơi này và sai ở nơi kia thường có nghĩa là các dấu mốc đoạn đáp xuống sai chỗ. Sau đó hãy coi kết quả Boolean mà CopyToClipboard trả về là có ý nghĩa, không phải trang trí: OpenClipboard có thể thất bại khi một tiến trình khác đang giữ clipboard mở, đủ phổ biến trên một desktop bận rộn để một lệnh gọi không được kiểm tra cuối cùng dán ra không gì cả mà không có lỗi giải thích vì sao, đó là điều lượt thử lại dưới đây phòng ngừa:

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;

Bản thân định dạng này không hề kỳ lạ một khi header chính xác đến từng byte và phương án dự phòng văn bản thuần túy trung thực về những gì nó chứa — nó đã tồn tại gần như không đổi từ khi Internet Explorer lần đầu định nghĩa nó, và mọi ứng dụng Windows lớn vẫn đọc nó theo cùng cách. CopyToClipboard nằm cạnh PasteFromClipboard, phía đọc của cùng trao đổi đó, trong bề mặt clipboard và xuất rộng hơn được ghi chép trên trang sản phẩm HotXLS Component