Artykuł techniczny

Ocena formatowania warunkowego Excel w Delphi z HotXLS

Reguła Top 10 percent siedzi na kolumnie 27 liczb sprzedaży, a Excel maluje trzy komórki. Dziesięć procent z 27 to 2,7, więc renderer, który zaokrągla, dostaje trzy, renderer, który bierze sufit, dostaje trzy, a renderer, który zaokrągla w dół, dostaje dwie. Dwa z tych trzech strzałów zgadzają się z Excelem na tej kolumnie i oba są złe: Excel zaokrągla liczbę w dół do dwóch, a potem maluje też trzecią komórkę, bo remisuje z drugą na 88. Zmień jedną wartość, a szczęśliwe strzały przestają się zgadzać

To cała trudność rysowania formatowania warunkowego poza Excelem. Reguły są w pliku; odpowiedzi na pytanie "które komórki się zapalają i na jaki kolor" tam nie ma. Jeśli twoim wyjściem jest raport HTML, PDF albo siatka malowana samodzielnie, musisz to policzyć, a rodziny reguł agregujących są miejscem, gdzie ta arytmetyka się kryje. Tworzenie reguł to osobny temat, omówiony w formatowaniu warunkowym i stylach rich text, a to, co dzieje się z zakresem reguły, gdy wiersze się przesuwają, opisuje partycjonowanie zakotwiczonych formatów warunkowych. Ta notatka jest o ewaluacji

Co element conditionalFormatting mówi, a czego nie

ECMA-376 i ISO 29500-1 definiują przechowywanie i na tym się kończą. Element conditionalFormatting (§18.3.1.18) niesie sqref i listę dzieci cfRule (§18.3.1.10). Każda reguła ma type, opcjonalny operator, priority, flagę stopIfTrue, jedno lub dwa dzieci formula i, dla rodzin wizualnych, zestaw progów cfvo. To wszystko wiernie zapisuje, co użytkownik kliknął w oknie dialogowym. Żaden z tych zapisów nie jest algorytmem

Dla wielu typów reguł ta luka jest nieszkodliwa. cellIs z operator="greaterThan" znaczy większe niż, a containsText znaczy, że podciąg jest obecny. Kłopoty zaczynają się przy regułach, których werdykt zależy od reszty zakresu: top10 z percent="1" nie mówi, jak zaokrągla się ułamkowa licznik, aboveAverage z stdDev="1" nie mówi, czy odchylenie jest próbkowe, czy populacyjne, a żadne nie mówi, czy wartość równa progowi się kwalifikuje. Strzelisz źle i twój PDF będzie się różnił od skoroszytu, który klient ma otwarty w sąsiednim oknie

Diagram przechowywania formatów warunkowych Excel kontra ich ewaluacji: element conditionalFormatting przechowuje sqref, typy reguł, priorytety i progi, podczas gdy renderer Delphi nadal musi policzyć, które komórki się kwalifikują, jak się zaokrągla liczbę rangi top10 i czy wartość graniczna jest wliczana
ISO 29500-1 przechowuje każde ustawienie reguły, a jednak żadnej matematyki oceny, i rodziny agregatów są tym miejscem, gdzie specyfikacja milknie

TCondFormatRule.Evaluate: co jedna komórka może rozstrzygnąć sama

HotXLS ma dwie powierzchnie ewaluacji i różnią się tym, na co są gotowe odpowiedzieć. Klasyczny silnik (.xls) wystawia TCondFormatRule.Evaluate w lxCondFormat.pas, które bierze kalkulator, indeks arkusza, pozycję komórki i wartość komórki, a zwraca wartość logiczną dla jednej reguły. Pokrywa osiem operatorów cellIs (between, not between, equal, not equal, greater, less, greater or equal, less or equal), dowolne reguły expression liczone na komórce, dzięki czemu referencje względne przeliczają się poprawnie, cztery predykaty tekstowe oraz predykaty pustości i błędu. Formuły progowe rozwiązują się na pozycji komórki, a reguła between z odwróconymi granicami dostaje je zamienione, a nie odrzucone. Porównania tekstu nie rozróżniają wielkości liter, tak jak w Excelu

// Klasyczny silnik: jedna reguła, jedna komórka, jedna wartość logiczna
function AnyRuleMatches(Calculator: TXLSCalculator; CondFormat: TCondFormat;
  SheetIndex, Row, Col: Integer; const Value: Variant): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to CondFormat.RuleCount - 1 do
    // top10, średnie, duplikaty, okresy czasu i rodziny wizualne
    // odpowiadają tu False; potrzebują całego zakresu
    if CondFormat.Rule(I).Evaluate(Calculator, SheetIndex, Row, Col, Value) then
      Exit(True);
