מאמר טכני

מימוש פורמט הלוח CF_HTML ב-Delphi

העתק טווח מתוך grid ב-Delphi והדבק אותו ל-Word, והעיצוב בדרך כלל נעלם: טקסט פשוט, ללא כותרות מודגשות, ללא גבולות, ללא מילויים. HotXLS סוגרת את הפער הזה עם TXLSRange.CopyToClipboard, שמניחה payload ללוח מסוג CF_HTML — פורמט Windows עבור HTML מעוצב עם סמני fragment מדויקי-בייט — על הלוח לצד טקסט יוניקוד פשוט

זה נשמע פשוט עד שאתה מסתכל על מה ש-payload מסוג CF_HTML בפועל דורש. הפורמט זקוק לכותרת טקסט קצרה ששמה בדיוק איפה ה-fragment מתחיל ומסתיים בתוך מאגר הלוח הגדול יותר, והמיקומים הללו הם היסטי בייט, נספרים דרך כל קידוד רב-בייט שה-HTML מסתיים בו. טעה בחשבון בבייט אחד ואפליקציית היעד או תופסת את פיסת ה-markup הלא-נכונה או מוותרת ונופלת בחזרה לטקסט פשוט, ואף אחד מהכשלים הללו לא נראה כמו באג בקוד שלך — הוא נראה כמו Word שהוא Word

למה העתק-הדבק מ-grid ב-Delphi בדרך כלל מאבד את העיצוב שלו

קריאת הלוח הבררת-מחדל של Windows שרוב הקוד ב-Delphi פונה אליה, ‏SetClipboardData עם CF_TEXT או CF_UNICODETEXT, אף פעם לא נושאת אלא תווים פשוטים, כך שכל עיצוב שיושם ב-grid המקור אין לו לאן ללכת. Word, ‏Outlook, וכל דפדפן מבוסס-Chromium מחפשים פורמט עשיר יותר כשאתה מדביק: ייצוג HTML של הבחירה, שלם עם סגנונות inline, מבנה טבלה, וקישורים. Excel עצמה נשענת בדיוק על התחבולה הזו — העתק טווח ב-Excel והלוח בשקט מקבל כמה פורמטים בבת אחת, HTML ביניהם, כך שכל אפליקציה שאתה מדביק אליה בוחרת את העשיר ביותר שהיא מבינה. רכיב שאף פעם לא כותב אלא CF_UNICODETEXT מוסר לכל אחד מהצרכנים העשירים יותר האלה שום דבר לעבוד איתו, והעושר החזותי שהמשתמש הרגע העתיק פשוט לא שם להדביק

מה בדיוק פורמט הלוח CF_HTML?

‏CF_HTML אינו פורמט לוח קבוע של המערכת כמו CF_TEXT; הוא רשום דינמית, מבוקש בשם דרך RegisterClipboardFormat('HTML Format'), וה-payload שלו הוא כותרת ASCII קצרה שאחריה מסמך או fragment של HTML. הכותרת נושאת חמישה שדות — Version, ‏StartHTML, ‏EndHTML, ‏StartFragment, ‏EndFragment — כאשר Version תמיד 0.9 וארבעת האחרים הם מספרים עשרוניים כתובים כספרות ASCII. ‏StartHTML ו-EndHTML תוחמים את המסמך השלם כפי שהאפליקציה המקבלת אמורה לפענח אותו להקשר, כולל גופנים וסגנונות, בעוד StartFragment ו-EndFragment תוחמים את הפיסה הצרה יותר שבפועל נוחתת בסמן, המסומנת באופן מקובל בתוך ה-markup עצמו עם הערות <!--StartFragment--> ו-<!--EndFragment--> כך שהגבולות שורדים סידור-מחדש (serialization) תמים

היסטי בייט, לא ספירת תווים: המלכודת הקלאסית של CF_HTML

ארבעת שדות הכותרת המספריים של CF_HTML הם היסטי בייט לתוך רצף הבייטים המדויק שיושב על הלוח, נספרים מהתו הראשון ממש של הכותרת עצמה — לא ספירת תווים, לא נקודות קוד יוניקוד, ולא היסטים יחסית ל-fragment או לתג ה-<body>. ההבחנה הזו היא המקום שבו מימושי CF_HTML כתובים-ביד משתבשים בשקט: ‏Length של UnicodeString ב-Delphi מדווח יחידות קוד UTF-16, שבמקרה שווה לספירת הבייטים עבור טקסט ASCII פשוט, כך שהבאג עובר נקי דרך כל בדיקה שנכתבה עם נתוני דוגמה באנגלית ומופיע רק ברגע שתא שהועתק מחזיק מקף-רוחב (em dash), סימן מטבע, או תו עם דיאקריטיקה — סימן יורו הוא יחידת קוד UTF-16 אחת אבל שלושה בייטים ב-UTF-8, וכל היסט שמחושב אחרי הנקודה הזו סוחף בכמה בייטים נוספים שהקידוד הוסיף. הכשל שבא אחר כך אינו קריסה; הוא האפליקציה המקבלת תופסת בדיוק את טווח הבייטים שהכותרת הצביעה עליו, מוצאת פיסת markup שמתחילה או מסתיימת באמצע-תג, ואו מציגה זבל או מוותרת ונופלת בחזרה לכל טקסט פשוט שיושב לידו על הלוח, בשקט, ללא שום דבר בקוד שלך שמסביר למה — הנה צורת הקוד שמייצרת בדיוק את הכשל הזה:

// 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 שומרת על הכותרת מדויקת-בייט

