Технічна стаття

Розв'язання OPC-зв'язків XLSX у Delphi-парсерах

Валідний 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. Звідти ти йдеш за зв'язком, чий Typehttp://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, разом з механізмом циклічного обміну, що зберігає незайманими частини, які він не парсить