end;

Metoda zwraca False dla top10, aboveAverage, belowAverage, duplicateValues i uniqueValues. Jedna komórka nie wie, czy jest w górnych dziesięciu procentach, dopóki nie zobaczy pozostałych wartości, więc odpowiedź byłaby zgadywaniem. Względne reguły dat (timePeriod) też odpowiadają False na tej ścieżce. Cztery rodziny wizualne, dataBar, colorScale2, colorScale3 i iconSet, odpowiadają False z innego powodu: nigdy nie produkują tak albo nie, produkują kolor, długość paska albo indeks ikony, a wartość logiczna nie ma na to miejsca. False z tego wywołania znaczy więc "nie rozstrzygnięte tutaj" równie często jak "nie pasuje", co uchodzi przy szybkiej kontroli reguł porównania i jest niebezpieczne dla wszystkiego, co wysyłasz

Diagram dwóch powierzchni ewaluacji formatowania warunkowego HotXLS w Delphi: TCondFormatRule.Evaluate odpowiada wartością logiczną na reguły pojedynczej komórki cellIs, expression, text i blank i odmawia agregatów oraz rodzajów wizualnych, podczas gdy TXLSXConditionalFormatEvaluator rozwiązuje każdą rodzinę w TXLSXCfCellResult
Powierzchnia pojedynczej komórki odmawia zgadywania tego, co musi rozstrzygnąć cały zakres, a snapshot arkusza rozwiązuje zamiast tego agregaty i ładunki wizualne

Jak ewaluator arkusza unika ponownego skanowania arkusza dla każdej komórki?

Całą pracę dla całego zakresu wykonuje raz, przy tworzeniu. TXLSXConditionalFormatEvaluator w lxHandleX.pas to niemutowalny snapshot formatów warunkowych jednego arkusza, do wzięcia z TXLSXWorksheet.CreateConditionalFormatEvaluator; wywołujący go posiada i zwalnia. Naiwna alternatywa, w której każda malowana komórka wywołuje skan swojego zakresu reguły, żeby przeliczyć średnią albo rangę, zamienia eksport 10 000 komórek w 10 000 skanów zakresu na regułę

Przy konstrukcji ewaluator parsuje każdy odrębny sqref raz, więc dziesięć reguł na tym samym zakresie dzieli jeden parse i jeden przebieg po jego wypełnionych komórkach. Ten przebieg zbiera średnią, odchylenie populacyjne, minimum i maksimum w jednym marszu, a uporządkowaną kopię liczb trzyma tylko wtedy, gdy reguła Top/Bottom albo próg percentylowy naprawdę potrzebuje statystyk porządkowych. Reguły duplikatów i unikatów dostają klucze wartości zbudowane i posortowane raz, więc każde późniejsze wyszukiwanie to wyszukiwanie binarne. Formuły reguł kompilują się raz i są liczone ponownie per komórka z nałożonym przesunięciem komórki, więc =A1>AVERAGE($A$1:$A$100) zachowuje części względne i bezwzględne dokładnie tak, jak w Excelu, bez ponownego parsowania dla każdego wiersza. Wiersze są grupowane tak, by EvaluateCell odwiedzał tylko reguły, których zakresy sięgają żądanego wiersza

Reguły nakładają się potem w kolejności priority, od najniższej liczby, a przy remisach rozstrzyga pozycja w pliku. Każdą właściwość formatowania bierze pierwsza pasująca reguła, która ją ustawia, a reguły o niższym priorytecie dopełniają tylko wciąż pustych właściwości: reguła o priorytecie 1 ustawiająca pogrubienie i reguła o priorytecie 2 ustawiająca czerwone wypełnienie pokazują się obie, i komórka wychodzi pogrubiona na czerwono. Trafienie w regułę z StopIfTrue kończy marsz dla tej komórki

procedure PaintConditional(Sheet: TXLSXWorksheet; Canvas: TCanvas;
  FirstRow, LastRow, FirstCol, LastCol: Integer);
var
  Evaluator: TXLSXConditionalFormatEvaluator;
  Res: TXLSXCfCellResult;
  Row, Col: Integer;
