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

ความกว้างคอลัมน์ Excel และ Max Digit Width (MDW) ใน Delphi

PDF ที่ส่งออกวางเส้นขอบคอลัมน์แต่ละเส้นเลยไปทางซ้ายครึ่งอักขระจากที่ Excel วาด และทุกเซลล์ตัดคำตอนนี้จึงหักตรงจุดไม่เหมือนเดิม ความกว้างคอลัมน์ของ Excel ไม่ได้วัดเป็นอักขระหรือจุด แต่วัดเป็นหน่วย Max Digit Width (MDW) ของฟอนต์ Normal ของเวิร์กบุ๊ก และ HotXLS วัดฟอนต์นั้นด้วย GDI ก่อนการสร้างการแบ่งหน้าทุกครั้ง ความล้มเหลวแบบนี้เงียบมาก: ไม่มีอะไร throw ความกว้างที่เก็บไว้ round-trip ครบทีละไบต์ และเรขาคณิตยังเพี้ยนอยู่ไม่กี่เปอร์เซ็นต์ต่อคอลัมน์ จนกระทั่งความคลาดสะสมดันตารางหน้าเดียวไปเป็นสองหน้า

หน่วยของความกว้างคอลัมน์ Excel คืออะไร?

ความกว้างคอลัมน์ในเวิร์กชีตคือจำนวนอักขระตัวเลขของฟอนต์ Normal ของเวิร์กบุ๊ก ไม่ใช่การวัดแบบสัมบูรณ์ ECMA-376 §18.3.1.13 นิยามแอตทริบิวต์ width ของ <col> บนพื้นฐาน Maximum Digit Width ของฟอนต์นั้นที่ 96 dpi พร้อมสูตรแปลงความกว้างที่เก็บไว้กลับเป็นพิกเซลแบบตัดเศษบน MDW สำหรับ Calibri 11 ซึ่งเป็นฟอนต์ที่ Excel แถมมาเป็นสไตล์ Normal MDW วัดได้ 7 พิกเซล เอาความกว้างเริ่มต้น 8.43 หน่วยผ่านสูตรในสเปกด้วย MDW 7 จะได้พอดี 64 พิกเซล ซึ่งคือ 48 จุดที่ 96 dpi นั่นคือตัวเลขที่ Excel รายงานเอง จึงเป็นจุดตรวจที่ใช้ประโยชน์ได้: ถ้าการแปลงของคุณทำซ้ำ 8.43 หน่วยให้ได้ 64 พิกเซล เลขคณิตถูกแล้ว และสิ่งที่ยังผิดได้อีกคืออินพุต MDW

const
  // Max Digit Width (MDW) ของฟอนต์เนื้อหาเริ่มต้น หน่วยพิกเซลที่ 96 dpi
  // Calibri 11 วัดได้ 7 px ซึ่งทำซ้ำความกว้างพิกเซลเป๊ะ
  // ที่ Excel เก็บไว้ (8.43 units -> 64 px -> 48 pt)
  DefaultMDW = 7;
  MinimumColumnWidth = 24.0;

function ColumnWidthToPointsMdW(Value: Double; MdW: Integer): Double;
var
  Pixels: Integer;
begin
  if Value <= 0 then
    Value := 8.43;
  if MdW <= 0 then
    MdW := DefaultMDW;
  Pixels := Trunc(((256 * Value + Trunc(128 / MdW)) / 256) * MdW) + 5;
  Result := Pixels * 0.75; // พิกเซล 96 dpi -> จุด
  if Result < MinimumColumnWidth then
    Result := MinimumColumnWidth;
end;

HotXLS เก็บเลขคณิตชุดนี้ไว้ในฟังก์ชันเดียวเป๊ะ ในยูนิต lxPagination จึงมีที่เดียวที่ไม้บรรทัดจะผิดได้ เครื่องหมาย + 5 คือ padding ที่ Excel เติมเพื่อ gridline กับระยะขอบเซลล์ * 0.75 แปลงพิกเซล 96 dpi เป็นจุด PostScript และเพดานต่ำสุดที่ MinimumColumnWidth มีไว้เพื่อให้คอลัมน์ที่แคบผิดปกติยังเหลือแถบให้ตัวเรนเดอร์ลากเส้นขอบลงไปได้ จุดเข้าสาธารณะ ColumnWidthToPoints คงลายเซ็นแบบอาร์กิวเมนต์เดียวเดิมและส่งต่อ MDW ที่วัดแล้วเข้าฟังก์ชันนี้ ซึ่งเป็นสิ่งที่ทำให้การเปลี่ยนพฤติกรรมลงได้โดยไม่ต้องแตะ call site แม้แต่จุดเดียว

