Техническа статия

XLSX OPC разрешаване на релации в Delphi парсъри

Валиден 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-тества

Диаграма на валиден XLSX пакет, в който HotXLS за Delphi достига office/book.xml, работен лист с потребителски път и част за коментари чрез OPC relationships, докато всяко конвенционално твърдо кодирано име липсва
Само _rels/.rels е фиксирано име — workbook, worksheet и comment части всички седят където сочат техните relationships
<!-- _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 име, а не до несъществуващ архивен запис; едва тогава функцията разделя абсолютно от относително

Диаграма на потока на XlsxResolveRelationshipTarget, показваща как HotXLS нормализира разделителите, отлепя фрагменти, съединява папката на изходната част и свива сегментите с точки в крайно ZIP име на елемент
Нормализация на разделители, одиране на fragment, свързване спрямо source и обхождане на стек от dot-сегменти превръщат суров Target в архивно-годно име на част
// Ядрото за нормализиране, както е реализирано в 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: премахнете само slash-а
else
begin
  p := LastDelimiter('/', String(OwnerPartName));
  if p > 0 then
    baseName := Copy(OwnerPartName, 1, p)
  else
    baseName := '';
  combined := baseName + combined;    // спрямо папката на source part
end;

source.StrictDelimiter := True;       // само '/'; без обработка на кавички или интервали
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;                         // празните и dot сегментите изчезват
  if segment = '..' then
  begin
    if parts.Count > 0 then
      parts.Delete(parts.Count - 1);  // pop и никога под 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

Диаграма на трите OPC нива, по които HotXLS върви при отваряне на XLSX файлове в Delphi, насочвайки всеки лист по r:id, докато защита „първият печели“ пази срещу дублирани relationship идентификатори
Всеки етаж повтаря един шаблон — открий rels частта и разреши всеки Target — докато листовете се вързват през identifier картата, никога чрез позиционно гадене
// Tier 1: единственото фиксирано име в целия формат
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 за самата част на workbook-а
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 се разрешават чрез URI на relationship type
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: всеки worksheet разрешава собствените си satellites спрямо собственото си име
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-овете, които не парсва