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

Selection record กับ pane scroll ของ BIFF8 ใน HotXLS

HotXLS เก็บการเลือกช่วงของชีตกับตำแหน่ง scroll แยกตาม pane ผ่าน API ที่รู้เรื่อง pane ชุดเดียวบนทั้ง TXLSWorksheet และ TXLSXWorksheet: SelectAreas, GetSelectedAreas, ScrollWindow และ TryGetWindowScroll สำหรับไฟล์ .xls คลาสสิก HotXLS เขียน BIFF8 Selection record (0x001D) ก้อนละไม่เกิน 1369 area แปลงชื่อ pane เชิงตรรกะเป็น pane byte ที่ฟอร์แมตนิยามไว้ และเก็บแกน scroll แต่ละแกนไว้บน Window2 หรือ Pane record ที่ Excel ตั้งใจให้อยู่

ปัญหานี้มักโผล่ในเครื่องมือ reconcile หรือ audit ตัวเครื่องมือเปิด export ของบัญชีแยกประเภท หาทุกเซลล์ที่ไม่ตรงกับระบบต้นทาง แล้ว save เวิร์กบุ๊กโดยเลือกเซลล์พวกนั้นไว้แล้วใต้แถวหัวตารางที่ freeze เอาไว้ ผู้ตรวจจึงตกลงบนจุดที่ต่างกันเลย ไม่ต้องเลื่อนไปตามหา สี่สิบจุดก็ทำงานได้สบาย ไฟล์ปลายเดือนมี 3,000 จุด และ Selection record เดียวที่ถือ 3,000 area นั้นมีตัวตนไม่ได้: body ของมันต้องใช้ 18,009 byte มากกว่าสองเท่าของที่ record BIFF8 หนึ่งอันจับได้ ตำแหน่ง scroll ก็มีกับดักแบบคล้ายกัน บนชีตที่ freeze pane ไว้ "ตอนที่ผู้ใช้มองอยู่" คือสี่ pane ที่ใช้ตำแหน่งแถวสองตัวกับตำแหน่งคอลัมน์สองตัวร่วมกัน ไม่ใช่พิกัดเดียว

ทำไมการเลือกช่วงใหญ่ถึงต้องใช้ Selection record มากกว่าหนึ่งอัน

การเลือกช่วงใหญ่ต้องใช้หลาย record เพราะ body ของ record BIFF8 ถูกจำกัดที่ 8224 byte และ area ที่เลือกแต่ละตัวมีราคาคงที่หก byte [MS-XLS] §2.4.248 วางโครง Selection record เป็นส่วนคงที่ 9 byte (pane byte, rwAct กับ colAct ของ active cell, irefAct ของ active area และ cref สำหรับจำนวน area) ตามด้วยโครงสร้าง RefU จำนวน cref ตัว แต่ละตัวถือแถว 16-bit สองตัวกับคอลัมน์ 8-bit สองตัว จำนวนมากสุดที่พอดีคือ (8224 − 9) / 6 ปัดลง ได้ 1369 และให้ body ขนาด 8223 byte ต่ำกว่าเพดานหนึ่ง byte TXLSWorksheet.StoreSelectionGroup ใช้ค่าคงที่นี้เป็น MaxAreasPerRecord และเขียนกลุ่มที่ใหญ่กว่าเป็น Selection record ต่อเนื่องกันของ pane เดียวกัน ครั้งละ 1369 area

รายละเอียดที่กัดคือ irefAct แต่ละ chunk พูดซ้ำ active row, active column และ active area index เดิม และ irefAct ชี้เข้าลำดับรวมของทุก chunk ไม่ใช่ area ภายใน record ที่มันขี่อยู่ การเลือกที่ยาวเกินเพดานไปหนึ่ง area ทำให้เห็นภาพชัด: 1370 area ที่ตัวสุดท้ายถูกเลือกอยู่กลายเป็นสอง record อันแรก cref 1369 อันที่สอง cref 1 และทั้งคู่พก irefAct 1369 ค่านั้นมากกว่าจำนวน area ของ record ที่สองเอง reader ที่เช็ก irefAct กับ cref ทีละ record จะปฏิเสธไฟล์ที่ถูกต้อง และ reader ที่เขียนทับสถานะทุก record จะทิ้ง 1369 area แรกไป reader ของ HotXLS ต่อ record ต่อเนื่องของ pane เดียวกันเข้าเป็นกลุ่มเดียว บังคับให้ทุก chunk เห็นตรงกันเรื่อง active cell กับ index และรัน range check เฉพาะที่ EOF record ของชีต ตอนที่ลำดับเต็มรู้แล้ว overload แบบ pane-first ของ SelectAreas จึงไม่มีเพดาน 1369 area มันตรวจ reference A1 ทุกตัวกับ active index ก่อนจับ write lock ของชีต และคืน False โดยการเลือกเดิมไม่เปลี่ยน ถ้ามีอะไรเป็น malformed