begin
  // Jeden snapshot na przebieg renderowania; wiersze i kolumny liczą się od 1
  Evaluator := Sheet.CreateConditionalFormatEvaluator;
  try
    for Row := FirstRow to LastRow do
      for Col := FirstCol to LastCol do
      begin
        if not Evaluator.EvaluateCell(Row, Col, Res) then
          Continue;
        if Res.HasFillColor then
          Canvas.Brush.Color := TColor(Res.FillColor and $00FFFFFF);  // BGR, ten sam układ co TColor
        if Res.HasDataBar then  // DrawBar to twoja własna procedura
          DrawBar(Row, Col, Res.DataBarAxis, Res.DataBarEnd, Res.DataBarColor);
        if Res.HasIcon then     // DrawIcon to twoja własna procedura
          DrawIcon(Row, Col, Res.IconSetType, Res.IconIndex);
        if Res.ShowCellValue and Res.HasNumberFormat and
           Sheet.Cells.HasCell(Row, Col) then  // DrawCellText to twoja własna procedura
          DrawCellText(Row, Col,
            Evaluator.FormatValue(Sheet.Cells[Row, Col].Value, Res.NumberFormatId));
      end;
  finally
    Evaluator.Free;
  end;
end;

Snapshot buduje się raz na przebieg celowo. Stworzenie go w pętli wewnętrznej kompiluje i skanuje wszystko od nowa dla każdej komórki, a to dokładnie ten koszt, którego ta konstrukcja unika. ShowCellValue wraca jako False, gdy reguła paska danych ma showValue="0" albo zestaw ikon jest ustawiony na pokazywanie samej ikony, więc renderer rysuje pasek albo glif i pomija liczbę

Ile komórek naprawdę podświetla reguła Top 10 percent?

Liczbę rangi zaokrągla się w dół, nigdy nie wolno jej zejść poniżej jedynki, a każda wartość zremisowana z progiem jest wliczana. Specyfikacja tego nie mówi; tak robi Excel, a potwierdzisz to w minutę, wpisując 27 wartości z rysunku do kolumny, nakładając Top 10% i licząc zamalowane komórki. TXLSXConditionalFormatEvaluator liczy liczbę rangi jako Floor(Count * Min(Rank, 100) / 100), podnosi ją do 1, gdy wypadnie zero, przycina do liczby wypełnionych komórek numerycznych, czyta próg z posortowanych wartości, a potem porównuje każdą komórkę przez >=. Dla kolumny 27 wartości to licznik dwa, próg 88 i trzy zamalowane komórki, bo dwie z nich trzymają 88. Reguły Bottom-N robią to samo z <=

Diagram HotXLS reguły formatu warunkowego Top 10 percent nad 27 wypełnionymi komórkami: Excel 16 zaokrągla liczbę rangi w dół do 2, porównuje wartość graniczną większe-lub-równe, a trzecia komórka zremisowana na granicy zapala się razem z dwoma najlepszymi
Excel podpiera liczbę rang minimum jednym i trzyma każdą komórkę związaną z progiem, a HotXLS stosuje tę samą liczbę i tę samą zasadę remisu

Reguły powyżej średniej noszą drugą niejednoznaczność. Z stdDev="1" reguła wybiera komórki co najmniej jedno odchylenie standardowe powyżej średniej, a odchylenie próbkowe i populacyjne różni poprawka Bessela, co najbardziej gra na krótkich zakresach, gdzie ludzie faktycznie używają tej reguły. Na pięciu wartościach różnica wynosi około 12 procent. HotXLS używa odchylenia populacyjnego, tej samej liczby, którą zwraca STDEV.P, więc szybkim sposobem sprawdzenia dowolnego renderera jest postawienie =AVERAGE(r)+STDEV.P(r) obok zakresu i zobaczenie, które komórki go przebiją. Porównania z pasmem odchylenia są włącznie; proste porównanie powyżej lub poniżej jest ostre, chyba że reguła niesie equalAverage="1"

Reguły duplikatów i unikatów włączają pytanie, co liczy się jako "ta sama wartość". Liczba 100 i tekst "100" dzielą klucz, bo tekst numeryczny normalizuje się do przestrzeni kluczy numerycznych; klucze tekstowe porównują się bez rozróżniania wielkości liter; TRUE i 1 zostają odrębne, podobnie wartości błędu różnego rodzaju. Tekst zawierający * albo ? dopasowuje się jako wzorzec z dzikimi znakami, w stylu COUNTIF, więc komórka trzymająca Q* liczy się przeciw każdej wartości zaczynającej się od Q. Puste komórki to odwrotna pułapka: naprawdę pusta komórka wewnątrz zakresu nigdy nie maluje się jako duplikat, więc niedokończona kolumna nie zapala się od góry do dołu, podczas gdy komórka przechowująca pusty string liczy się wśród pustych

Skale kolorów, paski danych i zestawy ikon wracają jako liczby

