מאמר טכני

רוחב עמודה ב־Excel ו־Max Digit Width (MDW) ב־Delphi

ה־PDF המיוצא מציב כל גבול עמודה חצי תו משמאל למקום שבו Excel מצייר אותו, וכל תא גלישה נשבר עכשיו במקום אחר. רוחב עמודה ב־Excel אינו נמדד בתווים או בנקודות. הוא נמדד ביחידות Max Digit Width (MDW) של גופן ה־Normal של החוברת, ו־HotXLS מודד את הגופן הזה עם GDI לפני כל בניית עימוד. מצב הכשל שקט: שום דבר לא זורק, הרוחבים האחסונים נטענים ונשמרים חזרה בית־אחר־בית, והגיאומטריה עדיין חוטאת בכמה אחוזים לכל עמודה עד שהסטה המצטברת דוחפת טבלה של עמוד אחד לשניים

באיזו יחידה נמדד רוחב עמודה ב־Excel?

רוחב עמודה בגיליון הוא מניין של תווי ספרה של גופן ה־Normal של החוברת, לא מדידה אבסולוטית. ECMA-376 §18.3.1.13 מגדיר את התכונה width של <col> במונחי ה־Maximum Digit Width של הגופן הזה ב־96 dpi, ונותן את ההמרה מרוחב אחסון חזרה לפיקסלים כביטוי חיתוך על גבי MDW. עבור Calibri 11, שהוא מה ש־Excel מספק כסגנון Normal, MDW נמדד 7 פיקסלים. הזינו את רוחב ברירת המחדל של 8.43 יחידות בנוסחת המפרט עם MDW 7 ותקבלו בדיוק 64 פיקסלים, שהם 48 נקודות ב־96 dpi. אלה המספרים ש־Excel עצמו מדווח, ולכן הם מהווים בדיקה שימושית: אם ההמרה שלכם משחזרת 8.43 יחידות ל־64 פיקסלים, האריתמטיקה נכונה ורק קלט ה־MDW עדיין יכול להיות שגוי

const
  // רוחב הספרה המרבי (MDW) של גופן גוף ברירת המחדל בפיקסלים ב־96 dpi.
  // Calibri 11 נמדד 7 px, מה שמשחזר את רוחבי הפיקסלים המדויקים
  // ש־Excel אחסן (8.43 יחידות -> 64 px -> 48 pt).
  DefaultMDW = 7;
  MinimumColumnWidth = 24.0;

function ColumnWidthToPointsMdW(Value: Double; MdW: Integer): Double;
var
  Pixels: Integer;
begin
  if Value <= 0 then
    Value := 8.43;
  if MdW <= 0 then
    MdW := DefaultMDW;
  Pixels := Trunc(((256 * Value + Trunc(128 / MdW)) / 256) * MdW) + 5;
  Result := Pixels * 0.75; // פיקסלים ב־96 dpi -> נקודות
  if Result < MinimumColumnWidth then
    Result := MinimumColumnWidth;
end;

HotXLS שומר את האריתמטיקה הזאת בפונקציה אחת בדיוק, ביחידת lxPagination, כך שיש מקום אחד בלבד שבו הסרגל יכול להיות שגוי. ה־+ 5 הוא הריפוד ש־Excel מוסיף לקווי רשת ולשולי תאים, ה־* 0.75 ממיר פיקסלים ב־96 dpi לנקודות PostScript, והרצפה ב־MinimumColumnWidth קיימת כדי שעמודה צרה פתולוגית עדיין תשאיר רצועה שאפשר לצייר בה גבול. נקודת הכניסה הציבורית ColumnWidthToPoints שומרת על החתימה הישנה עם ארגומנט יחיד ומעבירה MDW נמדד לפונקציה הזאת — זה מה שאיפשר לשינוי ההתנהגות לרדת בלי לגעת באתר קריאה אחד

שרשרת המרת רוחב העמודה של HotXLS ב־Delphi, המזינה את ה־Max Digit Width הנמדד של גופן ה־Normal של החוברת אל נוסחת המפרט כך שרוחב אחסון של 8.43 יחידות הופך ל־64 פיקסלים ואז ל־48 נקודות
הרוחב האחסון הוא מניין ספרות, ולכן ה־MDW הנמדד של גופן ה־Normal הוא קלט לנוסחה ולא פרט עיצוב, וסבב 8.43 ל־64 ול־48 בודק את האריתמטיקה

למה גופן Normal שאינו Calibri מזיז כל גבול

הסטה היא כפלית, ולכן היא נקראת כבאג תצוגה ולא כבאג יחידות. MDW הוא גורם על הרוחב, לא היסט. הקפיצו את MDW מ־7 ל־8 ועמודת ברירת המחדל של 8.43 יחידות עוברת מ־64 פיקסלים ל־72 — קפיצה של 8 פיקסלים או 6 נקודות בעמודה אחת. עשר עמודות כאלה והקצה הימני של הטבלה זז כמעט אינץ'. חוברות שנתקעות על זה רגילות לחלוטין: כל מה שנוצר על ידי כלי דיווח שמטביע Arial או Segoe UI בסגנון ה־Normal, כל מה שנשמר מתבנית ייצוא של ERP, וכל מה שלקוח עיצב מחדש פעם אחת ושכח

