Excel מציג "We found a problem with some content" על קובץ XLSX ש-LibreOffice וכל קורא ביתי פותחים בלי תלונה, כי Excel אוכף שני דברים שהקוראים האלה מתעלמים מהם: מאפיינים שהסכימה מחייבת ויחודיות שהאמנות של Open Packaging דורשת. HotXLS, רכיב הגיליון הילידי ל-Delphi ו-C++Builder, נתקל בדיוק בזה ב-v2.382.5 בפעם הראשונה שהפלט שלו עבר דרך מופע Excel COM אמיתי, ושלושת הגורמים היו <phoneticPr> בלי fontId, רשומת Override כפולה ב-[Content_Types].xml, ושתי תלויות שורש שחלקו את rId4
למה Excel פוסל חבילה שכל קורא אחר מקבל?
כי בקשת התיקון היא מאמת סכימה וחבילה, לא כשל פרסור. הקורפוס של HotXLS העביר במשך שבועות תבנית הלוואה עם 4805 נוסחאות הלוך ושוב דרך הספרייה, דרך LibreOffice ודרך מאמתי ה-XML בחבילת הבדיקות. הקובץ שנשמר היה תקין מבחינה מבנית במובן ה-OPC שתואר במאמר על פתרון יחסי OPC ב-XLSX: כל חלק נגיש, כל יעד ניתן לפתרון. ואז התפנה מחשב Windows עם Excel 16.0 build 20326, מריץ הקורפוס פתח את התבנית שנשמרה דרך Workbooks.Open במופע COM מבודד עם DisplayAlerts כבוי, והקריאה נכשלה לחלוטין. באופן אינטראקטיבי אותו קובץ מפיק את הדיאלוג המוכר שמציע לתקן, ויומן התיקון, כש-Excel טורח לכתוב אחד, נוקב בשם החלק אבל לא בכלל. שלושה פגמים נפרדים התחבאו מאחורי הבקשה האחת הזו, ו-Excel לא מדווח עליהם בזה אחר זה; הוא פוסל את החוברת ומשאיר לכם למצוא ולנתח אותם. מה שבא בהמשך הוא כל כלל, השורה ב-HotXLS שהפרה אותו, והתיקון שיצא, כי כל אחד מהם הוא כלל שכל כותב XLSX בדלפי יכול להיתקל בו
כלל 1: fontId ב-phoneticPr נדרש, גם כשהוא אפס
האלמנט <phoneticPr> נושא מאפיין fontId שמוצהר use="required" ב-ECMA-376 חלק 1 §18.4.3, וערך 0 הוא אינדקס גופן חוקי, לא היעדר. כותב הגיליון הישן של HotXLS התייחס לאפס כ"לא מוגדר" ופלט את המאפיין רק כש-Sheet.PhoneticFontId > 0. זה רפלקס דלפי טבעי, כי שדות שלמים כברירת מחדל הם אפס, אבל הוא מייצר <phoneticPr type="noConversion"/> לכל חוברת שהגופן הפונטי שלה הוא במקרה הגופן הראשון ב-styles.xml, וזה בדיוק מה שנשאה תבנית ההלוואה בקורפוס של HotXLS. Excel פוסל אז בכניסה חזרה ערך שהוא עצמו כתב
// lxHandleX.pas, כותב הגיליון — לפני v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — המאפיין נדרש, כולל אפס
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS עדיין פולטת את האלמנט רק כש-TXLSXWorksheet.PhoneticType אינו ריק, כך שחוברות שמעולם לא נשאו הגדרות פונטיות לא מושפעות. בדיקת הרגרסיה PhoneticSettings_DefaultFontIsExplicit מגדירה את PhoneticFontId לאפס בגיליון חדש, שומרת, ומאמתת ש-<phoneticPr fontId="0" נמצא ב-xl/worksheets/sheet1.xml. הלקח הרחב הוא ש"להשמיט כשברירת מחדל" בטוח רק כשהסכימה מצהירה על ברירת מחדל; ל-type ול-alignment יש ברירות מחדל באלמנט הזה, ל-fontId אין
כלל 2: רשומת Override אחת לכל שם חלק ב-[Content_Types].xml
זרם סוגי התוכן רשאי להצהיר על כל שם חלק לכל היותר פעם אחת, ו-Excel מתייחס ל-Override שני לאותו PartName כאל קורופציה גם כששתי הרשומות נושאות את אותו ContentType. ל-HotXLS יש שני כותבים שמזינים את הזרם הזה. BuildContentTypesXml מצהיר על כל חלק שהמודל האובייקטים מייצר: חוברת, סגנונות, מחרוזות משותפות, ערכה, גיליונות, וכשהתנאי TXLSXWorkbook.CustomProperties.Count > 0 מתקיים גם /docProps/custom.xml. כש-PreserveUnsupportedParts דלוקה, TXLSXOpaquePackage מוסיפה אז Override לכל חלק שהיא לכדה מילולית מחבילת המקור כדי שהבתים האלה יישארו מוצהרים ביציאה. ההתנגשות היא חלק שחי בשני הצדדים. מאפייני מסמך מותאמים נפרסים לתוך המודל, אבל docProps/custom.xml של חבילת המקור נלכד גם כן באופן אטום, כך שהזרם הממוזג הצהיר עליו פעמיים, וחלקי מטמון גרפים ו-pivot יכולים לנחות באותה נקודה כשהמודל מייצר מחדש חלק שגם השכבה האטומה שמרה. לפני v2.382.5 ל-ContentTypeOverridesXml לא היה מושג מה המודל כבר כתב, כך שהיא לא יכלה לדעת
<!-- מה ש-Excel ראה לפני v2.382.5 -->
<Override PartName="/docProps/custom.xml"
ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>
...
<Override PartName="/docProps/custom.xml"
ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>
התיקון מעביר את ה-XML שנוצר לתוך ContentTypeOverridesXml ומאפשר לכותב האטום לפרס אותו לפני שהוא פולט משהו. שני פרטים נושאים את הנכונות. OpcLowerPartName ממיר לאותיות קטנות, הופך קווים נטויים לאחור לקווים נטויים קדימה, ומסיר קווים נטויים מובילים לפני ההשוואה, כי שמות חלקים ב-OPC מושווים בלי תלות ברישיות והמודל כותב אותם עם קו נטוי מוביל בעוד השכבה האטומה מאחסנת שמות פריטי ZIP בלעדיו. והקורא ב-BuildContentTypesXml מעביר Result + '</Types>', וכך סוגר את המסמך שנבנה חלקית כדי ש-TXMLReader יראה קלט תקין ולא זרם קטוע. הכלל שנוצר הוא שהראשון מנצח והמודל לפני כולם: כל מה שהמודל האובייקטים מצהיר הוא הסמכות, וההשמעה האטומה רק ממלאת חורים
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// לפרס את הזרם שהמודל יצר ולאסוף כל PartName מוצהר.
while Reader.Read do
if (Reader.NodeType= xmlntElement)and (Reader.Name= 'Override') then
begin
Index:= Reader.AttributeIndex('PartName');
if Index>= 0 then
UsedNames.Add(String(OpcLowerPartName(Reader.Attribute[Index].Value)));
end;
for i:= 0 to FParts.Count- 1 do
begin
Part:= TXLSXOpaquePart(FParts[i]);
if (Part.ContentType= '')or (LowerCase(ExtractFileExt(String(Part.PartName)))= '.rels')or
(UsedNames.IndexOf(String(OpcLowerPartName(Part.PartName)))>= 0) then
Continue; // כבר מוצהר, או חלק rels
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
כלל 3: מזהי תלויות ייחודיים בתוך חלק תלויות
כל Relationship בחלק .rels צריך Id ייחודי בתוך אותו חלק, ו-Excel מסרב לחבילה כששניים חולקים אחד. HotXLS כותבת את _rels/.rels ברמת החבילה עם מזהים קבועים: rId1 לחוברת, rId2 ו-rId3 למאפייני המסמך הבסיסיים והמורחבים, ו-rId4 למאפיינים מותאמים כשלמודל יש כאלה. החבילה האטומה מוסיפה אחר כך כל תלות שורש שהיא שמרה מהמקור, וממספרת מחדש כל מזהה שכבר נמצא ברשימת UsedIds. הרשימה הכירה את rId1 עד rId3. היא לא הכירה את rId4, והיא לא ידעה שהמודל עומד לפלוט תלות משל עצמו למאפיינים מותאמים, כך שחבילת מקור שתלות המאפיינים המותאמים שלה הייתה גם rId4, וזה מה ש-Excel כותב כברירת מחדל, יצאה עם שתי רשומות rId4 שמצביעות לאותו יעד. הקורא, BuildRootRelsXml, מעביר כעת Workbook.FCustomProps.Count > 0 כארגומנט השני, כך שהשריון והדילוג נשלטים על ידי אותו תנאי שמחליט אם המודל פולט rId4 בכלל. מספור מחדש בטוח בשורש החבילה כי שום דבר בתוך החוברת לא מפנה למזהי תלויות שורש בשמם; אותו טריק היה שגוי רמה אחת למטה, שם מאפייני r:id ב-workbook.xml נקשרים למזהים בחלק התלויות של החוברת, ולכן MergeWorkbookRelationshipsXml שומר מפת מזהים נפרדת
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // נשמר על ידי כותב המודל
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// המודל הוא הבעלים של המאפיינים המותאמים כעת; לא להשמיע מחדש את העותק מהמקור.
if EmitCustomProps and OpcEndsWith(LowerCase(Rel.RelType), '/custom-properties') then
Continue;
Id:= Rel.Id;
if (Id= '')or (UsedIds.IndexOf(String(Id))>= 0) then
Id:= AllocateRelationshipId(UsedIds); // rIdN הפנוי הנמוך ביותר
UsedIds.Add(String(Id));
...
end;
מה משותף לשלושת הכשלים?
שלושתם תסמינים של כותב עם שני מקורות ובלי בעלים אחד של אינוריאנטי החבילה. המודל האובייקטים מייצר את החלקים שהוא מבין; השכבה האטומה משמיעה מחדש חלקים שהוא לא, כדי שהלוך ושוב ישמור גרפים, מטמוני pivot, XML מותאם וכל השאר שמתואר ברשומות על הלוך ושוב בלי אובדן של ערכה, extLst ו-calcChain. כל צד היה עקבי בפני עצמו. האילוצים ש-OPC מטיל על החבילה כולה, שמות חלקים ייחודיים ב-Override ומזהי תלויות ייחודיים לכל חלק, קיימים רק בתפר שבו השניים מחוברים, ועד v2.382.5 איש לא בדק את התפר. באג fontId הוא אותה צורה רמה אחת למטה: הכותב ידע מה הוא רוצה להשמיט אבל מעולם לא התייעץ עם הסכימה שאומרת שאסור. התיקון ש-HotXLS התפשרה עליו הוא קדימות קבועה ולא יוריסטיקת מיזוג. המודל כותב ראשון, השכבה האטומה רואה מה נכתב ומפנה מקום בכל התנגשות, ומריץ הקורפוס אוכף כעת את האינוריאנטים מבחוץ עם verify_opc_uniqueness, שקורא את [Content_Types].xml ואת כל פריט .rels בחבילה שנשמרה ומכשיל את המקרה על כל PartName, Extension או Id כפולים. הבדיקה הזו זולה, לא צריכה Excel, והייתה תופסת שניים מהשלושה פגמים כבר בהרצת הקורפוס הראשונה
באותה אצווה: אזורי הדפסה שהם נוסחאות, לא טווחים
מעבר ה-Excel סימן גם את ה-_xlnm.Print_Area של תבנית ההלוואה, ש-Excel דיווח עליו כ-$A$1:$J$29 במקור והיה צריך לדווח עליו בדיוק כך בעותק שנשמר. שני באגים נפרדים ישבו מאחורי הקביעה האחת הזו. בייבוא, XlsxStripSheetPrefix חתך כל מה שלפני ה-! הראשון שאינו במרכאות, כך שאזור הדפסה דינמי כמו OFFSET('Print Data'!$A$1,0,0,2,2) חזר כ-$A$1,0,0,2,2), ואיחוד עם שם גיליון כמו 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 איבד את הקידומת במקטע הראשון בלבד. בייצוא, הכותב הוסיף את שם הגיליון פעם אחת לפני כל ה-PrintArea המאוחסן, כך שאיחוד פשוט $A$1:$B$2,$D$1:$E$2 השאיר בספרייה מקטע ראשון עם שם גיליון ומקטע שני בלעדיו, וזו הגדרה ש-Excel לא מקבל כ-_xlnm.Print_Area לפי ECMA-376 חלק 1 §18.2.5
// ייבוא: להסיר את הקידומת רק כשמה שנשאר הוא sqref רגיל
function XlsxPrintAreaFromDefinition(const Formula: WideString): WideString;
begin
Result:= Formula;
if not XlsxReadFormulaSheetPrefixAt(Formula, 1, Prefix, SheetPart, Start) then
Exit;
Area:= Copy(Formula, Start, Length(Formula));
if XlsxParseSqrefPart(Area, R1, C1, R2, C2) then
Result:= Area; // 'Sheet'!$A$1:$J$29 הופך ל-$A$1:$J$29
end; // OFFSET(...) מוחזר בלי שינוי
// ייצוא: לסמן בשם גיליון כל מקטע מופרד בפסיק, או אף אחד מהם
function XlsxPrintAreaDefinition(const SheetName, Area: WideString): WideString;
begin
Result:= Area;
... split Area on ',' with StrictDelimiter ...
for I:= 0 to Parts.Count- 1 do
if not XlsxParseSqrefPart(WideString(Trim(Parts[I])), R1, C1, R2, C2) then
Exit; // נוסחה: לפלוט כמו שהיא
Result:= '';
for I:= 0 to Parts.Count- 1 do
begin
if I> 0 then Result:= Result+ ',';
Result:= Result+ XlsxQuoteSheetName(SheetName)+ '!'+ WideString(Trim(Parts[I]));
end;
end;
כלל ההצמדה זהה בשני הצדדים: אזור הדפסה הוא טווח חשוף רק אם כל מקטע מפורס כאחד, אחרת הוא נוסחה ועובר כמו שהוא. PrintArea_FormulaDefinitionSurvivesRoundTrip מכסה את הבסיס הנקוב בשם, את הבסיס עם שם הגיליון ואת האיחוד דרך שני מחזורי שמירה ופתיחה מחדש. איך אזורי הדפסה משתלבים עם הגדרת עמוד ושאר מודל ההדפסה מכוסה במאמר על הגנת גיליון, הגדרת עמוד והדפסה
איך מוצאים על איזה כלל Excel מתלונן?
מתחילים מההנחה שהמאמת שלכם שגוי, כי הוא עבר. מאמת ה-Open XML SDK ינקוב בהפרת סכימה כמו ה-fontId החסר עם החלק וה-XPath, ושכבת האריזה שמתחתיו מסרבת בכלל לפתוח חבילה עם רשומות סוג תוכן כפולות, אז הריצו אותו לפני כל דבר אחר. כשהוא שותק ו-Excel עדיין מתקן, עשו ביסקציה על החבילה: פרסו, מחקו חלק ואת התלות שלו ואת ה-Override שלו, ארזו מחדש, ופתחו מחדש, וחצו את קבוצת המועמדים בכל פעם עד שהבקשה נעלמת. שלושת הפגמים כאן נפלו בסדר הזה, ואף אחד מהם לא היה נראה בקובץ המתוקן ש-Excel מציע לשמור, כי התיקון משליך או ממספר מחדש בשקט את הרשומות הבעייתיות. גם גבולות התיקון ב-v2.382.5 ראויים להיאמר בפשטות. הסרת הכפילויות היא ראשון-מנצח כשהמודל לפני כולם, כך שאם חבילת המקור הצהירה סוג תוכן אחר לחלק שגם המודל מייצר, ההצהרה של המודל מנצחת וזו של המקור נזרקת, מה שנכון לחלקים ש-HotXLS מייצרת מחדש ומה שאינו מיזוג כללי. verify_opc_uniqueness בודק יחודיות בלבד; הוא לא מאמת סכימות, כך שמאפיין נדרש עתידי עדיין יצטרך את Excel או מאמת סכימה כדי לצוף. ומעבר ה-TXMLReader הנוסף מעל זרם סוגי התוכן שנוצר רץ בכל שמירה עם PreserveUnsupportedParts דלוקה, עלות קטנה מול זרם שלעיתים נדירות עולה על כמה קילובתים. עם כל אלה במקום, גם בנייני Win32 וגם Win64 של תבנית ההלוואה נפתחים כעת ב-Excel בלי בקשה, מחשבים מחדש את כל 4805 הנוסחאות המאומתות בלי אי-התאמות, ומדווחים על אותו אזור הדפסה כמו המקור
אם אתם כותבים XLSX מדלפי בעצמכם, רשימת הבדיקה קצרה: פלטו כל מאפיין שהסכימה מסמנת כנדרש בלי קשר לערך שלו, הצהירו על כל שם חלק פעם אחת, ושמרו רשימה אחת של מזהים בשימוש לכל חלק תלויות בכל הכותבים שנוגעים בו. אם הייתם מעדיפים שהרשימה הזו כבר תהיה קיימת ותיבדק מול Excel ולא רק מול הקורא שלכם, כותב החבילה שמתואר כאן מגיע עם רכיב הגיליון של HotXLS לDelphi, יחד עם הלוך ושוב של חלקים אטומים שעשה את התפר לשווה שמירה מלכתחילה