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

Отпечаток диаграмм HotXLS и смещения якорей в Delphi

HotXLS Delphi Component воспроизводит неизменённую диаграмму Excel байт в байт только при двух условиях: диаграмма была достигнута по связи drawing рабочего листа, а не по угаданному имени части, и 64-битный отпечаток модели снят после того, как разбор модели диаграммы завершился. Версия 2.382.0 починила первое условие, версия 2.382.3 — второе и заодно начала сохранять ненулевые смещения якорей xdr:colOff и xdr:rowOff, которые писатель drawing до этого жёстко зашивал в ноль. Оба дефекта вылезли из одного локального случая корпуса, two-charts.xlsx: сначала структурная проверка увидела, что две части диаграмм стали тремя, потом побайтовое сравнение каждого xl/charts/chartN.xml показало, что диаграммы, которых никто не трогал, всё равно перезаписываются, — и ни одна из проблем не бросала исключение и не заставляла Excel ругаться, поэтому они и прожили так долго

Почему книга с двумя диаграммами возвращалась с тремя частями диаграмм?

Потому что у загрузчика был запасной путь, который угадывал. Когда у рабочего листа нет связи drawing в его части .rels, старый код предполагал, что drawing лежит по традиционному имени xl/drawings/drawing{i+1}.xml, где i — позиция листа, и присоединял эту часть, если она была в архиве. В two-charts.xlsx у первого листа нет ни drawing, ни части .rels вообще, а xl/drawings/drawing1.xml существует — он принадлежит второму листу, который достаёт его через Target="../drawings/drawing1.xml". Так лист 1 унаследовал диаграмму, на которую никогда не ссылался, chart1.xml разобрался дважды, а сохранение записало книгу с тремя частями диаграмм вместо двух

Как HotXLS разрешает drawing рабочих листов в примере two-charts: у Sheet1 нет ни связи drawing, ни части rels, а Sheet2 достаёт xl/drawings/drawing1.xml через ParPartTargets, и запасной путь до 2.382.0 угадывал это традиционное имя по позиции листа, так что chart1.xml разбирался дважды и сохранения писали три части диаграмм, пока фикс не начал загружать drawing только через XlsxRtDrawing
Sheet1 никогда не ссылался на диаграмму, поэтому граф связей — единственный надёжный источник цели drawing, а угаданное традиционное имя превратило книгу с двумя диаграммами в сохранение с тремя частями

Фикс в HotXLS v2.382.0 убрал догадку полностью. Теперь drawing рабочего листа загружается только через ParPartTargets[i].Values[XlsxRtDrawing] — цель, записанную для типа связи drawing на этом листе, — а лист без такой связи не получает drawing вообще. Формат требует именно такого поведения: элемент <drawing r:id="…"/> в рабочем листе (ECMA-376 Part 1 §18.3.1.36) — единственная связь между листом и его drawing, а имена частей в OPC-пакете не значат ничего сверх того, что им назначает граф связей. Архивы, записанные Excel, случайно используют традиционные имена — вот почему этот короткий путь так долго сходил с рук; разбор разрешения связей OPC в HotXLS объясняет, почему угадывать имя части небезопасно всегда, даже когда догадка обычно верна

// До v2.382.0: отсутствующая связь drawing откатывалась к догадке
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // может принадлежать другому листу

// Начиная с v2.382.0: связь или ничего
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

Что гарантирует отпечаток диаграммы?

Отпечаток решает по каждой диаграмме, может ли сохранение скопировать исходную часть или обязано её перегенерировать. При импорте, если PreserveUnsupportedParts включён до Open, HotXLS хранит сырые байты UTF-8 каждой части диаграммы в FRawChartXml, строит собственную сериализацию типизированной модели через BuildChartKnownXml и записывает длину этой сериализации в FRawChartModelLength, а её хеш — в FRawChartModelHash. Хеш — это FNV-1a по код-единицам UTF-16 сгенерированного XML со стандартной 64-битной базой смещения 14695981039346656037 и простым множителем 1099511628211. При сохранении XlsxChartRawModelUnchanged заново строит известный XML и сравнивает длину с хешем; совпадение означает, что типизированная модель ровно такая, какой была при импорте, то есть ничего из того, что приложение могло изменить, не изменилось

HotXLS снимает отпечаток диаграммы при импорте, держа сырые байты UTF-8 в FRawChartXml, пока BuildChartKnownXml даёт FRawChartModelLength и хеш FNV-1a, а при сохранении XlsxChartRawModelUnchanged заново строит и сравнивает оба значения, так что совпадение воспроизводит исходные байты или копирует сжатую запись, а несовпадение уходит в XlsxMergeChartXml
Отпечаток хорош ровно настолько, насколько удачен момент его снятия, а снятие до завершения всех проходов восстановления гарантирует хеш, который больше никогда не совпадёт с готовой моделью
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
  const KnownXml: WideString): Boolean;
