บทความเทคนิค

การเขียน VBA Source ใหม่และบีบอัด MS-OVBA ใหม่ใน Delphi

การเปลี่ยนชื่อการอ้างอิงเวิร์กชีตที่ hardcode ไว้ข้ามเทมเพลตรายงานแบบมี macro หนึ่งพันไฟล์ ตัดตัวเลือกการเปิดแต่ละไฟล์ใน VBA editor ด้วยมือออกไป HotXLS คอมโพเนนต์ Excel แบบเนทีฟสำหรับ Delphi และ C++Builder จัดการกรณีนี้ด้วยการเปิด source ของโมดูล VBA เป็น property SourceCode ที่แก้ไขได้ และบีบอัดทุกการแก้ไขใหม่ด้วยอัลกอริทึมการบีบอัด MS-OVBA ที่ Microsoft นิยามไว้สำหรับที่เก็บ VBA เขียนผลลัพธ์กลับเข้าไปในที่เก็บ VBA ของ XLS แบบดั้งเดิม, ไฟล์โปรเจกต์ VBA แบบเดี่ยว หรือ workbook แบบมี macro XLSM ไม่มี instance ของ Excel ไม่มี VBA editor และไม่มี macro recorder เข้ามาเกี่ยวข้องในเส้นทางนั้นเลย

ทำไม stream ของโมดูล VBA ถึงไม่ใช่ไฟล์ข้อความ

โมดูล VBA ภายใน workbook XLS หรือไฟล์โปรเจกต์ VBA แบบเดี่ยวไม่ใช่ข้อความต้นฉบับที่นั่งอยู่ใน stream รอถูกอ่านเท่านั้น มันเป็น container แบบ binary เล็กๆ cache ประสิทธิภาพที่ compile แล้วมาก่อน เป็นไบต์ที่ Office ใช้ข้ามการ compile โมดูลใหม่เมื่อโหลด เมื่อ cache นั้นยังตรงกับเวอร์ชัน host แล้วข้อความ source จริงตามมาหลังจากนั้น ผ่านโครงร่างการบีบอัดที่เป็นกรรมสิทธิ์ซึ่ง MS-OVBA นิยามไว้เฉพาะสำหรับที่เก็บ VBA โครงร่างนั้นไม่ใช่ zip ไม่ใช่ deflate และไม่ใช่อะไรที่ Windows compression API สร้างขึ้นเองตามธรรมชาติ ซึ่งเป็นเหตุผลตรงๆ ที่ไลบรารี Excel จากบุคคลที่สามส่วนใหญ่อ่าน source ของโมดูลได้ การถอดรหัสคือครึ่งที่ง่ายกว่าของปัญหา แต่หยุดอยู่แค่นั้นโดยไม่เขียนมันกลับ เพราะการบีบอัดใหม่คือจุดที่บิตที่ผิดพลาดเล็กน้อยสร้างไฟล์ที่ Excel ปฏิเสธที่จะเปิด บทความสาธารณะเกี่ยวกับฝั่งอ่านมีอยู่ แต่ implementation ฝั่งเขียนที่ทำการบีบอัดใหม่จริงๆ แทนที่จะแค่แกะโมดูลที่มีอยู่แล้วเพื่อตรวจสอบ หายากพอที่จะทำให้นี่ยังคงเป็นหนึ่งในมุมที่มีเอกสารประกอบน้อยที่สุดของฟอร์แมตไฟล์ Excel

property SourceCode ของ HotXLS เปลี่ยนอะไรจริงๆ

HotXLS แสดงทุกโมดูล VBA เป็น object TXLSVBAModule ที่มี property SourceCode: WideString ธรรมดา และการกำหนดค่าใหม่ให้มันก็ง่ายพอๆ กับที่มันดูเลย โมดูลถูกทำเครื่องหมายว่า dirty ในหน่วยความจำ และไม่มีอะไรแตะ OLE stream ที่อยู่ข้างใต้จนกว่าโปรเจกต์จะถูกบันทึก ตัวโปรเจกต์เองมาจาก IXLSWorkbook.VBAProject บนเอนจิ้น XLS แบบดั้งเดิม หรือ TXLSXWorkbook.ParsedVBAProject บนเอนจิ้น OOXML แบบมี macro ทั้งคู่คืน TXLSVBAProject ที่โมดูลของมันอยู่หลัง indexer Item[] แบบเริ่มที่ 1 และ property Count ดังนั้นการแก้ไขแบบ batch ข้ามทุกโมดูลใน workbook จึงเป็นแค่ loop บนช่วง integer

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;

loop นั้นยังเป็นรูปร่างของรอบการตรวจสอบด้วย ก่อนที่เทมเพลตหนึ่งพันไฟล์จะถูกแตะ ทีมส่วนใหญ่ต้องการรู้ก่อนว่ามีกี่ไฟล์ที่พก macro จริงๆ และ macro เหล่านั้นอ้างอิงอะไร ซึ่งเป็นสถานการณ์เบื้องหลังเวิร์กเบนช์สำหรับตรวจสอบและแปลง workbook Project.Count ตัวเดียวกันที่ขับเคลื่อน loop การเขียนใหม่ตรงนี้ กลายเป็นการนับ macro ต่อไฟล์ที่นั่น

