מאמר טכני

למה Excel מתקן XLSX תקין: כללי חבילת OPC בדלפי

‏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 פוסל אז בכניסה חזרה ערך שהוא עצמו כתב

למה Excel דרש תיקון לחלק הגיליון של HotXLS: האלמנט phoneticPr מצהיר על fontId עם use required ב-ECMA-376 חלק 1, אינדקס גופן 0 הוא ערך חוקי, והכותב הישן שהשמיט את המאפיין כש-PhoneticFontId היה אפס ייצר phoneticPr type noConversion, בעוד שהסכימה נותנת ברירות מחדל ל-type ול-alignment ול-fontId לא
השמטת מאפיין כשהוא שווה לברירת המחדל בטוחה רק כשהסכימה מצהירה על ברירת המחדל הזו, ותבנית ההלוואה נשאה את הגופן הפונטי שלה כרשומה הראשונה ממש ב-styles.xml
// 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 לא היה מושג מה המודל כבר כתב, כך שהיא לא יכלה לדעת

איך שני כותבים ב-HotXLS התנגשו ב-[Content_Types].xml: BuildContentTypesXml הצהיר על docProps/custom.xml מהמודל האובייקטים בעוד TXLSXOpaquePackage הוסיפה Override לאותו חלק שנלכד מילולית, ומאז v2.382.5 השכבה האטומה מפרסה תחילה את הזרם שנוצר, מנרמלת שמות עם OpcLowerPartName ונותנת למודל לנצח בכל התנגשות
כל כותב היה עקבי בפני עצמו, והאילוץ שכל שם חלק רשאי להופיע פעם אחת קיים רק בתפר שבו הפלטים שלהם מחוברים, ולכן התיקון מעביר פנימה את זרם המודל
<!-- מה ש-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 שומר מפת מזהים נפרדת

התנגשות מזהי התלויות בשורש חבילת HotXLS: המודל כותב rId1 עד rId4 כשמ-rId4 שמור למאפיינים מותאמים, השכבה האטומה השמיעה מחדש תלות מקור שהגיעה גם היא כ-rId4 כי UsedIds הכירה רק rId1 עד rId3, והתיקון שומר מראש את rId4 בכל פעם ש-EmitCustomProps מתקיים וממספר מחדש את השאר
מספור מחדש בטוח בשורש החבילה כי שום דבר בתוך החוברת לא מפנה למזהי שורש בשמם, ואותו טריק רמה אחת למטה היה שובר כל קישור r:id ב-workbook.xml
// 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, יחד עם הלוך ושוב של חלקים אטומים שעשה את התפר לשווה שמירה מלכתחילה