你将一个 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 inch
XlsxEmuPerCm = 360000; // 1 centimetre
XlsxEmuPerPoint = 12700; // 1 point (1/72 inch)
XlsxEmuPerPixel = 9525; // 1 pixel at 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');
// Anchor a PNG at row 3, column 2; AddImage returns a 0-based index.
Idx := Sheet.AddImage(3, 2, LogoBytes, xlsxImagePng);
Img := Sheet.Images[Idx];
Img.WidthCM := 4.0; // 4 cm wide -> 1440000 EMU
Img.HeightCM := 3.0; // 3 cm tall -> 1080000 EMU
// Same geometry, read back in other units.
// Img.WidthPt is now 113.39 pt, Img.WidthInch is 1.5748 in.
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); // aspect locked: now 6.0 x 4.5 cm
Img.Scale(100); // no-op, returns immediately
Img.Scale(50, False); // width only: 3.0 cm wide, height unchanged at 4.5 cm
有一个小小的行为需要知道: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-check before use
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 cm == 0.7874 inch
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