מאמר טכני

תזמון טביעת האצבע של גרף HotXLS והיסטי עוגן בדלפי

‏רכיב HotXLS ל-Delphi משחזר גרף Excel שלא נערך בו שינוי בית-אחר-בית רק כששני תנאים מתקיימים: הגרף הושג דרך יחס הציור של הגיליון ולא דרך שם חלק מנוחש, וטביעת האצבע בת 64 הסיביות של המודל נלקחה אחרי שהמודל של הגרף סיים להיפרס. גרסה 2.382.0 תיקנה את התנאי הראשון, גרסה 2.382.3 תיקנה את השני והתחילה להעביר הלוך ושוב את היסטי העוגן xdr:colOff ו-xdr:rowOff שאינם אפס, שאותם כותב הציור קיבע כאפס. שני הפגמים יצאו ממקרה קורפוס מקומי אחד, two-charts.xlsx: תחילה בדיקת מבנה ראתה שני חלקי גרף הופכים לשלושה, ואחר כך השוואת בתים של כל xl/charts/chartN.xml הראתה שגרפים שאיש לא נגע בהם עדיין נכתבים מחדש — ואף אחת מהבעיות לא העלתה חריגה ולא גרמה ל-Excel להתלונן, וזו הסיבה שהן שרדו כל כך הרבה זמן

למה חוברת עם שני גרפים חזרה עם שלושה חלקי גרף?

כי לטוען היה נפילה לאחור שניחשה. כשלגיליון לא היה יחס ציור בחלק ה-.rels שלו, הקוד הישן הניח שהציור יושב בשם המוסכם xl/drawings/drawing{i+1}.xml, כאשר i הוא מיקום הגיליון, וצירף את החלק הזה אם הוא היה קיים בארכיון. ב-two-charts.xlsx לגיליון הראשון אין ציור ואין בכלל חלק .rels, בעוד ש-xl/drawings/drawing1.xml כן קיים — הוא שייך לגיליון השני, שמגיע אליו דרך Target="../drawings/drawing1.xml". הגיליון הראשון ירש לכן גרף שהוא מעולם לא הפנה אליו, chart1.xml נפרס פעמיים, והשמירה כתבה את החוברת עם שלושה חלקי גרף במקום שניים

איך HotXLS פותר ציורי גיליון בדגימה two-charts: גיליון ראשון לא נושא יחס ציור ולא חלק rels בעוד הגיליון השני מגיע אל xl/drawings/drawing1.xml דרך ParPartTargets, והנפילה לאחור שלפני 2.382.0 ניחשה את השם המוסכם הזה ממיקום הגיליון כך ש-chart1.xml נפרס פעמיים ושמירות כתבו שלושה חלקי גרף עד שהתיקון טען ציורים רק דרך XlsxRtDrawing
הגיליון הראשון מעולם לא הפנה לגרף, ולכן גרף היחסים הוא המקור הבטוח היחיד ליעד הציור, ושם מוסכם מנוחש הפך חוברת עם שני גרפים לשמירה עם שלושה חלקים

התיקון ב-HotXLS v2.382.0 הסיר את הניחוש לגמרי. ציור של גיליון נטען כעת רק דרך ParPartTargets[i].Values[XlsxRtDrawing], היעד שנרשם עבור סוג יחס הציור בגיליון הזה, וגיליון בלי יחס כזה לא מקבל ציור בכלל. זו ההתנהגות שהפורמט דורש: האלמנט <drawing r:id="…"/> בגיליון (ECMA-376 חלק 1 §18.3.1.36) הוא החוליה היחידה בין גיליון לציור שלו, ולשמות חלקים בחבילת OPC אין שום משמעות מעבר למה שגרף היחסים מקצה להם. ארכיונים ש-Excel כותב במקרה משתמשים בשמות המוסכמים, וזה מה שאיפשר לקיצור דרך הזה לעבור כל כך הרבה זמן; פתרון יחסי OPC ב-HotXLS מסביר למה ניחוש של שם חלק אף פעם אינו בטוח, גם כשהניחוש בדרך כלל נכון

