HotXLS читает реальную пиксельную геометрию картинки из заголовка самого файла изображения до того, как будет записана какая-либо геометрия отрисовки. GetImageSize в lxImage.pas по расширению файла направляет в GetJpegSize, GetGifSize, GetBmpSize, GetPngSize или GetWmfEmfSize, конвертирует пиксели в пункты коэффициентом 0.75 и возвращает -1, когда честно не может определить
Последняя оговорка — та часть, которую дольше всего доводили до ума. Читатель размеров, который не умеет падать, — это читатель, который врёт, а ложь о геометрии изображения не всплывает в момент вставки. Она всплывает недели спустя, когда кто-то открывает книгу, а логотип компании — сплющенный прямоугольник
Зачем картинке настоящая геометрия до того, как она попадёт в рисунок?
Потому что формат электронных таблиц хранит экстент, а не изображение. Когда AddPicture или Images.AddFromFile размещает картинку, рисовальная часть записывает явные ширину и высоту фигуры — в DrawingML это атрибуты cx и cy элемента a:ext, в English Metric Units, по ECMA-376 Part 1 §20.4. В этой записи ничего не сказано «используй естественный размер изображения», так что естественному размеру нужно откуда-то взяться, а единственное место, где он существует, — байты изображения. Ошибётесь в три раза — и картинка растянется в три раза, в каждом приложении, открывающем книгу, навсегда. Конвертация этих пиксельных размеров в EMU — отдельный вопрос, описанный в статье про единицы EMU и масштабирование; этот текст про шаг до неё, где пиксельные числа вообще добываются. Fallback при провале читателя — 100 на 100, и это число намеренно, очевидно неправильное. Это не угадывание правдоподобного размера, а маркер со значением «никто не смог прочитать файл», и он куда лучше правдоподобно выглядящего неверного числа, потому что пользователь сообщает о нём немедленно
GIF: дескриптор, который никогда не разбирали
GetGifSize новый, и до его появления каждый GIF, вставленный в лист, выходил ровно 100 на 100 — ветки GIF не было вообще, так что расширение проваливалось в ветку по умолчанию GetImageSize и возвращало -1. Это самый дешёвый заголовок из набора для разбора, из-за чего упущение слегка конфузное и очень легко чинится. Спецификация GIF89a кладёт Logical Screen Descriptor сразу после шестибайтовой сигнатуры, и §18 определяет его первые два поля как ширину и высоту логического экрана, каждое — беззнаковое 16-битное значение в little-endian. В терминах файла это байты 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 с шириной и
// высотой как little-endian словами на смещениях 6 и 8
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-заголовка и ветвится по значению, потому что BMP с размером заголовка 12 хранит свои размеры в совершенно другой ширине поля, чем все остальные 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 лишь метит top-down 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: какие маркеры на самом деле заголовки фреймов?
GetJpegSize принимает набор маркеров [$C0..$C3, $C5..$C7, $C9..$CB, $CD..$CF], и три пропуска в этом диапазоне — весь смысл. ITU-T T.81 §B.2.2 определяет заголовок фрейма как SOFn, и именно в заголовке фрейма живут число строк и число сэмплов в строке — во всех типах фреймов, а не только в ходовых. Маркеры, попадающие в тот же числовой диапазон, но не являющиеся заголовками фреймов, — это $C4 (DHT, define Huffman tables), $C8 (JPG, зарезервирован) и $CC (DAC, define arithmetic coding conditioning). Читатель, знающий только baseline, extended sequential и progressive — $C0, $C1 и $C2, — работает почти со всяким JPEG, который производят камера или инструмент скриншотов, а потом тихо падает на тех, что важны документным процессам: lossless JPEG ($C3), арифметически кодированные варианты ($C9–$CB) и иерархические типы фреймов ($CD–$CF) все проскакивают мимо заголовка фрейма, улетают за конец файла и падают в fallback. Медицинская визуализация и пайплайны сканированных документов производят ровно такие файлы
// Любой маркер 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. У обхода сегментов свой гард: когда маркер не без параметров, читатель берёт двухбайтовую длину, меняет порядок байт и отказывается от seek, если значение меньше 2, — длина короче собственного поля превратилась бы в обратный 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, чьи фреймы меньше логического экрана, всё равно сообщает логический экран — для раскладки это верно, а вот с тем, что показывает вьювер, может и не совпасть. JPEG с EXIF-тегом ориентации, задающим поворот на 90 градусов, сообщает неповёрнутые размеры фрейма, так что портретное фото с телефона может вернуться альбомным. DIB с неквадратным соотношением сторон пикселя сообщает сырые счётчики пикселей, а коэффициент 0.75 предполагает 96 DPI повсюду. Ни одно из этого не баг чтения заголовков, и ни одно не чинится без декодирования изображения, для чего этот слой и не предназначен, — это задокументированные границы, так что если ваш пайплайн имеет дело с телефонными фото, прочитайте тег ориентации сами и поменяйте значения местами перед вызовом AddPicture. Всё остальное про размещение картинок, якорение и объектную модель рисунков описано в статье про диаграммы, изображения и рисунки
HotXLS нативно читает и пишет XLS, XLSX, ODS и CSV в Delphi и C++Builder без установленного Excel, и вставка картинок во всех них идёт через этот путь заголовков. Поддержка форматов и детали лицензирования — на странице продукта HotXLS Delphi spreadsheet component