רכיב 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 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; התאמה פירושה שהמודל הטיפוסי הוא בדיוק מה שהיה בייבוא, כך ששום דבר שהאפליקציה הייתה יכולה לשנות לא השתנה
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> לכל פינה, והצמיד כל גרף לרשת התאים בשמירה
// מאז 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 מכיל את רשימת היכולות והורדת ניסיון