跳转至

将目录下的所有图片作为图片源

1. 需求

虚拟扫描仪不能没有"被扫描的稿件"。本项目以一个本地图片目录作为虚拟纸张来源,每次应用触发扫描时,DS 从目录里挑出"下一张"图片并按当前 settings(DPI / 像素类型 / 页面尺寸)输出。

主要功能需求:

  • 从固定目录 %APPDATA%\bntech\images\ 读取候选图片。
  • 支持常见输入格式:PNG、JPG、JPEG、BMP、TIF、TIFF。
  • 文件按字母序(不区分大小写、按 locale 稳定排序)排列;每次扫描自动前进到下一张。
  • 列表末尾后回绕到第一张(round-robin),以支持长时间循环测试。
  • 扫描索引必须持久化,DLL 被 unload / 重新 load 后仍能从上次的位置继续。
  • 当目录不存在 / 为空 / 全是不支持格式时,必须有可用的兜底图,不能让应用扫描失败。兜底图使用 DS 安装目录下的 TWAIN_logo.png
  • 重置遍历进度的方式必须简单:只需删除一个文件(info.json),无需任何 UI 操作。
  • 允许用户在扫描会话期间增删图片,下一次扫描时新目录状态生效。
  • 必须线程安全:UI 进程、TWAIN 主线程、可能的 strip 复制线程都可能间接访问。
  • ADF 多页场景下(未来扩展),需要支持"一次扫描会话内连续取多张",仍然按字母序。

非功能性需求:

  • 索引文件格式必须可读、易调试(人类肉眼读得懂、能手动修改 / 删除)。
  • 不引入外部 JSON 库依赖;如果可能就手写极简 JSON 读写或退化为纯文本。
  • 加载图片本身仍走 FreeImage,不增加额外解码库。
  • 遍历不依赖目录修改时间戳,避免不同文件系统时间精度差异。

2. 领域知识

2.1 TWAIN 扫描会话与"下一张"语义

TWAIN 一次完整扫描会话简化序列:

OpenDS → EnableDS → (XferReady) → DAT_IMAGEINFO → DAT_IMAGE{NATIVE|FILE}XFER → DAT_PENDINGXFERS → DisableDS → CloseDS

DAT_PENDINGXFERSCount 字段表示扫描仪声明"还有多少页可取"。对平板扫描仪通常是 0(取完这张就结束);对 ADF 则可能是 -1(未知,继续)或具体数字。本项目模拟平板,每次扫描出一张图,Count = 0

"下一张"指的是:每次新的扫描会话(即每次 EnableDS 之后第一次 DAT_IMAGENATIVEXFER / DAT_IMAGEFILEXFER)从目录里取下一张图片。同一会话内不会取多张。

2.2 Windows 用户特殊目录与 %APPDATA%

%APPDATA% 通常解析为 C:\Users\<user>\AppData\Roaming。用户级数据、配置、缓存放这里既能符合 Windows 约定,又不需要管理员权限:

  • 普通进程读写自由。
  • 不同 Windows 账户互不影响。
  • 卸载 DS 时不删用户文件,符合 MSI 卸载最佳实践。

Win32 拿这个目录的标准 API:

PWSTR path = nullptr;
SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, nullptr, &path);
// path 例如 L"C:\\Users\\xixi\\AppData\\Roaming"
CoTaskMemFree(path);

本项目固定使用 <%APPDATA%>\bntech\images\ 作为图片根目录、<%APPDATA%>\bntech\images\info.json 作为遍历索引文件、<%APPDATA%>\bntech\config.ini 作为语言配置。

2.3 目录遍历与排序稳定性

Win32 的 FindFirstFileW / FindNextFileW 不保证返回顺序,常见行为:

  • NTFS:按文件名 lexicographic 升序,但不保证。
  • FAT32:按目录写入顺序。

要让"下一张"在不同机器、不同文件系统上行为一致,必须显式排序。本项目用 std::sort 按 wide-string 的 _wcsicmp(不区分大小写)排序,避免大小写文件名混排时出现 Image1.png > image2.png 这种反直觉结果。

