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

Розв'язання 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 проводить регресійні тести

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

Потокова діаграма XlsxResolveRelationshipTarget: як HotXLS нормалізує розділювачі, зрізає фрагменти, приєднує каталог частини-джерела і згортає точкові сегменти в фінальне ім'я елемента ZIP
Нормалізація розділювачів, здирання фрагмента, приєднання відносно джерела й прохід стеком крізь крапкові сегменти обертають сирий 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)              // абсолютний щодо пакета: прибрати лише слеш
else
begin
  p := LastDelimiter('/', String(OwnerPartName));
  if p > 0 then
    baseName := Copy(OwnerPartName, 1, p)
  else
    baseName := '';
  combined := baseName + combined;    // відносний до папки вихідної частини
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;                         // порожні сегменти та сегменти з крапкою зникають
  if segment = '..' then
  begin
    if parts.Count > 0 then
      parts.Delete(parts.Count - 1);  // вилучати елементи, але ніколи не нижче кореня
  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, приземляється на правильний блоб

Діаграма трьох ярусів OPC, які HotXLS проходить при відкритті файлів XLSX у Delphi, маршрутизуючи кожен аркуш за r:id, тоді як охорона «перший виграє» захищає від дублікатів ідентифікаторів відношень
Кожен ярус повторює один патерн — знайти частину rels і розв'язати кожен Target, — тоді як аркуші прив'язуються через мапу ідентифікаторів, а не через позиційне вгадування
// Рівень 1: єдине фіксоване ім’я в усьому форматі
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
  WorkbookPartName := 'xl/workbook.xml';        // legacy fallback
if not zip.Exists(WorkbookPartName) then
  Exit;

// Рівень 2: <folder>/_rels/<name>.rels для самої частини книги
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParsePartRelationshipsXml(relsStream, WorkbookPartName,
      WorkbookTargetById, WorkbookTargetsByType);
  finally
    relsStream.Free;
  end;
end;

// Типізовані одиночні елементи розв’язуються за 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));

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