Книга с двумя диаграммами проходит открытие-и-сохранение, не трогающее ни одного символа, и на выходе возможны три разных результата. В одном вместо двух частей диаграмм оказывается три. В другом части по-прежнему две, но побайтовое сравнение показывает, что каждый xl/charts/chartN.xml перезаписан, а цвета серий уехали. Третий хранит XML диаграмм нетронутым, но каждая диаграмма спрыгнула на несколько пикселей к линиям сетки. Excel открывает все три без ремонта. Каждый симптом растёт из одного решения в пути загрузки и сохранения: как рабочий лист находит свой drawing, когда снимается отпечаток изменений диаграммы и записываются ли смещения якорей внутри части drawing обратно
Рабочий лист находит свой drawing через свои связи
Симптом с тремя частями растёт из загрузчика, угадывающего имена частей. В XLSX-пакете рабочий лист указывает на свой drawing элементом <drawing r:id="…"/> (ECMA-376 Part 1 §18.3.1.36), и r:id разрешается через часть .rels листа в цель вроде ../drawings/drawing1.xml. Excel нумерует drawings предсказуемо — просто по случайности, поэтому в самописных ридерах часто вырастает запасной путь: когда связи нет, попробовать xl/drawings/drawing{n}.xml для позиции листа n и взять её, если она есть в архиве
// Shortcut из самописных ридеров XLSX (такой код отправлять в прод нельзя)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
const RelTarget: string): string;
begin
Result := RelTarget; // разрешается из .rels листа, '' когда связи нет
if Result = '' then
begin
Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
if Zip.IndexOf(Result) < 0 then
Result := '';
end;
end;
Догадка держится, пока первым не придёт лист без диаграмм. В книге, у которой первый лист не имеет ни drawing, ни части .rels вовсе, а второй лист достаёт xl/drawings/drawing1.xml через Target="../drawings/drawing1.xml", запасной путь выдаёт листу 1 drawing, на который тот никогда не ссылался. chart1.xml разбирается дважды, по разу на лист, и сохранение пишет три части диаграмм. Имена частей в OPC-пакете не несут смысла помимо того, что назначает им граф связей — это весь аргумент разбора разрешения связей OPC в HotXLS
HotXLS Delphi Component придерживается строгого прочтения. Drawing рабочего листа загружается только из цели связи drawing этого листа, а лист без такой связи не получает drawing вовсе — какие бы файлы ни лежали в xl/drawings/. Часть drawing, на которую ничто не ссылается, никогда не подключается к листу
Что решает, будет ли нетронутая диаграмма скопирована или перестроена?
Решает отпечаток каждой диаграммы. Если PreserveUnsupportedParts включён на момент Open (по умолчанию включён), HotXLS хранит исходные байты каждой части диаграммы рядом с разобранной моделью. Затем он сериализует модель собственным писателем диаграмм и записывает два числа для этого вывода: длину и 64-битный хеш FNV-1a по код-единицам UTF-16 со стандартной базой смещения 14695981039346656037 и простым множителем 1099511628211. TXLSXChart.HasPreservedXml сообщает, хранит ли диаграмма эти исходные байты
При сохранении модель сериализуется заново, и оба числа сравниваются. Равные длина и хеш означают, что модель ровно та же, что была при импорте, то есть ничего из того, что приложение могло изменить, не изменилось. В этом случае при выключенном StrictOOXML писатель копирует сжатую запись прямо из исходного архива в выходной под новым именем части диаграммы; байты не декодируются и не пересжимаются. Если хоть одно число разошлось, исходный XML сливается с моделью. Смена форматирования линии планки погрешностей или линии тренда тоже считается правкой, хотя это форматирование и не входит в сериализованную модель
Слияние — правильная операция для диаграммы, которую кто-то отредактировал. Оно сохраняет те части исходного XML, которые модель не покрывает, даёт модели победить для серий, осей и групп построения и пересериализует результат. Чего оно не умеет — воспроизводить исходные байты. Заметка о редактировании диаграмм Excel без потери ChartML разбирает, что слияние сохраняет; эта статья — об условиях, при которых неотредактированная диаграмма до него вообще не доходит
uses System.SysUtils, lxHandleX;
procedure ResaveUntouched(const Src, Dst: string);
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
S, C: Integer;
begin
Book := TXLSXWorkbook.Create;
try
Book.PreserveUnsupportedParts := True; // значение по умолчанию; должно действовать до Open
Book.StrictOOXML := False; // строгий вывод перегенерирует каждую диаграмму
if Book.Open(Src) <> 1 then
raise Exception.Create('Cannot open ' + Src);
for S := 1 to Book.Sheets.Count do // Sheets[] индексируется с единицы
begin
Sheet := Book.Sheets[S];
for C := 0 to Sheet.Charts.Count - 1 do // Charts[] индексируется с нуля
Writeln(Sheet.Name, ' / ', Sheet.Charts[C].Name, ': original kept = ',
BoolToStr(Sheet.Charts[C].HasPreservedXml, True));
end;
if Book.SaveAs(Dst) <> 1 then
raise Exception.Create('Cannot save ' + Dst);
finally
Book.Free;
end;
end;
Когда диаграмму нужно специально перегенерировать из модели — например, чтобы срезать форматирование, пришедшее из источника, — TXLSXChart.ClearPreservedXml сбрасывает сохранённые байты, и эта диаграмма пишется только из модели
Почему все диаграммы всё равно уходят по пути слияния?
Потому что отпечаток, снятый на один шаг раньше срока, никогда не совпадает. XML диаграммы редко читается за один проход. Потоковый разбор строит серии, оси и группы построения, а затем набор проходов-продолжений вытаскивает из сырого текста диаграммы детали, которые удобнее восстанавливать, когда серии уже есть: флаг <c:smooth> у каждого <c:ser>, значения srgbClr заливки и линии маркера, режимы пересечения осей и стили основных и промежуточных делений. Коллекции серий попросту ещё не существует, пока идёт потоковый проход, поэтому эти проходы обязаны идти после него
Допустим, отпечаток снимается в конце потокового прохода, до проходов-продолжений. Тогда он описывает модель без флагов smooth, без цветов маркеров и с делениями по умолчанию. При сохранении сериализатор работает по завершённой модели и выдаёт <c:smooth val="1"/> плюс восстановленные цвета. XML длиннее, хеш другой — и каждая диаграмма в каждой сохраняемой книге идёт через слияние при каждом сохранении. На линейной диаграмме со сглаженными сериями и своими маркерами перегенерированные узлы <c:ser> заменяют исходные — вот откуда уезжают цвета
HotXLS снимает отпечаток только после того, как завершился каждый проход восстановления, поэтому хеш описывает модель такой, какой её впервые видит приложение. Те же два числа заново устанавливаются после успешного сохранения, на этот раз по выходному файлу, и второе снятие тоже стартует с полностью разобранной модели. Хеш для детекции изменений обязан сниматься после последнего прохода, который ещё может модель изменить. Снятый раньше даёт проверку, которая падает при каждом сохранении
Смещения якорей живут в части drawing
Симптом с прилипанием к сетке не имеет отношения к XML диаграммы. Положение диаграммы на листе хранится в части drawing, и обычная форма — xdr:twoCellAnchor (ECMA-376 Part 1 §20.5): угол from и угол to, каждый с col, colOff, row и rowOff. Смещения заданы в английских метрических единицах, 914400 на дюйм и 12700 на пункт, и Excel пишет ненулевые значения всякий раз, когда диаграмму таскали или растягивали мышью. Для файла, объявляющего угол from в столбце 0, строке 0 со значением rowOff 19049, и угол to в столбце 8, строке 15 со значениями colOff 247650 и rowOff 66674, диаграмма начинается на 1,5 пункта ниже верха строки 1 и заканчивается чуть дальше четверти дюйма внутрь столбца I
Писатель, который читает эти четыре числа, а затем выдаёт <xdr:colOff>0</xdr:colOff> для каждого угла, ставит каждую диаграмму на линии сетки. Часть диаграммы может воспроизводиться байт в байт, и диаграмма всё равно переедет, потому что живут они в разных частях. HotXLS хранит загруженный вид якоря (twoCellAnchor, oneCellAnchor или absoluteAnchor, доступный как PhysicalAnchorKind) вместе с его значениями EMU и записывает их обратно без изменений. Смещения читаются как FromOffsetXEMU, FromOffsetYEMU, ToOffsetXEMU и ToOffsetYEMU, а преобразования единиц за ними разобраны в заметке о геометрии изображений и масштабировании EMU в HotXLS
procedure NudgeChart(Chart: TXLSXChart);
const
EmuPerPoint = 12700;
begin
if Chart.PhysicalAnchorKind <> xlsxAnchorTwoCell then
Exit;
Writeln(Format('from R%dC%d +(%d, %d) EMU to R%dC%d +(%d, %d) EMU',
[Chart.FromRow, Chart.FromCol, Chart.FromOffsetXEMU, Chart.FromOffsetYEMU,
Chart.ToRow, Chart.ToCol, Chart.ToOffsetXEMU, Chart.ToOffsetYEMU]));
// Замените весь кадр одним вызовом: строки и столбцы с единицы,
// смещения — EMU внутри ячейки, последний аргумент — поведение editAs
Chart.SetTwoCellAnchor(Chart.FromRow, Chart.FromCol, Chart.ToRow, Chart.ToCol,
Chart.FromOffsetXEMU + 6 * EmuPerPoint, Chart.FromOffsetYEMU,
Chart.ToOffsetXEMU + 6 * EmuPerPoint, Chart.ToOffsetYEMU, Chart.AnchorType);
end;
SetTwoCellAnchor — для случая, когда цель — передвинуть или растянуть. Присваивание FromRow, FromCol, ToRow или ToCol у диаграммы с двумя якорями меняет только индекс ячейки и оставляет смещения как были — правильно для мелкой подгонки и неправильно для размещения диаграммы с нуля. SetTwoCellAnchor проверяет кадр как целое и поднимает ERangeError, когда конечный угол предшествует начальному или смещения выходят за пределы диапазона EMU Excel. Диаграмма, созданная через AddChart, стартует с нулевыми смещениями, поэтому сидит ровно на границах ячеек, пока вы их не зададите
Доказываем круговой рейс побайтовым сравнением
Открытие вывода в Excel доказывает здесь немногое. Excel при загрузке столько всего нормализует, что пересериализованная диаграмма выглядит идентично ровно до момента, когда кто-нибудь заметит, что цвет маркера изменился, а спрыгивание на пару пунктов легко пропустить на экране. Проверка, которую стоит автоматизировать, сравнивает части диаграмм байт в байт после открытия-и-сохранения без правок, и 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, потому что строгий вывод перегенерирует диаграммы по устройству. Держите его выключенным в любом конвейере, обещающем, что неотредактированные диаграммы переживут сохранение- Исходный пакет, записанный со строгими пространствами имён OOXML, конвертируется в переходные пространства имён при переходном сохранении, поэтому байты его диаграмм различаются, даже если больше ничего не менялось
- Код между
OpenиSaveAsможет свободно читать свойства диаграмм, но любой сеттер, меняющий модель, меняет отпечаток и отправляет диаграмму в слияние — это корректное поведение и не то, что измеряет этот тест - Части диаграмм при сохранении перенумеровываются сквозным счётчиком книги, поэтому книга, у которой изменился порядок листов или диаграмм, положит идентичные байты под другим именем
chartN.xml; проверяющий на этот случай ходит по связям drawing, а не сверяет имена
Якоря нужно сравнивать по значениям. Часть drawing пишется заново вокруг хранимых диаграмм, так что её байты — неправильный объект для диффа; повторное открытие обоих файлов и сравнение FromOffsetXEMU до ToOffsetYEMU по каждой диаграмме ловит писателя, превращающего 247650 в 0. А структурная проверка, обходящая связи рабочих листов, drawing и диаграмм и падающая на любой дублирующей, осиротевшей или висячей ссылке на диаграмму, ловит случай с тремя частями ещё до побайтового сравнения
Всё это работает в нативном коде Delphi и C++Builder без установленного Excel, поэтому книга, вернувшаяся из пакетного задания, — единственное свидетельство того, что случилось с её диаграммами. Лицензирование, полный API диаграмм и пробная сборка — на странице HotXLS Delphi spreadsheet component