Settings UI之 File DPI
1. 需求
虚拟扫描仪输出(File Transfer 写出的文件,或 Native Transfer 后由应用保存的文件)必须在 Windows 资源管理器 "属性 → 详细信息" 以及第三方图像应用中显示与 settings UI 选择一致的水平分辨率和垂直分辨率。
主要需求:
- 当用户在 settings UI 选择 150 / 200 / 300 / 600 DPI 中的某一档(或应用通过
ICAP_XRESOLUTION / ICAP_YRESOLUTION设定其他值)时,输出文件必须如实记录这一档 DPI。 - 必须覆盖项目支持的全部输出格式:BMP、PNG、JPG、TIFF。
- 水平和垂直分辨率可以不一致(虽然 UI 只暴露同一档,但底层
ScannerSettings.x_resolution / y_resolution是独立字段),必须分别正确写入。 - 单位声明必须明确为 "每英寸" (dots/pixels per inch),不要让消费方按 "每厘米" 或 "无单位" 解释。
- Windows Explorer "属性 → 详细信息" 中显示的水平/垂直分辨率必须与用户选择完全一致;常见消费方 XnView、IrfanView、Photoshop、NAPS2 也应一致。
- 写入失败不能破坏文件本身:要么 patch 成功并产生合法文件,要么保留
FreeImage_Save的原始输出。
非功能性需求:
- DPI 修补不能改变图像像素内容,只允许修改 / 插入元数据字段或 chunk。
- DPI 修补不能依赖外部库(不能再加 libpng / libjpeg / libtiff 等),全部按字节写代码自实现,避免 FreeImage 之外多一份依赖。
- 修补过程必须对 FreeImage 已经写入的合法字段宽容(如果 FreeImage 已经写对了,覆盖一遍也应得到同样结果)。
2. 领域知识
2.1 各文件格式对 DPI 的表达方式
| 格式 | 字段 / chunk | 单位 | 数值类型 | 备注 |
|---|---|---|---|---|
| BMP | BITMAPINFOHEADER.biXPelsPerMeter / biYPelsPerMeter |
pixels per meter | LE int32 | 直接在 DIB header 内。 |
| PNG | pHYs chunk |
pixels per meter (unit byte = 1) | BE uint32 + 1 字节单位 | unit = 0 时单位未定义。 |
| JPG (JFIF) | APP0 (0xFFE0) 段的 density 字段 |
density unit 1 = dpi, 2 = dpc | BE uint16 | 老 JFIF 也允许 unit = 0(无单位)。 |
| TIFF | IFD 标签 XResolution (282) / YResolution (283) + ResolutionUnit (296) |
RATIONAL + SHORT | TIFF endianness | ResolutionUnit 1 = none, 2 = inch, 3 = cm。 |
2.2 单位换算
- DPI -> pixels per meter:
ppm = dpi / 0.0254 = dpi * 39.3700787。 - 在 PNG / BMP 中 Windows 会把 ppm 换算回 DPI 展示,反向取整误差通常 < 1 DPI。
- JPG JFIF density 字段直接是无量纲整数,单位由 unit 字节解释,DPI 时无需换算。
- TIFF 用有理数 (numerator / denominator) 存储,本项目固定
denominator = 100,把 DPI 乘 100 写入 numerator,保留两位小数精度。
2.3 FreeImage 在 FreeImage_Save 后的实际行为
经实测:
- BMP:通常正确写入
biXPelsPerMeter / biYPelsPerMeter。 - PNG:部分 FreeImage 版本会忽略
FreeImage_SetDotsPerMeter,写出的 PNG 缺pHYs。 - JPG:经常不写 JFIF APP0 density,或 unit 字节填 0,导致 Windows 把 DPI 当成 96。
- TIFF:通常会写
XResolution / YResolution,但ResolutionUnit偶尔填 1(none),使消费方无法判断单位。
因此即便已经设置了 FreeImage 内部 DPI,仍需在保存后做 "二次修补"。
2.4 字节序
- PNG:全部大端 (BE)。
- JPG (JFIF):APP0 内的 density 字段大端。
- BMP:小端 (LE),
BITMAPINFOHEADER字段全部小端。 - TIFF:字节序由文件头前两个字节决定,
II= LE,MM= BE。
2.5 PNG pHYs chunk 结构
4 bytes Length = 9
4 bytes Type = "pHYs"
4 bytes X pixels per unit (BE uint32)
4 bytes Y pixels per unit (BE uint32)
1 byte Unit specifier (0 = unknown, 1 = meter)
4 bytes CRC32 (over Type + data)
合法 PNG 中,pHYs 必须放在 IDAT 之前;IHDR 之后是最稳妥的插入点。
2.6 JPG JFIF APP0 段结构
2 bytes Marker = 0xFF 0xE0
2 bytes Length = 16 (BE)
5 bytes Identifier = "JFIF\0"
2 bytes Version = 1.01
1 byte Density unit (0 = none, 1 = inch, 2 = cm)
2 bytes X density (BE uint16)
2 bytes Y density (BE uint16)
1 byte X thumbnail = 0
1 byte Y thumbnail = 0
如果文件已经含 APP0:原地覆盖 unit + density 字段即可。如果没有:在 0xFFD8 (SOI) 之后插入一段全新的 APP0。
2.7 BMP DIB header DPI 字段位置
BITMAPFILEHEADER 为 14 字节,紧接着是 BITMAPINFOHEADER:
offset 14: biSize (4 bytes)
offset 18: biWidth (4 bytes)
offset 22: biHeight (4 bytes)
offset 26: biPlanes (2 bytes)
offset 28: biBitCount (2 bytes)
offset 30: biCompression (4 bytes)
offset 34: biSizeImage (4 bytes)
offset 38: biXPelsPerMeter (4 bytes LE)
offset 42: biYPelsPerMeter (4 bytes LE)
offset 46: biClrUsed (4 bytes)
offset 50: biClrImportant (4 bytes)
只需直接覆盖 offset 38 / 42 这两个 4 字节小端整数。
2.8 TIFF IFD 结构
文件头 8 字节给出 IFD 偏移;IFD 起始 2 字节为 entry 数,紧跟若干 12 字节 entry,每个 entry:
2 bytes Tag
2 bytes Type (5 = RATIONAL, 3 = SHORT, ...)
4 bytes Count
4 bytes Value/Offset
XResolution (282) / YResolution (283) 是 RATIONAL,count = 1,value/offset 字段指向文件中 8 字节 (numerator + denominator) 的位置。ResolutionUnit (296) 是 SHORT,count = 1,值直接放在 entry 的 value/offset 字段。
2.9 Windows Explorer 的 DPI 判定逻辑
- BMP:直接读
biXPelsPerMeter / biYPelsPerMeter,换算 DPI。 - PNG:读
pHYs,unit 必须为 1 才认 DPI;否则按 96 DPI 展示。 - JPG:读 JFIF APP0,unit 必须为 1 才认 DPI;某些版本会 fallback 到 EXIF
XResolution / YResolution。 - TIFF:读
ResolutionUnit,必须为 2 (inch) 才认 DPI;为 1 (none) 时按像素无量纲处理。
因此 unit / ResolutionUnit 字段是否正确,比数值本身更关键。
3. 设计目标
- 覆盖项目所有输出文件格式(BMP / PNG / JPG / TIFF)。
- 不引入额外第三方依赖,全部基于
<fstream>+ 字节级读写。 - 与
ICAP_XRESOLUTION / ICAP_YRESOLUTION / ICAP_UNITS完全一致;File Transfer 与 Native Transfer 共用同一份 DPI 来源 (ScannerSettings)。 - 在
FreeImage_Save之后总是再 patch 一次,无论 FreeImage 是否已写对:覆盖一遍等效,写错的会被纠正。 - 单元测试式自检:在 patch 前后比较像素数据长度不变,保证未误改像素。
- 单位声明使用 "per inch" 分支(PNG 经 meter 间接表达,但与 Windows 显示协议一致)。
非目标:
- 不涉及 EXIF 完整解析;只在写元数据时同时写一组 EXIF
XResolution / YResolution / ResolutionUnit由 FreeImage 处理。 - 不支持多页 TIFF 的多组 IFD(只处理第一个 IFD)。
- 不修补除 DPI 之外的其他元数据(color profile、orientation、color space 等不在范围内)。
- 不支持 BMP
BITMAPV4HEADER/BITMAPV5HEADER的扩展 DPI 字段(项目目前只产出BITMAPINFOHEADER)。
4. 总体设计
VirtualScanner
├── applyDpiMetadata() // 在 FreeImage 内部写 DPI + EXIF tag
└── patchSavedDpiMetadata(fif, path)
│ 调度到具体格式 patcher:
├── patchPngDpiMetadata()
├── patchJpegDpiMetadata()
├── patchBmpDpiMetadata()
└── patchTiffDpiMetadata()
两层 DPI 写入:
- 保存前调用
applyDpiMetadata(): FreeImage_SetDotsPerMeterX / Y设 FreeImage 内部字段。- 写一组 EXIF tag (
XResolution,YResolution,ResolutionUnit) 到FIMD_EXIF_MAIN。 - 这一步保证 FreeImage 自己写文件时能写正确的字段。
- 保存后调用
patchSavedDpiMetadata(fif, path): - 按
FREE_IMAGE_FORMAT派发到对应 patcher,从文件二进制层面再覆盖一次。 - 保证即便 FreeImage 漏写或写错,最终文件里也是正确的 DPI。
公共字节工具:
readLittleEndian16/32、readBigEndian32、writeLittleEndian16/32、writeBigEndian16/32处理大小端。crc32Png()实现 PNG chunk 用的 CRC32。dpiToPixelsPerMeter(float dpi):(unsigned)(dpi * 39.3700787 + 0.5)。dpiToJpegDensity(float dpi):四舍五入到WORD,限制在 [1, 65535]。readFileBytes(path, &buf)/writeFileBytes(path, buf):一次性整文件读写,避免半改半写。
5. 重要决策和原因
5.1 在 FreeImage_Save 之后总是再 patch 一次
决策:无论 FreeImage 是否已经正确写入 DPI,都强制走 patcher。
原因:
- FreeImage 不同版本/不同插件对 DPI 行为不一致(特别 PNG / JPG),靠版本检测不可靠。
- patcher 是幂等的:FreeImage 写对的字段被覆盖后值不变;FreeImage 写错的会被纠正。
- 简化分支逻辑:只有一条 "save -> patch" 路径,方便回归测试。
5.2 自己写字节级 patcher,不引入 libpng / libjpeg / libtiff
决策:用纯 C++ 标准库 + Windows 类型,按格式规范手写 patcher。
原因:
- DLL 体积不增大,没有额外许可证 / 链接问题。
- 只改 DPI 一处字段,专用代码比通用库更短、更可控。
- 易于在不同 MSVC / FreeImage 版本之间保持一致行为。
5.3 每个格式用 unit = "inch"
决策:
- PNG:unit = 1(meter),数值用
dpi * 39.37间接表达 DPI(这是 PNG 规范的等效做法)。 - JPG:density unit = 1(dots per inch),density 字段直接用 DPI。
- BMP:
biXPelsPerMeter / biYPelsPerMeter用dpi * 39.37(DIB 本身没有 unit 概念,全靠 ppm)。 - TIFF:
ResolutionUnit = 2(inch),XResolution / YResolution用DPI * 100 / 100的有理数。
原因:
- Windows Explorer 与主流图像应用都把 PNG
pHYsunit=1 / JPG JFIF unit=1 / TIFFResolutionUnit=2视为 DPI。 - 与 TWAIN 项目
ICAP_UNITS = TWUN_INCHES一致。 - 避免 unit = 0 这类 "未知单位" 让消费方退化到默认 96 DPI 显示。
5.4 BMP 直接定位 offset 38 / 42 写两个 LE int32
决策:BMP patcher 不解析整个 DIB header,只检查 magic + DIB header 大小 >= 40,然后定位到固定 offset 写入。
原因:
- 项目产出的 BMP 都使用
BITMAPINFOHEADER(40 字节),不会出现BITMAPV4HEADER等扩展头。 - DIB header 起始位置和 DPI 字段偏移是规范保证的(offset 38 / 42 相对文件起点)。
- 简单代码、最低读改写成本。
5.5 PNG 在 IHDR 之后插入 pHYs
决策:如果文件已经有 pHYs,原地覆盖;否则在 IHDR chunk 末尾后插入新 pHYs。
原因:
- PNG 规范要求
pHYs必须出现在第一个IDAT之前,而IHDR是文件第一个 chunk,紧跟其后的位置最稳妥。 - 不破坏其他元数据 chunk(
gAMA,cHRM,iCCP,tEXt等)。 - 自实现 CRC32 (
crc32Png),保证插入的 chunk 在所有 PNG 解码器里都合法。
5.6 JPG 优先覆盖现有 APP0,没有就在 SOI 后插入
决策:扫描所有 marker 段:
- 找到 JFIF APP0 (
0xFFE0,identifier "JFIF\0") 则原地把 unit + density 字段写成 DPI。 - 遇到
0xFFD9(EOI) /0xFFDA(SOS) 之前没找到 JFIF APP0,则在0xFFD8(SOI) 之后插入一段 18 字节的 JFIF APP0。
原因:
- 大多数 FreeImage 写出的 JPG 已经有 JFIF APP0,但 density 字段经常错;覆盖比插入便宜。
- 没有 APP0 时,必须在 SOI 后立刻插入,不能放到 SOS 之后;放错位置会让一些解码器报告损坏。
- 18 字节的标准 JFIF 段足以满足 Windows / Explorer / Photoshop 的解析。
5.7 TIFF 只处理第一个 IFD,只覆盖现有 entry
决策:TIFF patcher 只解析第 0 个 IFD 的 entry 列表,找到 282 / 283 / 296 时原地覆盖,不新增 entry,不重排 IFD。
原因:
- 项目输出的 TIFF 都是单页,且 FreeImage 在 IFD 中至少会写出
XResolution / YResolution / ResolutionUnit三项 entry。 - 重排 IFD 会牵动 entry count、value/offset 链、可能的多 IFD
next IFD指针,复杂度远超收益。 - 一旦发现关键 entry 缺失(极少见),patcher 直接返回
false,保留原文件不变;上游可以选择重写或忽略。
5.8 applyDpiMetadata 兼写 EXIF tag
决策:applyDpiMetadata 不仅设 FreeImage 内部 ppm,还创建 EXIF XResolution / YResolution / ResolutionUnit 标签。
原因:
- 部分图像应用读 EXIF 而不读容器格式自己的 DPI 字段。
- 让 FreeImage 在写文件时同时把 EXIF 一起写出去(TIFF、JPG)。
- 即便 patcher 失败(如文件被瞬时锁),EXIF tag 也能提供一份退路。
6. 架构各组件改动点
6.1 virtual_scanner.h
- 新增私有方法声明:
void applyDpiMetadata();void patchSavedDpiMetadata(FREE_IMAGE_FORMAT fif, const std::string& path);
6.2 virtual_scanner.cpp(内部匿名 namespace)
新增字节级辅助:
readLittleEndian16/32、readBigEndian32:从std::vector<BYTE>偏移读取。writeLittleEndian16/32、writeBigEndian16/32:写入。crc32Png(const BYTE*, size_t):PNG chunk 用 CRC32(位反向多项式0xEDB88320)。dpiToPixelsPerMeter(float dpi):浮点 -> ppm 四舍五入。dpiToJpegDensity(float dpi):浮点 -> JFIF density 四舍五入。readFileBytes / writeFileBytes:整文件二进制 I/O。makePngPhysChunk / makeJpegJfifApp0Segment:构造标准 chunk / 段。
新增四个 patcher:
patchPngDpiMetadata(path, x_dpi, y_dpi):扫描 PNG chunk,找到pHYs就原地替换,否则在IHDR之后插入。patchJpegDpiMetadata(path, x_dpi, y_dpi):扫描 JPG marker,找到 JFIF APP0 就覆盖 density,否则在 SOI 后插入新 APP0。patchBmpDpiMetadata(path, x_dpi, y_dpi):定位到固定 offset 38 / 42 写biXPelsPerMeter / biYPelsPerMeter。patchTiffDpiMetadata(path, x_dpi, y_dpi):解析头 + 第一个 IFD,覆盖 tag 282 / 283 的 RATIONAL value 和 tag 296 的 SHORT。
6.3 VirtualScanner::applyDpiMetadata()
- 用
FreeImage_SetDotsPerMeterX / Y设置 FreeImage 内部 DPI。 - 创建
XResolution (0x011A)/YResolution (0x011B)(FIDT_RATIONAL),分母固定 100。 - 创建
ResolutionUnit (0x0128)(FIDT_SHORT,值 2 = inch)。 - 通过
FreeImage_SetMetadata(FIMD_EXIF_MAIN, dib_, key, tag)挂到当前 DIB。
6.4 VirtualScanner::patchSavedDpiMetadata()
- 从
settings_.x_resolution / y_resolution取 DPI(缺省 300 DPI)。 - 根据
fif派发: FIF_PNG->patchPngDpiMetadataFIF_JPEG->patchJpegDpiMetadataFIF_BMP->patchBmpDpiMetadataFIF_TIFF->patchTiffDpiMetadata
6.5 VirtualScanner::saveImageToFile() / saveImageToPath()
- 在
FreeImage_Save(...)之前调用applyDpiMetadata(),保证内部字段最新。 - 在保存成功 (
saved == TRUE) 之后调用patchSavedDpiMetadata(fif, path),按文件类型覆盖容器级 DPI。 - patcher 失败不视为整体失败:文件仍是合法图像,只是 DPI 可能回落到 FreeImage 输出值。
6.6 twain_data_source.cpp
- File Transfer / Native Transfer 都从同一份
ScannerSettings取 DPI,本设计不需要在 DS 层再次干预。 - DS 层只确保
getImageInfo()返回的TW_IMAGEINFO.XResolution / YResolution与ScannerSettings同源,且allocAndFillDibHeader()把同样的 DPI 写入biXPelsPerMeter / biYPelsPerMeter,使 Native Transfer 后应用保存出来的 DIB / BMP 也带正确 DPI。
7. 典型流程示例
7.1 File Transfer 保存 PNG
1. settings_.x_resolution = 600, settings_.y_resolution = 600
2. FreeImage 出图后 saveImageToFile():
applyDpiMetadata()
-> FreeImage_SetDotsPerMeter(dib_, 23622, 23622) // 600 * 39.37
-> EXIF XResolution = 60000/100, YResolution = 60000/100, ResUnit = 2
FreeImage_Save(FIF_PNG, dib_, "D:\\scans\\a.png", 0)
patchSavedDpiMetadata(FIF_PNG, "D:\\scans\\a.png")
-> patchPngDpiMetadata(...)
读全文件 -> 扫描 chunk
发现 pHYs -> 替换为新 chunk
ppm = 23622, unit = 1
写回文件
3. Windows Explorer 属性 -> 详细信息:
水平分辨率 600 dpi,垂直分辨率 600 dpi
7.2 File Transfer 保存 JPG(FreeImage 漏写 JFIF density)
1. settings_.x_resolution = 300, settings_.y_resolution = 300
2. FreeImage_Save 出的 JPG,APP0 density 单位为 0 (none)
3. patchJpegDpiMetadata("D:\\scans\\a.jpg", 300, 300)
-> 在 marker 序列里发现 JFIF APP0
-> 覆盖:unit = 1, x_density = 300, y_density = 300
-> 写回
4. Windows Explorer:300 dpi / 300 dpi
7.3 Native Transfer 后应用保存 BMP
1. allocAndFillDibHeader() 写入 biXPelsPerMeter = biYPelsPerMeter = round(150 * 39.37)
2. 应用 GlobalLock 拿到 DIB,自己写 BMP 文件,DIB header 原样写出
3. Explorer 显示 150 dpi / 150 dpi(无需 patcher,因为 DIB header 本身已正确)
8. 限制
- 修补失败时返回
false,但调用方目前不会因此 retry 或回退;文件仍然存在,DPI 可能不准。 - BMP patcher 仅支持
BITMAPINFOHEADER;若将来生成BITMAPV4HEADER或BITMAPV5HEADER,offset 38 / 42 的语义会变。 - TIFF patcher 仅处理第一个 IFD,多页 TIFF 的后续页 DPI 不会被覆盖(目前项目不产出多页 TIFF)。
- TIFF patcher 不会新增 entry:若 FreeImage 没写
XResolution / YResolution / ResolutionUnit(极少见),patcher 直接返回 false。 - TIFF 分子写死乘以 100:极端高 DPI(>4.2 亿 / 100)会溢出 uint32,但实际不可能。
- PNG patcher 在文件没有
IHDR时返回 false;FreeImage 写出的 PNG 一定有IHDR,但损坏文件会被原样保留。 - JPG patcher 不处理无 SOI 的退化文件、不处理 RST marker 之外的奇怪 marker 序列。
- patcher 用
std::ifstream / ofstream整文件读写,大文件内存占用与文件大小线性相关。 - 单位永远写成 "per inch",没有暴露给应用 / UI 选择厘米单位的入口。
- EXIF tag 只挂在
FIMD_EXIF_MAIN,没有写 EXIF IFD0 的对等字段;某些只看 IFD0 的应用可能仍读不到。
9. 下一步工作
- 给四个 patcher 增加单元测试:固定输入字节序列 + DPI,比较 patch 后输出。
- 在 patcher 失败时记录日志(
OutputDebugStringA),并在 UI 上提示 "DPI 元数据写入失败"。 - 支持 BMP V4 / V5 header(如果将来想用更宽的 color profile,需要重新定位 DPI 字段)。
- 支持多页 TIFF:遍历
next IFD指针,对每个 IFD 做同样的覆盖。 - 给 TIFF / JPG 增加 "缺失则插入" 的能力,覆盖 FreeImage 完全没写 entry / APP0 的情况。
- 把 DPI 单位选择暴露到 settings UI / capability 协商(添加
TWUN_CENTIMETERS支持,需要同时调整 patcher 单位字段)。 - 在
applyDpiMetadata内同时写 EXIF IFD0 等价字段,扩大兼容范围。 - 引入文件原子写:先写
.tmp再 rename,避免 patch 中途崩溃留下损坏的图像文件。 - 加入跨格式 DPI 一致性自检:保存后立即重新读取,比较
FreeImage_GetDotsPerMeter是否与设定一致,作为 CI 烟雾测试。 - 评估 PDF 输出场景(未来需求),PDF 用
/UserUnit或图像 stream 的Width / Height + Decode表达 DPI,与现有 patcher 思路差异较大。