מאמר טכני

ייצוא גיליונות אלקטרוניים בטוח ל-Unicode ב-Delphi: RTF ו-HTML

גיליון אלקטרוני מכיל עמודה של שמות לקוחות. חלקם בסינית, חלקם בקירילית, למעטים יש אומלאוט גרמני או מבטא צרפתי. אתם מייצאים את זה ל-CSV ופותחים את התוצאה, וכל תו נשאר שלם. אתם מייצאים את אותה חוברת עבודה ל-RTF עבור תבנית מיזוג דואר, פותחים אותה במעבד תמלילים, והשמות שאינם ASCII קרסו לשורות של סימני שאלה. הנתונים מעולם לא השתנו. מה שהשתנה הוא חוזה הקידוד של הפורמט שכתבתם, וכל נתיב ייצוא נושא חוזה אחר

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

RTF הוא פורמט בטוח ל-7 ביטים מתוך תכנון

Rich Text Format קודם ל-Unicode וצוין כדי לשרוד העברות (transports) שמעבירות רק ASCII הניתן להדפסה. מסמך RTF מצהיר על דף קוד (code page) בכותרת שלו, וכל תו שהכותב אינו יכול לייצג בדף קוד זה חייב להיפלט בתור רצף חמיקה (escape) ולא כבית גולמי (raw byte). המילוט הרלוונטי הוא \u, הנושא יחידת קוד של 16 ביטים עם סימן ואחריה תו גיבוי ASCII עבור קוראים ישנים מכדי להבין את המילוט בכלל

HotXLS כותב RTF בדרך זו. כותרת המסמך נפתחת בהצהרה על דף הקוד, בצורה \ansi\ansicpg1252\uc1, והכותב ביחידת lxRTF עובר על כל מחרוזת ופולט כל תו מעל ASCII רגיל כמילוט \u כך שזרם הבתים יישאר נקי ב-7 ביטים ללא קשר למה שדף הקוד המוצהר יכול להכיל. נקודת קוד (code point) כגון U+4E2D הופכת לרצף המילולי \u20013?, לא בית גולמי שצופה (viewer) ינסה אז לפרש דרך איזה דף קוד שקרה שהוא הניח. ללא המשמעת הזו, לכל דבר מחוץ לדף הקוד המוצהר אין ייצוג בתים חוקי, וכותב שפולט את הערך הגולמי מייצר את סימני השאלה שהתחילו את המאמר הזה

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

מילוט HTML הוא יותר מסוגריים משולשים

ייצוא ה-HTML מייצר מסמך מרובה גיליונות שבו מסגרות הניווט (navigation frames) נושאות את שמות הגיליונות כטקסט גלוי. שמות אלה הם מחרוזות הנשלטות על ידי המחבר ויכולות להכיל כל תו, כולל אלה המשמעותיים לתגיות. גיליון ששמו המילולי הוא Q1 & Q2 <draft> חייב להגיע לדף כישויות (entities) שמולטו, אחרת הסוגריים המשולשים פותחים תגית רפאים והאמפרסנד מתחיל הפניה לישות שמעולם לא תוכננה. זהו מילוט HTML רגיל, ודילוג עליו בתווית מסגרת הוא סוג ההשמטה שעובר כל בדיקה הבנויה משמות גיליונות ASCII בלבד

שאלת הקידוד יושבת שכבה אחת מתחת לזה. כאשר תווים שאינם ASCII נוחתים בהקשר שלא מובטח שיסופק כ-UTF-8, הייצוג הבטוח הוא הפניית תו מספרית (numeric character reference), ולכן U+00E9 נכתב כ-&#233; ולא כבית גולמי שמשמעותו תלויה בסט התווים (charset) של התגובה. תמונת הראי של כלל זה חלה בדרך פנימה. חוברת עבודה שנקראת בחזרה מ-XLSX נושאת מחרוזות משותפות (shared strings) בהן תו עשוי להיות כבר מאוחסן כישות XML מספרית, ויש לפענח את אותה ישות לתו אחד שלם לפני שהיא נכנסת למודל התא. פענחו אותה בחוסר זהירות, תוך פיצול נקודת קוד לבתים נפרדים, ותו בודד יצוץ מחדש כשתי חתיכות של mojibake ששום ייצוא מאוחר יותר לא יוכל לתקן

קונטיינר ה-XLSX הוא ZIP, ול-ZIP יש קידוד שמות משלו

