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

การรักษามาโคร VBA และลิงก์ภายนอกเมื่อโค้ด Delphi เขียนทับเวิร์กบุ๊ก

ลองนึกถึงงานที่แทบไม่ทำอะไรเลย: เปิด workbook รายเดือน เขียนวันที่วันนี้ลงในเซลล์เดียว แล้ว save กลับไป รันสิ่งนี้ผ่านบริการบ่อยพอ แล้วก็จะมีข้อร้องเรียนเข้ามาจนได้ macro หายไป หรือไม่ก็อัตราแลกเปลี่ยนที่ลิงก์ไว้กลายเป็น #REF! และทีมปฏิบัติการก็เชื่อว่าโค้ดของคุณเป็นคนลบมันทิ้ง แต่มันไม่ได้ลบอะไรเลย สิ่งที่มักเกิดขึ้นจริง ๆ คือ workbook ที่เปิดใช้ macro ถูกส่งออกไปภายใต้ชื่อ .xlsx ธรรมดา และ Excel ก็ทำตามกฎ content-type ของ ECMA-376: แพ็กเกจที่ content type ประกาศว่าไม่มี VBA จะโหลด VBA project ไม่ได้เลย ไม่ว่าไบต์จะอยู่ตรงนั้นจริงหรือไม่ก็ตาม ไฟล์ไม่ได้พังเลย มันแค่ถูกเปลี่ยนชื่อไปอยู่ในสถานะที่ Excel ถูกบังคับให้เพิกเฉยต่อบางส่วนของมัน

macro และลิงก์ workbook ภายนอกคือสองสิ่งที่ automation มักทำหายอย่างเชื่อถือได้ที่สุด ด้วยเหตุผลเดียวกัน ทั้งคู่อยู่นอกตารางเซลล์ที่โค้ดแก้ไขจริง ๆ แตะถึง ดังนั้นโค้ดที่คิดในแง่แถวและคอลัมน์จะทำมันหายไปโดยไม่เคยสั่งลบเลย HotXLS คือไลบรารี native สำหรับ Delphi และ C++Builder ที่อ่านและเขียน XLS และ XLSX ได้โดยไม่ต้องติดตั้ง Excel และมันปฏิบัติต่อทั้งสอง asset นี้เป็น payload ที่มันแบกไว้อย่างตั้งใจ ไม่ใช่ข้อมูลที่มันบังเอิญคัดลอกมา สิ่งที่ตามมาคือสิ่งที่แต่ละอย่างต้องการจาก save path ของคุณ และจุดที่การรับประกันสิ้นสุดลง

เหตุใด asset ทั้งสองนี้จึงมีพฤติกรรมต่างกันเมื่อถูกเขียนทับใหม่

VBA project คือไบนารีทึบก้อนเดียว ในแพ็กเกจ OOXML มันคือไฟล์ vbaProject.bin ในไฟล์ BIFF แบบดั้งเดิมมันคือ OLE storage มีทางที่จะทำมันหายไปอยู่แค่สองทางเท่านั้น: writer ไม่เคยคัดลอกมันเข้าไปในผลลัพธ์เลย หรือไม่ก็ผลลัพธ์ได้ชนิดไฟล์ที่ห้ามมันไว้ ความล้มเหลวแบบไหนก็ตามคือแบบเบ็ดเสร็จและเงียบสนิททั้งคู่ project นั้นมีอยู่หรือไม่มีอยู่เท่านั้น

ลิงก์ภายนอกไม่ใช่ blob เลยแม้แต่น้อย มันคือกราฟความสัมพันธ์เล็ก ๆ: path หรือ URL เป้าหมายที่ชี้ไปยัง workbook อีกไฟล์หนึ่ง รายชื่อ sheet ที่เป้าหมายนั้นเปิดเผยออกมา และแคชค่าที่เห็นล่าสุดในชีทเหล่านั้นซึ่งเป็นทางเลือก เพื่อให้ Excel แสดงอะไรบางอย่างได้เมื่อเป้าหมายออฟไลน์ สามส่วนนี้มีอายุที่ต่างกันเมื่อถูกเขียนทับใหม่ และไลบรารีอาจรักษาบางส่วนไว้อย่างซื่อสัตย์ในขณะที่ทำอีกบางส่วนหายไปแบบเงียบ ๆ ความไม่สมมาตรนี้คือส่วนที่ควรทำความเข้าใจให้แม่นยำ เพราะไม่มีอะไรในโค้ดแก้ไขเซลล์ที่จะเปิดเผยมันออกมาให้เห็นเลย