HotXLS נמנעת ממחלקת הבאג הזו מבנית: TXLSRange.CopyToClipboard ויחידת lxClipboard שמתחתיה בונות את מסמך ה-CF_HTML והכותרת שלו כולם כ-AnsiString, סוג מחרוזת-הבייט של Delphi, כך ש-Length ו-Pos כבר מחזירים מיקומי-בייט בכל מקום בחישוב — אין שלב נפרד, ולכן אין שלב לשכוח, שבו ספירת תווי יוניקוד הייתה צריכה להמיר לספירת-בייט לפני שהיא נכנסת לכותרת

יש תחבולה שנייה, קטנה יותר, ששווה לדעת אם אי-פעם תבנה כותרת CF_HTML ידנית. הכותרת נכתבת פעמיים: פעם אחת עם עשר ספרות אפס שעומדות במקום כל אחד מארבעת ההיסטים, כך שהאורך-בבייט שלה עצמה יכול להימדד, ופעם נוספת עם ההיסטים האמיתיים מוטמעים. מכיוון שכל היסט אמיתי מפורמט לאותו רוחב קבוע של עשר ספרות, הכותרת השנייה יוצאת באותו אורך בייט-לבייט כמו גרסת-הפלייסהולדר, וזו בדיוק הסיבה שהמדידה המוקדמת נשארת תקפה אחרי הכתיבה-מחדש. דלג על הרוחב הקבוע, פרמט מספר עם IntToStr פשוט במקום זאת, והכותרת יכולה להתכווץ או לגדול בספרה בין שני המעברים, ומבטלת בשקט כל היסט שבא אחריה:

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' לא יראה אותו בכלל, והטווח שהעתקת או מגיע כטקסט מופרד-טאב או שהוא לא מגיע. הטקסט המופרד-טאב הזה גם אינו קירוב גס: תאי נוסחה מועתקים כמחרוזת הנוסחה שלהם עם = מוביל ששוחזר אם הטקסט השמור השמיט אותו, מתאים לאיך שהטקסט של הלוח של Excel עצמה מתנהג, תאים רגילים מעתיקים את ה-FormattedText שלהם — המחרוזת כפי שמוצגת, כך שתא מטבע מועתק כ-$1,234.56, לא ה-1234.56 הבסיסי — וכל שדה שמכיל טאב, מרכאה, או שבירת שורה מוקף במרכאות עם מרכאות מוטמעות מוכפלות, אותה מוסכמה ש-CSV משתמש בה

SaveAsHTML אינה נתיב עיבוד נפרד שמוברג רק עבור מקרה הלוח. ‏CopyToClipboard קוראת לאותו כותב HTML בדיוק המתואר בייצוא CSV, ‏TSV, ו-HTML של HotXLS, ואז עוטפת מה שהכותב הזה מייצר במעטפת 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;

האם הטווח שהודבק שומר על הגופנים, הצבעים, והתאים הממוזגים שלו?

כן, משום שהחצי-HTML של ה-payload הוא עיבוד מלא של הטווח, לא היטלת נתונים ערומה: גופנים, צבעי מילוי, גבולות, פורמטי מספר, ותאים ממוזגים כולם עוברים כסגנונות inline ומבנה טבלה, אותו מנגנון עיצוב המכוסה בהמדריך של HotXLS לעיצוב מותנה וטקסט עשיר, שכן ריצות הטקסט-העשיר של תא ותוצאת העיצוב המותנה שניהם מזינים את אותו עיבוד ש-CopyToClipboard קוראת ממנו. מה שלא שורד את המסע הוא התנהגות-נוסחה חיה: הצורה בטקסט-פשוט של תא נוסחה נושאת את מחרוזת הנוסחה, כך שיעד הדבקה מודע-לגיליון-אלקטרוני יכול באופן עקרוני לחשב אותה מחדש, אבל הצורה ב-HTML נושאת רק את התוצאה המחושבת האחרונה, משום של-HTML אין מושג של נוסחה עבור דפדפן או מעבד תמלילים להעריך

אימות ההדבקה, וטיפול בלוח עסוק

שתי הרגלים תופסים את רוב בעיות הלוח לפני שלקוח עושה זאת. הדבק ל-Notepad קודם כדי לאשר שהחלופה CF_UNICODETEXT היא טקסט מופרד-טאב שפוי, ואז הדבק אותו העתק לתוך Word או דפדפן כדי לאשר שהגרסה המעוצבת מופיעה — payload שנראה נכון באחד ולא-נכון בשני בדרך כלל אומר שסמני ה-fragment נחתו במקום הלא-נכון. ואז התייחס לתוצאה הבוליאנית ש-CopyToClipboard מחזירה כמשמעותית, לא קישוטית: OpenClipboard יכולה להיכשל כאשר תהליך אחר מחזיק את הלוח פתוח, נפוץ מספיק על שולחן עבודה עסוק שקריאה בלתי-נבדקת אחת בסופו של דבר מדביקה כלום ללא שגיאה שמסבירה למה, וזה מה שהניסיון-החוזר להלן מגן מפניו:

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;

הפורמט עצמו אינו אקזוטי ברגע שהכותרת מדויקת-בייט וחלופת-הטקסט-הפשוט כנה לגבי מה שהיא מכילה — הוא קיים כמעט ללא שינוי מאז ש-Internet Explorer הגדיר אותו לראשונה, וכל אפליקציית Windows מרכזית עדיין קוראת אותו באותו אופן. ‏CopyToClipboard יושבת לצד PasteFromClipboard, צד-הקריאה של אותו חילופין, במשטח הלוח והייצוא הרחב יותר המתועד בדף המוצר של רכיב HotXLS