Валиден xlsx не е задължен да съдържа xl/worksheets/sheet1.xml. HotXLS, нативният Excel spreadsheet компонент за Delphi и C++Builder, локализира всяка part чрез графа на OPC релации, вместо да гадае имена, защото ISO/IEC 29500-2 гарантира само, че part-овете са достижими от _rels/.rels, никога че седят на конвенционални пътища
Защо моят parser се проваля на валиден xlsx?
Защото имената на part-ове, които сте запомнили, са конвенция на един производител, не изискване на формата. Всеки път, който някога сте hardcode-нали, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, е това, което desktop Excel writer-ът се случва да излъчва. Съвместим пакет може да постави workbook на office/book.xml, а първия worksheet на xl/custom/data-sheet.xml, и пак да е легитимен SpreadsheetML, стига релациите да сочат натам. Това е единствената най-честа причина домашно приготвен четец да докладва "не намирам sheet1.xml" за файл, който Excel, LibreOffice и Numbers отварят без оплакване
Производителите, правещи това, не са екзотични. Server-side генератори на отчети преизползват template пакет и пазят оригиналното му оформление. Export pipeline-и, сливащи два workbook-а, преномерират листове и оставят дупки, така че петлистов workbook има sheet1, sheet2, sheet4, sheet7, и sheet9. Инструменти, изтриващи лист, не винаги преномерират оцелелите. Във всеки от тези случаи предположението, базирано на индекс, xl/worksheets/sheet + IntToStr(i + 1) + .xml, тихо чете грешния лист или не чете нищо, което е по-лошо от изключение, защото workbook се зарежда, а числата са грешни. Минималният пакет по-долу упражнява целия проблем, и е формата, срещу която HotXLS regression-тества
<!-- _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 на стандарта, а нейната клауза за релации дефинира точно една фиксирана входна точка: part-а с релации на пакета на _rels/.rels. Оттам следвате релацията, чийто Type е http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument, за да достигнете workbook part-а, а всеки друг part се открива, четейки собствения part с релации на този part и следвайки типизирани ръбове навън
Две допълнителни правила от същия стандарт вършат истинската работа. Клаузата за именуване на part-ове фиксира къде живее part с релации: за part на <folder>/<name>, неговите релации са на <folder>/_rels/<name>.rels, а за part в корена на пакета папката е просто _rels/. Клаузата за markup на релации заявява, че Target е URI референция, разрешена спрямо URI на source part-а, в обичайния смисъл на RFC 3986, освен ако TargetMode="External" го не маркира като сочещ извън пакета. Разрешаването относно source-а е стъпката, която всички пропускат, и е защо същият буквален ../notes/review.xml означава едно нещо вътре в xl/custom/_rels/data-sheet.xml.rels и нещо съвсем различно вътре в rels файл една папка по-дълбоко. Едно последно усложнение седи между логическия модел и байтовете на диска: имената на part-ове в логическия модел са абсолютни и започват с наклонена черта, но клаузата за физическото ZIP мапиране маха тази черта, когато превръща part име в ZIP елемент име, така че resolver, забравящ това, търси /xl/sharedStrings.xml в архива и не намира нищо
Вътре в XlsxResolveRelationshipTarget
HotXLS концентрира цялото правило за разрешаване в една функция, XlsxResolveRelationshipTarget, декларирана в lxHandleX.pas като function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Тя приема ZIP елемент името на source part-а и суровия атрибут Target, и връща ZIP елемент име без водеща наклонена черта, готово да бъде подадено директно на архива. Подаването на празен OwnerPartName разрешава спрямо корена на пакета, което е точно от каквото се нуждае part-ът с релации на пакета. Редът на операциите има по-голямо значение от индивидуалните стъпки: обратните наклонени черти се нормализират към прави първо, защото някои производители пишат Windows разделители в Target; всеки fragment, въведен от #, се отрязва преди обработка на пътя, така че ../charts/chart1.xml#Sheet1 се разрешава до part име, а не до несъществуващ архивен запис; едва тогава функцията разделя абсолютно от относително
// 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 не е козметично. Без него Delphi TStringList третира интервалите като разделители и уважава кавички, което осакатява всяко part име, съдържащо интервал, а part имена с интервали са легални
Следвайки графа: workbook, worksheet, drawing
HotXLS обхожда три нива part-ове с релации по пътя TXLSXWorkbook.Open. Нивото на пакета се обработва от XlsxFindOfficeDocumentPart, който чете _rels/.rels и връща целта officeDocument. Нивото на workbook чете part-а с релации на workbook и изгражда две карти наведнъж: карта на идентификатори за справки по r:id, и карта на типове за единични part-ове. Нивата на worksheet и drawing повтарят шаблона с ParseWorksheetRelsXml и ParseDrawingRelsXml, всяко подавайки собственото си part име като база за разрешаване, така че drawing, реферираща ../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> в part-а на workbook носят атрибути r:id, и този идентификатор е единственото, свързващо име на лист с part. HotXLS събира тези идентификатори по време на ParseWorkbookXml и разрешава всеки спрямо картата на релациите на workbook, падайки обратно на конвенционалното номерирано име само когато идентификаторът липсва или е неразрешим
// 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 проектът под типа с namespace на Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, външни връзки, workbook-обхванатата person part, legacy коментари, threaded коментари, VML drawing-ът, носещ геометрията на балоните с коментари, drawing-и, изображения, диаграми, таблици и PivotTables — всички достигат байтовете си чрез разрешени targets. Темата в частност трябва да бъде локализирана правилно, иначе round-trip тихо презаписва брандирана палитра на клиент със стандартната тема на Office, един от режимите на провал, разгледан в бележките за безпотерен XLSX round-trip на theme, extLst и calcChain. Четенето на релации е и защо зареждането е поетапно по този начин: целият достъп до архива се случва на една нишка, преди worksheet XML да бъде парснат, защото inflate състоянието на ZIP архив не е thread-safe, ограничение, обяснено в статията за паралелно XLSX парсване и memory allocator-а
Защо дублиран rId чупи маршрутизацията по тип?
Защото по-късен малформиран запис може да презапише по-ранен валиден и да отвлече справката. Идентификаторите на релации би трябвало да са уникални в рамките на part с релации, но малформирани пакети ги преизползват, а наивно присвояване Values[Id] := е последно-печели. Ако rId3 първо сочи към истински worksheet, а втори rId3 сочи към неподдържан или празен target, последно-печели губи worksheet-а. ParsePartRelationshipsXml затова прилага правило първо-печели с две условия: разрешеният target трябва да е непразен, и идентификаторът не трябва да е вече наличен. И двете условия заедно правят това безопасно, защото проверката за непразен спира релация с липсващ 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. Тази разлика е load-bearing: workbook има точно една релация за споделени низове, но много релации за worksheet и external-link, така че справка по тип чрез Values[] връща първото съвпадение за единични part-ове, а многозначни типове като externalLink се изброяват чрез обхождане на списъка
Където спира следването на релации
Честните граници имат по-голямо значение от чиста история. HotXLS пада обратно на конвенционални имена винаги, когато релация липсва, така че пакет с повреден или липсващ part с релации все още се отваря, ако случайно следва оформлението на Excel; този fallback е функция за съвместимост, не втори източник на истина, и може да прикрие бъг на производителя по време на тестване. Три допълнителни лимита си струва да се знаят. Targets, маркирани с TargetMode="External", се съхраняват буквално, а не разрешени, което е правилно за hyperlinks и за релацията externalLinkPath, носеща отдалечен URL на workbook, но означава, че стойността, която получавате обратно, е каквото и да е производителят написал. Chart part-ове, открити чрез part с релации на drawing, се сдвояват с drawing anchors позиционно, а не по идентификатор, така че необичаен ред на anchors може да разкоордира свързването на диаграми. И streaming direct reader-ът в lxDirectRead.pas пази собствен по-лек път за обработка, ключуван към xl/, така че пълният resolver, описан тук, управлява входните точки TXLSXWorkbook.Open и GetSheetNames, не пътя за сканиране с ниско разпределение на памет, документиран в статията за streaming direct reader за Delphi
Ако изграждате това сами, най-краткото правилно резюме е: никога не конструирайте part име, винаги разрешавайте такова. Прочетете _rels/.rels, следвайте officeDocument, разрешете всеки Target спрямо part-а, декларирал го, и маршрутизирайте листовете по r:id. Ако предпочитате това вече тествано срещу преименувани part-ове, несъседна номерация на листове и дублирани идентификатори на релации, resolver-ът, описан тук, се доставя в HotXLS Delphi spreadsheet компонент, заедно с round-trip машинерията, пазеща недокоснати part-овете, които не парсва