ทำไม HotXLS เขียนการเลือกช่วงใหญ่ของชีตเป็น BIFF8 Selection record หลายอัน: เพดาน body 8,224 byte จุ 9 byte คงที่บวก RefU หก byte ต่อ area ได้ 1369 area พอดี 3,000 area จึงกลายเป็นสาม record ของ pane เดียวกันคือ 1369, 1369 และ 262 และ irefAct ชี้ลำดับรวม ทำให้ 1370 area ที่ตัวสุดท้ายถูกเลือกติด irefAct 1369 ทั้งสอง record
ทุก chunk พูดซ้ำ active cell กับ index เดิม reader ของ HotXLS ต่อ record ต่อเนื่องของ pane เดียวกันเข้าเป็นกลุ่มเดียว และ range check รันเฉพาะที่ EOF record เมื่อลำดับเต็มเป็นที่รู้แล้ว
var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // แถวหัวตารางกับคอลัมน์ A อยู่กับที่

    SetLength(Diffs, 3000);
    for I := 0 to High(Diffs) do
      Diffs[I] := Format('C%d', [I + 2]);

    // การ freeze ล้างการเลือกที่เก็บไว้ จึงต้องเลือกหลัง freeze
    // 3000 area ถูก save เป็นสาม Selection record: 1369 + 1369 + 262
    if not Sheet.SelectAreas(xlspBottomRight, Diffs, 0) then
      raise Exception.Create('Selection rejected');

    Book.SaveAs('reconciliation.xls');
  finally
    Book.Free;
  end;
end;

Selection record ใช้ pane byte ตัวไหน

Selection record ระบุ pane ของมันด้วยรหัสตัวเลขที่ฟอร์แมตนิยามไว้: 0 สำหรับขวาล่าง, 1 ขวาบน, 2 ซ้ายล่าง และ 3 ซ้ายบน ส่วน enum สาธารณะ TXLSPanePosition ประกาศตามลำดับการอ่าน คือ xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight Ord(xlspTopLeft) จึงเท่ากับ 0 ซึ่งเป็น pane ขวาล่างในไฟล์ ถ้า cast enum ตรง ๆ ลง pane byte การเลือกทุกอย่างฝั่งซ้ายบนจะถูกเขียนลง pane ขวาล่างโดยไม่มี error สักตัว จุดเข้าใช้งานของ HotXLS ทุกตัวที่รู้เรื่อง pane จึงแปลง enum ผ่าน case statement อย่างชัดเจน ผู้เรียกจึงไม่ต้องจับรหัสตัวเลขเลย การมีตัวตนของ pane ก็ถูกเช็กด้วย: pane ขวาบนมีอยู่เฉพาะเมื่อ split แนวตั้ง, ซ้ายล่างเฉพาะ split แนวนอน และขวาล่างเฉพาะเมื่อมีทั้งคู่ สำหรับ pane ที่เรขาคณิต split หรือ freeze ปัจจุบันไม่มี SelectAreas คืน False และ GetSelectedAreas คืน array ว่างพร้อม ActiveAreaIndex เป็น -1 โดยไม่สร้าง pane, object การเลือก หรือเซลล์ในเวิร์กบุ๊กใด ๆ

HotXLS map TXLSPanePosition ลง pane byte ของ Selection ใน BIFF8 อย่างไร: enum ประกาศตามลำดับการอ่าน จึงได้ Ord(xlspTopLeft) เป็น 0 ขณะที่ไฟล์นิยาม 0 สำหรับขวาล่าง, 1 ขวาบน, 2 ซ้ายล่าง และ 3 ซ้ายบน จุดเข้าทุกตัวที่รู้เรื่อง pane จึงแปลงผ่าน case statement อย่างชัดเจน
การ cast enum ตรง ๆ ลง pane byte จะเขียนการเลือกทุกอย่างฝั่งซ้ายบนลง pane ขวาล่าง HotXLS จึงเช็กการมีตัวตนของ pane กับเรขาคณิต split หรือ freeze ปัจจุบันก่อนเขียนด้วย

ตำแหน่ง scroll ของ pane แต่ละตัวอยู่ที่ไหน