2.4 索引持久化的常见做法

可选方案:

  • A. 写到 INI(config.inilast_index=3)。
  • B. 写到 JSON(info.json{"next_index": 3, "last_file": "image3.png"})。
  • C. 写到注册表。
  • D. 写到 DLL 同目录的临时文件。

约束:

  • DS 通常被多个应用循环 load / unload,索引必须落盘。
  • 写到 C:\Windows\twain_64\bntech\ 不行:需要管理员权限。
  • 写到注册表对调试和重置不友好。

合理选择是 B(info.json),结构简单、调试方便、可手工修改。

2.5 文件格式识别

FreeImage 在 FreeImage_GetFileTypeU(path) 时按签名判断格式,对扩展名不敏感(PNG 改成 .bmp 仍能正确识别)。但是预筛选(决定哪些文件算"候选图片")仍按扩展名做:开发者一眼能看出哪些文件参与遍历。

支持扩展名集合:.png / .jpg / .jpeg / .bmp / .tif / .tiff,比较时统一转小写。

2.6 兜底图 (TWAIN_logo.png)

DS 通常被安装到 C:\Windows\twain_64\bntech\bntech_virtual_scanner.ds。在同目录放一张 TWAIN_logo.png 作为永远存在的兜底图。定位它的 API:

HMODULE h = nullptr;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
                   GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                   reinterpret_cast<LPCWSTR>(&someStaticFunc), &h);
wchar_t buf[MAX_PATH];
GetModuleFileNameW(h, buf, MAX_PATH);
// strip filename, append L"TWAIN_logo.png"

注意必须用模块自身的句柄 (HINSTANCE),不能用 nullptr(那是宿主 EXE 的路径)。

2.7 跨进程并发

同一个用户可能同时打开多个扫描应用(XnView、Twack 等),每个进程都会把 .ds load 一份;它们的 info.json 视图可能竞争。Windows 文件系统的写入是文件级原子(小文件 + 原子 rename),但仍然可能出现两个进程读相同 next_index、各自扫描同一张图。可接受的折衷:

  • 读 / 写 info.json 时用 CreateFileW(...,..., GENERIC_READ|GENERIC_WRITE,..., OPEN_EXISTING / CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL,...)FILE_SHARE_READ,写时用临时文件 + MoveFileExW(..., MOVEFILE_REPLACE_EXISTING) 保证原子替换。
  • 不引入跨进程互斥锁;测试场景下两个应用同时扫描概率低,且复用同一张图并不会影响功能。

3. 设计

3.1 目录与文件

%APPDATA%\bntech\
├── config.ini                     # 语言等用户偏好
└── images\
    ├── info.json                  # 遍历索引(next_index, last_file, total)
    ├── 001_a4_color.png
    ├── 002_a4_text.png
    ├── 010_letter_photo.jpg
    └── ...

兜底图:

<install_dir>\TWAIN_logo.png       # 与 .ds 同目录

3.2 ImageSource 组件

新增(或在 VirtualScanner 内部维护)一个 ImageSource 概念,对外暴露:

class ImageSource {
 public:
  // 解析 %APPDATA%\bntech\images,列出候选文件并稳定排序。
  void refresh();

  // 取"下一张"。无候选时返回 fallback (TWAIN_logo.png) 的路径。
  // 推进索引并持久化。
  std::wstring acquireNext();

  // 当前候选总数;> 0 表示来自目录,0 表示用兜底图。
  size_t size() const;

  // 重置索引到 0。
  void reset();

 private:
  void loadIndex();
  void saveIndex() const;

  std::vector<std::wstring> files_;   // 绝对路径,按 _wcsicmp 排序
  size_t next_index_ = 0;
  std::wstring images_dir_;           // %APPDATA%\bntech\images
  std::wstring fallback_path_;        // <install_dir>\TWAIN_logo.png
  mutable std::mutex mutex_;
};

调用关系:

VirtualScanner::acquireImage()
        │
        └─► ImageSource::acquireNext()
                ├─► (lazy) refresh() 列目录
                ├─► (lazy) loadIndex() 读 info.json
                ├─► pick files_[next_index_ % files_.size()]
                ├─► next_index_++
                └─► saveIndex() 写 info.json (原子 rename)
        │
        └─► FreeImage_LoadU(path)  → FIBITMAP*

3.3 候选文件枚举

void ImageSource::refresh() {
  files_.clear();
  WIN32_FIND_DATAW fd{};
  std::wstring pattern = images_dir_ + L"\\*";
  HANDLE h = FindFirstFileW(pattern.c_str(), &fd);
  if (h == INVALID_HANDLE_VALUE) return;
  do {
    if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
    if (!isSupportedExt(fd.cFileName)) continue;
    files_.push_back(images_dir_ + L"\\" + fd.cFileName);
  } while (FindNextFileW(h, &fd));
  FindClose(h);
  std::sort(files_.begin(), files_.end(),
            [](const std::wstring& a, const std::wstring& b) {
              return _wcsicmp(a.c_str(), b.c_str()) < 0;
            });
}

bool isSupportedExt(const wchar_t* name) {
  static const wchar_t* kExts[] = {L".png", L".jpg", L".jpeg",
                                   L".bmp", L".tif", L".tiff"};
  const wchar_t* dot = wcsrchr(name, L'.');
  if (!dot) return false;
  for (auto e : kExts) if (_wcsicmp(dot, e) == 0) return true;
  return false;
}

3.4 info.json 格式

{
  "next_index": 3,
  "last_file": "002_a4_text.png",
  "total": 12,
  "updated_at": "2026-05-26T10:21:33+08:00"
}

字段说明:

  • next_index:下一次扫描要拿的文件下标(0-based)。
  • last_file:上一次扫描使用的文件名(便于调试日志和定位)。
  • total:上一次 refresh() 看到的候选数量(仅供肉眼校验)。
  • updated_at:写入时间,调试用。

读写策略:

  • 读:文件不存在或解析失败,视为 next_index = 0
  • 写:构造 UTF-8 字符串 → 写入 info.json.tmpMoveFileExW(..., MOVEFILE_REPLACE_EXISTING) 原子替换 info.json
  • 解析:用极简手写解析器,按 "key"\s*:\s*<value> 提取 next_index 即可,其余字段允许缺失或格式异常。

3.5 round-robin 与文件集合变化

acquireNext() 取下标的写法:

if (files_.empty()) {
  return fallback_path_;
}
size_t idx = next_index_ % files_.size();
auto path = files_[idx];
next_index_ = (next_index_ + 1) % files_.size();
saveIndex();
return path;

next_index_files_.size() 之后再保存,确保 info.json 内值始终落在 [0, total) 范围。即使用户在两次扫描之间删除了若干文件,最坏情况下下一次扫描会从头开始,而不会越界。

3.6 缓存与刷新策略

  • 第一次 acquireNext() 内部触发 refresh() + loadIndex()
  • 之后每次 acquireNext()refresh(),让用户在扫描会话之间新增 / 删除图片生效。
  • refresh() 的代价是一次 FindFirstFile 遍历 + sort,对几十张图片量级开销可以忽略。
  • loadIndex() 只在构造时调用一次;运行期 next_index_ 由内存维护,每次 acquireNext()saveIndex() 落盘。

3.7 兜底图位置解析

std::wstring resolveFallbackPath() {
  HMODULE h = nullptr;
  GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
                     GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                     reinterpret_cast<LPCWSTR>(&resolveFallbackPath), &h);
  wchar_t buf[MAX_PATH];
  GetModuleFileNameW(h, buf, MAX_PATH);
  std::wstring p = buf;
  size_t slash = p.find_last_of(L"\\/");
  if (slash != std::wstring::npos) p.resize(slash);
  return p + L"\\TWAIN_logo.png";
}