สายการแปลงความกว้างคอลัมน์ของ HotXLS ใน Delphi ป้อน Max Digit Width ที่วัดได้ของฟอนต์ Normal ของเวิร์กบุ๊กเข้าสูตรในสเปก ความกว้าง 8.43 หน่วยที่เก็บไว้จึงกลายเป็น 64 พิกเซลแล้ว 48 จุด
ความกว้างที่เก็บไว้เป็นจำนวนตัวเลขอักขระ MDW ที่วัดได้ของฟอนต์ Normal จึงเป็นอินพุตของสูตร ไม่ใช่รายละเอียดการจัดสไตล์ และวงจร 8.43 ไป 64 ไป 48 ใช้ตรวจเลขคณิต

ทำไมฟอนต์ Normal ที่ไม่ใช่ Calibri จึงเลื่อนเส้นขอบทุกเส้น

ความคลาดเป็นแบบคูณ นั่นคือเหตุที่มันดูเหมือนบั๊กเรนเดอร์มากกว่าบั๊กหน่วยวัด MDW เป็นตัวคูณของความกว้าง ไม่ใช่ตัวบวกเพิ่ม ดัน MDW จาก 7 เป็น 8 แล้วคอลัมน์เริ่มต้น 8.43 หน่วยขยับจาก 64 พิกเซลเป็น 72 กระโดด 8 พิกเซลหรือ 6 จุดในคอลัมน์เดียว สิบคอลัมน์แบบนี้ขอบขวาของตารางเลื่อนไปเกือบหนึ่งนิ้ว เวิร์กบุ๊กที่โดนเรื่องนี้ธรรมดามาก: อะไรก็ตามที่เครื่องมือรายงานปั๊ม Arial หรือ Segoe UI ลงสไตล์ Normal, อะไรก็ตามที่บันทึกออกจากเทมเพลตส่งออก ERP, อะไรก็ตามที่ลูกค้าเปลี่ยนสไตล์ครั้งเดียวแล้วลืมมันไป

ระบบเลย์เอาต์ที่เกี่ยวข้องอีกสองระบบรับเอาความผิดพลาดนี้ต่อ แทนที่จะเป็นต้นเหตุ พื้นที่ merge รวมความกว้างจุดของคอลัมน์สมาชิก การ merge ที่ลงพอดีหน้าเดียวใน Excel จึงอาจล้นหลัง MDW คลาด ซึ่งควรจำไว้เวลาคุณสร้าง เทมเพลตรายงานเซลล์ merge โหมด shrink-to-fit เทียบความกว้างข้อความที่วัดกับความกว้างคอลัมน์เดียวกัน MDW ที่ผิดจึงเปลี่ยนด้วยว่าเซลล์ไหนย่อและย่อมากแค่ไหน ตระกูลความสับสนเรื่องหน่วยชุดเดียวกันโผล่ในจุดยึดวัตถุวาดด้วย ที่ซึ่ง เรขาคณิตรูปภาพกับการสเกล EMU มีสายการแปลงค่าของตัวเองให้พลาดได้เหมือนกัน

ไม้บรรทัดคอลัมน์ของ HotXLS สองอันเทียบกัน อันหนึ่งวัดด้วย MDW 7 พิกเซล อีกอันด้วย 8 แสดงว่าการกระโดดต่อคอลัมน์จาก 64 เป็น 72 พิกเซลสะสมคร่อมสิบคอลัมน์อย่างไร ขณะที่พื้นที่ merge และ shrink-to-fit รับความผิดพลาดต่อ
เพราะ MDW คูณแทนที่จะบวกเพิ่ม การวัดผิดครั้งเดียวจึงเลื่อนเส้นขอบทุกคอลัมน์ พื้นที่ merge กับ shrink-to-fit รับความคลาดต่อโดยไม่มีอะไร throw

HotXLS วัด MDW ตอนรันไทม์อย่างไร

HotXLS ระบุ MDW จากเวิร์กบุ๊กเองแทนการสมมติค่าคงที่ โดยมีสองโพรซีเดอร์ทำงาน PaginationApplyNormalFont อ่านฟอนต์ของสไตล์ Normal ออกจากเวิร์กบุ๊กและรันบนสุดของการสร้างการแบ่งหน้า ก่อนเรขาคณิตคอลัมน์ใดจะถูกคำนวณ มันรีเซ็ตกลับเป็น Calibri 11 ก่อนเสมอ จึงไม่มีเวิร์กบุ๊กที่ไม่มีตารางฟอนต์จะรับสถานะค้างจากบิลด์ก่อนหน้าได้ ฟอนต์ของสไตล์ Normal คือ fonts[0] ใน styles.xml ซึ่งคอมโพเนนต์เปิดให้เห็นเป็น Workbook.Fonts[0]

