HotXLS 在写出任何 drawing 几何之前,先从图片文件自己的头部读出真实的像素几何。lxImage.pas 里的 GetImageSize 按文件扩展名分派到 GetJpegSize、GetGifSize、GetBmpSize、GetPngSize 或 GetWmfEmfSize,用 0.75 的系数把像素换算成点,真正读不出来时返回 -1
最后一句话是花最久才做对的部分。不能失败的尺寸读取器就是会撒谎的尺寸读取器,而关于图片几何的谎言不会在插入时浮出水面。它会在几周后浮出来,当有人打开工作簿、公司 logo 变成一个被压扁的矩形时
为什么图片在进 drawing 之前就需要真实几何?
因为电子表格格式存的是范围,不是图片。当 AddPicture 或 Images.AddFromFile 放置一张图片时,drawing 部件为图形记录显式的宽和高——在 DrawingML 里是 a:ext 元素的 cx 和 cy 属性,单位是 EMU,按 ECMA-376 Part 1 §20.4。记录里没有任何东西说"用图片的自然尺寸",所以自然尺寸只能从某个地方来,而它存在的唯一地方就是图片字节。错三倍,图片就在每个打开工作簿的应用里被拉伸三倍,永远如此。从像素尺寸到 EMU 的单位换算是一个独立问题,见EMU 单位与缩放文章;这篇讲的是它前面的那一步——像素数字是怎么拿到的。读取器失败时的兜底值是 100 乘 100,这个数字是刻意地、明显地错。它不是对合理尺寸的猜测,而是一个意思是"没人能读这个文件"的标记,而且它远比一个看起来合理的错误数字好,因为用户会立即报告它
GIF:从未被解析过的描述符
GetGifSize 是新的,在它存在之前,插入工作表的每个 GIF 都恰好以 100 乘 100 出来——根本没有 GIF 分支,所以扩展名落到 GetImageSize 的默认臂上返回 -1。这是整套里最便宜好解析的头部,这让遗漏有点尴尬,也非常好修。GIF89a 规范把逻辑屏幕描述符放在六字节签名之后紧挨着,§18 把它的前两个字段定义为逻辑屏幕宽和高,每个都是小端存储的无符号 16 位值。落到文件里就是字节 6 和 7 是宽、字节 8 和 9 是高,所以一次十字节读取就覆盖了签名检查和两个维度
function GetGifSize(const AFileName: WideString; var AWidth, AHeight: Double): Integer;
var
f: TWFileStream;
Header: array [0.. 9] of Byte;
wWidth, wHeight: Word;
begin
Result:= - 1;
AWidth:= 100;
AHeight:= 100;
f:= TWFileStream.Create(AFileName, fmOpenRead);
try
// 签名 "GIF8",然后是逻辑屏幕描述符,其宽度和
// 高度是偏移 6 和 8 处的 little-endian 字
if (f.Read(Header[0], SizeOf(Header))= SizeOf(Header))and
(Header[0]= Ord('G'))and (Header[1]= Ord('I'))and
(Header[2]= Ord('F'))and (Header[3]= Ord('8')) then
begin
wWidth:= Header[6]or (Header[7] shl 8);
wHeight:= Header[8]or (Header[9] shl 8);
if (wWidth> 0)and (wHeight> 0) then
begin
AWidth:= wWidth* 0.75;
AHeight:= wHeight* 0.75;
Result:= 1;
end;
end;
finally
f.Free;
end;
end;
注意它的形状:先赋失败值,成功路径是唯一会覆盖它们的东西。lxImage.pas 里每个读取器都遵循这个顺序,因为它让"我读不了这个"成为默认,而不是每次提前退出时都要记得的东西。只检查 GIF8 而不是完整的 GIF87a 或 GIF89a 字符串是有意的——两个版本里描述符布局完全相同,拒绝一个几何可读的文件没有任何好处
BMP 头部大小为 12 到底改变了什么?
GetBmpSize 把偏移 14 处的四个字节读作 DIB 头部大小并按值分支,因为头部大小为 12 的 BMP 用与其他所有 BMP 完全不同的字段宽度存它的维度。[MS-WMF] §2.2.2 把这一族摆清楚了:BITMAPCOREHEADER 是 12 字节,带 16 位宽和高;而 BITMAPINFOHEADER、BITMAPV4HEADER 和 BITMAPV5HEADER 都以 32 位有符号 biWidth 和 biHeight 开头。文件里只有两个字节的地方读四个字节不会响亮地失败;它把平面数和位深当作数字的高半拉进来,报告一张几十万像素宽的位图。同一头部的第二个陷阱是 biHeight 的符号:负值不意味着负高度,它意味着 DIB 行从上到下而不是从下到上存储,这是与几何无关的像素顺序陈述
f.Seek(14, soFromBeginning);
if f.Read(HeaderSize, 4)= 4 then
begin
if HeaderSize= 12 then
begin
// BITMAPCOREHEADER 存储 16 位尺寸
if (f.Read(wWidth, 2)= 2)and (f.Read(wHeight, 2)= 2) then
begin
lWidth:= wWidth;
lHeight:= wHeight;
Result:= 1;
end;
end
else if (f.Read(lWidth, 4)= 4)and (f.Read(lHeight, 4)= 4) then
Result:= 1;
end;
// ...
// biHeight 为负只表示 DIB 自上而下存储;绝对值才是尺寸
if (Result= 1)and (lWidth> 0)and (lHeight<> 0) then
begin
AWidth:= lWidth* 0.75;
AHeight:= Abs(lHeight)* 0.75;
end
else
Result:= - 1;
最后的守卫值得细读。高度用 <> 0 而不是 > 0 测试,因为负数是合法的、零不是,而且 Abs 只在换算点应用。宽度没有对等的用例——格式没有定义负的 biWidth,所以它被当作损坏文件并降级为 -1。把原始负值向上传会在 drawing 记录里产生负的范围,视哪个应用打开工作簿,它会被钳制、忽略或渲染成翻转的图形
JPEG:哪些标记才是真正的帧头?
GetJpegSize 接受标记集合 [$C0..$C3, $C5..$C7, $C9..$CB, $CD..$CF],这个区间里的三个缺口正是全部要点。ITU-T T.81 §B.2.2 把帧头定义为 SOFn,行数和每行采样数就在帧头里,在每一种帧类型里而不只是常见的那几种。落在同一个数字区间里却不是帧头的标记是 $C4(DHT,定义 Huffman 表)、$C8(JPG,保留)和 $CC(DAC,定义算术编码条件)。只认 baseline、extended sequential 和 progressive——$C0、$C1 和 $C2——的读取器能处理相机或截图工具产出的几乎每张 JPEG,然后在文档工作流真正关心的那些上静默失败:无损 JPEG($C3)、算术编码变体($C9 到 $CB)和层次帧类型($CD 到 $CF)全都扫过帧头、跑出文件末尾、落进兜底。医学影像和扫描文档流水线产出的正是这些文件
// 任何 SOFn 标记都携带帧尺寸;$C4 (DHT)、$C8 (JPG)
// 与 $CC (DAC) 共享该区间,但不是帧头
if Seg in [$C0.. $C3, $C5.. $C7, $C9.. $CB, $CD.. $CF] then
begin
ReadLen:= f.Read(Dummy[0], 3);
ReadLen:= f.Read(wHeight, 2);
wHeight:= swapByte(wHeight);
ReadLen:= f.Read(wWidth, 2);
wWidth:= swapByte(wWidth);
found:= True;
Break;
end
那七行里有两个细节。跳过的三个字节是段长度字加采样精度字节,所以读取落在行数上;JPEG 先存高度再存宽度,这与大多数格式相反,也是很容易写反的转置。而且值要过 swapByte,因为 JPEG 通篇都是大端,GetPngSize 把 32 位 IHDR 值过 lswapByte 也是同一个原因。段遍历有自己的守卫:当标记不是无参数时,读取器取两字节长度、做字节交换,值低于 2 就拒绝 seek——比自身字段还短的长度会变成向后 seek,而扫描循环里的向后 seek 在别人控制的文件上就是死循环
返回负一胜过返回一个貌似合理的数字
五个读取器共有的主线是 GetImageSize 区分三种结果而不是两种。1 意味着几何从文件里读出来了。-1 意味着没有,调用方该用它的默认值。7 由 GetWmfEmfSize 返回,意思完全不同:它告诉 AddPicture 从图元文件 BSE 记录取尺寸,因为 WMF 或 EMF 把它的范围放在工作簿自己的图片记录里,而不是文件头部的固定偏移处。图元文件处理总体上见有界的 EMF 和 WMF 解码器文章。GetJpegSize 展示了为什么这种区分需要显式标志而不是推断:扫描循环可以三种方式终止——找到了帧头、撞到文件末尾、或者因损坏的段长度而放弃——只有第一种给 wWidth 和 wHeight 赋值。没有 found 布尔,另外两条路径会把那些局部变量碰巧装着的值转换后当作成功向上交。未初始化的局部变量常常是零,而零在这里是最糟的答案:它通过下游所有有效性检查,产出一张没人能选择或删除的隐形图片
var
W, H: Double;
begin
case GetImageSize(FileName, W, H) of
1: Sheet.AddPicture(FileName, Row, Col, W, H);
7: Sheet.AddPicture(FileName, Row, Col); // 图元文件:尺寸取自 BSE 记录
else
// W 和 H 是 100 x 100;要决定策略,而不是接受占位值
LogWarning('unreadable image geometry: ' + FileName);
end;
end;
诚实的边界在哪里
这些读取器刻意地解析一个头部然后停下。它们不解码像素,所以只报告存储的维度、没有更多,而且有些情况下存储的维度并不是全部真相。帧比逻辑屏幕小的动画 GIF 仍然报告逻辑屏幕——这对布局是正确的,却可能和查看器显示的并不一致。带 EXIF orientation 标签、指定 90 度旋转的 JPEG 报告未旋转的帧维度,所以手机拍的竖幅照片可能回来成横幅。非方形像素宽高比的 DIB 报告原始像素计数,0.75 系数通篇假设 96 DPI。这些都不是头部读取的 bug,也都无法不解码图片就修复——那不是这一层该干的——它们是文档化的边界,所以如果你的流水线处理手机照片,调用 AddPicture 前自己读 orientation 标签并交换数值。图片放置、锚定和 drawing 对象模型的其他一切见图表、图片和绘图文章
HotXLS 在 Delphi 和 C++Builder 中原生读写 XLS、XLSX、ODS 和 CSV,无需安装 Excel,图片插入在每一种里都走这条头部路径。格式支持和许可细节在 HotXLS Delphi spreadsheet component 产品页上