不论 DS 被安装到 C:\Windows\twain_64\bntech 还是开发期被加载自 build\win64\Release,都能正确定位与自身共存的 TWAIN_logo.png

3.8 与 VirtualScanner 的集成

VirtualScanner::preScanPrep(settings)
   1. image_source_.acquireNext()       → wstring path
   2. FreeImage_LoadU(detectFormat(path), path)  → FIBITMAP*
   3. ensure24BitDib()
   4. applyPageSizeScaling()
   5. applyPixelFormat()
   6. applyDpiMetadata()
   → current_fibitmap_

ImageSourceVirtualScanner 的成员,生命周期与 VirtualScanner 一致;外部不直接访问 ImageSource,只通过 VirtualScanner::preScanPrep 间接驱动。

4. 主要设计决策与原因

4.1 把图片源放在 %APPDATA%\bntech\images

  • 决策:固定路径,不让用户在 UI 配置。
  • 原因:测试机器多、目录变化多会让"自动化测试 + 文档"不稳。固定路径任何脚本、CI 都能复制图片到那里;同时 %APPDATA% 不需要管理员权限,普通用户即可操作。config.iniinfo.json 都放同一根目录便于运维。

4.2 字母序而不是按修改时间

  • 决策:按文件名 (_wcsicmp) 升序。
  • 原因:可预期、可控、跨文件系统稳定。用户可以用 001_, 002_ 前缀手动控制顺序;按 mtime 排序会被 git checkout、复制粘贴等操作改变。

4.3 round-robin 而不是扫到尽头停止

  • 决策:到末尾回绕。
  • 原因:测试场景常常需要持续触发扫描(性能、稳定性测试),如果停止会让自动化脚本卡住。回绕也让用户感受"无穷的纸张"。需要重置时只需删除 info.json,比加 UI 按钮简单。

4.4 索引持久化用 info.json

  • 决策:JSON 文件 + 原子 rename。
  • 原因:人类肉眼读得懂、能手工修改 / 删除;和 config.ini 区分(一个偏好、一个状态);JSON 解析比 INI 解析略复杂但适合未来扩展(添加 last_file / total / updated_at 字段)。

4.5 不引入 JSON 第三方库

  • 决策:手写极简 JSON 读写。
  • 原因:项目只需要解析 1 个数值字段(next_index),加 nlohmann / RapidJSON 增加构建复杂度不值得。手写 50 行内的极简实现足够。

4.6 目录为空时回退 TWAIN_logo.png 而不是返回错误

  • 决策:用兜底图。
  • 原因:DS 第一次被新用户安装时 images\ 必然为空,如果直接报错应用就什么都看不到,初次使用体验差。兜底图能让用户立刻验证扫描链路通畅,再去补图。

4.7 兜底图与 .ds 同目录

  • 决策:MSI 把 TWAIN_logo.png 一起装到 C:\Windows\twain_64\bntech\
  • 原因:用 GetModuleFileNameW 拿自身路径再拼即可,定位简单稳定;不依赖 %APPDATA%,新用户也保证有图。

4.8 每次 acquireNext 重新 refresh 目录

  • 决策:不缓存目录列表。
  • 原因:测试场景下用户经常往目录里加图。缓存会让"我刚加了 image_new.png 为什么没扫到"成为常见 bug。FindFirstFile 对几十张文件几乎零成本。

4.9 不加跨进程锁

  • 决策:只用进程内 std::mutex,跨进程通过文件原子 rename 抢占。
  • 原因:测试环境下两个应用同时扫描的概率低;即使竞争也只是两个应用扫到同一张图,无功能损坏;引入 named mutex 反而带来僵尸锁等运维负担。

4.10 索引按文件总数取模而不是计数到下一帧

  • 决策:保存 next_index = (next_index + 1) % files_.size()
  • 原因:避免 next_index 无界增长(虽然实际不会溢出,但便于人工查看 info.json 时一眼看出"下一张是第几张")。

5. 架构各组件改动点