// อ่าน fonts[0] (ฟอนต์ของสไตล์ Normal) จากเวิร์กบุ๊กของเวิร์กชีต
// เวิร์กชีตคลาสสิกที่ไม่มีตารางฟอนต์คงค่าเริ่มต้น Calibri 11
procedure PaginationApplyNormalFont(Worksheet: TObject);
var
  Sh: TXLSXWorksheet;
  Fnt: TXLSXFont;
begin
  PaginationNormalFontName := 'Calibri';
  PaginationNormalFontSize := 11;
  if not (Worksheet is TXLSXWorksheet) then
    Exit;
  Sh := TXLSXWorksheet(Worksheet);
  if (Sh.Workbook = nil) or (Sh.Workbook.Fonts.Count < 1) then
    Exit;
  Fnt := Sh.Workbook.Fonts[0];
  if Fnt.Name <> '' then
    PaginationNormalFontName := Fnt.Name;
  if Fnt.Size > 0 then
    PaginationNormalFontSize := Fnt.Size;
end;

โพรซีเดอร์ที่สอง PaginationMeasureMdW ถาม GDI หาความกว้างของอักขระเดี่ยว '0' ผ่าน GetTextExtentPoint32W บน bitmap canvas ที่ใช้ร่วมกันนอกจอ ถ้าการวัดล้มเหลวก็ถอยไปใช้ tmAveCharWidth จาก GetTextMetricsW และถ้าไม่มีทั้งสองก็ถอยไป DefaultMDW แคชของมันเป็นช่องเดียวกุญแจด้วย (name, size) ฟังดูหยาบจนกว่าคุณจะมองรูปแบบการเข้าถึง: การสร้างการแบ่งหน้าหนึ่งครั้งถามหาฟอนต์ Normal เดิมทุกคอลัมน์ของทุกหน้า ช่องเดียวจึงมีอัตรา hit ใกล้เคียงสมบูรณ์และต้นทุนสามการเปรียบเทียบต่อการเรียก

เกิดอะไรขึ้นเมื่อไม่มีตารางฟอนต์ ไม่มี GUI หรือฟอนต์หาย?

HotXLS ถดถอยไปที่ค่าคงที่ Calibri 11 ในทุกกรณีที่หาฟอนต์ Normal จริงไม่ได้ และมันทำเช่นนั้นอย่างเงียบ ๆ ตามการออกแบบ เวิร์กชีต BIFF คลาสสิกคือกรณีที่พบบ่อย: ฟอร์แมตรุ่นเก่าไม่แบกคลังฟอนต์แบบ XLSX ให้ fonts[0] อ้างถึง type guard จึงออกก่อนเวลาและ MDW เริ่มต้น 7 ยืนอยู่ นั่นไม่ใช่การแก้ แต่เป็นพฤติกรรมเดิมที่ถูกเก็บรักษาไว้ตั้งใจ เพื่อให้การเพิ่มการวัดเข้าเส้นทาง XLSX ไม่ก่อการถดถอยกับผลลัพธ์ฟอร์แมตคลาสสิก

การพึ่งพา GDI คือข้อสงวนที่พูดตรง ๆ การวัดวิ่งบน device context ของ Windows เส้นทางนี้จึงสมมติโฮสต์ Windows ที่ติดตั้งฟอนต์แล้ว ในเซอร์วิสหรือ build agent แบบ headless GDI text metric มักยัง resolve ได้ แต่ฟอนต์ที่ไม่ได้ติดตั้งบนเครื่องนั้นจะถูก font mapper แทนที่ และคุณวัดตัวแทนแทนตัวจริง มันไม่เคยล้มเหลวอย่างเสียงดัง มันคืนตัวเลขที่ดูน่าเชื่อแทน typeface ที่ผิด ถ้าการส่งออกฝั่งเซิร์ฟเวอร์ต้องตรงกับตัวอ้างอิงบนเดสก์ท็อป ให้ติดตั้งฟอนต์ที่เทมเพลตของคุณเรียกใช้บนโฮสต์ส่งออก หรือตรึงฟอนต์ Normal ก่อนเรียก เส้นทางส่งออกเวิร์กชีตเป็น PDF

var
  Book: TXLSXWorkbook;
  Exporter: TXLSPDFExport;
begin
  Book := TXLSXWorkbook.Create;
  Exporter := TXLSPDFExport.Create;
  try
    Book.Open('quarterly-report.xlsx');

    // ตรึงฟอนต์ Normal เพื่อให้ MDW ที่วัดบนโฮสต์นี้เป็นค่าที่
    // เลย์เอาต์ถูกออกแบบบนพื้นฐานนั้น ไม่ใช่ฟอนต์ที่ font mapper แทนให้
    if Book.Fonts.Count > 0 then
    begin
      Book.Fonts[0].Name := 'Calibri';
      Book.Fonts[0].Size := 11;
    end;

    Exporter.UseWorksheetPageSetup := True;
    Exporter.SaveAsPDF(Book, 'quarterly-report.pdf');
  finally
    Exporter.Free;
    Book.Free;
  end;
