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

BIFF SupBook และ XTI: จำแนกลิงก์ภายนอกใน Delphi

เปิดไฟล์ xls เก่า บันทึกซ้ำอีกครั้ง แล้วสูตร add-in ที่เคยเรียกเข้าไลบรารีวิเคราะห์ข้อมูลที่ลงทะเบียนไว้กลับชี้ไปที่การอ้างอิงว่าง ๆ ภายในเวิร์กบุ๊กตัวเอง HotXLS สืบย้อนความเสียหายเงียบ ๆ แบบนี้จนเจอสมมติฐานผิดเพียงข้อเดียว: เรกคอร์ด BIFF SupBook ต้องเป็นได้แค่ self หรือไฟล์ภายนอกเท่านั้น [MS-XLS] นิยามไว้เจ็ดชนิด ไม่ใช่สอง

ทำไมเวิร์กบุ๊กที่บันทึกแล้วจึงเสียลิงก์ add-in?

เพราะการทดสอบจำแนกชนิดใช้โครงสร้างแทนที่จะอิงประเภททางตรรกะ ทางลัดแบบดั้งเดิมอ่านเรกคอร์ด SupBook ($01AE) ตรวจว่ามี self marker หรือไม่ ถ้าไม่มีก็ถือว่าสตริงที่ตามมาคือ URL ของเอกสาร เรกคอร์ดทุกตัวที่ไม่ตรงสองอย่างนั้นจะไหลลงสู่ branch ปริยาย และ branch ปริยายแทบตอบเสมอว่า "นี่คือเวิร์กบุ๊กเอง" ลิงก์สนับสนุน add-in ลิงก์ same-sheet ช่องที่ไม่ได้ใช้ และเรกคอร์ดที่ถูกตัดขาด ล้วนติดป้ายผิดเหมือนกันหมด ระหว่างนั้นไม่มีอะไร throw เลย: เรกคอร์ดถูก parse สูตรถูกคอมไพล์ใหม่ ไฟล์ถูกบันทึกโดยไร้คำเตือน แล้วข้อบกพร่องโผล่มาสามสัปดาห์ถัดมาเมื่อมีคนสังเกตเห็นคอลัมน์เลขศูนย์แทนที่การแปลงสกุลเงินเดิม [MS-XLS] §2.4.271 บรรยายเรกคอร์ดที่อาจเป็น self-reference, same-sheet reference, คอนเทนเนอร์ฟังก์ชัน add-in, เวิร์กบุ๊กภายนอกพร้อม virtual path กับตารางชื่อชีต, ลิงก์ข้อมูล DDE หรือ OLE หรือตัวยึดตำแหน่งที่ไม่ใช้ — และสถานะที่เจ็ดซึ่งไม่อยู่ในสเปกแต่มีอยู่บนดิสก์จริง คือเรกคอร์ดที่ parse ไม่ผ่าน ทางแก้ไม่ใช่ฮิวริสติกที่ฉลาดขึ้น แต่คือการปฏิเสธที่จะมีฮิวริสติกเลย

เจ็ดชนิดที่เรกคอร์ด SupBook แบกได้

HotXLS ประกาศชุดจำแนกประเภทของลิงก์สนับสนุนเป็น enumeration แบบปิดใน lxExternSheet.pas และการตัดสินใจปลายทางทุกจุดสลับบนมัน เก้าค่า enumeration ครอบคลุมเจ็ดหมวด เพราะกรณี DDE กับ OLE ต้องมีสถานะชั่วคราวก่อนจะ resolve ได้:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // parse ไม่ผ่าน หรือมีไบต์เกินท้าย
    slkSelf,              // เวิร์กบุ๊กนี้
    slkSameSheet,         // U+0000 marker
    slkAddIn,             // คอนเทนเนอร์ฟังก์ชัน add-in
    slkExternalWorkbook,  // virtual path + ตารางชื่อชีต
    slkDde,               // resolve จาก flag ของ ExternName
    slkOle,               // resolve จาก flag ของ ExternName
    slkDdeOrOle,          // สองอย่างนี้อย่างใดอย่างหนึ่ง ยังไม่รู้ว่าอะไร
    slkUnused);           // ตัวยึดตำแหน่งเว้นวรรคเดียว

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // zero-based ตามที่เก็บใน ExternSheet.rgXTI
    ExternID    : Integer;   // one-based ตามธรรมเนียมภายใน
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