ตำแหน่ง scroll ของ pane แต่ละตัวถูกแบ่งอยู่บนสอง record เพราะสี่ pane ใช้ตำแหน่งแถวสองตัวกับตำแหน่งคอลัมน์สองตัวร่วมกันเท่านั้น ในเวิร์กบุ๊กคลาสสิก แถวแรกที่มองเห็นของ pane ฝั่งบนกับคอลัมน์แรกของ pane ฝั่งซ้ายคือ Window2.rwTop กับ Window2.colLeft ส่วนแถวของ pane ฝั่งล่างกับคอลัมน์ของ pane ฝั่งขวาคือ Pane.rwTop กับ Pane.colLeft ScrollWindow(xlspTopRight, R, C) จึงเขียน Window2.rwTop กับ Pane.colLeft และการตั้งคอลัมน์ของ pane ขวาบนก็ดัน pane ขวาล่างไปด้วย เหมือนที่ทั้งสองใช้ scrollbar แนวนอนตัวเดียวร่วมกันใน Excel เมธอดสาธารณะใช้เลขแถวกับคอลัมน์แบบเริ่มที่ 1 pane ที่ไม่มีจริงคืน False แล้วตั้งผลลัพธ์ของการถามทั้งสองค่าเป็นศูนย์ และพิกัดที่เกินช่วงถูกปฏิเสธก่อนแกนใดจะขยับ ทั้งหมดนี้ไม่ขึ้นกับว่า viewer วาด grid อย่างไร control ฝั่ง render เก็บ TopRow กับ LeftCol ของตัวเอง ดังที่บทความเรื่องการ render เวิร์กบุ๊กใน VCL grid แบบ custom อธิบาย และค่าพวกนั้นเป็น state ตอน runtime ไม่ใช่สิ่งที่ถูก save

แกน scroll ของ pane แต่ละตัวใน HotXLS อยู่ตรงไหน: สี่ pane ใช้ตำแหน่งแถวสองตัวกับคอลัมน์สองตัวร่วมกัน แถวฝั่งบนกับคอลัมน์ฝั่งซ้ายจึงเป็น Window2.rwTop กับ Window2.colLeft ส่วนแถวฝั่งล่างกับคอลัมน์ฝั่งขวาเป็น Pane.rwTop กับ Pane.colLeft และ ScrollWindow(xlspTopRight, 1, 6) เขียน field Window2 หนึ่งตัวบวก field Pane หนึ่งตัว ขวาล่างจึงตามไปด้วย
XLSX กระจายข้อมูลชุดเดิมไว้บน attribute topLeftCell ของ sheetView กับ pane และการยุบสองชั้นนี้ให้เหลือชั้นเดียวคือทางเดียวที่ตำแหน่ง scroll ฝั่งบนหรือซ้ายหายไปเงียบ ๆ ตอนโหลด

XLSX กระจายข้อมูลชุดเดิมไว้บนสอง element: sheetView/@topLeftCell (ECMA-376 Part 1, §18.3.1.87) สำหรับหน้าต่างทั้งอัน และ element ลูก pane/@topLeftCell (§18.3.1.66) สำหรับฝั่งขวาล่างของ split ทั้งสอง attribute มาพร้อมกันได้ HotXLS อ่าน attribute ชั้นนอกเข้า field ระดับหน้าต่างก่อน ให้ element ลูก pane เขียนทับเฉพาะ field ระดับ pane แล้วเขียนทั้งคู่กลับแยกจากกัน การยุบสองชั้นให้เหลือชั้นเดียวคือทางเดียวที่ตำแหน่ง scroll ฝั่งบนหรือซ้ายหายไปเงียบ ๆ ตอนโหลด การ copy ชีตพกทั้งสองชั้นในทั้งสอง engine จุดเข้ารุ่นเก่ายังคงพฤติกรรมเดิม: property ScrollRow กับ ScrollColumn แบบคลาสสิก และ SetPaneScroll กับ GetPaneScroll ของ XLSX แบบเริ่มที่ 0 ส่วนเรขาคณิต freeze กับ split เองตั้งค่าผ่านการตั้งค่าระดับชีตที่การป้องกันชีต, การตั้งหน้ากระดาษ และการพิมพ์ ครอบอยู่

var
  Row, Col: Integer;
begin
  Sheet.FreezePanes(1, 1);

  // ขวาล่าง: แกนแถวฝั่งล่าง (Pane.rwTop) กับแกนคอลัมน์ฝั่งขวา (Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // ขวาบนใช้แกนคอลัมน์ฝั่งขวาร่วมกัน คำสั่งนี้จึงดันขวาล่างไปคอลัมน์ 6 ด้วย
  Sheet.ScrollWindow(xlspTopRight, 1, 6);

  if Sheet.TryGetWindowScroll(xlspBottomRight, Row, Col) then
    Memo1.Lines.Add(Format('Bottom-right starts at row %d, column %d', [Row, Col]));
    // ขวาล่างเริ่มที่แถว 500, คอลัมน์ 6
