您將一個 600×400 像素的標誌放入產生的發票標頭中,它在您 96-DPI 的開發螢幕上看起來很完美,但一週後,一位使用高 DPI 筆記型電腦的客戶卻回報說印出來的尺寸像郵票一樣小。像素從未改變,改變的是「像素數量等於實體尺寸」這個假設,而在 OOXML 中,這是不成立的。試算表圖片的尺寸是以 EMU 來表示的,除非您以 EMU(或是能精確對應到它的真實世界單位)來思考,否則您的版面配置將任由渲染機器剛好預設的 DPI 所擺佈。
HotXLS 是一個適用於 Delphi 與 C++Builder 的原生 VCL 試算表元件,不需要 Excel 或是任何 COM 相依性,就能讀寫 XLS 與 XLSX。從 v2.91.0 開始,XLSX 圖片物件不再需要您手動進行單位運算:除了原始的 EMU 之外,它還公開了以公分 (cm)、英吋 (inch) 和點數 (point) 為單位的寬度與高度,外加一個 Scale 方法,可依百分比進行縮放,並可選擇鎖定長寬比。這篇文章將探討 EMU 到底是什麼、DrawingML 為何選擇它,以及如何使用新的幾何介面,依據實體尺寸來放置圖片,而不是依賴您無法信任的像素數量。
什麼是 EMU 以及 DrawingML 為何使用它
EMU 代表英制公制單位 (English Metric Unit),它是 DrawingML 的基本長度單位,這是在整個 Office Open XML 系列 (ECMA-376, Part 1, §20) 中共用的繪圖層。一個 EMU 的定義是,每英吋剛好有 914400 EMU,而每公分有 360000 EMU。這兩個常數是這個單位存在的全部理由。914400 可以被 2、3、4、5、6、8、9、10、12 以及更多數字整除;它的因數分解為 26 × 32 × 52 × 127。因為 1 英吋精確等於 2.54 公分,選擇一個既能被 360000 整除,又是 914400 乾淨分數的單位,讓這個格式能以整數來表示英吋、公分和點數,在單位邊界上完全不需要四捨五入。當浮點數的「1.27 公分」會產生誤差時,EMU 儲存 457200 卻能保持精確。
另一個在這裡很重要的單位是點數 (point)。一個排版用的點是 1/72 英吋,所以每點有 12700 EMU (914400 / 72)。點數是 Excel 在底層思考列高、字型大小與邊界的方式,這也是為何當您想讓圖片與文字規格對齊,而不是與列印尺標對齊時,將圖片幾何以點數公開會非常有用。HotXLS 將這四種關係編碼為程式庫中的單位常數:
const
XlsxEmuPerInch = 914400; // 1 英吋
XlsxEmuPerCm = 360000; // 1 公分
XlsxEmuPerPoint = 12700; // 1 點 (1/72 英吋)
XlsxEmuPerPixel = 9525; // 1 像素於 96 DPI (914400 / 96)
最後一行是郵票大小臭蟲 (bug) 的核心。像素只有在您固定了 DPI 之後才有實體尺寸,而 9525 EMU 特定在 96 DPI 下是一個像素的大小。Excel 預設的渲染 DPI 是 96,因此一個 100 像素的圖片在預設設定下,會落在 100 × 9525 = 952500 EMU ≈ 2.54 公分——但是檔案中沒有任何東西能保證消費者使用的是 96 DPI。使用真實單位來建立內容,這種模糊性就會消失:4 公分就是 4 公分,無論螢幕是 96 還是 220 DPI。
TXLSXImage 幾何介面
HotXLS 中的內嵌圖片是一個 TXLSXImage。它的權威 (canonical) 儲存是兩個整數欄位:WidthEMU 與 HeightEMU,並錨定 (anchor) 於以 1 為基底的 Row 與 Col(圖片懸掛的左上角儲存格)。真實單位的屬性只是這些 EMU 欄位的計算檢視,而不是獨立的狀態——讀取 WidthCM 會將 EMU 除以 360000,寫入則會將其乘回去並四捨五入。因此,您設定的每一個維度,都只是同一個底層 EMU 值的另一種寫法:
WidthInch/HeightInch— EMU ÷ 914400WidthCM/HeightCM— EMU ÷ 360000WidthPt/HeightPt— EMU ÷ 12700WidthEMU/HeightEMU— 真相的整數來源
您使用 AddImage(ARow, ACol, AData, AFormat) 加入圖片,傳遞原始編碼位元組以及 TXLSXImageFormat(xlsxImagePng、xlsxImageJpeg、xlsxImageGif 或 xlsxImageBmp);它會傳回工作表 Images 集合中以 0 為基底 (zero-based) 的索引。另外還有 AddImageFromFile(ARow, ACol, AFileName),它會從副檔名推斷格式。請注意索引基底:AddImage 傳回以 0 為基底的索引,而 Images[] 也是以 0 為基底的,這與以 1 為基底的 Cells[Row, Col] 網格形成刻意的對比,所以不要預設兩者是一致的。
var
Sheet: TXLSXWorksheet;
Img: TXLSXImage;
Idx: Integer;
begin
Sheet := Workbook.Sheets.Add('Images');
// 將 PNG 錨定在第 3 列、第 2 欄;AddImage 傳回一個以 0 為基底的索引。
Idx := Sheet.AddImage(3, 2, LogoBytes, xlsxImagePng);
Img := Sheet.Images[Idx];
Img.WidthCM := 4.0; // 寬 4 公分 -> 1440000 EMU
Img.HeightCM := 3.0; // 高 3 公分 -> 1080000 EMU
// 相同的幾何尺寸,以其他單位讀回。
// Img.WidthPt 現在是 113.39 pt,Img.WidthInch 是 1.5748 inch。
end;
剛建立的圖片預設為 100×100 像素,也就是 952500 平方 EMU,在大約 96 DPI 下約為 2.54 公分的方塊。這個預設值的存在,是為了確保即使您忘記設定尺寸,圖片也是可見的,但對於任何真實的版面配置,您都應該設定明確的實體尺寸,而不是依賴由像素衍生出來的預設值。
縮放與長寬比旗標
當您想要相對於目前的尺寸進行縮放,而不是縮放至絕對目標(例如,將圖表圖片縮小為其匯入時尺寸的 60%),請使用 Scale:
procedure Scale(APercent: Double; AKeepAspect: Boolean = True);
APercent 是一個百分比,其中 100 代表不變,150 代表放大一半,50 代表減半。在 AKeepAspect 為其預設值 True 的情況下,寬度和高度會乘上相同的係數,所以比例會保持不變,一個 4×3 公分的圖片在執行 Scale(150) 之後會變成 6×4.5 公分。如果傳遞 False,則只有寬度會縮放——高度會完全保持原樣。這種不對稱是刻意的:當您想要獨立拉伸某個軸時,正確的工具是明確的 WidthCM/HeightCM 設定器 (setter),而沒有維持長寬比分支的 Scale 則是為了單獨調整寬度這個較狹窄的情境而存在的。很容易將 Scale(150, False) 誤讀為「自由拉伸兩者」而感到驚訝,所以當您真正想要設定兩個獨立維度時,請使用設定器。
Img.WidthCM := 4.0;
Img.HeightCM := 3.0;
Img.Scale(150); // 鎖定長寬比:現在是 6.0 x 4.5 公分
Img.Scale(100); // 無作用,立即傳回
Img.Scale(50, False); // 僅限寬度:寬 3.0 公分,高維持在 4.5 公分
有一個小小的行為需要知道:Scale(100) 會短路 (short-circuit) 並傳回,不會觸碰任何一個欄位,所以在百分比可能是 100 的迴圈中無條件呼叫它是安全的。而且由於幾何尺寸儲存為整數 EMU,每一個設定器都會進行四捨五入。因此,將帶有小數的公分轉換來回 (round-tripping) 可能會產生一小部分 EMU 的誤差——這遠低於任何可見的程度,但如果您曾在測試中断言絕對相等,這點就值得了解。為了達成像素級的完美控制 (pixel-perfect control),請直接設定 WidthEMU 和 HeightEMU,並完全跳過單位轉換。
讀回幾何資料
圖片集合是可以查詢的,當您載入現有的活頁簿,並且需要檢查或調整已經存在的內容,而不是您剛剛新增的內容時,這點很重要。Images.Count 會列舉工作表上的每一張圖片,Images[i] 會以 0 為基底為它們建立索引,而 FindAt(ARow, ACol) 則會傳回錨定在特定儲存格的圖片——如果沒有,則傳回 nil。另外還有 IndexOfCell 用於取得索引而不是物件,以及 DeleteAt / DeleteInRange 用於移除。
var
i: Integer;
Img: TXLSXImage;
begin
for i := 0 to Sheet.Images.Count - 1 do
begin
Img := Sheet.Images[i];
Writeln(Format('[%d] R%dC%d %.2f x %.2f cm (%d x %d EMU)',
[i, Img.Row, Img.Col, Img.WidthCM, Img.HeightCM,
Img.WidthEMU, Img.HeightEMU]));
end;
Img := Sheet.Images.FindAt(3, 2); // 使用前檢查 nil
if Img <> nil then
Img.Scale(80);
end;
因為真實單位屬性是即時的檢視畫面,一張從其他工具以某個 EMU 尺寸匯入的圖片,會立即以公分回報它的幾何尺寸——您不需要做任何轉換步驟。這與更廣泛的繪圖模型自然地搭配;如果您也要放置圖表、形狀以及光柵圖片 (raster image),在在 Delphi 中的 HotXLS 圖表、圖片與 Excel 繪圖這篇姊妹指南中,有探討這些物件共用的錨點模型。
公制的頁面設定邊界
同樣的 EMU 與真實單位之間的張力,也出現在更外一層:頁面。OOXML 與 Excel 將列印邊界儲存為英吋,如果您的報表範本像美國以外的世界大多數地方一樣是以公釐為單位,這就很尷尬了。v2.91.0 在英吋邊界之上加入了公分包裝函式 (wrapper):MarginLeftCM、MarginRightCM、MarginTopCM、MarginBottomCM、MarginHeaderCM 與 MarginFooterCM。每一個都是相對應英吋屬性的薄型便利功能,以精確的 1 英吋 = 2.54 公分比例進行轉換。
Sheet.MarginLeftCM := 2.0; // 2 公分 == 0.7874 英吋
Sheet.MarginRightCM := 2.0;
Sheet.MarginTopCM := 2.5;
Sheet.MarginBottomCM := 2.5;
Sheet.MarginHeaderCM := 1.0;
Sheet.MarginFooterCM := 1.0;
英吋屬性(MarginLeft 及其夥伴)仍然是權威的儲存格式,所以您可以混合使用兩者——設定以公分為單位的上邊界,並以英吋讀回,或者反過來——無論用哪種方式寫入磁碟的檔案都是相同的。轉換只是單純乘以 2.54,沒有將其四捨五入到粗略的網格,所以 2 公分在完整的雙倍精確度下仍然是 2 公分。這與圖片幾何的公制便利性哲學相同:該格式在底層使用的是英制,而程式庫讓您能以您規格中使用的任何單位進行撰寫。關於如何配置周圍的報表——標題、詮釋資料區塊、總計——請參閱 HotXLS 中的合併儲存格與報表範本版面配置,該文將這些邊界與合併範圍及列印區域結合使用。
關於幾何能保證與不能保證什麼的說明
幾何屬性控制的是檔案中宣告的圖片尺寸——也就是符合規範的消費者在渲染時會使用的尺寸。它們並不會對圖片位元組重新取樣 (resample);一個 50×50 像素的 PNG 被放大到 8 公分,將會出現馬賽克,這與在 Excel 中的結果完全一樣。設定尺寸是一種版面配置操作,而不是影像處理操作,所以請為圖片提供足夠的來源解析度,以符合您預期的實體尺寸。程式庫也不會重新編碼格式:您傳遞給 AddImage 的位元組會被原封不動地儲存與寫入,並帶有您宣告的 TXLSXImageFormat。如果您傳遞 JPEG 位元組卻將它們標記為 xlsxImagePng,您將會產出一個 Excel 無法開啟的檔案,所以盡可能讓 AddImageFromFile 從副檔名來推斷格式。
一旦您將底層的這一個觀念內化,這一切都不會顯得奇特:在 OOXML 中,實體尺寸才是真正的數值,而像素只是一個衍生的、依賴於 DPI 的影子。以公分、英吋或點數來製作圖片與邊界,讓 HotXLS 將它們對應到精確的 EMU,您的發票與報表在每一台開啟它們的機器上,印出來的尺寸都會是一樣的。
這裡描述的圖片幾何、縮放與公制邊界 API,皆隨附於 HotXLS Delphi 試算表元件中,它讓您從 Delphi 與 C++Builder 讀寫 XLS 和 XLSX,且不需要安裝 Excel。