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 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 и сравнивает длину с хешем; совпадение означает, что типизированная модель ровно такая, какой была при импорте, то есть ничего из того, что приложение могло изменить, не изменилось
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> для каждого угла, притягивая каждую диаграмму к сетке ячеек при сохранении
// Начиная с 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 для работы с электронными таблицами есть список возможностей и пробная версия