แผนภาพเปรียบเทียบ blob โปรเจกต์ VBA กับสามส่วนของ external workbook link ที่ HotXLS พาข้ามการเขียนใหม่ของ Delphi
โปรเจกต์ VBA รอดการเขียนใหม่ในรูป payload ไบนารีแบบได้ทั้งหมดหรือไม่เอาเลย ในขณะที่ลิงก์ภายนอกเป็นกราฟเล็กที่เป้าหมาย ชื่อชีต และค่าที่แคชไว้สามารถอนุรักษ์หรือทิ้งได้อิสระต่อกัน

แบก VBA project ผ่านการเขียน XLSX ใหม่

ในฝั่ง XLSX นั้น TXLSXWorkbook เก็บ payload ของ macro ไว้เป๊ะ ๆ ตามต้นฉบับ property VbaProject เก็บไบต์ดิบของ vbaProject.bin ไว้ใน AnsiString และสตริงว่างคือวิธีที่โมเดลบอกว่าไม่มี macro รอบ ๆ มันมีสามการดำเนินการ: HasVbaProject ตอบว่ามี project อยู่หรือไม่ ClearVbaProject ลบมันออกอย่างตั้งใจ และ LoadVbaProjectFromFile ฉีด project ที่สกัดมาจาก template เข้าไป เมธอดสุดท้ายนี้มีค่ามากกว่าที่มองแวบแรก มันทำให้ workbook ที่สร้างขึ้นรับ macro project มาตรฐานได้โดยไม่ต้องลากไฟล์ template เต็มรูปแบบผ่าน pipeline เลย

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Data');
    Sheet.Cells[1, 1].Value := 'Refreshed ' + DateTimeToStr(Now);

    Book.LoadVbaProjectFromFile('macros\vbaProject.bin');
    if not Book.HasVbaProject then
      raise Exception.Create('VBA payload failed to load');

    // นามสกุล .xlsm ไม่ใช่แค่การตกแต่ง: มันเลือก
    // content type แบบเปิดใช้ macro ไว้ข้างในแพ็กเกจ
    Book.SaveAs('monthly-report.xlsm');
  finally
    Book.Free;
  end;
end;

บรรทัด save คือจุดที่ปัญหาทั้งหมดพลิกกลับ workbook ที่ถือ VBA project ต้องถูกเขียนด้วยความหมายแบบเปิดใช้ macro และ HotXLS จะใช้มันเมื่อชื่อเป้าหมายลงท้ายด้วย .xlsm ถ้าส่ง .xlsx ให้แทน Excel จะปฏิเสธ macro ทันที แม้ว่าไบต์จะอยู่ในแพ็กเกจจริง ๆ และจะ deserialize ได้ดีก็ตาม นามสกุลไฟล์ไม่ใช่การตกแต่ง มันเลือก content type ที่บอก Excel ว่า VBA project ได้รับอนุญาตให้มีอยู่ ส่วนใหญ่แล้วคุณแค่ต้องแบก payload ผ่านไปเฉย ๆ เมื่อคุณต้องการอ่านเข้าไปข้างใน เช่นแสดงรายชื่อโมดูลสำหรับรายงาน audit ParsedVBAProject จะเปิดโมเดลโมดูลที่แกะแล้วให้ ในขณะที่ VbaProject ยังคงเป็นไบต์ต้นฉบับที่ไม่ถูกแตะต้อง

นำ macro จาก XLS workbook แบบดั้งเดิมกลับมาใช้ใหม่

facade ของ BIFF สะท้อนชุดเครื่องมือนั้นด้วยขั้นตอนเพิ่มอีกหนึ่งขั้น HasVBAProject ตรวจสอบไฟล์ที่โหลดไว้ SaveVBAProjectToFile เขียน project storage ออกไปยังดิสก์ และ LoadVBAProjectFromFile อ่านกลับเข้าไปใน workbook อีกไฟล์หนึ่ง การวนผ่านไฟล์แบบนี้ทำให้งานปรับปรุงให้ทันสมัยที่พบบ่อยทำได้ง่ายขึ้น: ยก macro ออกจากโมเดลยุคปี 2003 แล้วปลูกมันลงในผลลัพธ์ XLS ที่สร้างขึ้นใหม่ โดยไม่ต้องมี template ต้นฉบับตอนรันไทม์เลย

