Zmiana zakodowanego na sztywno odwołania do arkusza w tysiącu szablonów raportów z obsługą makr wyklucza ręczne otwieranie każdego pliku w edytorze VBA. HotXLS, natywny komponent Delphi i C++Builder do obsługi Excela, radzi sobie z tym przypadkiem, udostępniając kod źródłowy modułu VBA jako edytowalną właściwość SourceCode i rekompresując każdą edycję algorytmem kompresji MS-OVBA, który Microsoft definiuje specjalnie dla przechowywania VBA, zapisując wynik z powrotem do klasycznego magazynu VBA XLS, samodzielnego pliku projektu VBA lub skoroszytu XLSM z obsługą makr. Żadna instancja Excela, żaden edytor VBA i żaden rejestrator makr nie bierze udziału na żadnym etapie tej ścieżki
Dlaczego strumień modułu VBA nie jest plikiem tekstowym
Moduł VBA wewnątrz skoroszytu XLS lub samodzielnego pliku projektu VBA to nie tekst źródłowy leżący w strumieniu i czekający na odczyt — to niewielki kontener binarny. Najpierw znajduje się skompilowany bufor wydajności, bajty, których Office używa, by pominąć ponowną kompilację modułu przy wczytywaniu, gdy bufor wciąż pasuje do wersji hosta, a po nim następuje właściwy tekst źródłowy, przepuszczony przez zastrzeżony schemat kompresji, który MS-OVBA definiuje specjalnie dla przechowywania VBA. Ten schemat to nie zip, nie deflate i nic, co natywnie produkują API kompresji Windows, i właśnie dlatego większość bibliotek Excel firm trzecich potrafi odczytać kod źródłowy modułu — dekompresja jest łatwiejszą połową problemu — a jednocześnie nie potrafi zapisać go z powrotem, ponieważ rekompresja to miejsce, w którym subtelnie zły bit produkuje plik, którego Excel odmawia otworzyć. Publiczne opracowania strony odczytu istnieją; implementacje strony zapisu, które faktycznie realizują rekompresję, a nie tylko rozpakowują istniejący moduł do inspekcji, są na tyle rzadkie, że pozostaje to jednym z najsłabiej udokumentowanych zakątków formatów plików Excela
Co dokładnie zmienia właściwość SourceCode w HotXLS?
HotXLS reprezentuje każdy moduł VBA jako obiekt TXLSVBAModule z prostą właściwością SourceCode: WideString, a przypisanie jej nowej wartości jest dokładnie tak proste, jak wygląda: moduł zostaje oznaczony jako zmodyfikowany w pamięci, a nic nie dotyka bazowego strumienia OLE, dopóki projekt nie zostanie zapisany. Sam projekt pochodzi z IXLSWorkbook.VBAProject w klasycznym silniku XLS lub TXLSXWorkbook.ParsedVBAProject w silniku OOXML z obsługą makr, oba zwracają TXLSVBAProject, którego moduły znajdują się za jednostkowym (1-based) indeksatorem Item[] i właściwością Count, więc wsadowa edycja każdego modułu w skoroszycie to po prostu pętla po zakresie liczb całkowitych
var
Wb: TXLSWorkbook;
Project: TXLSVBAProject;
I: Integer;
Updated: WideString;
begin
Wb := TXLSWorkbook.Create;
try
Wb.Open('MonthlyReport.xls');
if Wb.HasVBAProject then
begin
Project := Wb.VBAProject;
for I := 1 to Project.Count do
begin
Updated := StringReplace(Project[I].SourceCode,
'ReportSheet2025', 'ReportSheet2026', [rfReplaceAll]);
if Updated <> Project[I].SourceCode then
Project[I].SourceCode := Updated; // marks the module dirty
end;
Wb.SaveAs('MonthlyReport.xls'); // recompresses on write
end;
finally
Wb.Free;
end;
end;
Ta pętla ma też kształt przebiegu audytowego. Zanim dotknie się tysiąca szablonów, większość zespołów najpierw chce wiedzieć, ile z nich faktycznie zawiera makra i do czego te makra się odwołują — to scenariusz stojący za warsztatem audytu i konwersji skoroszytów — to samo Project.Count, które napędza tutaj pętlę przepisującą, tam staje się zestawieniem makr na plik
Wnętrze kontenera kompresji MS-OVBA
Format kompresji MS-OVBA pakuje bajty źródłowe w to, co specyfikacja nazywa CompressedContainer: pojedynczy bajt sygnatury, wymagany, by równać się 0x01, po którym następuje sekwencja bloków CompressedChunk, z których każdy obejmuje do 4096 bajtów danych zdekompresowanych. 16-bitowy nagłówek fragmentu niesie trzy pola — 3-bitową sygnaturę, która musi równać się 3, 12-bitowe pole rozmiaru oraz bit CompressedChunkFlag oznaczający, czy ładunek fragmentu to bajty dosłowne, czy sekwencja skompresowana za pomocą tokenów. Gdy flaga jest ustawiona, ładunek to seria grup po osiem tokenów, każda poprzedzona bajtem flagowym, a każdy token to albo pojedynczy bajt dosłowny, albo CopyToken: referencja wsteczna offset/długość do bajtów już zdekompresowanych wcześniej w tym samym fragmencie, przy czym podział szerokości bitowej między offset a długość zmienia się w zależności od tego, jak daleko w głąb fragmentu znajduje się aktualnie dekompresor. Ta część MS-OVBA (§2.4.1, Compression and Decompression) to miejsce, w którym ręcznie pisana implementacja najczęściej traci cały dzień na błąd o jeden w tym obliczeniu szerokości bitowej
Dlaczego HotXLS zapisuje surowe fragmenty zamiast dopasowywać tokeny
Ścieżka zapisu HotXLS całkowicie omija połowę tego algorytmu odpowiedzialną za dopasowywanie tokenów. Gdy rekompresuje edytowany moduł, każdy fragment wychodzi z wyczyszczoną flagą CompressedChunkFlag, co oznacza, że fragment zawiera bajty dosłowne, a nie tokeny referencji wstecznych — jest to legalne w MS-OVBA, ponieważ skompresowany kontener może w całości składać się z fragmentów nieskompresowanych, i usuwa dokładnie tę część algorytmu, którą najtrudniej poprawnie zaimplementować ręcznie: znajdowanie prawidłowych referencji wstecznych i pakowanie pary offset/długość w szerokość bitową zależną od bieżącej pozycji wewnątrz fragmentu. Kompromis ujawnia się w rozmiarze pliku, nie w poprawności — przepisany strumień modułu wychodzi bliski rozmiarowi swojego tekstu źródłowego plus dwubajtowy nagłówek na każdy blok 4096 bajtów, nie mniejszy, jak byłby w pełni skompresowany za pomocą tokenów fragment. Każdy czytnik implementujący stronę dekompresji specyfikacji, w tym Excel, nadal otwiera wynik poprawnie, ponieważ surowy fragment jest tak samo prawidłowym CompressedChunk jak fragment skompresowany tokenami
Co HotXLS pozostawia nietknięte, gdy przepisuje moduł
Rekompresja zastępuje zawsze tylko część strumienia modułu. Każdy strumień modułu przechowuje najpierw swój bufor wydajności, a dopiero potem skompresowane źródło, a strumień dir projektu zapisuje dokładnie, gdzie przebiega ten podział dla każdego modułu we wpisie MODULEOFFSET; HotXLS odczytuje to przesunięcie, zachowuje każdy bajt przed nim dokładnie tak, jak go zastał, i przebudowuje wyłącznie skompresowany kontener od tego przesunięcia w dalszą część
Sam tekst źródłowy przechodzi w obie strony przez własną stronę kodową projektu VBA, a nie przez UTF-8 — tę samą przestarzałą stronę kodową, z którą Office pierwotnie zapisał projekt. Edycja SourceCode, która wprowadza znaki spoza repertuaru tej strony kodowej, zostaje po cichu zastąpiona najlepiej dopasowanymi znakami zastępczymi, gdy HotXLS koduje łańcuch z powrotem do bajtów, a nie odrzucona, więc nietypowy znak regionalny wrzucony do komentarza lub literału łańcuchowego to najbardziej prawdopodobne miejsce, w którym zauważysz tę stratę. Odwołania zewnętrzne i powiązania z bibliotekami wewnątrz tego samego projektu podążają powiązaną, ale osobną ścieżką zachowania, omówioną w towarzyszącym artykule o zachowaniu zewnętrznych odwołań VBA, i warto go przeczytać, zanim przebieg przepisywania dotknie projektu, który łączy się z innymi skoroszytami lub bibliotekami typów
Jak przepisane makra wracają do skoroszytu?
Nic nie wywołuje kroku rekompresji jawnie — uruchamia się on automatycznie w chwili zapisu skoroszytu lub samodzielnego projektu VBA. TXLSVBAProject.ApplyChanges przechodzi przez każdy moduł, rekompresuje te, których SourceCode zmieniło się od ostatniego zapisu, i przepisuje wyłącznie strumień tego modułu; klasyczny TXLSWorkbook.SaveAs, gdy cel zapisu zachowuje oryginalny format pliku, oraz TXLSXWorkbook.SaveAs w OOXML dla pakietu XLSM z obsługą makr, oba wywołują to wewnętrznie, zanim cokolwiek zostanie zapisane na dysk, a SaveVBAProjectToFile wywołuje tę samą metodę, gdy celem jest odłączony plik projektu VBA, a nie pełny skoroszyt
var
Wb: TXLSWorkbook;
begin
Wb := TXLSWorkbook.Create;
try
if Wb.LoadVBAProjectFromFile('LegacyMacros.ole') = 1 then
begin
Wb.VBAProject[1].SourceCode :=
StringReplace(Wb.VBAProject[1].SourceCode, 'OldServer', 'NewServer', [rfReplaceAll]);
Wb.SaveVBAProjectToFile('LegacyMacros_Patched.ole'); // ApplyChanges runs internally
end;
finally
Wb.Free;
end;
end;
var
Xlsx: TXLSXWorkbook;
Project: TXLSVBAProject;
begin
Xlsx := TXLSXWorkbook.Create;
try
Xlsx.Open('Dashboard.xlsm');
Project := Xlsx.ParsedVBAProject;
if Assigned(Project) then
begin
Project[1].SourceCode := StringReplace(Project[1].SourceCode,
'ConnStringV1', 'ConnStringV2', [rfReplaceAll]);
Xlsx.SaveAs('Dashboard.xlsm'); // SyncParsedVBAProject recompresses before the part is written
end;
finally
Xlsx.Free;
end;
end;
Wszystkie trzy miejsca docelowe dzielą pod spodem tę samą mechanikę SourceCode i ApplyChanges; jedyna prawdziwa różnica między nimi to to, które wywołanie zapisu w końcu wyzwala rekompresję
Gdzie to wciąż się psuje
Dwa tryby awarii są na tyle częste, że warto się na nie przygotować, zanim przebieg przepisywania uruchomi się na plikach produkcyjnych. Cyfrowo podpisany projekt VBA przestaje być ważnie podpisany w chwili, gdy zmienia się jego źródło, ponieważ podpis obejmuje treść projektu; HotXLS nie ma sposobu, by ponownie podpisać projekt w twoim imieniu, a Excel odrzuca lub oznacza podpis przy kolejnym otwarciu pliku, więc podpisany projekt makr wymaga niżej w potoku kroku ponownego podpisania, jeśli ten podpis jest czymś, co twój proces roboczy faktycznie sprawdza. Drugi tryb awarii dotyczy każdego, kto kusi się, by od zera zaimplementować ten format kompresji zamiast użyć biblioteki, która już sobie z nim radzi: jeden zły bit w nagłówku fragmentu, w połówce sygnatury, w polu rozmiaru lub we fladze kompresji, produkuje plik, którego Excel odmawia otworzyć, zwykle za ogólnym ostrzeżeniem o uszkodzeniu, które nie daje żadnej wskazówki, który bajt był zły — dokładnie ta klasa błędów, przed którą chroni opisana wcześniej strategia zapisu surowych fragmentów
Nic z tego nie wymaga inżynierii wstecznej formatu, aby z niego korzystać. Programiści Delphi i C++Buildera dostają odczyt i zapis SourceCode, rekompresję zgodną z MS-OVBA oraz wszystkie trzy opisane tu miejsca docelowe zapisu jako część standardowego komponentu HotXLS, obok reszty jego API skoroszytów klasycznego XLS i OOXML