// לפני v2.382.0: יחס ציור חסר נפל לניחוש
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // אולי שייך לגיליון אחר

// מאז v2.382.0: יחס או כלום
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

מה מבטיחה טביעת האצבע של הגרף?

טביעת האצבע מחליטה, לכל גרף בנפרד, אם השמירה יכולה להעתיק את החלק המקורי או חייבת לייצר אותו מחדש. בייבוא, כשהאפשרות PreserveUnsupportedParts מופעלת לפני Open, HotXLS שומר את הבתים הגולמיים ב-UTF-8 של כל חלק גרף ב-FRawChartXml, בונה סריאליזציה משל עצמו של המודל הטיפוסי עם BuildChartKnownXml, ומאחסן את האורך שלה ב-FRawChartModelLength ואת ה-hash שלה ב-FRawChartModelHash. ה-hash הוא FNV-1a מעל יחידות הקוד ב-UTF-16 של ה-XML שנוצר, עם בסיס ההיסט התקני של 64 סיביות 14695981039346656037 והמכפיל 1099511628211. בזמן השמירה XlsxChartRawModelUnchanged בונה מחדש את ה-XML המוכר ומשווה אורך ו-hash; התאמה פירושה שהמודל הטיפוסי הוא בדיוק מה שהיה בייבוא, כך ששום דבר שהאפליקציה הייתה יכולה לשנות לא השתנה

HotXLS לוכד את טביעת האצבע של הגרף בייבוא ושומר בתים גולמיים ב-UTF-8 ב-FRawChartXml בעוד BuildChartKnownXml מפיק FRawChartModelLength ו-hash מסוג FNV-1a, ובזמן השמירה XlsxChartRawModelUnchanged בונה ומשווה את שני הערכים, כך שהתאמה משמיעה מחדש את הבתים המקוריים או מעתיקה את הרשומה הדחוסה ואי-התאמה נופלת ל-XlsxMergeChartXml
טביעת האצבע טובה בדיוק כמו הרגע שבו נלקחה, ולכידה שלה לפני שכל מעבר שחזור הסתיים מבטיחה hash שלעולם לא יתאים שוב למודל המוגמר
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
  const KnownXml: WideString): Boolean;
