Tệp PDF xuất ra đặt mỗi biên cột lệch nửa ký tự về bên trái so với nơi Excel vẽ nó, và mọi ô xuống dòng giờ ngắt ở chỗ khác. Độ rộng cột Excel không đo bằng ký tự hay point. Nó đo bằng đơn vị Max Digit Width (MDW) của font Normal trong workbook, và HotXLS đo font đó bằng GDI trước mỗi lần dựng phân trang. Kiểu hỏng này rất lặng lẽ: không có gì văng ra, các độ rộng đã lưu round-trip đúng từng byte, và hình học vẫn lệch vài phần trăm mỗi cột cho đến khi độ trôi tích tụ đẩy một bảng một trang sang hai trang
Độ rộng cột Excel đo theo đơn vị nào?
Một độ rộng cột trong worksheet là số lượng ký tự chữ số của font Normal trong workbook, không phải một phép đo tuyệt đối. ECMA-376 §18.3.1.13 định nghĩa thuộc tính width của <col> theo Maximum Digit Width của font đó ở 96 dpi, và cho phép chuyển đổi từ độ rộng đã lưu ngược về pixel bằng một biểu thức cắt cụt dựa trên MDW. Với Calibri 11 — font mà Excel đóng gói làm Normal style — MDW đo được 7 pixel. Đưa độ rộng mặc định 8.43 unit qua công thức đặc tả với MDW 7 và bạn thu về đúng 64 pixel, tức 48 point ở 96 dpi. Đó là những con số mà chính Excel báo ra, nên chúng là một phép kiểm tra hữu ích: nếu phép chuyển đổi của bạn tái hiện được 8.43 unit thành 64 pixel thì phép tính đã đúng, và thứ duy nhất còn có thể sai là input MDW
const
// Maximum digit width (MDW) của font thân mặc định, tính bằng pixel ở 96 dpi.
// Calibri 11 đo được 7 px, tái hiện chính xác độ rộng pixel
// mà Excel lưu (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; // pixel 96 dpi -> point
if Result < MinimumColumnWidth then
Result := MinimumColumnWidth;
end;
HotXLS giữ phép tính đó trong đúng một hàm, trong unit lxPagination, nên chỉ có một nơi mà cây thước có thể sai. + 5 là phần đệm Excel cộng cho gridline và lề ô, * 0.75 chuyển pixel 96 dpi sang PostScript point, còn sàn tại MinimumColumnWidth tồn tại để một cột hẹp bệnh hoạn vẫn chừa lại một dải mà renderer có thể vẽ viền vào. Entry point công khai ColumnWidthToPoints giữ nguyên chữ ký một đối số cũ và chuyển tiếp một MDW đã đo tới hàm này — chính điều đó khiến thay đổi hành vi hạ cánh mà không đụng tới một call site nào
Vì sao một font Normal khác Calibri làm dịch mọi biên
Độ trôi mang tính nhân, vì vậy nó trông như một bug render chứ không phải bug đơn vị. MDW là một hệ số nhân lên độ rộng, không phải một độ dời. Đẩy MDW từ 7 lên 8 và cột mặc định 8.43 unit đi từ 64 pixel lên 72, một bước nhảy 8 pixel hay 6 point trên một cột. Mười cột như vậy và mép phải của bảng đã dịch gần một inch. Những workbook dính bẫy này hoàn toàn bình thường: bất cứ thứ gì do công cụ báo cáo tạo ra mà đóng dấu Arial hay Segoe UI vào Normal style, bất cứ thứ gì lưu từ một template xuất ERP, bất cứ thứ gì khách hàng từng đổi giao diện một lần rồi quên
Hai hệ layout liên quan kế thừa lỗi này chứ không sinh ra nó. Vùng merge cộng tổng độ rộng point của các cột thành viên, nên một merge vừa một trang trong Excel có thể tràn sau khi MDW trôi — điều đáng nhớ khi bạn dựng template báo cáo với ô merge. Shrink-to-fit so độ rộng văn bản đã đo với cùng độ rộng cột đó, nên MDW sai cũng đổi việc ô nào co lại và co bao nhiêu. Cùng họ nhầm lẫn đơn vị xuất hiện trong drawing anchor, nơi hình học ảnh và tỉ lệ EMU có riêng một chuỗi chuyển đổi để lỡ sai
HotXLS đo MDW lúc chạy thế nào
HotXLS xác định MDW từ chính workbook thay vì giả định một hằng số, và hai procedure đảm nhiệm việc đó. PaginationApplyNormalFont đọc font của Normal style từ workbook và chạy ở đầu mỗi lần dựng phân trang, trước khi bất kỳ hình học cột nào được tính; nó reset về Calibri 11 trước, nên một workbook không có bảng font không thể kế thừa trạng thái cũ từ lần dựng trước. Font của Normal style là fonts[0] trong styles.xml, được component phơi ra thành Workbook.Fonts[0]
// Đọc fonts[0] (font của Normal style) từ workbook của worksheet.
// Worksheet classic không có bảng font giữ default 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;
Procedure thứ hai, PaginationMeasureMdW, hỏi GDI về extent của đúng một ký tự '0' qua GetTextExtentPoint32W trên một bitmap canvas off-screen dùng chung, quay về tmAveCharWidth từ GetTextMetricsW khi lời gọi extent thất bại, và quay về DefaultMDW khi cả hai đều không có. Cache của nó là một slot duy nhất khóa theo (name, size) — nghe thô sơ cho đến khi bạn nhìn vào mô hình truy cập: một lần dựng phân trang xin cùng font Normal trên mọi cột của mọi trang, nên một slot có tỉ lệ hit gần tuyệt đối và tốn ba phép so sánh mỗi lần gọi
Chuyện gì xảy ra khi không có bảng font, không có GUI, hay thiếu font?
HotXLS suy giảm về hằng Calibri 11 trong mọi trường hợp không xác định được font Normal thật, và nó làm điều đó một cách lặng lẽ có chủ đích. Worksheet BIFF classic là trường hợp phổ biến: các định dạng legacy không mang pool font XLSX cho fonts[0] tham chiếu tới, nên type guard thoát sớm và MDW mặc định 7 giữ nguyên. Đó không phải một bản sửa, mà là hành vi trước đó được giữ lại có chủ ý, để việc thêm đo lường vào đường XLSX không thể làm thoái trào output định dạng classic
Sự phụ thuộc GDI là lời cảnh báo phải nói thẳng. Việc đo chạy trên một Windows device context, nên đường này giả định một host Windows có cài font. Trong một service hay build agent headless, text metrics của GDI nhìn chung vẫn phân giải được, nhưng font không được cài trên máy đó sẽ bị font mapper thay thế và bạn đo chính thứ được thay vào. Nó không bao giờ hỏng ầm ĩ; nó trả về một con số nghe hợp lý cho đúng kiểu chữ sai. Nếu xuất phía máy chủ phải khớp với chuẩn desktop, hãy cài các font mà template của bạn nêu tên lên máy xuất, hoặc ghim font Normal trước khi gọi đường xuất PDF của worksheet
var
Book: TXLSXWorkbook;
Exporter: TXLSPDFExport;
begin
Book := TXLSXWorkbook.Create;
Exporter := TXLSPDFExport.Create;
try
Book.Open('quarterly-report.xlsx');
// Ghim font Normal để MDW đo trên host này đúng là thứ
// mà layout được thiết kế dựa trên, chứ không phải bản thay của 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;
Các cache đo lường, và cái từng gây crash trên Win64
Khi phép đo văn bản là một vòng GDI thay vì một phép nhân, nó phải được cache, và cache bên trong một render pass là nơi công việc này chảy máu. Vòng lặp shrink-to-fit hạ kích thước font theo bước 0.5 pt rồi đo lại sau mỗi bước, nên một ô có thể gọi PaginationMeasureTextWidth hàng chục lần với cùng một chuỗi, và word wrap lại gọi tiếp cho mỗi dòng ứng viên. Một memo khóa theo tên font, kích thước và văn bản gói tất cả thành một lần gọi GDI cho mỗi chuỗi khác nhau, lưu trong một TStringList dưới dạng các cặp name/value
Cache thêm kèm theo nó không gọn gàng đến thế. Render pass 5 phân giải pool font theo từng ô qua FontIndex, và memo của nó dùng các mảng động song song với một FontMemoCount tự giữ tay. Phiên bản đầu quên gọi ResetFontMemo ở đầu mỗi trang, nên count cứ leo qua các trang trong khi các mảng thì không, và code viết trượt qua cuối tất cả chúng. Trên Win32, việc đó lặng lẽ ghi lấn vào heap kế bên rồi kết thúc; trên Win64 nó raise access violation ngay khi ghi vào 0x538. Bài học khái quát: một cache dựa trên mảng nằm trong biến cấp unit phải được reset ở đầu mỗi pass dùng nó, vì string list hay dictionary tha thứ cho một lần reset thiếu bằng cách lớn lên còn mảng song song thì không
Tự kiểm tra phép chuyển đổi của bạn
Bạn không cần component để kiểm chứng bất cứ điều nào trong số này. Lấy một workbook có font Normal không phải Calibri 11, đọc một độ rộng từ <col width="..."/>, và chạy qua công thức đặc tả hai lần, một lần với MDW 7 và một lần với MDW mà renderer của bạn thực đo cho font đó; nếu hai câu trả lời lệch nhau và output của bạn khớp vế đầu, bạn đã tìm ra độ trôi. Hình học cột thuộc dạng bộ phận của một spreadsheet engine hoặc vô hình, hoặc là thứ duy nhất ai cũng để ý, và làm đúng nó có nghĩa là coi font Normal là một input của layout chứ không phải chi tiết trình bày. Nếu bạn dựng ứng dụng Delphi hay C++Builder đọc, ghi, render và in workbook Excel mà không cài Office, HotXLS Delphi Excel component đảm nhận phép đo MDW, mô hình phân trang và pipeline PDF đằng sau một bộ lớp VCL duy nhất