end;

เกิดอะไรขึ้นเมื่อ Selection record เสียหาย

เมื่อ Selection record เสียหาย HotXLS เก็บมันไว้เป็น byte ล่องหน รายงาน diagnostic code 1304 (xlsDiagnosticSelectionRecordInvalid) แล้วเขียน body เดิมกลับลงไป byte ต่อ byte ตอน save ก่อน record หนึ่งจะเข้ากลุ่มของ pane ตัวเอง reader ตรวจมันตามลำดับ pane byte ต้องไม่เกิน 3 record ของ pane เดียวกันต้องต่อเนื่องกันใน stream 9 byte คงที่ต้องมีครบ cref ต้องอยู่ระหว่าง 1 ถึง 1369 และ body ต้องยาวพอดี 9 + cref × 6 byte ทุก chunk ในกลุ่มต้องเห็นตรงกันเรื่อง active cell กับ irefAct irefAct ห้ามมี sign bit ติด คอลัมน์ของ active cell ต้องอยู่บน grid และห้ามมี area ใดมีขอบสลับกัน ปัญหาที่อยู่ใน record กายภาพตัวเดียวรายงานครั้งเดียวต่อ record ส่วนข้อขัดแย้งที่โผล่เฉพาะหลังรวมกลุ่มแล้ว อย่าง irefAct ที่ชี้เลยจำนวน area รวมหรือ active cell ที่อยู่นอก area ที่ index ชี้ รายงานครั้งเดียวต่อกลุ่มที่ EOF กลุ่มที่ invalid จะมองไม่เห็นจาก typed API: GetSelectedAreas คืน array ว่างกับ index -1 สำหรับ pane นั้น ขณะที่ pane อื่นทุกตัวยังทำงานปกติ

var
  I: Integer;
  D: TXLSDiagnostic;
begin
  if Book.Open('supplier-upload.xls') <> 1 then
    Exit;
  for I := 0 to Book.Diagnostics.Count - 1 do
  begin
    D := Book.Diagnostics[I];
    if D.Code = xlsDiagnosticSelectionRecordInvalid then
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

การเลือกช่วงรอดจากการแทรกแถวกับคอลัมน์อย่างไร

การเลือกช่วงรอดจากการแก้โครงสร้างได้ เพราะการแทรกหรือลบทั้งแถวหรือทั้งคอลัมน์จะ map ใหม่ทุกกลุ่ม pane ที่ถูกจดไว้ในทั้ง classic และ XLSX engine ผ่าน remapper ตัวเดียวที่ใช้ร่วมกัน area ที่รอดรักษาลำดับของมัน และ active area ก็รักษาตัวตนของมัน ถ้า active area ถูกลบ ตัวสืบทอดที่รอดตัวแรกจะขึ้นเป็น active และถ้าไม่มีตัวต่อจากมันแล้ว ก็เป็นตัวก่อนหน้าที่รอดตัวสุดท้าย ถ้าทุก area ถูกลบ กลุ่มจะยุบเหลือหนึ่งเซลล์ที่ขอบเขตของการลบ และ active cell ที่ไม่ได้อยู่ใน area ที่เลือกอีกต่อไปจะย้ายไปมุมซ้ายบนของ area นั้น index กับพิกัดจึงไม่มีวันขัดแย้งกันเอง ขอบเขตเหล่านี้ตั้งใจให้เป็นแบบนี้ กลุ่มคลาสสิกที่ invalid ถูก remapper ข้ามไป แทนที่จะถูกแต่งใหม่เป็นการเลือกที่สมมุติขึ้น ไฟล์ต้นฉบับของมันจึงยัง round-trip ได้ การแก้ pane เดียวเขียนทับเฉพาะ record ของ pane นั้น อื่น ๆ ยังเหมือนเดิมทีละ byte ส่วน ODS ไม่มี state การเลือกแบบ pane ให้เลย เพราะ ODF ไม่มีโครงสร้าง view ของชีตที่เทียบเท่าเพื่อพกมัน

ถ้าแอปพลิเคชันของคุณเขียนไฟล์ .xls ที่ผู้ใช้ต้องเปิดแล้วเดินดูต่อ ไม่ว่าจะไล่ตรวจเซลล์ที่ถูก mark ไว้, กลับมาที่เดิมที่หยุดไว้ หรือแชร์ dashboard ที่ freeze เอาไว้ API การเลือกช่วงกับ scroll ที่รู้เรื่อง pane เป็นส่วนหนึ่งของ HotXLS Delphi spreadsheet component และทำงานเหมือนกันทั้ง XLS และ XLSX