begin
  Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
    (Length(KnownXml) = Chart.FRawChartModelLength) and
    (XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;

function BuildChartXmlFromKnown(Chart: TXLSXChart;
  const KnownXml: WideString): WideString;
begin
  if Chart.FRawChartXml = '' then
    Result := KnownXml                                  // ничего не сохранено
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // дословное воспроизведение
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // структурное слияние
end;

Писатель XLSX идёт на шаг дальше BuildChartXmlFromKnown. Когда модель не изменилась и StrictOOXML выключен, он сначала пытается скопировать сжатую запись напрямую из исходного архива в выходной под новым именем части диаграммы, так что байты даже не декодируются и не пересжимаются. Только если такая копия невозможна, он проваливается на путь декодирования или слияния. Сам механизм — длина плюс хеш, воспроизведение при равенстве, слияние при неравенстве — это тот, что описан в заметке про редактирование диаграмм Excel без потери ChartML. Эта статья о том, как он тихо перестал работать

Почему все диаграммы всё равно шли по пути слияния?

Потому что отпечаток снимался на один вызов слишком рано. Разбор диаграммы в HotXLS — это проход SAX по части диаграммы, за которым идёт набор проходов восстановления, вытаскивающих из сырого текста детали, которые обработчики SAX напрямую не моделируют: XlsxChartParseSeriesFlags читает каждый блок <c:ser> ради его флага <c:smooth> и значений srgbClr заливки и линии маркера, а затем восстанавливает режимы пересечения осей и стили основных и промежуточных делений для оси категорий и оси значений. До v2.382.3 порядок в конце ParseChartXml был такой: классифицировать группы осей, построить известный XML, снять длину и хеш и только потом запустить XlsxChartParseSeriesFlags. То есть отпечаток описывал модель, в которой ещё не было ни флагов smooth, ни цветов маркеров, ни делений. При сохранении BuildChartKnownXml отрабатывал по завершённой модели, которая теперь выдавала <c:smooth val="1"/> и восстановленные цвета маркеров. XML длиннее, хеш другой, XlsxChartRawModelUnchanged возвращал False, и диаграмма уходила в XlsxMergeChartXml. Слияние — корректная операция для диаграммы, которую кто-то редактировал, но оно не сохраняет байты: дерево сериализуется заново, а правило владения, по которому типизированная модель побеждает для серий, осей и групп построения, означает, что перегенерированные узлы заменяют исходные. Видимый результат прогона по корпусу — уехавшие цвета серий на диаграммах, которых никто не редактировал: каждая диаграмма в каждой сохранённой книге, при каждом сохранении, и ни одной диагностики где-либо

Починка — это одна перестановка: XlsxChartParseSeriesFlags теперь выполняется до построения известного XML, так что отпечаток описывает модель такой, какой она будет, когда приложение впервые её увидит. Урок выходит за пределы диаграмм. Отпечаток для детекции изменений хорош ровно настолько, насколько удачен момент его снятия, а безопасный момент — после того, как завершились все проходы, способные модель изменить. У тех же двух значений в HotXLS есть второе место снятия — базовая линия, которую библиотека заново устанавливает относительно выходного файла после успешного сохранения, — и там отпечаток всегда снимался с полностью разобранной модели; исключением было именно место при импорте

Куда делись смещения якорей?

В литеральный ноль. twoCellAnchor в части drawing привязывает диаграмму между двумя ячейками, и каждый угол несёт индекс ячейки плюс смещение внутри неё: from (ECMA-376 Part 1 §20.5.2.5) и to (§20.5.2.32) содержат col, colOff (§20.5.2.4), row и rowOff. Смещения заданы в английских метрических единицах, 914400 на дюйм, и Excel пишет ненулевые значения всякий раз, когда диаграмму ставили или растягивали мышью, то есть почти всегда. Первая диаграмма в two-charts.xlsx начинается со строки 0 со значением rowOff 19049 и заканчивается на столбце 8, строке 15 со значениями colOff 247650 и rowOff 66674 — примерно четверть дюйма внутрь последнего столбца. Парсер drawing в HotXLS читал эти четыре значения всегда — код изображений ими пользовался, — но писатель диаграмм выдавал <xdr:colOff>0</xdr:colOff> и <xdr:rowOff>0</xdr:rowOff> для каждого угла, притягивая каждую диаграмму к сетке ячеек при сохранении

Анатомия углов xdr:twoCellAnchor для первой диаграммы примера HotXLS: from содержит col 0 и rowOff 19049, а to содержит col 8, colOff 247650 и rowOff 66674 в EMU из 914400 на дюйм, и писатель, выдававший нулевые смещения, притягивал диаграммы к сетке, пока FFromColOff, FToColOff и их соседи не начали воспроизводить импортированные значения
Якорь живёт в части drawing, а не в части диаграммы, поэтому эта починка не зависит от фикса отпечатка, и обе должны были выйти, прежде чем книга действительно поехала туда-обратно
// Начиная с v2.382.3 писатель якорей воспроизводит импортированные смещения EMU
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
  IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
  '<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...

Теперь TXLSXChart несёт FFromColOff, FFromRowOff, FToColOff и FToRowOff, заполняемые из парсера drawing и копируемые вместе с остальным состоянием якоря, когда диаграмма присваивается. Они намеренно приватные: публичная поверхность якоря — это по-прежнему четыре координаты ячейки FromRow, FromCol, ToRow и ToCol, и диаграмма, созданная из кода Delphi, как и раньше, садится на границы ячеек. Смещения существуют, чтобы круговой рейс был точным, а не чтобы выставить внутриячеечное позиционирование как фичу. Обратите внимание, что этот фикс не зависит от отпечатка: якорь живёт в части drawing, а не в части диаграммы, так что диаграмма, чей ChartML воспроизводился идеально, всё равно прыгнула бы на сетку без него. Преобразования единиц, стоящие за этими значениями EMU, разобраны в заметке про геометрию изображений и масштабирование EMU в HotXLS

Как доказать, что диаграмма едет туда-обратно без изменений?

Сравнением байтов, а не открытием результата в Excel. Excel при загрузке столько всего ремонтирует и нормализует, что уехавшая диаграмма выглядит нормально ровно до момента, когда аналитик замечает, что цвет маркера изменился. Тест по корпусу, поймавший оба дефекта, делает после открытия и сохранения без правок три вещи: обходит связи рабочих листов, drawing и диаграмм и падает на любой дублирующей, осиротевшей или висячей ссылке на диаграмму; сравнивает подпись из типа диаграммы, формул серий и геометрии якоря между оригиналом и выводом; а для two-charts.xlsx читает каждый xl/charts/chartN.xml из обоих архивов и требует идентичных байтов. Ту же проверку легко написать на Delphi через RTL-класс TZipFile

uses System.Zip, System.SysUtils;

function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
  Src, Dst: TZipFile;
  Name: string;
  A, B: TBytes;
begin
  Result := True;
  Src := TZipFile.Create;
  Dst := TZipFile.Create;
  try
    Src.Open(Original, zmRead);
    Dst.Open(Resaved, zmRead);
    for Name in Src.FileNames do
      if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
      begin
        Src.Read(Name, A);
        Dst.Read(Name, B);   // бросит исключение, если часть исчезла
        if (Length(A) <> Length(B)) or
           ((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
        begin
          Writeln('changed: ', Name);
          Result := False;
        end;
      end;
  finally
    Dst.Free;
    Src.Free;
  end;
end;

Сравнение осмысленно при трёх условиях, и каждое тихо ломается, если о нём забыть. PreserveUnsupportedParts должен быть True до Open, иначе сырые байты не снимаются и каждая диаграмма строится из модели заново. StrictOOXML должен быть False, потому что строгий режим по своему устройству заставляет перегенерировать. И приложение не должно трогать диаграмму между открытием и сохранением: читать свойства можно, но любой сеттер, меняющий типизированную модель, переворачивает отпечаток и отправляет диаграмму по пути слияния — это корректное поведение, но не то, что проверяет тест. К тому же части диаграмм при сохранении перенумеровываются из сквозного счётчика книги, так что книга, у которой изменился порядок листов или диаграмм, положит идентичные байты под другим именем chartN.xml; именно поэтому проверка по корпусу ходит по связям, а не по именам

Оба фикса вышли в HotXLS 2.382.0 и 2.382.3 и проверены на Win32 и Win64 на локальном корпусе, а сохранённые примеры с диаграммами дополнительно отрендерены независимым офисным пакетом в PDF и сравнены с оригиналами постранично. HotXLS читает, редактирует и пишет диаграммы XLSX из нативного кода Delphi и C++Builder без установленного Excel — именно поэтому такая точность становится обязанностью библиотеки; на странице компонента HotXLS Delphi для работы с электронными таблицами есть список возможностей и пробная версия