การ dispatch ขับเคลื่อนด้วยเซนติเนล ไม่ใช่ด้วยสตริง ค่าฟิลด์ $0401 หมายถึงเรกคอร์ด self จำนวนชีตเท่ากับหนึ่งคู่กับ $3A01 หมายถึงคอนเทนเนอร์ add-in เฉพาะค่าในช่วง 1 ถึง $00FF เท่านั้นที่แปลว่า virtual path แบบเข้ารหัสตามมา และก็ต่อเมื่อนั้น HotXLS จึงจะถอดสตริงสักตัว สิ่งใดอยู่นอกสามรูปแบบนี้คงอยู่ที่ slkUnknown และเรกคอร์ดที่ตารางชื่อชีตไม่กินเนื้อหาเรกคอร์ดพอดีจะถูกลดระดับกลับไปเป็น slkUnknown แม้หัวเรกคอร์ดจะดูน่าเชื่อก็ตาม

บันไดจำแนกแบบขับเคลื่อนด้วยเซนติเนลที่ HotXLS ใช้จัดชนิดเรกคอร์ด BIFF SupBook เป็นเจ็ดชนิด ถอดสตริงเฉพาะค่าในช่วง encoded path และตกไปเป็นชนิด unknown แทนที่จะลง branch ปริยาย
แต่ละชนิดถูกเข้าถึงด้วยเซนติเนล ไม่ใช่การทดสอบสตริง และเรกคอร์ดที่ไม่ตรงรูปแบบใดเลยจะคงอยู่แบบ unknown แทนที่จะหลุดเข้า branch ปริยายที่หมายถึงเวิร์กบุ๊กนี้เอง

ทำไม same-sheet marker จึงถอดออกมาเป็นสตริงว่าง?

เพราะตัวอ่านสตริง BIFF แบบใช้งานทั่วไปทำลายไบต์ที่การจำแนกพึ่งพา ลิงก์สนับสนุน same-sheet เป็นสตริงหนึ่งอักขระที่อักขระนั้นคือ U+0000 และ TXLSBlob.GetBiffString ส่งค่านั้นกลับมาเป็น WideString ว่าง ๆ ซึ่งแยกไม่ออกจาก path ที่ว่างจริง ๆ — นั่นเองคืออินพุตที่ฮิวริสติก self-reference ตอบว่า self HotXLS จึงอ่าน code point แรกแบบดิบออกจากเนื้อเรกคอร์ด แทนที่จะเชื่อค่าที่ถอดแล้ว:

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // บีบอัด หนึ่งไบต์
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // กว้าง สองไบต์
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

สังเกต branch ระหว่างบีบอัดกับกว้าง ไบต์ option อยู่ที่ออฟเซ็ตคงที่จากหัวสตริง และ code point แรกเป็นหนึ่งหรือสองไบต์ขึ้นกับบิต 0 การอ่านมันเป็นไบต์อย่างเดียวโดยไม่มีเงื่อนไขจึงผ่านกับไฟล์ส่วนใหญ่แต่พังกับไฟล์ที่เขียนโดยบิลด์แบบ localized — การกระจายที่แย่ที่สุดเท่าที่บั๊กจะเป็นได้ ตัวยึดตำแหน่ง unused ถูกจับด้วยวิธีเดียวกัน จาก payload เว้นวรรคเดียวแบบ literal ส่วนกรณี DDE หรือ OLE จับจากตัวคั่น U+0003 ที่ฝังอยู่ใน encoded path

เหตุผลที่ HotXLS อ่าน code point แรกแบบดิบจากเนื้อเรกคอร์ด BIFF SupBook แทนสตริงที่ถอดแล้ว เพราะตัวอ่านสตริงทั่วไปพับ same-sheet U+0000 marker ให้กลายเป็นค่าว่าง
same-sheet marker เป็นสตริงหนึ่งอักขระที่อักขระนั้นคือ U+0000 ตัวอ่านสตริงทั่วไปจึงพับมันเป็นค่าว่าง และมีเพียง code point ดิบที่ออฟเซ็ต option-byte เท่านั้นที่รักษามันไว้

ทำไมจึงแยก DDE กับ OLE ไม่ได้ตอนอ่าน SupBook?

เพราะเรกคอร์ด SupBook ไม่ได้แบกบิตที่ใช้แยก มันบอกได้แค่ว่าลิงก์เป็นสองอย่างนี้อย่างใดอย่างหนึ่ง ส่วน flag fOle กับ fOleLink ที่ชี้ขาดอยู่ในเรกคอร์ด ExternName ($0023) ซึ่งมาถึงทีหลังในสตรีม HotXLS บันทึก slkDdeOrOle ไว้ตอน parse แล้วค่อยแคบลงใน ParseExternalName ถ้าไม่มี ExternName มาเลย สถานะก็คงอยู่แบบชั่วคราวตลอดไป — ซึ่งถูกต้องแล้ว เพราะไฟล์ก็ตกลงไม่ได้บอกจริง ๆ ผู้บริโภคปลายทางทุกตัวถือค่าชั่วคราวนั้นเป็นค่าจริง ไม่ใช่ค่าที่หายไป ผู้เรียกจึงไม่ต้องแต่งทางตัดสินขึ้นมาเอง เดาว่า "น่าจะ DDE" ตรงนี้จะแลกมาได้แค่ enumeration ที่ดูเรียบร้อยขึ้น บวกกับคำตอบผิดทั้งประเภทที่ไม่มีใครสืบย้อนได้:

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

