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

Разрешение 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 и совсем другое внутри файла 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 листа парсится, потому что состояние распаковки 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. Если вы предпочли бы иметь это уже протестированным против переименованных частей, несмежной нумерации листов и дублирующихся идентификаторов связей, описанный здесь резолвер поставляется в Delphi-компоненте электронных таблиц HotXLS, наряду с механикой циклического сохранения, сохраняющей нетронутыми части, которые он не парсит