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