ดัชนี XTI เป็น zero-based บนดิสก์ แต่ one-based ข้างใน

HotXLS ทำการแปลง off-by-one นี้เพียงจุดเดียว คือตอน token เข้าสู่ syntax tree ภายใน และไม่ทำที่อื่นอีก PtgNameX.ixti ([MS-XLS] §2.5.198.85) เป็นดัชนี zero-based ลงในอาร์เรย์ rgXTI ของเรกคอร์ด ExternSheet ($0017, §2.4.106) ขณะที่ธรรมเนียม ExternID ภายในของไลบรารีเป็น one-based โดยศูนย์สงวนไว้ให้ "ไม่มีชีตภายนอก" เส้นทางอ่าน BIFF8 ทำ FExternID := wValue + 1 เมื่อถอด token tNameX และเส้นทางเขียนปล่อย StoreExternID - 1 โดยมุมมอง token ดิบกับความหมายบนดิสก์ยังคงเดิม พลาดจุดนี้แล้วจับยากผิดปกติ: ชื่อที่นิยามภายนอก resolve ไปติดรายการข้างเคียง ในไฟล์ที่มีรายการ XTI เดียว ดัชนี 0 กลายเป็น 1 เลื่อนพลาด แล้วชื่อนั้นเสียเงียบ ๆ การถดถอยที่ซ้อมแค่คอมไพล์ข้อความสูตรใหม่ไม่มีวันเห็นมัน เพราะการคอมไพล์ใหม่ไม่แตะดัชนีบนดิสก์เลย — กับดักเดียวกับที่ทำให้ ชื่อที่นิยามคร่อมชีตและเวิร์กบุ๊กคุ้มค่าที่จะทดสอบกับสตรีมไบต์จริง การ resolve ถูกจำกัดขอบเขตทั้งสองปลาย: TlxExternSheetSheet.TryResolveXti คืน False เมื่อดัชนีติดลบหรือรายการหาย TXLSSupBook.TryGetKind คืน False เมื่อดัชนี SupBook หลุดอาร์เรย์ แล้ว ClassifyXti จึงแมป slkSelf กับ slkSameSheet ไป frcInternal, slkExternalWorkbook ไป frcExternalWorkbook และ slkAddIn, slkDde, slkOle กับ slkDdeOrOle ไป frcExternalOther ที่เหลือทั้งหมด รวมทุกเส้นทาง out-of-range ลงเอยที่ frcUnknownOrMalformed

HotXLS แปลงดัชนี XTI แบบ zero-based ของ token BIFF PtgNameX เป็น ExternID ภายในแบบ one-based ที่จุดเดียว พร้อมการ resolve ที่จำกัดขอบเขตทั้งสองปลาย และแผนที่จำแนกชนิดที่รับต่อ
off-by-one ระหว่างดัชนีบนดิสก์แบบ zero-based กับ ExternID ภายในแบบ one-based ถูกใช้เพียงครั้งเดียว ตอน token เข้า syntax tree และดัชนีที่ resolve ไม่ได้ทุกตัวลงเอยที่คลาส malformed

จำแนกสูตรก่อนแปลงเป็นค่า

TXLSCompiledFormula.ClassifyReferences สแกนสตรีม token BIFF ที่เก็บไว้โดยตรง แทนที่จะถอดสูตรออกมาแล้วค้นหาวงเล็บเหลี่ยม การล่าวงเล็บในข้อความสูตรคือฮิวริสติกสวมเสื้อคลุม parser: มันตรงกับ string literal ตรงกับ structured reference และพลาดชื่อที่นิยามภายนอกทั้งหมด เพราะชื่อพวกนั้นไม่มีวงเล็บในรูปที่ถอดแล้ว การสแกน token มองเฉพาะ PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d กับ PtgAreaErr3d โดยมีการเดิน syntax tree เป็นทางสำรองเมื่อสตรีม BIFF ไม่รอดมา การ merge ตั้งใจให้ถ่อมตัว — ลำดับความสำคัญคงที่คือ frcUnknownOrMalformed, ตามด้วย frcExternalWorkbook, ตามด้วย frcExternalOther, ตามด้วย frcInternal — มี token เดียวที่อ่านไม่ได้ก็พอจะเป็นพิษต่อสูตรทั้งตัว สำหรับชื่อที่นิยามภายนอก ดัชนีชื่อถูกตรวจด้วย: เป็น one-based, อยู่ในช่วง และมีเรกคอร์ด ExternName ที่ถูกเก็บไว้รองรับ

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Sheets เป็น one-based
    begin
      Sheet := Wb.Sheets[i];
      // แปลงเป็นค่าเฉพาะสูตรที่จำแนกเป็น frcExternalWorkbook เท่านั้น
      // ส่วนการอ้างอิง internal, add-in, DDE/OLE และเสียรูปยังคงเป็นสูตร
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

