HotXLS Delphi Component відтворює незмінену діаграму Excel байт за байтом лише за двох умов: діаграму знайдено через зв'язок малюнка аркуша, а не через вгадану назву частини, і 64-бітний відбиток моделі знято вже після того, як розбір моделі діаграми завершився. Версія 2.382.0 виправила першу умову, версія 2.382.3 — другу, і заодно почала відтворювати ненульові зміщення якорів xdr:colOff та xdr:rowOff, які записувач малюнків досі жорстко зашивав нулями. Обидва дефекти випливли з одного локального кейсу корпусу, two-charts.xlsx: спершу структурна перевірка побачила, як дві частини діаграм стали трьома, а потім порівняння байтів кожного xl/charts/chartN.xml показало, що діаграми, яких ніхто не торкався, все ще перезаписуються — і жодна з проблем не кидала винятку та не змушувала Excel скаржитися, саме тому вони й протрималися так довго
Чому книга з двома діаграмами поверталася з трьома частинами?
Бо в завантажувачі був fallback, який вгадував. Коли аркуш не мав зв'язку малюнка у своїй частині .rels, старий код припускав, що малюнок лежить за звичною назвою xl/drawings/drawing{i+1}.xml, де i — позиція аркуша, і приєднував цю частину, якщо вона існувала в архіві. У two-charts.xlsx перший аркуш не має ні малюнка, ні частини .rels взагалі, тоді як xl/drawings/drawing1.xml існує — він належить другому аркушу, який дістається до нього через Target="../drawings/drawing1.xml". Тож аркуш 1 успадкував діаграму, на яку ніколи не посилався, chart1.xml розібрали двічі, а збереження записало книгу з трьома частинами діаграм замість двох
Виправлення в HotXLS v2.382.0 прибрало вгадування повністю. Малюнок аркуша тепер завантажується лише через ParPartTargets[i].Values[XlsxRtDrawing] — ціль, записану для типу зв'язку малюнка на цьому аркуші, — а аркуш без такого зв'язку не отримує малюнка взагалі. Це та поведінка, якої вимагає формат: елемент <drawing r:id="…"/> в аркуші (ECMA-376 Part 1 §18.3.1.36) — єдине посилання між аркушем і його малюнком, а назви частин в OPC-пакеті не несуть жодного сенсу понад те, що їм призначив граф зв'язків. Архіви, які пише Excel, випадково використовують звичні назви — саме це й дозволяло шорткату так довго проходити; докладніше про те, чому вгадувати назву частини ніколи не безпечно навіть тоді, коли здогад зазвичай правильний, — у статті про розв'язання зв'язків OPC у HotXLS
// До v2.382.0: відсутній зв'язок малюнка відкочувався до здогаду
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 у частині малюнка пришпилює діаграму між двома клітинками, і кожен кут несе індекс клітинки плюс зміщення всередині неї: 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 — це десь чверть дюйма в останню колонку. Парсер малюнків у 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, заповнені з парсера малюнків і скопійовані разом з іншим станом якоря, коли діаграму призначають. Вони навмисно приватні: публічна поверхня якоря — це досі чотири координати клітинок FromRow, FromCol, ToRow і ToCol, а діаграма, створена з коду Delphi, як і раніше, приземляється на межі клітинок. Зміщення існують, щоб зробити round-trip вірним, а не щоб виставити субклітинкове позиціонування як фічу. Зауважте, що це виправлення не залежить від відбитка: якір живе в частині малюнка, а не в частині діаграми, тож діаграма, чий ChartML відтворювався бездоганно, все одно стрибнула б до сітки без нього. Перетворення одиниць за тими значеннями EMU розібрано в замітці про геометрію зображень HotXLS і масштабування EMU
Як довести, що діаграма відтворюється без змін?
Порівнянням байтів, а не відкриттям результату в Excel. Excel так багато лагодить і нормалізує при завантаженні, що діаграма, яка попливла, виглядає цілком нормально рівно доти, доки аналітик не помітить, що колір маркера змінився. Корпусний тест, який упіймав обидва дефекти, робить після відкриття й збереження без жодних правок три речі: проходить зв'язки аркушів, малюнків і діаграм і падає на будь-якому дубльованому, осиротілому чи висячому посиланні на діаграму; порівнює підпис із типу діаграми, формул серій і геометрії якоря між оригіналом і виводом; а для 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 spreadsheet component є список можливостей і тріальна версія