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

HotXLS в Delphi: chart fingerprint и anchor отмествания

HotXLS Delphi Component възпроизвежда немодифицирана Excel графика байт по байт само когато две условия са налице: графиката е достигната през worksheet drawing релацията, а не през предположено име на part, и 64-битовият model fingerprint е уловен след като chart моделът е приключил парсването. Версия 2.382.0 оправи първото условие, версия 2.382.3 оправи второто и започна да round-trip-ва ненулевите anchor отмествания xdr:colOff и xdr:rowOff, които drawing writer-ът hard-code-ваше на нула. И двата дефекта излязоха от един локален corpus случай, two-charts.xlsx: първо една структурна assertion видя как две chart части станат три, после байтово сравнение на всеки xl/charts/chartN.xml показа графики, които никой не беше пипнал, но продължаваха да се пренаписват — и нито единият проблем не вдигна exception, нито накара Excel да се оплаче, затова оцеляха толкова дълго

Защо работна книга с две графики се върна с три chart части?

Защото loader-ът имаше fallback, който познаваше. Когато един worksheet нямаше drawing релация в своя .rels part, старият код предполагаше, че drawing-ът живее на конвенционалното име xl/drawings/drawing{i+1}.xml, където i е позицията на листа, и прикачваше тази част, ако тя съществуваше в архива. В two-charts.xlsx първият лист няма drawing и изобщо няма .rels част, докато xl/drawings/drawing1.xml съществува — той принадлежи на втория лист, който го достига чрез Target="../drawings/drawing1.xml". Лист 1 следователно наследи графика, която никога не беше реферирал, chart1.xml беше парснат два пъти, а записът изведе работната книга с три chart части вместо две

Как HotXLS резолвне worksheet drawings в примера two-charts: Sheet1 няма drawing релация и няма rels част, докато Sheet2 достига xl/drawings/drawing1.xml чрез ParPartTargets, а fallback-ът преди 2.382.0 познаваше това конвенционално име от позицията на листа, така че chart1.xml беше парснат два пъти и записите извеждаха три chart части, докато поправката не започна да зарежда drawings само чрез XlsxRtDrawing
Sheet1 никога не е реферирал графика, така че relationship графът е единственият безопасен източник за drawing целта, а предположено конвенционално име превърна работна книга с две графики в запис с три части

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

// Преди 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);

Какво гарантира chart fingerprint-ът?

Fingerprint-ът решава, за всяка графика поотделно, дали записът може да копира оригиналната част или трябва да я регенерира. При импорт, с PreserveUnsupportedParts включено преди Open, HotXLS пази суровите UTF-8 байтове на всяка chart част в FRawChartXml, изгражда собствената сериализация на typed модела с BuildChartKnownXml и записва дължината на тази сериализация в FRawChartModelLength, а hash-ът ѝ — в FRawChartModelHash. Hash-ът е FNV-1a върху UTF-16 code unit-ите на генерирания XML, със стандартния 64-битов offset basis 14695981039346656037 и простото число 1099511628211. По време на запис XlsxChartRawModelUnchanged пресъздава known XML-а и сравнява дължина и hash; съвпадение означава, че typed моделът е точно това, което беше при импорт, значи нищо, което приложението е можало да промени, не е променено

HotXLS улавя chart fingerprint при импорт, пазейки суровите UTF-8 байтове в FRawChartXml, докато BuildChartKnownXml дава FRawChartModelLength и FNV-1a hash, а по време на запис XlsxChartRawModelUnchanged пресъздава и сравнява двете стойности, така че съвпадение възпроизвежда оригиналните байтове или копира компресирания entry, а несъответствие отива към XlsxMergeChartXml
Fingerprint-ът е толкова добър, колкото моментът, в който е уловен, а улавянето му преди всички recovery преминавания да са приключили гарантира hash, който никога повече не съвпада с завършения модел
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)   // дословен replay
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // структурен merge
end;

XLSX writer-ът върви една стъпка по-далеч от BuildChartXmlFromKnown. Когато моделът е непроменен и StrictOOXML е изключен, той първо се опитва да копира компресирания entry направо от source архива в изхода под новото име на chart частта, така че байтовете дори не се декодират и пре-дефлират. Само ако това копиране не е възможно, пътят продължава към decode-или-merge варианта. Самият механизъм — дължина плюс hash, replay при равенство, merge при различие — е описаният в бележката за редактиране на Excel графики без загуба на ChartML. Тази статия е за начина, по който той тихо спря да работи

Защо тогава всяка графика поемаше по merge пътя?