พารามิเตอร์ OnlyExternal คือจุดที่ชุดจำแนกประเภทนี้คืนทุน การแปลงสูตรเป็นค่าเป็นการกระทำที่ย้อนกลับไม่ได้ การกระทำนั้นจึงต้องพิสูจน์ว่าการอ้างอิงเป็นเวิร์กบุ๊กภายนอก ไม่ใช่แค่สงสัย การเรียก add-in รอด DDE กับ OLE รอด และสิ่งใดที่ parser ยังเข้าใจไม่ครบก็รอด เพราะผลลัพธ์ที่ปลอดภัยของความไม่แน่นอนคือการไม่เปลี่ยนอะไรเลย วินัยแบบเดียวกันนี้กำกับ การผูกสูตรที่คัดลอกระหว่างเวิร์กบุ๊กใหม่ด้วย ซึ่งการอ้างอิงที่จำแนกผิดจะผูกเข้าเวิร์กบุ๊กผิดตัว แทนที่จะพังอย่างเสียงดัง

เรกคอร์ดที่ parse ไม่ผ่านจะถูกเขียนกลับแบบไม่แตะต้อง

HotXLS เก็บ payload ของ SupBook ต้นฉบับไว้และปล่อยมันกลับออกไปทีละไบต์เมื่อเรกคอร์ดไม่เคยถูกแก้ ความล้มเหลวในการ parse ตั้ง slkUnknown และล้างสถานะที่ derive มา แต่เนื้อหาที่จับไว้ยังอยู่ใน FRawData และเส้นทางบันทึกเลือกใช้มันแทนการสร้างใหม่ ตราบใดที่รายการไม่ dirty และไม่ใช่เรกคอร์ด self ทางเลือกตรงข้าม — การทำให้เรกคอร์ดที่ parse ไม่ได้กลายเป็น self-reference เพื่อให้ตัวเขียนมีของที่รูปร่างดูดีจะได้ปล่อยออกไป — เท่ากับแปลงเรกคอร์ดที่คุณไม่เข้าใจให้กลายเป็นเรกคอร์ดที่ผิดแน่นอน หลักการนี้คือสัญญาเดียวกับที่ใช้กับ โปรเจกต์ VBA กับการอ้างอิงภายนอกของมันตลอดวงจรโหลดและบันทึก และมันคือเส้นแบ่งระหว่างไลบรารีที่ round-trip ไฟล์จริงในโลกได้ กับไลบรารีที่ round-trip ได้แค่ไฟล์ที่ชุดทดสอบของมันบังเอิญมี เวิร์กบุ๊กที่ผ่านมือ Excel สิบห้าปี โปรแกรมสร้างรายงานหนึ่งตัว และเครื่องมือย้ายข้อมูลสองตัว จะมีเรกคอร์ดที่ไม่มีใครที่ยังมีชีวิตอยู่เคยออกแบบ เขียนมันกลับไปตามที่คุณพบ

การจำแนกประเภทของเรกคอร์ด SupBook กับ XTI ที่มีชนิดชัดเจน มาพร้อม HotXLS 2.361.2 ถึง 2.361.4 ร่วมกับการ resolve XTI ที่จำกัดขอบเขต และเส้นทาง ConvertFormulasToValues ที่ปลอดภัยขึ้นตามที่เล่าไว้ในบทความนี้ ถ้าคุณดูแลโค้ด Delphi หรือ C++Builder ที่อ่านไฟล์ xls รุ่นเก่าซึ่งแบกการเรียก add-in, ลิงก์ DDE หรือ OLE หรือชื่อที่นิยามภายนอก คอมโพเนนต์สเปรดชีต HotXLS สำหรับ Delphi รองรับชุดจำแนกประเภททั้งหมดแบบ native โดยไม่ต้องติดตั้ง Excel และไม่ใช้ OLE automation บนเครื่องที่ทำงาน