5.1 src/virtual_scanner.h/.cpp

  • 引入 ImageSource 成员(或同等内部模块),构造时确定 images_dir_fallback_path_
  • 新增 / 整理:
  • acquireImage():调用 image_source_.acquireNext() + FreeImage_LoadU
  • loadFallbackImage():在 acquireNext 返回 fallback_path_ 时使用同一个加载路径。
  • 暴露 resetImageIndex()(可选)作为调试/测试入口。

5.2 src/twain_data_source.cpp

  • DG_CONTROL / DAT_USERINTERFACE / MSG_ENABLEDS 触发的 preScanPrep 之前不做特殊处理;VirtualScanner 内部完成"下一张"的选择。
  • 在日志中记录 last_file,便于调试 Native / File Transfer 链路。

5.3 src/ds_entry.cpp

  • 无直接改动;只要保证 VirtualScanner / ImageSource 在 DLL 卸载时正确析构 (Stage4 关闭 DS 时)。

5.4 安装层 (installer/*.wxs)

  • TWAIN_logo.png 加入 Component,安装到 <INSTALL_DIR>\bntech\TWAIN_logo.png
  • 不创建 %APPDATA%\bntech\images\(首次扫描时按需创建即可,避免无谓写入用户目录)。

5.5 文档

  • README.md 的 "Image source folder" / "准备测试图片" 章节描述目录、扩展名、重置方式。
  • docs/index.md / 博客新增"如何替换扫描源"教程。

5.6 测试影响

  • 单元 / 集成测试用例:
  • 空目录:扫描应得到 TWAIN_logo.png 的内容。
  • 1 张图片:连续扫 3 次,三次都返回同一张。
  • 3 张图片:连续扫 5 次,文件顺序应为 1 → 2 → 3 → 1 → 2,info.jsonnext_index 落点正确。
  • 删除 info.json:下次扫描从第一张开始。
  • 两次扫描之间新增一张:下一次扫描应能看到(refresh 生效)。
  • 同时打开两个应用扫描:不崩溃,不破坏 info.json

6. 限制

  • 目录路径固定为 %APPDATA%\bntech\images,无法通过 UI 修改(需手工操作文件系统)。
  • 排序按文件名字典序,不支持自然数字序(image10 会排在 image2 之前)。如需自然序需自实现比较器。
  • 不支持递归子目录;图片必须放在 images\ 根。
  • 同一会话内只取一张图,不支持 ADF 模拟"一次会话连续 N 张"。
  • 索引精度只到"下一张是第几张",不记录"已扫过哪几张"或扫描历史。
  • 文件竞争策略弱:极端情况下两个应用同时扫描可能扫到同一张。
  • 兜底图固定为 TWAIN_logo.png,无法在 UI 切换其他兜底图。
  • 极大目录(> 数千张)下 refresh() + sort 的延迟会变明显(当前未做分页 / 懒列举)。
  • 文件被独占打开(其他进程正在写)时,FreeImage_LoadU 会失败;当前实现直接报错,不重试。

7. 下一步工作

  • settings UI 增加 "Reset image index" 按钮,调用 VirtualScanner::resetImageIndex()
  • settings UI 增加 "Choose image folder" 选项,把路径写入 config.ini;缺省仍是 %APPDATA%\bntech\images
  • 支持自然数字序排序(image2.png < image10.png),实现 naturalCompare
  • ADF 模拟:在一次会话内按 feeder_count 连续输出 N 张,DAT_PENDINGXFERS.Count 报告剩余张数。
  • 增加 "shuffle" 模式:每次会话用伪随机顺序选图,方便压力测试。
  • 增加 info.json 中的 history(最近 N 张),便于调试与回溯。
  • 跨进程并发改进:用 LockFileExinfo.json.lock 上做短锁,避免同图重复扫描。
  • 监视目录变化(ReadDirectoryChangesW),自动 refresh,减少手动重置场景。
  • 在博客 / docs/ 中加图文教程:怎么准备 ADF 测试集、怎么配合 images/info.json 做回归测试。