שתי מערכות פריסה קשורות יורשות את השגיאה במקום לגרום לה. אזורים ממוזגים מסכמים את רוחבי הנקודות של עמודות החברים בהם, כך שמיזוג שנכנס לעמוד אחד ב־Excel יכול לגלוש אחרי סטיית MDW — שווה לזכור כשבונים תבניות דוח עם תאים ממוזגים. Shrink-to-fit משווה רוחב טקסט נמדד מול אותו רוחב עמודה, ולכן MDW שגוי משנה גם אילו תאים מתכווצים ובכמה. אותה משפחה של בלבול יחידות מופיעה בעוגני שרטוט, שם ל־גיאומטריית תמונות וקנה מידה של EMU יש שרשרת המרה משלה לטעות בה

שני סרגלי עמודות של HotXLS בהשוואה, אחד שנמדד עם MDW של 7 פיקסלים ואחד עם 8, מראים כיצד הקפיצה לכל עמודה מ־64 ל־72 פיקסלים מצטברת על פני עשר עמודות בזמן שאזורים ממוזגים ו־shrink-to-fit יורשים את השגיאה
מכיוון ש־MDW כופל ואינו מסיט, מדידה שגויה אחת מזיזה כל גבול עמודה, ואזורים ממוזגים ו־shrink-to-fit יורשים את הסטייה בלי שמשהו זורק

איך HotXLS מודד MDW בזמן ריצה

HotXLS גוזר את MDW מהחוברת עצמה במקום להניח קבוע, ושתי פרוצדורות עושות את העבודה. PaginationApplyNormalFont קורא את גופן הסגנון Normal מהחוברת ורץ בראש בניית העימוד, לפני חישוב כל גיאומטריית עמודה; הוא מאפס ל־Calibri 11 תחילה, כך שחוברת בלי טבלת גופנים אינה יכולה לרשת מצב מיושן מבנייה קודמת. גופן הסגנון Normal הוא fonts[0] ב־styles.xml, חשוף ברכיב כ־Workbook.Fonts[0]

// קורא את fonts[0] (גופן הסגנון Normal) מהחוברת.
// גיליונות קלאסיים בלי טבלת גופנים שומרים על ברירת המחדל Calibri 11.
procedure PaginationApplyNormalFont(Worksheet: TObject);
var
  Sh: TXLSXWorksheet;
  Fnt: TXLSXFont;
begin
  PaginationNormalFontName := 'Calibri';
  PaginationNormalFontSize := 11;
  if not (Worksheet is TXLSXWorksheet) then
    Exit;
  Sh := TXLSXWorksheet(Worksheet);
  if (Sh.Workbook = nil) or (Sh.Workbook.Fonts.Count < 1) then
    Exit;
  Fnt := Sh.Workbook.Fonts[0];
  if Fnt.Name <> '' then
    PaginationNormalFontName := Fnt.Name;
  if Fnt.Size > 0 then
    PaginationNormalFontSize := Fnt.Size;
end;

הפרוצדורה השנייה, PaginationMeasureMdW, שואלת את GDI את הממד של התו הבודד '0' דרך GetTextExtentPoint32W על קנבס מפת סיביות משותף מחוץ למסך, נופלת אחורה ל־tmAveCharWidth מ־GetTextMetricsW כשקריאת הממד נכשלת, ונופלת אחורה ל־DefaultMDW כשאף אחת מהן אינה זמינה. המטמון שלה הוא משבצת אחת עם מפתח (name, size) — נשמע גס עד שמסתכלים על דפוס הגישה: בניית עימוד שואלת את אותו גופן Normal על כל עמודה בכל דף, ולכן משבצת אחת משיגה קצב פגיעות כמושלם ועולה שלוש השוואות לכל קריאה

מה קורה בלי טבלת גופנים, בלי GUI, או עם גופן חסר?

HotXLS מידרדר לקבוע Calibri 11 בכל מקרה שבו אי אפשר לקבוע את גופן ה־Normal האמיתי, והוא עושה זאת בשקט בכוונה. גיליונות BIFF קלאסיים הם המקרה הנפוץ: הפורמטים הישנים אינם נושאים מאגר גופנים של XLSX שאליו fonts[0] יכול להפנות, ולכן שומר הטיפוס יוצא מוקדם וברירת המחדל MDW של 7 עומדת בעינה. זה לא תיקון — זו ההתנהגות הקודמת שנשמרה בכוונה, כדי שהוספת מדידה לנתיב XLSX לא תדרדר את הפלט בפורמט הקלאסי

