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

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-тества

<!-- _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-овете, които не парсва