begin
  Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
    (Length(KnownXml) = Chart.FRawChartModelLength) and
    (XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;

function BuildChartXmlFromKnown(Chart: TXLSXChart;
  const KnownXml: WideString): WideString;
begin
  if Chart.FRawChartXml = '' then
    Result := KnownXml                                  // לא נשמר דבר
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // השמעה מדויקת
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // מיזוג מבני
end;

כותב ה-XLSX הולך צעד אחד רחוק יותר מ-BuildChartXmlFromKnown. כשהמודל לא השתנה ו-StrictOOXML כבוי, הוא מנסה קודם להעתיק את הרשומה הדחוסה ישר מארכיון המקור לתוך הפלט תחת השם החדש של חלק הגרף, כך שהבתים אפילו לא מפוענחים ומכווצים מחדש. רק אם ההעתקה הזו אינה אפשרית הוא נופל למסלול הפענוח-או-מיזוג. המנגנון עצמו — אורך ועוד hash, השמעה כשיש התאמה, מיזוג כשאין — הוא זה שמתואר ברשומה על עריכת גרפי Excel בלי לאבד ChartML. המאמר הזה עוסק בדרך שבה הוא הפסיק לעבוד בשקט

למה כל גרף עבר בכל זאת למסלול המיזוג?

כי טביעת האצבע נלכדה קריאה אחת מוקדם מדי. פירוש הגרף ב-HotXLS הוא מעבר SAX על חלק הגרף ואחריו סדרה של מעברי שחזור שמוציאים מהטקסט הגולמי פרטים שה-handlers של SAX לא מדלים ישירות: XlsxChartParseSeriesFlags קורא כל בלוק <c:ser> בשביל הדגל <c:smooth> שלו ובערכי ה-srgbClr של מילוי הסמן וקו הסמן, ואחר כך משחזר מצבי חציית צירים וסגנונות סימוני טיק ראשיים ומשניים עבור ציר הקטגוריות וציר הערכים. לפני v2.382.3 הסדר בסוף ParseChartXml היה: לסווג את קבוצות הצירים, לבנות את ה-XML המוכר, ללכוד אורך ו-hash, ורק אז להריץ את XlsxChartParseSeriesFlags. טביעת האצבע תיארה לכן מודל שעדיין היה חסר דגלי smooth, צבעי סמנים וסימוני טיק. בזמן השמירה BuildChartKnownXml רץ מול המודל המוגמר, שפלט כעת <c:smooth val="1"/> ואת צבעי הסמנים ששוחזרו. XML ארוך יותר, hash שונה, XlsxChartRawModelUnchanged החזיר False, והגרף עבר דרך XlsxMergeChartXml. המיזוג הוא פעולה נכונה לגרף שמישהו ערך, אבל הוא לא פעולה ששומרת בתים: הוא מסריאל מחדש את העץ, וכלל הבעלות שנותן למודל הטיפוסי לנצח עבור סדרות, צירים וקבוצות פלוט אומר שהצמתים שנוצרים מחדש מחליפים את המקוריים. התוצאה הנראית לעין בהרצת הקורפוס הייתה צבעי סדרות שנסחפו בגרפים שאיש לא ערך — כל גרף בכל חוברת שנשמרה, בכל שמירה, בלי שום אבחון בשום מקום

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

לאן נעלמו היסטי העוגן?

לתוך אפס מפורש. twoCellAnchor בחלק הציור מצמיד גרף בין שני תאים, וכל פינה נושאת אינדקס תא ועוד היסט בתוך אותו תא: from (ECMA-376 חלק 1 §20.5.2.5) ו-to (§20.5.2.32) מחזיקים כל אחד col, colOff (§20.5.2.4), row ו-rowOff. ההיסטים הם ביחידות מטריות אנגליות, 914400 לאינץ', ו-Excel כותב ערכים שאינם אפס בכל פעם שגרף הונח או שונה בגודלו עם העכבר, שזה רוב הגרפים. הגרף הראשון ב-two-charts.xlsx מתחיל בשורה 0 עם rowOff של 19049 ומסתיים בעמודה 8, שורה 15 עם colOff של 247650 ו-rowOff של 66674 — בערך רבע אינץ' לתוך העמודה האחרונה. המפרסר של הציור ב-HotXLS תמיד קרא את ארבעת הערכים האלה — קוד התמונות השתמש בהם — אבל כותב הגרפים פלט <xdr:colOff>0</xdr:colOff> ו-<xdr:rowOff>0</xdr:rowOff> לכל פינה, והצמיד כל גרף לרשת התאים בשמירה

אנטומיה של פינות xdr:twoCellAnchor בגרף הראשון של דגימת HotXLS: from מחזיק col 0 ו-rowOff 19049 בעוד to מחזיק col 8, colOff 247650 ו-rowOff 66674 ב-EMU של 914400 לאינץ', והכותב שפלט היסטים של אפס הצמיד גרפים לרשת עד ש-FFromColOff, FToColOff ואחיהם השמיעו מחדש את הערכים שיובאו
העוגן יושב בחלק הציור ולא בחלק הגרף, ולכן התיקון הזה בלתי תלוי בתיקון טביעת האצבע, ושניהם היו חייבים לצאת לפני שהחוברת באמת עשתה הלוך ושוב
// מאז v2.382.3 כותב העוגן משמיע מחדש את היסטי ה-EMU שיובאו
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
  IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
  '<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...

TXLSXChart נושא כעת FFromColOff, FFromRowOff, FToColOff ו-FToRowOff, שמתמלאים מהמפרסר של הציור ומועתקים יחד עם שאר מצב העוגן כשגרף מוקצה. הם פרטיים בכוונה: משטח העוגן הציבורי הוא עדיין ארבע קואורדינטות התא FromRow, FromCol, ToRow ו-ToCol, וגרף שנוצר מקוד דלפי נוחת על גבולות תאים כמו קודם. ההיסטים קיימים כדי שהלוך ושוב יהיה נאמן, לא כדי לחשוף מיקום תת-תאי כתכונה. שימו לב שהתיקון הזה בלתי תלוי בטביעת האצבע: העוגן יושב בחלק הציור ולא בחלק הגרף, כך שגרף שה-ChartML שלו הושמע מחדש באופן מושלם עדיין היה קופץ לרשת בלעדיו. המרות היחידות שמאחורי ערכי ה-EMU האלה מכוסות ברשומה על הגאומטריה של תמונות ב-HotXLS והסקיילינג ב-EMU

איך מוכיחים שגרף עושה הלוך ושוב בלי שינוי?

בהשוואת בתים, לא בפתיחת התוצאה ב-Excel. Excel מתקן ומנרמל כל כך הרבה בטעינה שגרף שנסחף נראה תקין עד ש-Analyst מבחין שצבע הסמן השתנה. בדיקת הקורפוס שתפסה את שני הפגמים עושה שלושה דברים אחרי פתיחה ושמירה בלי עריכות: היא עוברת על יחסי גיליון, ציור וגרף ונכשלת על כל הפניה כפולה, יתומה או תלויה; היא משווה חתימה של סוג גרף, נוסחאות סדרות וגאומטריית עוגן בין המקור לפלט; וב-two-charts.xlsx היא קוראת כל xl/charts/chartN.xml משני הארכיונים ודורשת בתים זהים. את אותה בדיקה קל לכתוב בדלפי עם ה-TZipFile של ה-RTL

uses System.Zip, System.SysUtils;

function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
  Src, Dst: TZipFile;
  Name: string;
  A, B: TBytes;
begin
  Result := True;
  Src := TZipFile.Create;
  Dst := TZipFile.Create;
  try
    Src.Open(Original, zmRead);
    Dst.Open(Resaved, zmRead);
    for Name in Src.FileNames do
      if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
      begin
        Src.Read(Name, A);
        Dst.Read(Name, B);   // זורק חריגה אם החלק נעלם
        if (Length(A) <> Length(B)) or
           ((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
        begin
          Writeln('changed: ', Name);
          Result := False;
        end;
      end;
  finally
    Dst.Free;
    Src.Free;
  end;
end;

שלושה תנאים הופכים את ההשוואה הזו למשמעותית, וכל אחד מהם נכשל בשקט אם שוכחים אותו. PreserveUnsupportedParts חייב להיות True לפני Open, אחרת שום בתים גולמיים לא נלכדים וכל גרף נבנה מחדש מהמודל. StrictOOXML חייב להיות False, כי מצב strict כופה יצירה מחדש מעצם תכנונו. והאפליקציה אסור שתיגע בגרף בין הפתיחה לשמירה — קריאת מאפיינים זה בסדר, אבל כל setter שמשנה את המודל הטיפוסי הופך את טביעת האצבע ושולח את הגרף למסלול המיזוג, וזו התנהגות נכונה אבל לא מה שהבדיקה הזו באה לבדוק. חלקי גרף גם ממוספרים מחדש ממונה רוחבי של החוברת בשמירה, כך שחוברת שסדר הגיליונות או סדר הגרפים שלה השתנה תמקם בתים זהים תחת שם chartN.xml אחר; בודק הקורפוס עוקב אחרי יחסים ולא אחרי שמות בדיוק מהסיבה הזו

שני התיקונים יצאו ב-HotXLS 2.382.0 ו-2.382.3 ומאומתים ב-Win32 וב-Win64 מול הקורפוס המקומי, כשדגימות הגרפים שנשמרו מחדש מוצגות גם דרך חבילת משרד עצמאית ל-PDF ומושוות עמוד מול עמוד למקוריות. HotXLS קורא, עורך וכותב גרפי XLSX מקוד דלפי ו-C++Builder ילידי בלי שום התקנה של Excel, וזה מה שהופך רמת נאמנות כזו לאחריות של הספרייה — עמוד רכיב הגיליון HotXLS ל-Delphi מכיל את רשימת היכולות והורדת ניסיון