ภายใน container การบีบอัด MS-OVBA

รูปแบบการบีบอัดของ MS-OVBA บรรจุไบต์ source เข้าไปในสิ่งที่สเปคเรียกว่า CompressedContainer คือไบต์ signature เดียว ต้องเท่ากับ 0x01 ตามด้วยลำดับของบล็อก CompressedChunk แต่ละบล็อกครอบคลุมข้อมูลที่ถอดรหัสแล้วได้สูงสุด 4096 ไบต์ chunk header ขนาด 16 บิตพก field สามตัว คือ signature 3 บิตที่ต้องเท่ากับ 3, field ขนาด 12 บิต และบิต CompressedChunkFlag ที่ทำเครื่องหมายว่า payload ของ chunk นั้นเป็นไบต์ literal หรือลำดับที่บีบอัดแบบ token เมื่อ flag ถูกตั้งไว้ payload จะเป็นชุดของกลุ่มแปด token ที่นำหน้าด้วย flag-byte และแต่ละ token เป็นไบต์ literal เดี่ยวหรือ CopyToken คือ back-reference แบบ offset/length กลับเข้าไปในไบต์ที่ถอดรหัสไปแล้วก่อนหน้าใน chunk เดียวกัน โดยความกว้างบิตที่แบ่งระหว่าง offset กับ length เปลี่ยนไปตามว่าตัวถอดรหัสอยู่ลึกแค่ไหนใน chunk ณ ขณะนั้น ส่วนนี้ของ MS-OVBA (§2.4.1, Compression and Decompression) คือจุดที่ implementation ที่เขียนด้วยมือมักเสียเวลาไปหนึ่งวันกับ off-by-one ในการคำนวณความกว้างบิตนั้น

ทำไม HotXLS ถึงเขียน chunk แบบดิบแทนการจับคู่ token

เส้นทางการเขียนของ HotXLS ข้ามครึ่งหนึ่งของอัลกอริทึมที่เป็นการจับคู่ token ไปทั้งหมด เมื่อมันบีบอัดโมดูลที่แก้ไขแล้วใหม่ ทุก chunk ออกไปโดยที่ CompressedChunkFlag ถูกล้างไว้ หมายความว่า chunk นั้นถือไบต์ literal แทนที่จะเป็น token แบบ back-reference ซึ่งถูกกฎภายใต้ MS-OVBA เพราะ container ที่บีบอัดแล้วได้รับอนุญาตให้ประกอบด้วย chunk ที่ไม่ได้บีบอัดล้วนๆ ได้ และมันตัดส่วนของอัลกอริทึมที่ยากที่สุดที่จะทำให้ถูกต้องด้วยมือออกไปพอดี คือการหา back-reference ที่ถูกต้องและบรรจุคู่ offset/length เข้าไปในความกว้างบิตที่ขึ้นอยู่กับตำแหน่งปัจจุบันภายใน chunk การแลกเปลี่ยนนี้ปรากฏในขนาดไฟล์ ไม่ใช่ความถูกต้อง stream ของโมดูลที่เขียนใหม่ลงเอยใกล้กับขนาดของข้อความ source ของมันบวก header สองไบต์ต่อบล็อก 4096 ไบต์ ไม่ได้เล็กลงแบบที่ chunk ที่บีบอัดด้วย token เต็มรูปแบบจะเป็น reader ทุกตัวที่ implement ฝั่งการถอดรหัสของสเปค รวมถึง Excel เอง ยังคงเปิดผลลัพธ์ได้อย่างถูกต้อง เพราะ chunk แบบดิบก็เป็น CompressedChunk ที่ถูกต้องพอๆ กับ chunk ที่บีบอัดด้วย token

สิ่งที่ HotXLS ไม่แตะเลยเมื่อมันเขียนโมดูลใหม่

การบีบอัดใหม่แทนที่แค่ส่วนหนึ่งของ stream โมดูลเท่านั้น ทุก stream โมดูลเก็บ cache ประสิทธิภาพของมันไว้ก่อนและ source ที่บีบอัดไว้ทีหลัง และ stream dir ของโปรเจกต์บันทึกไว้ตรงๆ ว่าการแบ่งนั้นตกลงตรงไหนสำหรับแต่ละโมดูลใน entry MODULEOFFSET HotXLS อ่าน offset นั้น เก็บทุกไบต์ก่อนหน้ามันไว้เหมือนที่พบเป๊ะ และสร้าง container ที่บีบอัดใหม่แค่จาก offset นั้นเป็นต้นไปเท่านั้น

