Валідний xlsx не зобов'язаний містити xl/worksheets/sheet1.xml. HotXLS, нативний компонент електронних таблиць Excel для Delphi та C++Builder, знаходить кожну частину через граф зв'язків 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 та щось зовсім інше всередині файлу зв'язків на папку глибше. Одна остання зморшка сидить між логічною моделлю та байтами на диску: назви частин у логічній моделі абсолютні й починаються з похилої риски вперед, але пункт про фізичне відображення ZIP відрізає цю риску, коли перетворює назву частини на назву елемента ZIP, тож резолвер, що про це забуває, шукає /xl/sharedStrings.xml в архіві й нічого не знаходить
Всередині XlsxResolveRelationshipTarget
HotXLS концентрує все правило розв'язання в одній функції, XlsxResolveRelationshipTarget, оголошеній у lxHandleX.pas як function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Вона приймає назву елемента ZIP вихідної частини та сирий атрибут Target і повертає назву елемента ZIP без провідної риски, готову передати прямо в архів. Передача порожнього OwnerPartName розв'язує проти кореня пакета, що рівно те, що потрібно частині зв'язків пакета. Порядок операцій має більше значення, ніж окремі кроки: зворотні риски спершу нормалізуються на прямі, бо деякі виробники пишуть роздільники Windows у Target; будь-який фрагмент, введений через #, вирізається до обробки шляху, тож ../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;
Цикл сегментів — звичайний обхід стеку: порожні сегменти та . відкидаються, .. виштовхує один рівень, а .., що втекла б за корінь пакета, поглинається замість того, щоб виробити від'ємний індекс чи ім'я, що починається з ../. Присвоєння StrictDelimiter := True — не косметика. Без нього TStringList Delphi трактує пробіли як роздільники й поважає символи лапок, що спотворює будь-яку назву частини, яка містить пробіл, а назви частин з пробілами легальні
Проходження графом: книга, аркуш, малюнок
HotXLS обходить три рівні частин зв'язків на шляху TXLSXWorkbook.Open. Рівень пакета обробляється XlsxFindOfficeDocumentPart, що читає _rels/.rels і повертає ціль officeDocument. Рівень книги читає частину зв'язків книги й будує одразу дві мапи: мапу ідентифікаторів для пошуків r:id та мапу типів для одиночних частин. Рівні аркуша та малюнка повторюють шаблон з ParseWorksheetRelsXml та ParseDrawingRelsXml, кожен передаючи власну назву частини як базу розв'язання, тож малюнок, що посилається на ../media/image3.png, приземляється на правильний блоб
// 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, зовнішні посилання, частина person, прив'язана до книги, застарілі коментарі, потокові коментарі, VML-малюнок, що несе геометрію бульбашки коментаря, малюнки, зображення, діаграми, таблиці та зведені таблиці — усі досягають своїх байтів через розв'язані цілі. Частина теми зокрема мусить бути знайдена правильно, інакше циклічний обмін тихо перезаписує брендовану палітру клієнта стандартною темою Office, один з режимів збою, розглянутий у нотатках про безвтратний циклічний обмін XLSX теми, extLst та calcChain. Читання зв'язків — також причина, чому завантаження поставлене саме так: увесь доступ до архіву відбувається на одному потоці до того, як парситься XML аркуша, бо стан inflate ZIP-архіву не є потокобезпечним, обмеження, пояснене в статті про паралельний парсинг XLSX та алокатор пам'яті
Чому дублікат rId ламає маршрутизацію за типом?
Тому що пізніший некоректний запис може переписати ранішій валідний і викрасти пошук. Ідентифікатори зв'язків мають бути унікальними всередині частини зв'язків, але некоректні пакети повторно використовують їх, а наївне присвоєння Values[Id] := — це "останній переміг". Якщо rId3 спершу вказує на реальний аркуш, а другий rId3 вказує на непідтримувану чи порожню ціль, "останній переміг" втрачає аркуш. Тож ParsePartRelationshipsXml застосовує правило "перший переміг" з двома умовами: розв'язана ціль має бути непорожньою, а ідентифікатор ще не має бути присутнім. Обидві умови разом і роблять це безпечним, бо тест на непорожність зупиняє зв'язок з відсутнім 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));
Зверни увагу на навмисну асиметрію в цьому фрагменті. Мапа ідентифікаторів — справжня мапа із захистом "перший переміг", тоді як колекція типів — список лише для дописування пар type=target. Ця відмінність несуча: книга має рівно один зв'язок спільних рядків, але багато зв'язків аркушів і зовнішніх посилань, тож пошук за типом через Values[] повертає перший збіг для одиночних, а багатозначні типи, такі як externalLink, перелічуються обходом списку
Де проходження зв'язків зупиняється
Чесні межі мають більше значення, ніж чиста історія. HotXLS відкочується до звичайних назв щоразу, коли зв'язок відсутній, тож пакет з пошкодженою чи відсутньою частиною зв'язків усе одно відкривається, якщо він за випадковістю слідує розкладці Excel; цей відкат — функція сумісності, а не друге джерело істини, і він може приховати баг виробника під час тестування. Ще три межі варто знати. Цілі, позначені TargetMode="External", зберігаються дослівно, а не розв'язуються, що коректно для гіперпосилань та для зв'язку externalLinkPath, що несе URL віддаленої книги, але це означає, що значення, яке ти отримуєш назад, — те, що написав виробник. Частини діаграм, знайдені через частину зв'язків малюнка, спарені з якорями малюнка позиційно, а не за ідентифікатором, тож незвичайний порядок якорів може розсинхронізувати прив'язки діаграм. І потоковий прямий читач у lxDirectRead.pas тримає власний легший шлях обробки, прив'язаний до xl/, тож повний резолвер, описаний тут, керує точками входу TXLSXWorkbook.Open та GetSheetNames, а не шляхом сканування з низьким виділенням, задокументованим у статті про потоковий прямий читач для Delphi
Якщо ти будуєш це самостійно, найкоротше коректне резюме: ніколи не конструюй назву частини, завжди розв'язуй її. Читай _rels/.rels, йди за officeDocument, розв'язуй кожен Target проти частини, що його оголосила, і маршрутизуй аркуші за r:id. Якщо волієш мати це вже перевіреним проти перейменованих частин, неперервної нумерації аркушів та дублікатів ідентифікаторів зв'язків, резолвер, описаний тут, постачається в компоненті електронних таблиць HotXLS для Delphi, разом з механізмом циклічного обміну, що зберігає незайманими частини, які він не парсить