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

ฝังออบเจกต์ชาร์ตลงบนชีตงานด้วย HotXLS

HotXLS วางชาร์ตลงบนชีตงานได้โดยตรง สมอไว้กับช่วงเซลล์ แทนที่จะกดมันไว้บน chart sheet เฉพาะกิจ พูดในศัพท์ BIFF8 คือการเขียน drawing shape พร้อม OBJ record ชนิด 5 แล้วจอด substream ของชาร์ตไว้ท้ายสุดของ sheet record stream ซึ่งเป็นเลย์เอาต์ที่ Excel ผลิตออกมาเป๊ะ ๆ และเป็นที่ที่ reader คาดว่าจะเจอ

ความต่างนี้สำคัญกับใครก็ตามที่ผลิตรายงานเชิงปฏิบัติการ chart sheet เป็นบ้านที่ดีของภาพพรีเซนต์ตัวเดียว แต่รายงานรายเดือนแยกภูมิภาคต้องการชาร์ตที่นั่งติดตัวเลขที่มันสรุป อยู่ชีตเดียวกัน ขนาดตรงบล็อกเซลล์ที่มันเป็นของ ผู้อ่านจึงเลื่อนหน้าจอแค่รอบเดียว ไม่ต้องสลับแท็บแล้วทำบริบทหาย

ฝั่งอ่านมีอยู่แล้ว ฝั่งเขียนยังไม่มี

ความไม่สมมาตรนี้ควรถูกเรียกชื่อให้ชัด เพราะมันกำหนดรูปร่างของงาน HotXLS อ่านชาร์ตแบบฝังได้อยู่แล้ว เมื่อ sheet record stream มี BOF ที่ทำเครื่องหมายเป็น chart substream parser จะสลับบริบท เก็บ chart record ไว้ครบ แล้วตอน EOF ปิดมาก็ส่งมันคืนให้ drawing shape ที่ OBJ record เปิดตัวมา เส้นทางนี้ถูกออกกำลังมาโดยตลอดจากทุกเวิร์กบุ๊กที่เขียนโดย Excel ซึ่งไลบรารีเคยเปิดมา

สิ่งที่ขาดคือฝั่งผู้เขียนไฟล์ และผลที่มีประโยชน์คือ writer ตัวใหม่มีสเปกที่แม่นยำรออยู่ ผลิตเลย์เอาต์ไบต์ที่ reader ตัวเดิมเกี่ยวกลับเข้าที่ได้อยู่แล้ว ไม่มีเกณฑ์ยอมรับที่ดีไปกว่า reader ที่ถูกเขียนอย่างอิสระซึ่งคุณไม่มีสิทธิ์แก้ สำหรับฟีเจอร์ของฟอร์แมตไบนารี

ชาร์ตแบบฝังหนึ่งตัวประกอบจากอะไร

มีสามชิ้นที่ต้องเห็นพ้องกัน drawing layer จัดมาเป็น host-control shape, object layer จัดมาเป็น OBJ record ที่ common object data ของมันประกาศชนิดออบเจกต์ 5 และ record stream จัดมาเป็น substream ของชาร์ตเอง แฟล็ก option บน OBJ record เป็นชุดที่ Excel เขียนให้ chart frame คือ positioned, locked, automatic line และ automatic fill ซึ่งทำให้ชาร์ตฝังประพฤติตัวเหมือนชาร์ตพื้นเมืองตอนผู้ใช้คลิกมัน

HotXLS สมอ substream ชาร์ต BIFF8 เข้ากับชีตงาน Delphi ผ่านสามชิ้นที่ต้องเห็นพ้องกัน: host-control shape ของ drawing layer, OBJ record ที่ common object data ประกาศชนิดออบเจกต์ 5 และลูกโซ่ chart record ที่จอดไว้ท้าย sheet record stream ซึ่ง chart BOF จะสลับบริบทของ parser และ EOF ปิดท้ายจะเกี่ยว record กลับเข้าที่
สามชั้นแบกชาร์ตฝังหนึ่งตัว drawing shape เป็นตัวสมอ OBJ record เป็นตัวระบุว่าเป็น host ของชาร์ต และ chart substream ที่ท้าย sheet stream เป็นตัวจัดหา record ที่ reader จะเกี่ยวกลับเข้าที่

