xlsx תקין לא חייב להכיל xl/worksheets/sheet1.xml. HotXLS, רכיב גיליון האלקטרוני של Excel הילידי עבור Delphi ו-C++Builder, מאתר כל חלק (part) דרך גרף הקשרים (relationship) של OPC במקום לנחש שמות, כי ISO/IEC 29500-2 מבטיח רק שחלקים נגישים מ-_rels/.rels, לעולם לא שהם יושבים בנתיבים קונבנציונליים
מדוע המנתח שלי נכשל על xlsx תקין?
כי שמות החלקים שזכרתם בעל פה הם מוסכמה של יצרן אחד, לא דרישה של הפורמט. כל נתיב שאי פעם קידדתם קשיח, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, הוא מה שהכותב של Excel שולחני קורה לפלוט. חבילה תואמת יכולה לשים את חוברת העבודה ב-office/book.xml ואת גיליון העבודה הראשון ב-xl/custom/data-sheet.xml ועדיין להיות SpreadsheetML חוקי, כל עוד הקשרים מצביעים לשם. זו הסיבה הנפוצה ביותר לבד שקורא בית-גידול מדווח "לא מוצא sheet1.xml" על קובץ ש-Excel, LibreOffice, ו-Numbers כולם פותחים בלי תלונה
יצרנים שעושים זאת אינם אקזוטיים. מחוללי דוחות בצד השרת עושים שימוש חוזר בחבילת תבנית ושומרים על הפריסה המקורית שלה. צינורות ייצוא שממזגים שתי חוברות עבודה ממספרים מחדש גיליונות ומשאירים פערים, כך שחוברת עבודה בת חמישה גיליונות יש לה sheet1, sheet2, sheet4, sheet7, ו-sheet9. כלים שמסירים גיליון לא תמיד ממספרים מחדש את השורדים. בכל אחד מהמקרים הללו הניחוש מבוסס-האינדקס xl/worksheets/sheet + IntToStr(i + 1) + .xml קורא בשקט את הגיליון הלא נכון או לא קורא כלום, שגרוע יותר מחריגה כי חוברת העבודה נטענת והמספרים שגויים. החבילה המינימלית למטה מפעילה את כל הבעיה, וזו הצורה ש-HotXLS בודק רגרסיה מולה
<!-- _rels/.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
Target="office/book.xml"/>
</Relationships>
<!-- office/_rels/book.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId42"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
Target="../xl/custom/data-sheet.xml"/>
</Relationships>
<!-- xl/custom/_rels/data-sheet.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="note7"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
Target="../notes/review.xml"/>
</Relationships>
מה ISO/IEC 29500-2 באמת מבטיח?
הוא מבטיח נגישות, לא מיקום. ISO/IEC 29500-2 הוא חלק ה-Open Packaging Conventions של התקן, וסעיף הקשרים שלו מגדיר בדיוק נקודת כניסה קבועה אחת: חלק קשר החבילה ב-_rels/.rels. משם עוקבים אחר הקשר שה-Type שלו הוא http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument כדי להגיע לחלק חוברת העבודה, וכל חלק אחר מתגלה על ידי קריאת חלק הקשר של אותו חלק עצמו ומעקב אחר קשתות מוקלדות החוצה
שני כללים נוספים מאותו תקן עושים את העבודה האמיתית. סעיף מתן שמות החלקים קובע היכן חלק קשר חי: עבור חלק ב-<folder>/<name>, הקשרים שלו נמצאים ב-<folder>/_rels/<name>.rels, ועבור חלק בשורש החבילה התיקייה היא פשוט _rels/. סעיף סימון הקשרים קובע שה-Target הוא הפניית URI שנפתרת מול ה-URI של חלק המקור, במובן הרגיל של RFC 3986, אלא אם TargetMode="External" מסמן אותו כמצביע מחוץ לחבילה. פתרון יחסי-למקור הוא השלב שכולם מדלגים עליו, וזו הסיבה שאותו ../notes/review.xml מילולי אומר דבר אחד בתוך xl/custom/_rels/data-sheet.xml.rels ומשהו אחר לגמרי בתוך קובץ rels תיקייה אחת עמוקה יותר. קמט אחרון אחרון יושב בין המודל הלוגי לבייטים על הדיסק: שמות חלקים במודל הלוגי הם מוחלטים ומתחילים בקו נטוי קדמי, אך סעיף מיפוי ה-ZIP הפיזי מסיר את הקו הנטוי הזה כשהוא הופך שם חלק לשם פריט ZIP, כך שresolver ששוכח את זה מחפש /xl/sharedStrings.xml בארכיון ולא מוצא כלום
בתוך XlsxResolveRelationshipTarget
HotXLS מרכז את כל כלל הפתרון בפונקציה אחת, XlsxResolveRelationshipTarget, מוצהרת ב-lxHandleX.pas כ-function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. היא לוקחת את שם פריט ה-ZIP של חלק המקור ואת מאפיין ה-Target הגולמי, ומחזירה שם פריט ZIP ללא קו נטוי מוביל, מוכן למסירה ישירה לארכיון. העברת OwnerPartName ריק פותרת מול שורש החבילה, שזה בדיוק מה שחלק קשר החבילה זקוק לו. סדר הפעולות חשוב יותר מהשלבים הבודדים: קווים נטויים אחוריים מנורמלים לקווים נטויים קדמיים תחילה, כי כמה יצרנים כותבים מפרידי Windows לתוך Target; כל fragment שהוכנס על ידי # נחתך לפני טיפול הנתיב, כך ש-../charts/chart1.xml#Sheet1 נפתר לשם חלק במקום לרשומת ארכיון לא-קיימת; רק אז הפונקציה מפצלת מוחלט מיחסי
// Normalization core, as implemented in lxHandleX.pas.
combined := StringReplace(Target, '\', '/', [rfReplaceAll]);
p := Pos('#', combined);
if p > 0 then
combined := Copy(combined, 1, p - 1);
if (combined <> '') and (combined[1] = '/') then
Delete(combined, 1, 1) // package-absolute: strip the slash only
else
begin
p := LastDelimiter('/', String(OwnerPartName));
if p > 0 then
baseName := Copy(OwnerPartName, 1, p)
else
baseName := '';
combined := baseName + combined; // relative to the source part folder
end;
source.StrictDelimiter := True; // '/' only, no quote or space handling
source.Delimiter := '/';
source.DelimitedText := String(combined);
for i := 0 to source.Count - 1 do
begin
segment := WideString(source[i]);
if (segment = '') or (segment = '.') then
Continue; // empty and dot segments vanish
if segment = '..' then
begin
if parts.Count > 0 then
parts.Delete(parts.Count - 1); // pop, and never below the root
end
else
parts.Add(String(segment));
end;
לולאת הסגמנטים היא מעבר מחסנית פשוט: סגמנטים ריקים ו-. מושמטים, .. מבצע pop רמה אחת, ו-.. שהיה בורח משורש החבילה נספג במקום להפיק אינדקס שלילי או שם שמתחיל ב-../. ההקצאה StrictDelimiter := True אינה קוסמטית. בלעדיה TStringList ב-Delphi מתייחס לרווחים כמפרידים ומכבד תווי מרכאות, מה שמעוות כל שם חלק המכיל רווח, ושמות חלקים עם רווחים חוקיים
מעקב אחר הגרף: חוברת עבודה, גיליון עבודה, ציור
HotXLS עובר על שלוש שכבות של חלקי קשר בנתיב TXLSXWorkbook.Open. שכבת החבילה מטופלת על ידי XlsxFindOfficeDocumentPart, שקוראת _rels/.rels ומחזירה את יעד ה-officeDocument. שכבת חוברת העבודה קוראת את חלק קשר חוברת העבודה ובונה שתי מפות בבת אחת: מפת מזהים עבור חיפושי r:id ומפת סוגים עבור חלקים יחידניים (singleton). שכבות גיליון העבודה והציור חוזרות על התבנית עם ParseWorksheetRelsXml ו-ParseDrawingRelsXml, כל אחת מעבירה את שם החלק שלה עצמה כבסיס הפתרון כך שציור שמפנה ל-../media/image3.png נוחת על ה-blob הנכון
// Tier 1: the only fixed name in the whole format.
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
WorkbookPartName := 'xl/workbook.xml'; // legacy fallback
if not zip.Exists(WorkbookPartName) then
Exit;
// Tier 2: <folder>/_rels/<name>.rels for the workbook part itself.
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
relsStream := zip.OpenFile(relsName);
try
ParsePartRelationshipsXml(relsStream, WorkbookPartName,
WorkbookTargetById, WorkbookTargetsByType);
finally
relsStream.Free;
end;
end;
// Typed singletons resolve by relationship type URI.
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
PartName := 'xl/sharedStrings.xml';
גיליונות באופן ספציפי חייבים לעבור דרך מפת המזהים, לא מפת הסוגים. אלמנטי ה-<sheet> בחלק חוברת העבודה נושאים מאפייני r:id, ואותו מזהה הוא הדבר היחיד שקושר שם גיליון לחלק. HotXLS אוסף את המזהים הללו במהלך ParseWorkbookXml ופותר כל אחד מהם מול מפת קשר חוברת העבודה, נופל בחזרה לשם הממוספר הקונבנציונלי רק כשהמזהה נעדר או בלתי-ניתן-לפתרון
// Tier 2b: r:id -> worksheet part, per sheet, in workbook order.
PartName := '';
if (i < SheetRelIds.Count) and (SheetRelIds[i] <> '') then
PartName := WorkbookTargetById.Values[SheetRelIds[i]];
if PartName = '' then
PartName := 'xl/worksheets/sheet' + IntToStr(i + 1) + '.xml';
SheetPartNames.Add(String(PartName));
// Tier 3: each worksheet resolves its own satellites against its own name.
relsName := XlsxRelationshipPartName(PartName);
if zip.Exists(relsName) then
begin
relsStream := zip.OpenFile(relsName);
try
ParseWorksheetRelsXml(relsStream, PartName,
FParRels[i], ParTableTargets[i], ParPartTargets[i]);
finally
relsStream.Free;
end;
end;
כל מה שבמורד הזרם רוכב על אותו מנגנון עצמו. מחרוזות משותפות, סגנונות, ערכת נושא, פרויקט ה-VBA תחת סוג המרחב-שם של Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, קישורים חיצוניים, חלק האדם בהיקף חוברת העבודה, הערות ישנות, הערות משורשרות, ציור ה-VML שנושא גיאומטריית בלון ההערה, ציורים, תמונות, תרשימים, טבלאות, ו-PivotTables כולם מגיעים לבייטים שלהם דרך יעדים שנפתרו. חלק ערכת הנושא בפרט חייב להיות ממוקם נכון או שסבב-הלוך-ושוב מוחק בשקט פלטת מותג של לקוח עם ערכת הנושא הסטנדרטית של Office, אחד ממצבי הכשל המכוסים בהערות על סבב הלוך-ושוב חסר-אובדן של XLSX עבור ערכת נושא, extLst, ו-calcChain. קריאת קשרים היא גם הסיבה שהטעינה מבוימת בדרך שהיא: כל גישת ארכיון קורית ב-thread אחד לפני שXML של גיליון עבודה מנותח, כי מצב ה-inflate של ארכיון ZIP אינו thread-safe, אילוץ שמוסבר בכתבה על ניתוח XLSX מקבילי ומקצה הזיכרון
מדוע rId כפול שובר ניתוב מבוסס-סוג?
כי רשומה פגומה מאוחרת יותר יכולה לדרוס רשומה תקינה קודמת ולחטוף את החיפוש. מזהי קשר אמורים להיות ייחודיים בתוך חלק קשר, אך חבילות פגומות משתמשות בהם חוזר, והקצאת Values[Id] := נאיבית היא last-write-wins. אם rId3 ראשון מצביע על גיליון עבודה אמיתי ו-rId3 שני מצביע על יעד לא-נתמך או ריק, last-write-wins מאבד את גיליון העבודה. ParsePartRelationshipsXml לכן מיישם כלל first-wins עם שני תנאים: היעד שנפתר חייב להיות לא-ריק, והמזהה חייב לא להיות כבר נוכח. שני התנאים ביחד הם מה שהופך את זה לבטוח, כי בדיקת האי-ריקנות עוצרת קשר עם Target חסר מלטעון את החריץ לפני שאחד שמיש מגיע
if (TargetById <> nil) and (Id <> '') and (resolvedTarget <> '') and
(TargetById.IndexOfName(String(Id)) < 0) then
TargetById.Values[String(Id)] := String(resolvedTarget);
if (TargetsByType <> nil) and (relType <> '') and (resolvedTarget <> '') then
TargetsByType.Add(String(relType + '=' + resolvedTarget));
שימו לב לחוסר הסימטריה המכוון בקטע הזה. מפת המזהים היא מפה אמיתית עם שומר first-wins, בעוד אוסף הסוגים הוא רשימת append-only של זוגות type=target. ההבחנה הזו נושאת עומס: לחוברת עבודה יש בדיוק קשר shared-strings אחד אך הרבה קשרי worksheet וexternal-link, כך שחיפוש סוג דרך Values[] מחזיר את ההתאמה הראשונה עבור יחידניים (singletons), וסוגים רבי-ערכים כמו externalLink מנוספרים על ידי מעבר על הרשימה
איפה מעקב הקשרים נעצר
גבולות כנים חשובים יותר מסיפור נקי. HotXLS נופל בחזרה לשמות קונבנציונליים בכל פעם שקשר נעדר, כך שחבילה עם חלק קשר פגום או חסר עדיין נפתחת אם היא קורה לעקוב אחר פריסת Excel; ה-fallback הזה הוא תכונת תאימות, לא מקור אמת שני, והוא יכול להסתיר באג יצרן במהלך בדיקה. שלושה גבולות נוספים שווים ידיעה. יעדים המסומנים TargetMode="External" מאוחסנים כלשונם במקום להיפתר, שנכון עבור hyperlinks ועבור הקשר externalLinkPath שנושא URL מרוחק של חוברת עבודה, אך זה אומר שהערך שמתקבל בחזרה הוא מה שהיצרן כתב. חלקי תרשים שהתגלו דרך חלק קשר ציור מוצמדים לעוגני ציור לפי מיקום ולא לפי מזהה, כך שסידור עוגן חריג יכול לפגוע בהתאמת קשרי תרשים. והקורא הישיר בזרימה ב-lxDirectRead.pas שומר על טיפול נתיב קליל משלו הממופתח ל-xl/, כך שה-resolver המלא המתואר כאן שולט על נקודות הכניסה TXLSXWorkbook.Open ו-GetSheetNames, לא על נתיב הסריקה בעל ההקצאה-הנמוכה המתועד במאמר על הקורא הישיר בזרימה עבור Delphi
אם אתם בונים את זה בעצמכם, הסיכום הנכון הקצר ביותר הוא: לעולם אל תבנו שם חלק, תמיד פתרו אחד. קוראים _rels/.rels, עוקבים אחר officeDocument, פותרים כל Target מול החלק שהכריז עליו, ומנתבים גיליונות לפי r:id. אם הייתם מעדיפים שזה כבר נבדק מול חלקים ששונה שמם, מספור גיליונות לא-רציף, ומזהי קשר כפולים, ה-resolver המתואר כאן מגיע ברכיב גיליון האלקטרוני ל-Delphi של HotXLS, יחד עם מנגנון סבב-הלוך-ושוב ששומר על החלקים שהוא לא מנתח שלמים