var
  Src, Dst: IXLSWorkbook;   // interface reference: ไม่ต้อง Free ด้วยมือ
begin
  Src := TXLSWorkbook.Create;
  if Src.Open('legacy-model.xls') <= 0 then
    raise Exception.Create('Cannot open legacy model');
  if Src.HasVBAProject then
    Src.SaveVBAProjectToFile('extracted-vba.bin');

  Dst := TXLSWorkbook.Create;
  Dst.Sheets.Add.Name := 'Report2026';
  Dst.LoadVBAProjectFromFile('extracted-vba.bin');
  Dst.SaveAs('report-with-macros.xls');
end;

โมเดลหน่วยความจำคือกับดักตรงนี้ และมันตรงข้ามกับ class ของ XLSX เลย TXLSWorkbook ถูกถือไว้ผ่าน interface IXLSWorkbook ที่นับ reference ดังนั้นคุณไม่ต้อง free มันด้วยมือเลย ส่วน TXLSXWorkbook ของ XLSX เป็นอ็อบเจ็กต์ธรรมดาที่คุณต้องห่อด้วย try..finally แล้ว free เอง ถ้าผสมสองธรรมเนียมนี้ไว้ในยูนิตเดียวกัน อาการ double-free crash ก็จะตามมา มีอีกขอบเขตหนึ่งที่ควรเคารพ: เก็บการสกัดและการฉีดไว้ภายในฟอร์แมตไฟล์เดียวกันเท่านั้น project storage ของ BIFF กับ vbaProject.bin ของ OOXML เป็นญาติกัน ไม่ใช่ container เดียวกัน และ pipeline ที่ต้องปล่อย macro ออกมาทั้งสองฟอร์แมตควรเก็บ macro template แยกกันไว้สำหรับแต่ละฟอร์แมต

ลิงก์ภายนอก: แผนที่รอด แต่ค่าที่แคชไว้ไม่รอด

สำหรับ workbook แบบ XLSX นั้น HotXLS เปิดให้เข้าถึงลิงก์ภายนอกผ่าน collection ชื่อ ExternalLinks แต่ละ TXLSXExternalLink มี Target ซึ่งเป็น path หรือ URL ของ workbook ระยะไกล บวกกับรายชื่อ SheetNames ที่ระบุชื่อ sheet ที่มันอ้างอิงถึง ทั้งสองส่วนรอดจากรอบการเปิดแล้ว save ได้อย่างครบถ้วน และคุณยังสร้างลิงก์ขึ้นมาใหม่ตั้งแต่ต้นได้ด้วย:

var
  Link: TXLSXExternalLink;
begin
  Link := Book.ExternalLinks.Add('\\fileserver\finance\fx-rates-2026.xlsx');
  Link.SheetNames.Add('FX');

  if Book.ExternalLinks.Count > 0 then
    Writeln(Format('%d external link(s): delivery requires reachable targets',
      [Book.ExternalLinks.Count]));
end;

ขอบเขตอยู่ลึกลงไปอีกชั้นหนึ่งกว่ารายการเป้าหมาย HotXLS round-trip แผนที่ลิงก์ได้ หมายถึงเป้าหมายและรายชื่อ sheet แต่มันไม่ parse หรือเขียนค่าเซลล์ที่แคชไว้ใหม่ ซึ่ง OOXML เก็บไว้ใน element sheetDataSet ของลิงก์ แคชนั้นคือสิ่งที่ทำให้ Excel แสดงตัวเลขล่าสุดที่เคยเห็นได้เมื่อไฟล์ต้นทางออฟไลน์ และ workbook ที่ถูกสร้างขึ้นก็จะส่งออกไปโดยไม่มีมัน ผลที่ตามมาตกอยู่ที่ผู้รับ ไม่ใช่ที่คุณ เปิดไฟล์แบบนั้นในที่ที่เป้าหมายเข้าถึงไม่ได้ เช่นแล็ปท็อปที่ไม่ได้ต่อ VPN หรือ share ที่ถูกเปลี่ยนชื่อไปแล้ว สูตรที่พึ่งพาลิงก์นั้นก็จะกลายเป็น #REF! หรือค้างอยู่หลัง prompt ให้อัปเดต ดังนั้นจึงได้กฎสองข้อจากเรื่องนี้ อย่าสัญญาว่า workbook ที่สร้างขึ้นจะแสดงค่าที่ลิงก์ไว้ภายนอกได้แบบออฟไลน์ และให้อ่านค่า ExternalLinks.Count ที่ไม่เป็นศูนย์เป็นเงื่อนไขก่อนส่งมอบ ไม่ใช่ฟีเจอร์: ทุกเป้าหมายต้องเข้าถึงได้จากที่ที่ไฟล์จะถูกเปิดจริง ๆ