จุดสมอควรได้รับหมายเหตุไว้หนึ่งข้อ เพราะเป็นแหล่งบั๊ก off-by-one ที่พบบ่อย API ของ HotXLS รับเลขแถวและคอลัมน์แบบหนึ่งอ้างอิง ตรงกับส่วนอื่นของไลบรารี แต่ client anchor ที่ถูกเขียนลงไฟล์เป็นแบบศูนย์อ้างอิง การแปลงเกิดขึ้นข้างใน AddChartObject ผู้เรียกจึงอยู่ในระบบพิกัดเดียวกับที่ใช้ทุกที่อื่น แต่ใครก็ตามที่กำลังเทียบ hex dump กับการเรียกของตัวเอง ต้องจำไว้ว่ากำลังอ่านฝั่งไหนของเส้นแบ่งนี้

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Series: array[0..1] of TXLSChartSeriesInfo;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('regional-sales.xls');
    Sheet := Book.Sheets[0];

    FillChar(Series, SizeOf(Series), 0);
    Series[0].Name := 'Actual';
    Series[0].Categories := 'Data!$A$2:$A$13';
    Series[0].Values := 'Data!$B$2:$B$13';
    Series[0].DataLabels.ShowValue := True;
    Series[0].HasDataLabels := True;

    Series[1].Name := 'Target';
    Series[1].Categories := 'Data!$A$2:$A$13';
    Series[1].Values := 'Data!$C$2:$C$13';
    Series[1].SecondaryAxis := True;

    // สมอไว้ที่ E2:M20 บนชีตนี้ นับเลขแบบ one-based
    Sheet.AddChartObject(xlsChartTypeColumn, 'Regional sales',
      'Month', 'Amount', Series, 2, 5, 20, 13);

    Book.SaveToFile('regional-sales-charted.xls');
  finally
    Book.Free;
  end;
end;

FillChar บนอาร์เรย์ series ไม่ใช่ของตกแต่ง TXLSChartSeriesInfo พก sub-record เสริมหลายชิ้น ทั้ง data labels, สไตล์ราย series, trendline และ error bar แต่ละชิ้นถูกคุมด้วย boolean หนึ่งตัว และ record ที่ถูก initialize ครึ่ง ๆ กลาง ๆ ที่นั่งบนสแตกจะยื่นแฟล็กที่ไม่มีใครตั้งไว้ให้ emitter จัดการ เคาะอาร์เรย์ให้เป็นศูนย์ก่อน แล้วค่อยตั้งฟิลด์ที่คุณหมายถึง

เส้นทางฝังยอมรับ series reference แบบไหน

ช่วงแบบ A1 ธรรมดาภายในเวิร์กบุ๊กเดียวกันเท่านั้น และข้อจำกัดนี้เป็นการตั้งใจ ไม่ใช่การมองข้าม reference ทุกตัวถูก resolve กับรายการชีตของเวิร์กบุ๊กแล้วแปลงเป็น external reference index ที่ chart record ต้องการ named range หรือ reference ไปเวิร์กบุ๊กอื่นจะตกไปเป็น placeholder พร้อม parsed expression ความยาวศูนย์ ชาร์ตจึงถูกเขียนออกไปได้อย่างสะอาด แต่ series นั้น ๆ ยังไร้แหล่งข้อมูลจนกว่าคุณจะชี้มันกลับไปที่ช่วงจริง

เกณฑ์ยอมรับ series reference ของ HotXLS บนเส้นทางชาร์ต BIFF8 แบบฝัง: ช่วงแบบ A1 เช่น Data!$B$2:$B$13 ภายในเวิร์กบุ๊กเดียวกัน resolve กับรายการชีตเป็น external reference index ที่ chart record ต้องการ ขณะที่ named range กับ reference ข้ามเวิร์กบุ๊กตกไปเป็น placeholder พร้อม parsed expression ความยาวศูนย์ โดยทั้งสองกรณีมี AddChartSheet รองรับ
มีเฉพาะช่วงแบบ A1 ธรรมดาในเวิร์กบุ๊กเดียวกันที่คอมไพล์เป็น reference ของ series ชาร์ตได้ ส่วนที่เหลือเขียนออกไปอย่างสะอาดในรูป placeholder จนกว่าจะถูกชี้ใหม่ และเส้นทางเต็มอยู่บน AddChartSheet

เหตุผลคือการแลกเปลี่ยนทางวิศวกรรมตรง ๆ เส้นทางคอมไพล์ reference เต็มรูปมีอยู่บนสาย chart sheet โดยถูกห่อไว้ในชั้น worksheet collection การเหลามันออกมาอย่างสะอาดแปลว่าทำ logic การ resolve ร้อยกว่าบรรทัดซ้ำขึ้นมาอีกชุด เพื่อกรณีที่ในทางปฏิบัติไม่ค่อยมีใครเจอ ชาร์ตฝังเกือบทุกตัวพลอตเซลล์บนชีตตัวเองหรือชีตข้อมูลพี่น้อง named กับ external reference ถูกดูแลบนสาย chart sheet ผ่าน AddChartSheet อะไรไม่พร้อมใช้เลยแม้แต่อย่างเดียว แค่เข้าถึงจากจุดเรียกต่างกัน

สิ่งอื่นในโมเดล series ทำงานเหมือนกันเป๊ะบนทั้งสองสาย การผูกแกนรอง สไตล์เส้น สีเติมและ marker ราย series, trendline, error bar และ data label ล้วนเป็นส่วนหนึ่งของ TXLSChartSeriesInfo และถูก emit แบบเดียวกันหมด นิยามชาร์ตหนึ่งชุดจึงย้ายระหว่างออบเจกต์ฝังกับ chart sheet ได้โดยเปลี่ยนแค่บรรทัดเรียก กลไก axis group ที่อยู่หลังแฟล็กแกนรองมีเล่าไว้ใน กลุ่มแกนรองบนการเขียน BIFF