Rodziny wizualne rozwiązują się do wartości gotowych do renderowania, a ich krawędzie to miejsca, gdzie ręcznie pisane renderery się rozjeżdżają. Dla skali kolorów EvaluateCell przycina pozycję do przedziału od 0 do 1 i interpoluje każdy kanał RGB z obcięciem, więc wartość poniżej dolnego stopnia dostaje kolor minimalny zamiast ekstrapolowanego. Skala o trzech stopniach używa pary środek-maksimum dla wartości powyżej progu środkowego i pary minimum-środek w pozostałych przypadkach. Skala, której pierwszy i ostatni próg rozwiązują się do tej samej liczby, zwraca ostatni kolor zamiast dzielić przez zero. Progi percentylowe interpolują się liniowo między sąsiednimi posortowanymi wartościami, według definicji PERCENTILE.INC

Paski danych normalizują się do szerokości komórki. Dla zakresu w pełni dodatniego DataBarAxis wynosi 0, a DataBarEnd biegnie między minimalną a maksymalną długością paska reguły, domyślnie 10 i 90 procent, więc najmniejsza wartość wciąż dostaje krótki pasek, a największa nigdy do końca nie wypełnia komórki. Gdy zakres przecina zero, oś siedzi na swojej proporcjonalnej pozycji, a DataBarEnd znaczy, gdzie pasek się zatrzymuje, po dalekiej stronie osi dla wartości ujemnych. Kierunek, gradient, obramowanie i kolory paska ujemnego dopełniają się na tym samym rekordzie, z kolorami motywu i tintami już rozwiązanymi

Zestawy ikon wymagają uwagi przy ostrości każdego progu, bo każdy cfvo po pierwszym niesie własną flagę gte. Ewaluator przechodzi progi w górę, nakładając >= albo > per próg, jak mówi ThresholdEqualsInclude, i wygrywa najwyższy próg, który wartość przebija. Odwrócony zestaw odwraca rozwiązany indeks, nadpisania per ikona mogą podstawić glif z innej rodziny przez IconSetType, a próg, którego nie da się rozwiązać, zatrzymuje regułę dla tej komórki, więc komórka nie dostaje żadnej ikony zamiast wiarygodnie złej

Jeden wynik dla siatki, eksportu HTML i PDF

TXLSXCfCellResult niesie wszystko, czego renderer potrzebuje: kolor wypełnienia i czcionki z nałożoną tintą motywu, pogrubienie, kursywę, podkreślenie, przekreślenie, identyfikator formatu liczbowego, geometrię i kolory paska danych oraz rodzinę i indeks ikony. HotXLS podaje ten sam rekord swojemu eksportowi HTML, eksportowi PDF i TXLSWorkbookViewer, co trzyma trzy wyjścia w zgodzie komórka w komórkę. Podgląd tworzy jeden ewaluator dla aktywnego arkusza przy pierwszym malowaniu i używa go ponownie przy przewijaniu, zaznaczaniu i przemalowywaniu, a wyrzuca, gdy przełączysz arkusz albo podepniesz inny skoroszyt

// Edycja za aktywnym podglądem: zbuforowany snapshot nie widzi zmiany
Sheet := Viewer.XlsxWorkbook.Sheets[1];   // indeks arkusza liczony od 1
Sheet.Cells[5, 2].Value := 4200;          // przesuwa średnią, maksimum i ranking
Viewer.RefreshConditionalFormats;         // wyrzuć snapshot i przemaluj

Ponieważ snapshot jest niemutowalny, edycja w miejscu zostawia średnie, rangi i krańce skali kolorów nieaktualne, dopóki nie ruszy RefreshConditionalFormats. To samo dotyczy twojego kodu: ewaluator trzymany przez edycje trzeba zwolnić i stworzyć od nowa, bo CreateConditionalFormatEvaluator przechwytuje wartości w chwili wywołania

Względne reguły dat zasługują na decyzję z góry. Ewaluator arkusza porównuje reguły timePeriod z bieżącą datą maszyny, więc reguła "w tym tygodniu" renderuje się dziś inaczej, a inaczej, gdy ten sam skoroszyt wyeksportujesz w przyszły wtorek. To poprawne zachowanie i wciąż zgłoszenie supportowe, jeśli od twojego archiwum oczekuje się odtwarzalności. Dla raportów archiwizowanych zamień reguły timePeriod na regułę expression porównującą z datą raportu zapisaną w komórce, na przykład =AND(A2>=$Z$1-6,A2<=$Z$1) dla siedmiodniowego okna; wyjście zależy wtedy od skoroszytu i od niczego więcej. Excel odmawia też referencji strukturalnych tabel wewnątrz formuły formatu warunkowego, więc skieruj regułę na zakres komórek kolumny

Jeśli celem jest ekran, a nie plik, ten sam rekord wyniku napędza też własną siatkę arkuszową VCL opisaną gdzie indziej na tym blogu. Model reguł, referencja API ewaluatora i wersje próbne są na stronie HotXLS Delphi spreadsheet component