แผนภาพขั้นตอนการเรียกบันทึกของ Delphi ที่นามสกุล .xlsm เลือก content type แบบเปิดใช้แมโคร ขณะที่ .xlsx ทำให้ Excel ปฏิเสธแมโครอย่างเงียบๆ
HotXLS พาไบต์ดิบของ vbaProject.bin ผ่านการบันทึก และนามสกุล .xlsm คือสิ่งที่เลือกชนิดเนื้อหา macro-enabled ที่ Excel ต้องการ

XLS reader รักษาอะไรไว้แบบไบต์ต่อไบต์

สำหรับโครงสร้างที่ไม่ได้ถูกโมเดลไว้ ฝั่ง BIFF มีคำตอบที่ต่างออกไป: ปล่อยมันไว้ตามที่พบเจอเป๊ะ ๆ pivot cache และ pivot view (ตระกูล record SX*) นิยาม QueryTable การเชื่อมต่อข้อมูลภายนอก custom view รูปภาพส่วนหัว และ theme record ทั้งหมดผ่านรอบการเปิดแล้ว save ในฐานะก้อน record ดิบ ที่ไม่ถูก parse และไม่ถูกแก้ไข การอ้างอิงภายนอกเองก็ round-trip ผ่าน record EXTERNSHEET และ SupBook ที่อยู่ข้างใต้ ไม่มี typed creation API สำหรับมันในฝั่ง XLS เลย แต่ลิงก์ที่มีอยู่แล้วรอดจากการแก้ไขโดยไม่ถูกแตะต้อง

แผนภาพว่าส่วนใดของ external workbook link ของ HotXLS รอดจากการเขียนใหม่ และเกิดอะไรขึ้นเมื่อค่าที่แคชไว้หลัง sheetDataSet ไม่อยู่ตอนออฟไลน์
HotXLS ทำ round-trip เป้าหมายลิงก์และชื่อชีตของมัน แต่ค่าเซลล์ที่แคชไว้หลัง sheetDataSet ไม่ถูกพาไปยังไฟล์ที่สร้างขึ้น

การรักษาไว้แบบไบต์ต่อไบต์คือการรับประกันที่แท้จริง แต่ก็มีขอบคมของมันอยู่ เพราะไม่มีอะไรอ่านโครงสร้างที่ถูกรักษาไว้ การแก้ไขของคุณจึงทำให้มันเสียหายไม่ได้เลย ด้วยเหตุผลเดียวกัน ก็ไม่มีอะไรอัปเดตมันเช่นกัน แทรกแถวผ่านพื้นที่ที่ pivot cache หรือ query table ที่ถูกรักษาไว้ชี้ไปถึง แล้วโครงสร้างนั้นก็จะยังคงพิกัดเดิมไว้ ในขณะที่ข้อมูลข้างใต้เลื่อนไป ไฟล์ยังคงเป็น XML หรือ BIFF ที่ถูกต้องอยู่ แต่ความหมายได้เบี่ยงออกจากแนวไปแบบเงียบ ๆ และไม่มี error ใด ๆ ยิงขึ้นมาเตือนคุณเลย layout ที่ปลอดภัยคือเก็บการแก้ไขที่สร้างขึ้นไว้บน sheet ที่ไม่มีโครงสร้างที่ถูกรักษาไว้เลย ซึ่งเป็นวินัยเดียวกับที่ปกป้อง sheet ที่ล็อกไว้และตั้งค่าการพิมพ์แล้วใน บทความของเราเรื่อง worksheet protection และ page setup

ตรวจสอบไฟล์ที่คุณเขียนจริง ๆ

