HotXLS 在寫入任何繪圖幾何之前,先從影像檔案自己的標頭讀出圖片真正的像素幾何。lxImage.pas 裡的 GetImageSize 依副檔名分派到 GetJpegSize、GetGifSize、GetBmpSize、GetPngSize 或 GetWmfEmfSize,用 0.75 的係數把像素換成點,真的讀不出來時回傳 -1
最後那個子句是花最久才做對的部分。不會失敗的尺寸讀取器就是會說謊的尺寸讀取器,而關於影像幾何的謊言不會在插入時浮現。它幾週後才浮現——某人打開活頁簿,公司 logo 變成一個被壓扁的矩形
為什麼圖片在進繪圖之前就需要真實幾何?
因為試算表格式存放的是範圍,不是影像本身。AddPicture 或 Images.AddFromFile 放一張圖片時,繪圖部件為圖形記錄明確的寬與高——在 DrawingML 裡就是 a:ext 元素的 cx 與 cy 屬性,單位是 English Metric Units,依 ECMA-376 Part 1 §20.4。那份記錄裡沒有任何東西說「用影像的自然尺寸」,所以自然尺寸得從某處來,而它唯一存在的地方就是影像位元組。差三倍,圖片就會被拉成三倍,在所有開啟這個活頁簿的應用程式裡,永遠如此。把那些像素尺寸換算成 EMU 是另一件事,見EMU 單位與縮放一文;這篇講的是它前面那一步——像素數字到底怎麼拿到。讀取器失敗時的後備是 100 乘 100,這個數字是刻意、明顯地錯。它不是對合理尺寸的猜測,而是「沒人讀得了這個檔案」的標記,而且比一個看起來合理的錯誤數字好得多,因為使用者會立刻回報它
GIF:從未剖析過的描述子
GetGifSize 是新的,在它出現之前,插進工作表的每一張 GIF 都剛好是 100 乘 100——根本沒有 GIF 分支,副檔名直接落到 GetImageSize 的預設臂,回傳 -1。這是整套裡最便宜的標頭,所以這個遺漏稍微有點難看,也非常好修。GIF89a 規格把 Logical Screen Descriptor 放在六位元組簽名之後,§18 定義它的前兩個欄位是邏輯螢幕寬與高,各自是 little-endian 儲存的無號 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",然後是 Logical Screen Descriptor,其寬度與
// 高度是偏移 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 列以 top-down 而非 bottom-up 儲存——這是像素順序的陳述,跟幾何一點關係都沒有
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。改把未處理的負值往上傳,繪圖記錄裡會出現負的範圍,而它會被截斷、忽略或渲染成翻轉的圖形,取決於哪個應用程式打開活頁簿
JPEG:哪些 marker 其實是框架標頭?
GetJpegSize 接受的 marker 集合是 [$C0..$C3, $C5..$C7, $C9..$CB, $CD..$CF],範圍裡的三個缺口正是重點所在。ITU-T T.81 §B.2.2 把框架標頭定義成 SOFn,而每種框架類型——不只是常見的那幾種——的行數與每行取樣數都住在框架標頭裡。落在同一個數字區間但又不是框架標頭的 marker 是 $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 全檔 big-endian,GetPngSize 把 32 位元 IHDR 值過 lswapByte 也是同一個理由。段掃描有自己的守衛:marker 不是無參數時,讀取器取兩位元組長度、位元組交換,值低於 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 方向標記(指定旋轉 90 度)的 JPEG 回報的是未旋轉的框架尺寸,所以手機拍的直幅照片可能以橫幅回來。非方形像素寬高比的 DIB 回報原始像素計數,而 0.75 係數假設全程 96 DPI。這些沒有一件是標頭讀取的問題,也沒有一件能在不解碼影像的狀況下修好——而那不是這個層級的職責——它們是寫明文件的界線,所以如果你的管線會處理手機照片,自己讀方向標記、在呼叫 AddPicture 前交換數值。圖片擺放、錨定與繪圖物件模型的其他一切,都在圖表、圖片與繪圖一文裡
HotXLS 在 Delphi 與 C++Builder 中原生讀寫 XLS、XLSX、ODS 與 CSV,不需要安裝 Excel,而圖片插入在每一種格式裡都走這條標頭路徑。格式支援與授權細節在HotXLS Delphi spreadsheet component產品頁