ข้อความ source เองไปกลับผ่าน code page ของโปรเจกต์ VBA เอง ไม่ใช่ UTF-8 เป็น code page แบบดั้งเดิมเดียวกับที่ Office เขียนโปรเจกต์ไว้ตั้งแต่แรก การแก้ไข SourceCode ที่นำตัวอักษรนอกเหนือขอบเขตของ code page นั้นเข้ามา จะถูกแทนที่ด้วยตัวอักษรทดแทนที่ดีที่สุดอย่างเงียบๆ เมื่อ HotXLS เข้ารหัส string นั้นกลับเป็นไบต์ ไม่ใช่การปฏิเสธ ดังนั้นตัวอักษรประจำภูมิภาคที่แปลกไปที่ถูกใส่ลงในความคิดเห็นหรือ string literal จึงเป็นจุดที่มีแนวโน้มมากที่สุดที่จะสังเกตเห็นการสูญเสีย การอ้างอิงภายนอกและการผูก library ภายในโปรเจกต์เดียวกันเดินตามเส้นทางการรักษาที่เกี่ยวข้องกันแต่แยกต่างหาก ครอบคลุมในบทความคู่กันเรื่องการรักษาลิงก์ภายนอกของ VBA และคุ้มค่าที่จะอ่านก่อนที่รอบการเขียนใหม่จะแตะโปรเจกต์ที่ลิงก์ออกไปยัง workbook หรือ type library อื่น

คุณเอา macro ที่เขียนใหม่กลับเข้า workbook ได้อย่างไร

ไม่มีอะไรเรียกขั้นตอนการบีบอัดใหม่อย่างชัดเจนเลย มันรันโดยอัตโนมัติทันทีที่ workbook หรือโปรเจกต์ VBA แบบเดี่ยวถูกบันทึก TXLSVBAProject.ApplyChanges เดินผ่านทุกโมดูล บีบอัดโมดูลที่ SourceCode เปลี่ยนไปตั้งแต่การบันทึกครั้งล่าสุดใหม่ และเขียน stream ของโมดูลนั้นเท่านั้นใหม่ TXLSWorkbook.SaveAs แบบดั้งเดิม เมื่อเป้าหมายการบันทึกคงฟอร์แมตเดิมของไฟล์ไว้ และ TXLSXWorkbook.SaveAs แบบ OOXML สำหรับ package XLSM แบบมี macro ทั้งคู่เรียกมันภายในก่อนที่อะไรจะถูกเขียนลงดิสก์ และ SaveVBAProjectToFile เรียก method เดียวกันเมื่อเป้าหมายเป็นไฟล์โปรเจกต์ VBA แบบแยกต่างหาก ไม่ใช่ workbook เต็มรูปแบบ

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;

ปลายทางทั้งสามใช้กลไก SourceCode และ ApplyChanges เดียวกันข้างใต้ ความแตกต่างจริงเดียวระหว่างพวกมันคือการเรียกบันทึกตัวไหนที่จะกระตุ้นการบีบอัดใหม่ในที่สุด

จุดที่ยังคงพังอยู่

โหมดความล้มเหลวสองแบบพบได้บ่อยพอที่ควรวางแผนไว้ก่อนที่รอบการเขียนใหม่จะรันกับไฟล์ production โปรเจกต์ VBA ที่เซ็นดิจิทัลไว้จะหยุดเป็นลายเซ็นที่ถูกต้องทันทีที่ source ของมันเปลี่ยน เพราะลายเซ็นครอบคลุมเนื้อหาของโปรเจกต์ HotXLS ไม่มีวิธีเซ็นโปรเจกต์ใหม่ให้คุณเลย และ Excel จะทิ้งหรือ flag ลายเซ็นนั้นในครั้งถัดไปที่ไฟล์ถูกเปิด ดังนั้นโปรเจกต์ macro ที่เซ็นไว้ต้องการขั้นตอนการเซ็นใหม่ตามมาถ้าลายเซ็นนั้นเป็นสิ่งที่ workflow ของคุณตรวจสอบจริงๆ โหมดความล้มเหลวที่สองเป็นของใครก็ตามที่ถูกล่อลวงให้ implement รูปแบบการบีบอัดนี้ใหม่เองตั้งแต่ต้น แทนที่จะใช้ไลบรารีที่จัดการมันไว้แล้ว บิตที่ผิดหนึ่งบิตใน chunk header ไม่ว่าจะใน signature nibble, size field หรือ compressed flag จะสร้างไฟล์ที่ Excel ปฏิเสธที่จะเปิด มักอยู่หลังคำเตือนความเสียหายทั่วไปที่ไม่ให้เบาะแสเลยว่าไบต์ไหนผิด ซึ่งเป็นบั๊กประเภทเดียวกันเป๊ะที่กลยุทธ์การเขียน chunk แบบดิบที่อธิบายไว้ก่อนหน้านี้มีอยู่เพื่อหลีกเลี่ยง

ไม่มีสิ่งใดในนี้ที่ต้องการ reverse-engineer รูปแบบเพื่อใช้งาน นักพัฒนา Delphi และ C++Builder ได้สิทธิ์อ่านและเขียน SourceCode, การบีบอัดใหม่ที่สอดคล้องกับ MS-OVBA และปลายทางเขียนกลับทั้งสามแบบที่อธิบายไว้ในบทความนี้ เป็นส่วนหนึ่งของHotXLS Componentรุ่นมาตรฐาน ควบคู่ไปกับ API workbook แบบ XLS ดั้งเดิมและ OOXML ที่เหลือ