โหมดความล้มเหลวทั้งสองแบบเงียบสนิทตอนเขียน ดังนั้นการยืนยันที่สำคัญจริง ๆ ทำได้ด้วยการเปิดผลลัพธ์กลับมาใหม่ ไม่ใช่เชื่อโค้ดที่สร้างมันขึ้นมา มีสามการตรวจสอบที่ครอบคลุมแทบทุกอย่าง เปิดไฟล์กลับมาใหม่แล้วยืนยันว่า HasVbaProject ยังคืนค่า true อยู่เมื่อไรก็ตามที่คาดหวัง macro ไว้ ซึ่งจับได้ทั้ง payload ที่หายไปและนามสกุลไฟล์ที่ผิดในการทดสอบเดียว อ่าน ExternalLinks.Count แล้วเทียบกับจำนวนก่อนการเขียนใหม่ จากนั้นเปิดไฟล์หนึ่งครั้งใน Excel โดยปิด macro ไว้ เพราะการตรวจสอบ content-type ของ Excel เข้มงวดกว่าของไลบรารีใด ๆ และ Excel ก็คือโปรแกรมที่ลูกค้าของคุณจะใช้ตัดสินไฟล์นั้น

ไม่มีข้อไหนต้อง parse แบบเต็มตอนขาเข้าเลย เมื่อ workbook เข้ามาเป็นจำนวนมากและคุณแค่ต้องคัดแยกว่าไฟล์ไหนมีเนื้อหาที่ต้องกำกับดูแลเป็นพิเศษ การตรวจสอบแบบเบา ๆ ใน บทความของเราเรื่อง sheet listing และการตรวจสอบ workbook แบบเบา ก็ช่วยให้คุณ route ไฟล์ที่มี macro และลิงก์เข้าสู่ pipeline ที่เข้มงวดกว่าได้ ก่อนที่การเขียนใหม่ครั้งแรกจะรันเลยด้วยซ้ำ

มีคำถามไม่กี่ข้อที่โผล่มาบ่อยพอจะตอบตรง ๆ ไว้ตรงนี้ HotXLS ไม่เคยรัน macro ที่มันรักษาไว้เลย: ในไลบรารีไม่มี VBA runtime อยู่เลย มีแค่กลไกสำหรับเก็บ คัดลอก สกัด และฉีด project ในฐานะข้อมูลเท่านั้น บนเซิร์ฟเวอร์ นี่คือคุณสมบัติด้านความปลอดภัยที่ควรพูดถึงไว้ เพราะ macro ที่เป็นอันตรายซึ่งผ่าน pipeline ไปจะยังคงเฉื่อยชาอยู่จนกว่า Excel บนเดสก์ท็อปจะเปิดไฟล์และผู้ใช้เปิดใช้งาน content เอง การแปลง .xlsm เป็น .xlsx โดยยังเก็บ macro ไว้เป็นไปไม่ได้เลย และนั่นคือกฎของฟอร์แมตเอง ไม่ใช่ข้อจำกัดของไลบรารี: content type ของ .xlsx ประกาศว่าเป็น workbook ที่ปลอด macro ดังนั้นผลลัพธ์ที่ซื่อตรงมีแค่สองทาง คือคงเป็น .xlsm ต่อไป หรือเรียก ClearVbaProject แล้วส่งไฟล์ที่ไม่มี macro อยู่จริง ๆ ออกไป การเปลี่ยนชื่อแบบเงียบ ๆ คือทางเลือกเดียวที่ไม่ทำให้ใครพอใจเลย และเมื่อเซลล์ที่ลิงก์ไว้แสดง #REF! หลังเขียนใหม่ สาเหตุคือแคชค่าที่หายไปตามที่กล่าวไว้ข้างต้น: ไฟล์ใหม่พาเป้าหมายไปด้วยแต่ไม่พาตัวเลขที่แคชไว้ไปด้วย ดังนั้น Excel ต้อง resolve แหล่งที่มาตอนเปิดไฟล์ และ path ที่เข้าถึงไม่ได้หรือขึ้นกับ environment ก็จะเอาชนะมันได้ ให้เลือกอย่างใดอย่างหนึ่ง คือรับประกันว่าเป้าหมายเข้าถึงได้ หรือไม่ก็เขียนค่าที่คำนวณแล้วลงในเซลล์ก่อนส่งมอบแล้วตัด dependency นั้นทิ้งไปเลยทั้งหมด

การแก้ไข workbook ของคนอื่นส่วนใหญ่แล้วคืองานของการรักษาสิ่งที่คุณไม่ได้เขียนเองและไม่เข้าใจอย่างถ่องแท้ไว้ให้คงเดิม ความสามารถ round-trip ของ VBA และลิงก์ภายนอกที่อธิบายไว้ในบทความนี้มาพร้อมกับ HotXLS Delphi Component สำหรับ Delphi และ C++Builder พร้อมกับ property สำหรับ audit ที่ให้คุณตรวจจับเนื้อหาที่ต้องกำกับดูแลได้ทันทีที่ไฟล์มาถึง