ทำไมชื่อชาร์ตถึงอ่านมาได้แค่สองตัวอักษร

เพราะจำนวนตัวอักษรถูกส่งไปที่ที่รอจำนวนไบต์ และสตริง BIFF Unicode ทำให้พลาดแบบนี้พิมพ์ง่ายแต่ตาเห็นยาก สตริง BIFF Unicode แบบสั้นเริ่มด้วยจำนวนตัวอักษรกับไบต์แฟล็ก ไบต์แฟล็กพกบิต high-byte ที่บอกว่า payload เป็นไบต์ละหนึ่งหรือสองตัวอักษร อ่าน payload 16 บิตโดยใช้จำนวนตัวอักษรเป็นความยาวไบต์ คุณจะได้สตริงมาครึ่งเดียวพอดี: series ชื่อ Sales กลับมาเป็น Sa และชื่อชาร์ตถูกตัดแบบเดียวกัน เพราะชื่อชาร์ตกับป้าย series ใช้เส้นทางถอดรหัสร่วมกัน

สิ่งที่ทำให้บั๊กนี้น่าจดจำคือมันเกิดซ้ำถึงสามครั้งในตระกูล record เดียวกัน ครั้งแรกในชื่อ trendline ครั้งสองในชื่อ pivot chart ครั้งสามในชื่อชาร์ต แต่ละครั้งดูเหมือนบั๊กใหม่ในฟีเจอร์ใหม่ แต่ทั้งสามคือการลืมคูณเหมือนกันหมด กฎที่ปิดเรื่องนี้ในที่สุดเป็นแบบเครื่องจักรและควรใช้โดยไม่ต้องใช้ดุลพินิจ: เมื่อไรที่อ่านสตริงพวกนี้ ให้แอบดูแฟล็ก high-byte ก่อนเป็นอย่างแรก แล้วคูณจำนวนตัวอักษรด้วยความกว้างของ payload ก่อนแตะ buffer รายละเอียดระดับ record อยู่ใน การถอดจำนวนตัวอักษร XLUnicodeString กับแฟล็ก high-byte

// ชาร์ตแบบฝังใช้ drawing layer ร่วมกับรูปภาพและรูปทรง การวาดที่
// มีอยู่บนชีตจึงถูกเก็บไว้ครบ AddChartObject คืนดัชนี
// ของออบเจกต์ที่สร้างขึ้น
var
  ObjIndex: Integer;
begin
  ObjIndex := Sheet.AddChartObject(xlsChartTypeLine, 'Trend',
    'Week', 'Units', Series, 2, 8, 18, 16);
  if ObjIndex < 0 then
    raise Exception.Create('chart object was not created');
end;

ชาร์ตฝังวางตัวเทียบกับทางเลือกอื่นอย่างไร

มีสามเส้นทาง และมันตอบคำถามคนละข้อ ออบเจกต์ชาร์ตฝังมีที่อยู่ข้างข้อมูลบนชีตงาน และเป็นสิ่งที่รายงานส่วนใหญ่ต้องการ chart sheet เหมาะกับภาพพรีเซนต์ตัวเดียวและให้เส้นทางคอมไพล์ reference เต็มรูป ส่วนการเก็บชาร์ตเดิมจากไฟล์ที่โหลดมาโดยไม่แตะต้องคือคำตอบที่ถูกเมื่อเวิร์กบุ๊กมาจาก Excel พร้อมฟอร์แมตที่ไม่มีใครอยากให้ไลบรารีตีความแทน พฤติกรรม pass-through แบบนั้นมีเล่าไว้ใน การเก็บรักษา ChartML และชาร์ตแบบ combination

เพราะชาร์ตฝังขี่อยู่บน drawing layer มันจึงอยู่ร่วมกับรูปภาพและรูปทรงบนชีตเดียวกัน แทนที่จะแย่งกัน โมเดลทั่วไปของชั้นนี้มีเล่าไว้ใน ชาร์ต รูปภาพ และ drawing ใน HotXLS ทั้งสามเส้นทางมาพร้อมกับ HotXLS Delphi spreadsheet component การเลือกจึงเป็นเรื่องว่ารายงานควรหน้าตาเป็นอย่างไร ไม่ใช่เรื่องว่าไลบรารีแสดงอะไรได้

ประเด็นทางวิธีวิทยาคือสิ่งที่ควรเก็บติดตัวไป เมื่อฟีเจอร์ของฟอร์แมตไบนารีมี reader เดิมอยู่แล้ว ให้สร้าง writer โดยยึด reader เป็นเป้า ไม่ใช่ยึดการตีความสเปกของคุณเอง reader ถูกฝังประสบการณ์หลายปีที่ปะทะกับไฟล์จริงที่แอปจริงผลิตออกมา รวมถึงส่วนที่สเปกพูดกำกวม ๆ writer ที่ทำให้ reader พอใจจึงมีโอกาสทำให้ Excel พอใจด้วยสูงกว่าอยู่มาก