end;

แคชการวัด กับตัวที่ล้มบน Win64

พอการวัดข้อความกลายเป็น round trip ของ GDI แทนการคูณ มันต้องถูกแคช และการแคชข้างใน render pass คือจุดที่งานนี้เลือดฉีด ลูป shrink-to-fit ลดขนาดฟอนต์ทีละ 0.5 pt แล้ววัดใหม่หลังแต่ละขั้น เซลล์หนึ่งเซลล์จึงเรียก PaginationMeasureTextWidth กับสตริงเดียวกันได้มากกว่าสิบครั้ง และ word wrap เรียกซ้ำอีกต่อบรรทัดที่กำลังทดลองจัด memo ที่กุญแจด้วยชื่อฟอนต์ ขนาด และข้อความ ยุบสิ่งนี้เหลือการเรียก GDI หนึ่งครั้งต่อสตริงที่ไม่ซ้ำ เก็บใน TStringList เป็นคู่ชื่อ/ค่า

อีกแคชที่เพิ่มพร้อมกันไม่เรียบร้อยเช่นนั้น Render pass 5 ระบุคลังฟอนต์ต่อเซลล์ด้วย FontIndex และ memo ของมันใช้ dynamic array คู่ขนานที่มี FontMemoCount ดูแลมือ เวอร์ชันแรกลืมเรียก ResetFontMemo ตอนเริ่มแต่ละหน้า ตัวนับจึงไต่ขึ้นคร่อมหน้าไปเรื่อย ๆ ขณะที่อาร์เรย์ไม่โตตาม แล้วโค้ดก็เขียนล้นท้ายมันทุกตัว บน Win32 มันระบายลง heap ข้างเคียงอย่างเงียบ ๆ แล้วจบไป บน Win64 มันปล่อย access violation ทันทีที่เขียนไปที่ 0x538 บทเรียนที่ใช้ต่อได้: แคชที่อาศัยอาร์เรย์และถืออยู่ในตัวแปรระดับยูนิตต้องถูกรีเซ็ตที่เข้าสู่ทุก pass ที่ใช้มัน เพราะ string list หรือ dictionary ให้อภัยการรีเซ็ตที่หายไปด้วยการโต แต่ parallel array ไม่ให้อภัย

HotXLS ระบุฟอนต์ Normal ของเวิร์กบุ๊ก วัด Max Digit Width ผ่าน GDI พร้อมทางถอยสองชั้น และแคชผลลัพธ์ คู่กับ memo ของ render pass ทั้งสองและกฎการรีเซ็ตที่แคชแบบ parallel array ต้องมี
MDW ถูกระบุจากเวิร์กบุ๊กและวัดด้วย GDI หนึ่งครั้งต่อฟอนต์ แล้วแคชตามกุญแจ ขณะที่ memo ของ render pass แสดงเหตุผลที่แคชแบบ parallel array ต้องรีเซ็ตที่เข้าสู่ทุก pass

ตรวจการแปลงค่าของคุณเอง

คุณไม่ต้องมีคอมโพเนนต์เพื่อยืนยันเรื่องเหล่านี้ เอาเวิร์กบุ๊กที่ฟอนต์ Normal ไม่ใช่ Calibri 11 มา อ่านความกว้างจาก <col width="..."/> แล้วรันผ่านสูตรในสเปกสองรอบ รอบหนึ่งด้วย MDW 7 อีกรอบด้วย MDW ที่เรนเดอร์ของคุณวัดฟอนต์นั้นได้จริง ถ้าคำตอบไม่เท่ากันและผลลัพธ์ของคุณตรงกับรอบแรก คุณเจอความคลาดแล้ว เรขาคณิตคอลัมน์เป็นชิ้นส่วนของเอนจินสเปรดชีตที่มักมองไม่เห็น หรือไม่ก็เป็นสิ่งเดียวที่ใครต่อใครสังเกต การทำมันให้ถูกจึงหมายถึงการถือฟอนต์ Normal เป็นอินพุตของเลย์เอาต์ ไม่ใช่รายละเอียดการจัดสไตล์ ถ้าคุณสร้างแอปพลิเคชัน Delphi หรือ C++Builder ที่อ่าน เขียน เรนเดอร์ และพิมพ์เวิร์กบุ๊ก Excel โดยไม่ติดตั้ง Office คอมโพเนนต์ Excel HotXLS สำหรับ Delphi ดูแลการวัด MDW โมเดลการแบ่งหน้า และไปป์ไลน์ PDF ไว้หลังชุดคลาส VCL ชุดเดียว