קובץ XLSX הוא ארכיון ZIP, והארכיון מאחסן שם לכל חבר (member) שהוא מכיל. ZIP ישן מספיק כך שהמפרט המקורי שלו לא אמר דבר על הקידוד של השמות האלה, כך שקורא שלא מוצא אות מניח שמדובר בדף הקוד המקומי של הארכיון. הנחה זו שגויה ברגע ששם חבר מכיל תו שאינו ASCII, מה שקורה עם שמות חלקים של גיליונות עבודה שעברו לוקליזציה ועם מדיה מוטמעת ששמות הקבצים שלה נושאים מבטאים או כתב שאינו לטיני

התיקון הוא ביט יחיד. ביט למטרה כללית 11 (general-purpose bit 11) בכל כותרת קובץ מקומית מצהיר ששם החבר מקודד כ-UTF-8. HotXLS בודק בדיוק את הביט הזה כאשר הוא קורא ארכיון, ובוחן את הדגלים הכלליים מול המסכה $0800, וקורא או כותב שמתעלם ממנו יקרא באופן שגוי שם שיישום נכון אחסן כ-UTF-8. הביט זול להגדרה וזול לכיבוד, והוא כל ההבדל בין שם חבר ששורד את ההלוך ושוב לבין כזה שמגיע מושחת לפני שתוכן הגיליון האלקטרוני בכלל מפוענח

קיפול אותיות רישיות וסריקת מספרים מסתירים את אותה סכנה

הערכת נוסחאות היא המקום שבו בטיחות Unicode מפסיקה להיות קשורה לסריאליזציה והופכת להיות קשורה להשוואה. הפונקציה SEARCH אינה תלויה ברישיות (case-insensitive), מה שאומר שהיא צריכה לקפל אותיות רישיות (case folding) לפני שהיא מחפשת תת-מחרוזת. הדרך השגויה לקפל היא דרך דף הקוד של ANSI, מכיוון שהפיכת טקסט שאינו ASCII לאותיות רישיות בדרך זו מנתבת את התווים דרך דף קוד צר ומשחיתה כל דבר מחוצה לו. הדרך הנכונה היא הפיכה לאותיות רישיות במחרוזת רחבה, השומרת על הטווח המלא של UTF-16. HotXLS מקפל עם WideUpperCase בדיוק מסיבה זו, כך שחיפוש אחר טקסט עם מבטאים או שאינו לטיני תואם לאותם תווים שניתנו לו במקום קירוב שלהם שהושחת על ידי דף קוד

מפרק אסימוני (tokenizer) הנוסחאות נושא חובה קשורה שאין לה שום קשר לאותיות וכל קשר לאיפה אסימון מסתיים. כתיב מדעי כמו 1E3 או 2.5E-3 הוא ליטרל מספרי יחיד, ועל הסורק (scanner) לזהות את ה-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 ברמת חוברת העבודה פועל לפי אותה צורה אם אתם זקוקים לייצוא מופרד (delimited) מאותו מקור זהה

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

בטיחות Unicode היא לפי נתיב, לא לפי ספרייה

הלקח שכדאי לקחת איתכם הוא שאין מקום יחיד להיות בטוח ב-Unicode. RTF צריך דף קוד מוצהר פלוס מילוטי \u. HTML צריך מילוט ישויות עבור תווים משמעותיים לתגיות והפניות מספריות במקומות שבהם סט התווים (charset) אינו מובטח, פלוס פענוח נכון של ישויות שמגיעות במחרוזות משותפות. קונטיינר ה-ZIP דורש שביט 11 הכללי יוגדר כדי ששם חבר ב-UTF-8 ייקרא כ-UTF-8. הערכת נוסחאות זקוקה לקיפול אותיות רישיות למחרוזת רחבה ולמפרק אסימונים ששומר כתיב מדעי בחתיכה אחת. כל אחד מאלה הוא חוזה שונה, וספרייה יכולה לקיים אחד תוך שהיא מפרה בשקט אחר. זו הסיבה שכלי שמבצע CSV נכון יכול עדיין להעביר לכם RTF מלא בסימני שאלה

אם הייצוא שלכם נשען על הפורמטים המופרדים, הפשרות ביניהם מכוסות בהדרכה שלנו של ייצוא CSV, TSV ו-HTML, וכאשר המקור הוא מערך תוצאות במקום גיליון שנבנה ידנית, התבניות בייצוא מסד נתונים לדוחות Delphi משתלבות בטבעיות עם כללי הקידוד המתוארים כאן. כל זה נשלח כחלק מ-רכיב ה-HotXLS עבור Delphi ו-C++Builder, יחד עם ממשקי ה-API של קריאה, נוסחאות ועיצוב המכוסים במקומות אחרים בבלוג זה