התלות ב־GDI היא האזהרה הכנה. המדידה רצה כנגד הקשר התקן של Windows, ולכן הנתיב מניח מארח Windows שבו הגופן מותקן. בשירות או בסוכן בנייה חסר תצוגה, מדדי הטקסט של GDI בדרך כלל עדיין נגזרים, אבל גופן שאינו מותקן על אותה מכונה מוחלף על ידי ממפה הגופנים ואתם מודדים את המחליף. זה לעולם לא נכשל בקול רם; הוא מחזיר מספר סביר לגופן הלא נכון. אם ייצוא בצד השרת חייב להתאים להתייחסות שולחנית, התקינו את הגופנים שהתבניות שלכם מזכירות על מארח הייצוא, או קבעו את גופן ה־Normal לפני הפעלת נתיב ייצוא ה־PDF של הגיליון

var
  Book: TXLSXWorkbook;
  Exporter: TXLSPDFExport;
begin
  Book := TXLSXWorkbook.Create;
  Exporter := TXLSPDFExport.Create;
  try
    Book.Open('quarterly-report.xlsx');

    // קובעים את גופן ה־Normal כדי שה־MDW שנמדד על המארח הזה
    // יהיה זה שהפריסה עוצבה כנגדו, ולא מחליף של ממפה הגופנים.
    if Book.Fonts.Count > 0 then
    begin
      Book.Fonts[0].Name := 'Calibri';
      Book.Fonts[0].Size := 11;
    end;

    Exporter.UseWorksheetPageSetup := True;
    Exporter.SaveAsPDF(Book, 'quarterly-report.pdf');
  finally
    Exporter.Free;
    Book.Free;
  end;
end;

מטמוני מדידה, וזה שקרס ב־Win64

ברגע שמדידת טקסט היא סבב GDI במקום כפל, היא חייבת להישמר במטמון, ושמירה במטמון בתוך מעבר תצוגה היא המקום שבו העבודה הזאת שילמה בדם. הלולאה של shrink-to-fit מורידה את גודל הגופן בצעדים של 0.5 pt ומודדת מחדש אחרי כל צעד, כך שתא אחד יכול לקרוא ל־PaginationMeasureTextWidth תריסר פעמים עם אותה מחרוזת, וגלישת מילים קוראת לו שוב לכל שורה מועמדת. מזכר עם מפתח שם גופן, גודל וטקסט מצמצם את זה לקריאת GDI אחת לכל מחרוזת ייחודית, השמורה ב־TStringList כזוגות שם/ערך

המטמון השני שנוסף לצידו לא היה מסודר כל כך. מעבר תצוגה 5 גוזר את מאגר הגופנים לכל תא לפי FontIndex, והמזכר שלו השתמש במערכים דינמיים מקבילים עם FontMemoCount המתוחזק ידנית. הגרסה הראשונה שכחה לקרוא ל־ResetFontMemo בתחילת כל דף, ולכן המניין המשיך לטפס בין דפים בזמן שהמערכים לא, והקוד כתב מעבר לסוף של כולם. ב־Win32 זה שרבט בשקט לתוך ערימה סמוכה והסתיים; ב־Win64 זה זרק חריגת גישה על כתיבה ל־0x538 מיד. הלקח הניתן להכללה: מטמון הנשען על מערכים ומוחזק במשתנה ברמת יחידה חייב להתאפס בכניסה לכל מעבר שמשתמש בו, כי רשימת מחרוזות או מילון סולחים על איפוס חסר באמצעות גדילה, ומערכים מקבילים לא

כיצד HotXLS גוזר את גופן ה־Normal של החוברת, מודד את ה־Max Digit Width שלו דרך GDI עם שתי נפילות לאחור ושומר את התוצאה במטמון — לצד שני המזכרים של מעברי התצוגה וכלל האיפוס שמטמון מערכים מקבילים צריך
MDW נגזר מהחוברת ונמדד עם GDI פעם אחת לכל גופן ואז נשמר במטמון לפי מפתח, בזמן שהמזכרים של מעברי התצוגה מראים למה מטמון מערכים מקבילים חייב להתאפס בכניסה לכל מעבר

בדיקת ההמרה שלכם

אתם לא צריכים את הרכיב כדי לאמת אף אחד מאלה. קחו חוברת שגופן ה־Normal שלה אינו Calibri 11, קראו רוחב מ־<col width="..."/> והזינו אותו פעמיים בנוסחת המפרט — פעם עם MDW 7 ופעם עם ה־MDW שהמציג שלכם באמת מודד עבור הגופן הזה; אם התשובות חולקות והפלט שלכם תואם לראשונה, מצאתם את הסטייה. גיאומטריית עמודות היא אחד החלקים האלה של מנוע גיליונות שהם או בלתי נראים או הדבר היחיד שכולם מבחינים בו, ולהשיג אותה נכון פירושו להתייחס לגופן ה־Normal כקלט לפריסה ולא כפרט עיצוב. אם אתם בונים אפליקציות Delphi או C++Builder שקוראות, כותבות, מציגות ומדפיסות חוברות Excel בלי Office מותקן, רכיב ה־Excel של HotXLS ל־Delphi מטפל במדידת ה־MDW, במודל העימוד ובצינור ה־PDF מאחורי קבוצה אחת של מחלקות VCL