跳转至

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 写入:

  1. 保存前调用 applyDpiMetadata()
  2. FreeImage_SetDotsPerMeterX / Y 设 FreeImage 内部字段。
  3. 写一组 EXIF tag (XResolution, YResolution, ResolutionUnit) 到 FIMD_EXIF_MAIN
  4. 这一步保证 FreeImage 自己写文件时能写正确的字段。
  5. 保存后调用 patchSavedDpiMetadata(fif, path)
  6. FREE_IMAGE_FORMAT 派发到对应 patcher,从文件二进制层面再覆盖一次。
  7. 保证即便 FreeImage 漏写或写错,最终文件里也是正确的 DPI。

公共字节工具:

  • readLittleEndian16/32readBigEndian32writeLittleEndian16/32writeBigEndian16/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 / biYPelsPerMeterdpi * 39.37(DIB 本身没有 unit 概念,全靠 ppm)。
  • TIFF:ResolutionUnit = 2(inch),XResolution / YResolutionDPI * 100 / 100 的有理数。

原因:

  • Windows Explorer 与主流图像应用都把 PNG pHYs unit=1 / JPG JFIF unit=1 / TIFF ResolutionUnit=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/32readBigEndian32:从 std::vector<BYTE> 偏移读取。
  • writeLittleEndian16/32writeBigEndian16/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 -> patchPngDpiMetadata
  • FIF_JPEG -> patchJpegDpiMetadata
  • FIF_BMP -> patchBmpDpiMetadata
  • FIF_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 / YResolutionScannerSettings 同源,且 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;若将来生成 BITMAPV4HEADERBITMAPV5HEADER,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 思路差异较大。