Защото fingerprint-ът беше улавян едно извикване твърде рано. Парсването на графики в HotXLS е SAX преминаване върху chart частта, последвано от набор recovery преминавания, които изваждат от суровия текст детайли, които SAX handler-ите не моделират директно: XlsxChartParseSeriesFlags чете всеки <c:ser> блок за неговия флаг <c:smooth> и srgbClr стойностите на marker fill и marker line, след което възстановява axis crossing режимите и стиловете на major и minor tick marks за category и value осите. Преди v2.382.3 редът в края на ParseChartXml беше: класифицирай axis групите, изгради known XML, улови дължина и hash и чак тогава пусни XlsxChartParseSeriesFlags. Fingerprint-ът следователно описваше модел, на който още му липсваха smooth флаговете, marker цветове и tick marks. По време на запис BuildChartKnownXml вървеше върху завършения модел, който вече излъчваше <c:smooth val="1"/> и възстановените marker цветове. По-дълъг XML, различен hash, XlsxChartRawModelUnchanged връщаше False, а графиката минаваше през XlsxMergeChartXml. Merge-ът е коректна операция за графика, която някой е редактирал, но не е запазваща байтовете: той ресериализира дървото, а правилото за собственост, което позволява на typed модела да печели за series, оси и plot групи, означава, че регенерираните възли заместват оригиналите. Видимият резултат в corpus пробегът беше изместени series цветове по графики, които никой не беше пипнал — всяка графика във всяка запазена работна книга, при всеки запис, без никаква диагностика никъде

Поправката е едно единствено пренареждане: XlsxChartParseSeriesFlags вече върви преди known XML-ът да е изграден, така че fingerprint-ът описва модела така, както той ще съществува, когато приложението го види за първи път. Урокът се обобщава отвъд графиките. Fingerprint за засичане на промени е толкова добър, колкото моментът, в който е уловен, а безопасният момент е след като всяко преминаване, което може да мутира модела, е приключило. HotXLS има втора позиция на улавяне на същите две стойности — baseline-ът, който възстановява спрямо изходния файл след успешен запис — и тази позиция винаги е работила върху напълно парснат модел; импортната беше странното изключение

Накъде отидоха anchor отместванията?

В буквална нула. twoCellAnchor в drawing частта закача графика между две клетки, като всеки ъгъл носи клетъчен индекс плюс отместване вътре в тази клетка: from (ECMA-376 Part 1 §20.5.2.5) и to (§20.5.2.32) держат всяко по col, colOff (§20.5.2.4), row и rowOff. Отместванията са в English Metric Units, 914400 на инч, а Excel записва ненулеви стойности винаги, когато графиката е поставена или преоразмерена с мишката, което е повечето графики. Първата графика в two-charts.xlsx започва на ред 0 с rowOff 19049 и свършва на колона 8, ред 15 с colOff 247650 и rowOff 66674 — около четвърт инч навътре в последната колона. Drawing parser-ът в HotXLS винаги е четял тези четири стойности — image кодът ги ползваше — но chart writer-ът излъчваше <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 на инч, а writer-ът, излъчвал нулеви отмествания, залепяше графиките към мрежата, докато FFromColOff, FToColOff и техните събратя не възпроизведоха импортираните стойности
Anchor-ът живее в drawing частта, а не в chart частта, така че тази поправка е независима от тази на fingerprint-а, и двете трябваше да излязат, преди работната книга наистина да round-trip-ва
// От v2.382.3 anchor writer-ът възпроизвежда импортираните 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 parser-а и копирани заедно с останалото anchor състояние, когато графика е присвоява. Те са нарочно private: публичната anchor повърхност си остава четирите клетъчни координати FromRow, FromCol, ToRow и ToCol, а графика, създадена от Delphi код, каца на клетъчни граници както преди. Отместванията съществуват, за да направят round trip-ът верен, а не за да излагат sub-cell позиционирането като функционалност. Обърнете внимание, че тази поправка е независима от fingerprint-а: anchor-ът живее в drawing частта, не в chart частта, така че графика, чийто ChartML се възпроизвеждаше идеально, пак би скачала към мрежата без нея. Конверсиите на единици зад тези EMU стойности са разгледани в бележката за image геометрията и EMU скалирането в HotXLS

Как доказвате, че една графика преминава round-trip непроменена?

Чрез сравняване на байтове, не чрез отваряне на резултата в Excel. Excel толкова много поправя и нормализира при зареждане, че изместена графика изглежда наред чак до момента, в който аналитик забележи, че marker цветът се е променил. Corpus тестът, който хвана и двата дефекта, прави три неща след open-and-save без редакции: обхожда worksheet, drawing и chart релациите и се проваля при всяко дублирано, осиротяло или висящо chart упоменание; сравнява сигнатура от chart тип, series формули и anchor геометрия между оригинал и изход; и за 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);   // вдига exception, ако частта е изчезнала
        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, защото strict режимът принуждава регенерация по замисъл. И приложението не трябва да пипа графиката между отваряне и запис — четенето на свойства е ок, но всеки setter, който променя typed модела, обръща fingerprint-а и праща графиката по merge пътя, което е коректно поведение и не е това, за което е този тест. Chart частите освен това се преименуват от брояч за цялата работна книга при запис, така че работна книга, чийто ред на листовете или на графиките е сменен, ще постави идентични байтове под различно име chartN.xml; corpus checker-ът следва релациите, а не имената, точно заради това

И двете поправки излязоха в HotXLS 2.382.0 и 2.382.3 и са проверени на Win32 и Win64 срещу локалния corpus, като презаписаните chart образци са рендерирани и през независим office suite към PDF и сравнени страница по страница с оригиналите. HotXLS чете, редактира и записва XLSX графики от нативен Delphi и C++Builder код без никаква инсталация на Excel, което прави това ниво на дословност отговорност на библиотеката — страницата на HotXLS Delphi spreadsheet компонента има списъка с функционалности и trial изтегляне