简介:这是一份面向 Qt/C++ 桌面开发者的扫描仪集成示例,运行于 Windows,通过 TWAIN 协议调用扫描仪并完成双面扫描与多页图像合成 PDF,适合需要在文档归档、影像采集类应用中嵌入扫描功能的开发者参考。资源包共 25 个文件、约 64KB,以 8 个 cpp 源文件和 11 个 h 头文件为主体,另有 Qt 界面文件、工程配置、qrc 资源及 DIB 图像处理相关的 c 文件,覆盖界面交互、TWAIN 接口封装、设备参数控制与 PDF 输出等模块,工程规模紧凑但环节较完整。已有 2054 人学习,说明其在中级 Qt 外设集成场景中具有一定参考价值。读者可从中了解扫描分辨率与色彩模式设置、双面图像对齐合并、DIB 位图读写及 QPrinter 输出 PDF 的实现思路,也能对照学习 Windows 下 TWAIN 会话流程与异常处理方式,并将其作为二次开发与排错对照的代码骨架。
1. 一台馈纸扫描仪加 Qt5:从“点一下出 PDF”倒推 TWAIN 调用链
Qt5 调 TWAIN 驱动扫描仪,扫完直接合成 PDF——这三件事凑在一个工程里,难点从来不在语法上。需求方只会说一句“点一下,出个文件”,可落到代码里,你得先摸清驱动支持哪种传输模式、窗口句柄传给谁、双面扫完之后那一叠 DIB 什么时候释放,任何一环没对上,表现都是“扫不出来”或者“扫一半程序没了”。
这份 Scan 工程把整条链路走通了:加载 TWAIN 管理器、枚举数据源、协商分辨率与进纸方式、循环取图、最后拼成多页 PDF。适合手上有一台支持 TWAIN 的桌面扫描仪、需要在 Qt5 桌面程序里集成扫描入口的开发者,也适合被原厂 MFC 示例卡住、想找一份能直接对照着改的骨架的人。下面按调用顺序拆开讲。
2. TWAIN 三层结构与 Qt5 消息泵:DSM、DS 和窗口句柄怎么对上
2.1 三层模型:DSM 只做转发,真正干活的是 DS
TWAIN 的结构看着简单,理解错了后面对不上号。最上面是你写的应用(Application),中间是数据源管理器 DSM,最下面是数据源 DS——也就是扫描仪厂商装进系统里的那个驱动模块。应用不直接和 DS 说话,所有请求都塞进一个叫DSM_Entry的函数里,由 DSM 根据三元组DG_ / DAT_ / MSG_转发给对应的 DS。
这个三元组是整套协议的核心:DG_CONTROL管会话与能力,DG_IMAGE管图像数据,DG_AUDIO基本用不上。DAT_说明操作对象,比如DAT_IDENTITY是设备身份,DAT_CAPABILITY是能力项,DAT_IMAGENATIVEXFER是原生图像传输。MSG_是动作,MSG_OPENDS、MSG_GET、MSG_ENABLEDS。记住这个组合方式,后面查手册会快很多。
DS 在 Windows 上通常落在系统目录的twain_32文件夹里,一个厂商一个.ds文件,有的还带自己的配置数据。应用侧的DSM_Entry导出在twain_32.dll里——注意这个名字,它是 32 位的,这是第一个坎,后面避坑章节会专门说。
2.2 把 HWND 交给 DSM:句柄、线程和协议版本
MSG_OPENDSM需要传一个父窗口句柄,驱动弹出的扫描界面会以它为 owner。Qt5 里拿这个句柄不难,顶层窗口的winId()返回WId,在 Windows 平台上就是一个HWND。麻烦的是另外两件事:一是所有 TWAIN 调用必须在同一个线程里完成,二是传进去的窗口在会话期间不能被销毁。
第一条的原因很直接:MSG_OPENDSM时 DSM 会记下这个 HWND 和当前线程,后续驱动的模态对话框靠这个线程的消息队列泵消息。你要是图省事把扫描放到QThread里跑,UI 线程收不到消息,驱动界面就是一块白板。常见做法是把整个 TWAIN 会话固定在主线程,耗时的图像处理和 PDF 写入再扔到工作线程去。
第二条是我踩过的:有人把扫描入口做成一个临时QDialog,点完按钮dialog->close()顺手delete,结果扫描仪界面对着已经析构的 owner 窗口发消息。稳妥点,父窗口用主窗口或者一个生命周期覆盖整个会话的常驻对象。
协议版本也在这一步定。TW_IDENTITY里的ProtocolMajor/ProtocolMinor常见填 1.9,兼容性最好;填 2.x 能用到一些新特性,但老驱动不一定认。应用标识里的ProductName、Manufacturer随便填,但别留空,个别驱动会读这个字段做白名单判断,空字符串直接给你返回失败。
2.3 最小可跑的会话骨架
先看加载和打开 DSM 这段,代码不多,但每一步失败都得有对应的清理动作:
// twainsession.h #pragma once #include <windows.h> #include <QString> #include "twain.h" // 工程自带,来自 TWAIN 规范发布的数据包头文件 typedef TW_UINT16 (FAR PASCAL *DSMENTRYPROC)( TW_IDENTITY *pOrigin, TW_IDENTITY *pDest, TW_UINT32 dg, TW_UINT32 dat, TW_UINT32 msg, TW_MEMREF data); class TwainSession { public: bool openDsm(HWND hwnd); void closeDsm(); TW_UINT16 call(TW_UINT32 dg, TW_UINT32 dat, TW_UINT32 msg, TW_MEMREF data); private: HMODULE m_dsm = nullptr; DSMENTRYPROC m_entry = nullptr; TW_IDENTITY m_app = {}; TW_IDENTITY m_src = {}; bool m_dsmOk = false; bool m_srcOk = false; };bool TwainSession::openDsm(HWND hwnd) { // 32 位进程走 twain_32.dll;64 位进程需要另一套 DSM 组件,不能混用 m_dsm = ::LoadLibraryW(L"twain_32.dll"); if (!m_dsm) return false; m_entry = reinterpret_cast<DSMENTRYPROC>(::GetProcAddress(m_dsm, "DSM_Entry")); if (!m_entry) { ::FreeLibrary(m_dsm); m_dsm = nullptr; return false; } m_app.Id = 0; // 应用侧固定填 0 m_app.Version.MajorNum = 1; m_app.Version.MinorNum = 0; m_app.Version.Language = TWLG_ENGLISH_USA; m_app.Version.Country = TWCY_USA; m_app.ProtocolMajor = TWON_PROTOCOLMAJOR; // 1 m_app.ProtocolMinor = TWON_PROTOCOLMINOR; // 9 m_app.SupportedGroups = DG_IMAGE | DG_CONTROL; strcpy_s(m_app.ProductName, "QtScanDemo"); strcpy_s(m_app.Manufacturer, "Internal"); strcpy_s(m_app.ProductFamily, "Desktop"); TW_UINT16 rc = call(DG_CONTROL, DAT_PARENT, MSG_OPENDSM, &hwnd); m_dsmOk = (rc == TWRC_SUCCESS); return m_dsmOk; }call的实现里要区分目标对象:DS 打开之后,除了一部分会话级消息,其余都发给m_src,否则 DSM 会回你一个“目标无效”。
TW_UINT16 TwainSession::call(TW_UINT32 dg, TW_UINT32 dat, TW_UINT32 msg, TW_MEMREF data) { TW_IDENTITY *dest = m_srcOk ? &m_src : nullptr; // 未打开 DS 时目标为空 return m_entry(&m_app, dest, dg, dat, msg, data); }2.4 关闭顺序反了就是崩溃
会话的收尾顺序是固定死的:先MSG_DISABLEDS关掉驱动界面,再MSG_CLOSEDS关数据源,最后MSG_CLOSEDSM关管理器,每一步都要判断前一步是否真的成功过。我见过把MSG_CLOSEDS放在MSG_DISABLEDS前面的写法,大部分驱动能容忍,个别驱动会在关闭 UI 时去访问已经释放的数据源上下文,直接访问越界。
还有一点,MSG_DISABLEDS之前如果还有未完成的图像传输(比如用户中途点了取消),得先发一次MSG_ENDXFER把通道收干净,否则驱动侧的状态机停在半路,下次打开会报“设备忙”。这段清理逻辑建议包成一个closeAll(),用m_srcOk、m_dsmOk两个标志位做守卫,重复调用也不会出问题。
3. 枚举与能力协商:把 300dpi、双面和进纸器谈成驱动认账的值
3.1 三段式打开:先看有哪些设备,再挑一个
DAT_IDENTITY的枚举是标准的三段式:MSG_GETFIRST拿第一个,MSG_GETNEXT循环拿后面的,每次拿到一个TW_IDENTITY就把它塞进下拉框,同时记住ProductName或Id。注意枚举完不需要额外关闭,但MSG_GETFIRST返回TWRC_FAILURE就说明一台设备都没有,这时候别再往下走。
选好设备后MSG_OPENDS打开它,成功后m_srcOk置位。有些驱动支持重复打开同一个 DS,有些会返回“已经打开”,工程里建议加个判重,避免用户手快连点两下弹两个扫描界面出来。
bool TwainSession::openSource() { TW_IDENTITY id = {}; TW_UINT16 rc = call(DG_CONTROL, DAT_IDENTITY, MSG_OPENDS, &id); if (rc != TWRC_SUCCESS) return false; m_src = id; m_srcOk = true; // 打开后立刻确认传输机制,驱动默认值不一定是你想要的 TW_UINT16 mech = TWSX_NATIVE; setCap(ICAP_XFERMECH, TWTY_UINT16, &mech); return true; }3.2 能力协商:先问支不支持,再动手设
能力项是最容易翻车的地方。DAT_CAPABILITY有三个语义完全不同的动作:MSG_QUERYSUPPORT问这个能力支持不支持 get/set,MSG_GET读当前值和取值范围,MSG_SET写。很多人跳过第一步直接 set,遇到驱动不支持就收到一个TWRC_FAILURE,然后对着ConditionCode猜半天。
MSG_GET拿回来的是一个容器,ConType可能是单值、枚举或者区间。区间型的(比如分辨率)要读TW_RANGE的MinValue/MaxValue/StepSize,把用户输入夹到合法范围内再 set。枚举型的(比如像素类型)要遍历TW_ENUMERATION的每一项,只有目标值在列表里才能设。
bool TwainSession::queryCap(TW_UINT16 cap, TW_UINT16 &support) { TW_CAPABILITY twcap = {}; twcap.Cap = cap; twcap.ConType = TWON_DONTCARE16; TW_UINT16 rc = call(DG_CONTROL, DAT_CAPABILITY, MSG_QUERYSUPPORT, &twcap); if (rc != TWRC_SUCCESS) return false; support = *static_cast<TW_UINT16 *>(::GlobalLock(twcap.hContainer)); ::GlobalUnlock(twcap.hContainer); ::GlobalFree(twcap.hContainer); // 容器是 DSM 分配的,必须自己还回去 return true; }这里的内存规矩要记牢:MSG_GET和MSG_QUERYSUPPORT返回的hContainer是全局内存句柄,用完GlobalUnlock之后必须GlobalFree,不还就是每次协商漏一小块,扫几百页之后进程占用肉眼可见地涨。
设值的写法类似,用TWON_ONEVALUE包一个值进去:
bool TwainSession::setCap(TW_UINT16 cap, TW_UINT16 type, void *value, TW_UINT16 size) { TW_CAPABILITY twcap = {}; twcap.Cap = cap; twcap.ConType = TWON_ONEVALUE; twcap.hContainer = ::GlobalAlloc(GHND, sizeof(TW_ONEVALUE) + size); auto *one = static_cast<TW_ONEVALUE *>(::GlobalLock(twcap.hContainer)); one->ItemType = type; memcpy(&one->Item, value, size); ::GlobalUnlock(twcap.hContainer); TW_UINT16 rc = call(DG_CONTROL, DAT_CAPABILITY, MSG_SET, &twcap); ::GlobalFree(twcap.hContainer); // TWRC_CHECKSTATUS 表示驱动接受了近似值,不算失败 return rc == TWRC_SUCCESS || rc == TWRC_CHECKSTATUS; }TWRC_CHECKSTATUS这个返回值值得单独说。它意味着驱动没给你要的精确值,而是找了个最接近的替代——比如你要 600dpi,驱动最高只到 400,它给你 400 然后返回这个码。这时候正确的做法是发一次MSG_GET把实际生效的值读回来,同步到界面上,而不是假装设置成功了。用户看到的“600dpi”和扫描仪实际输出的分辨率对不上,是最容易在验收环节被抓出来的问题。
3.3 常用能力项与取值范围
工程里用到的能力项不多,但每一个都影响最终成品。下表是我在这台设备上实际协商过的几个,取值一栏是驱动返回的实际范围,不同设备会有差异:
| 能力常量 | 作用 | 典型取值 / 注意事项 |
|---|---|---|
ICAP_XRESOLUTION | 水平分辨率 | 区间型,常见 100~600,步长 1 或 50,设置后必须回读 |
ICAP_YRESOLUTION | 垂直分辨率 | 同上,多数场景与 X 保持相等,避免图像被拉伸 |
ICAP_PIXELTYPE | 像素类型 | 枚举型,黑白 / 灰度 / RGB,彩色文档选 RGB |
ICAP_BITDEPTH | 位深 | 与像素类型联动,灰度常见 8,彩色常见 24 或 48 |
ICAP_XFERMECH | 传输机制 | 原生 / 文件 / 内存,按后续处理方式选 |
CAP_FEEDERENABLED | 是否用进纸器 | 置真才走 ADF,平板模式下必须置假 |
CAP_DUPLEXENABLED | 是否双面 | 只有支持双面的机型才返回成功,不支持的会失败 |
ICAP_BRIGHTNESS | 亮度 | 区间型,通常 -1000~1000,浅色原件适当调高 |
ICAP_ROTATION | 旋转 | 部分驱动支持自动纠偏,效果因厂商而异 |
有个细节:CAP_DUPLEXENABLED属于CAP_前缀的能力,和ICAP_前缀的那些不是一个命名空间,写代码时别照着ICAP_的规律去推。设置双面之前先MSG_QUERYSUPPORT确认,不支持的机型上强行设置,有的驱动会直接忽略,有的会让整个MSG_ENABLEDS失败,表现就是“点了没反应”。
3.4 无界面干跑:最快的验证手段
调试阶段最省时间的做法是不弹驱动界面,直接扫一张出来看效果。MSG_ENABLEDS传进去的TW_USERINTERFACE结构里有个ShowUI字段,置FALSE就走静默扫描,取图流程完全一样。把分辨率、像素类型、进纸模式一组一组地改,配合静默扫描跑一遍,能很快定位到底是哪一项参数被驱动改写了。
提示:静默模式下用户无法取消,调试时要给循环加一个上限保护,避免进纸器空转或者重复抓同一张纸。
4. 取图链路:DIB 到 QImage 的行对齐、通道序与 ADF 循环
4.1 三种传输机制,该选哪个
ICAP_XFERMECH决定了图像怎么从驱动交到你手上,三条路各有适用面:
| 机制 | 常量 | 优点 | 代价 |
|---|---|---|---|
| 原生传输 | TWSX_NATIVE | 代码最短,一次调用拿到完整 DIB | 整张图在内存里,大图或高分辨率吃内存 |
| 文件传输 | TWSX_FILE | 驱动直接落盘,省内存,可写多页 TIFF | 格式和文件组织由驱动决定,路径处理要小心 |
| 内存传输 | TWSX_MEMORY | 分块拿,适合超大图 | 要自己拼缓冲,逻辑复杂 |
这台设备的驱动对多页 TIFF 支持得不错,所以工程里给了一条备选路线:设成文件传输,指定.tif输出,双面 20 页也就一个文件,后面再转 PDF。但文件传输有个硬伤——路径里有中文或者空格时,个别驱动会写失败还不报错,表现是文件大小为零。我一般会把临时文件落在纯 ASCII 路径下,转完再挪到目标位置,省掉这类玄学问题。
默认路线还是走原生传输,代码可控,出问题好查。
4.2 DIB 转 QImage:pitch 和通道顺序是两个固定坑
MSG_GET拿回来的是一个全局内存句柄,里面装的是标准的 Windows DIB:先是BITMAPINFOHEADER,紧接着调色板(8 位及以下才有),再往后才是像素数据。转换时有两处必须按规矩来。
第一处是行步长。DIB 的每一行按 4 字节边界对齐,图像宽度乘位深除以 8 之后如果不是 4 的倍数,行尾会补零。直接按width * 3当步长去构造QImage,结果就是每往下一行偏移一点,整张图斜着歪下去,看起来像“扫描仪坏了”,其实是算法错了。正确写法是((width * bitCount + 31) / 32) * 4。
第二处是通道顺序。24 位 DIB 在内存里是 B、G、R 排列,不是 R、G、B。用Format_RGB888接会得到一张红蓝互换的图,人脸变蓝脸。Qt 5.14 之后可以直接用Format_BGR888,更早的版本要构造完再rgbSwapped()一次。
QImage dibToQImage(HANDLE hDib) { if (!hDib) return QImage(); auto *base = static_cast<uchar *>(::GlobalLock(hDib)); if (!base) return QImage(); auto *bih = reinterpret_cast<BITMAPINFOHEADER *>(base); int paletteBytes = 0; if (bih->biBitCount <= 8) { int colors = bih->biClrUsed ? int(bih->biClrUsed) : (1 << bih->biBitCount); paletteBytes = colors * sizeof(RGBQUAD); } const uchar *bits = base + bih->biSize + paletteBytes; // 4 字节对齐后的真实行宽,不能直接用 biWidth * 通道数 const int pitch = ((bih->biWidth * bih->biBitCount + 31) / 32) * 4; QImage view; if (bih->biBitCount == 24) view = QImage(bits, bih->biWidth, bih->biHeight, pitch, QImage::Format_BGR888); else if (bih->biBitCount == 32) view = QImage(bits, bih->biWidth, bih->biHeight, pitch, QImage::Format_RGB32); else if (bih->biBitCount == 8) view = QImage(bits, bih->biWidth, bih->biHeight, pitch, QImage::Format_Grayscale8); QImage out = view.copy(); // 必须深拷贝,句柄一释放这块内存就没了 ::GlobalUnlock(hDib); ::GlobalFree(hDib); return out; }view.copy()这一行不是可选项。QImage用外部缓冲区构造时不接管所有权,句柄GlobalFree之后那块内存就还给系统了,后面再访问就是悬空指针。这个错误很隐蔽,因为刚释放的内存往往还没被复用,图片能正常显示,跑几百页之后才开始随机崩溃。
4.3 ADF 循环:一张一张拿,直到驱动说没有
用进纸器时,一次MSG_ENABLEDS之后可以连续取多张。循环的终止条件有两个:MSG_GET返回TWRC_CANCEL(用户点了取消或者纸张用完),或者DAT_PENDINGXFERS返回的Count为 0。
QVector<QImage> TwainSession::scanBatch(int maxPages) { QVector<QImage> pages; TW_USERINTERFACE ui = {}; ui.ShowUI = TRUE; ui.hParent = m_hwnd; if (call(DG_CONTROL, DAT_USERINTERFACE, MSG_ENABLEDS, &ui) != TWRC_SUCCESS) return pages; for (int i = 0; i < maxPages; ++i) { ::GlobalFree(m_pending); // 上一张的原生句柄,先还掉 m_pending = nullptr; TW_UINT16 rc = call(DG_IMAGE, DAT_IMAGENATIVEXFER, MSG_GET, &m_pending); if (rc == TWRC_XFERDONE || rc == TWRC_SUCCESS) { QImage img = dibToQImage(m_pending); if (!img.isNull()) pages.append(img); } else { break; // TWRC_CANCEL / TWRC_FAILURE 都收工 } // 通知驱动这一张处理完了,可以继续走纸 TW_PENDINGXFERS pending = {}; rc = call(DG_CONTROL, DAT_PENDINGXFERS, MSG_ENDXFER, &pending); if (rc != TWRC_SUCCESS || pending.Count == 0) break; } call(DG_CONTROL, DAT_USERINTERFACE, MSG_DISABLEDS, &ui); return pages; }几个参数得说清楚。maxPages是我加的保护上限,防止进纸器卡纸时程序无限循环;MSG_ENDXFER必须每张调一次,它同时承担“释放当前图像”和“请求下一张”两个职责,漏掉这一步的表现是永远只出第一页。pending.Count是驱动告诉你还剩几张,有些驱动返回 0 但实际还有纸,所以循环里两个条件要并列判断,不能只看其中一个。
内存管理上还有一个容易忽略的点:m_pending这个句柄在每轮开头要显式GlobalFree,虽然dibToQImage内部已经释放了,但失败路径上可能没走到。把它提出来单独处理,能避免异常分支上的泄漏。
5. 避坑与排查:五条从崩溃日志里换来的经验
5.1 打不开管理器,或者一台设备都枚举不到
现象:MSG_OPENDSM返回失败,或者枚举循环第一次就结束,下拉框空空如也。原因大概率是位数不匹配——Qt 工程编译成了 64 位,而去加载的twain_32.dll是 32 位模块,LoadLibrary直接失败,GetProcAddress自然拿不到入口。解决方式两条:把工程切成 32 位构建,这是最省事的;或者换用支持 64 位的那套 DSM 组件,代价是部分老驱动仍然只有 32 位版本,能枚举到也扫不出来。判断方法很简单,看LoadLibrary的返回值和GetLastError,别急着怀疑驱动没装。
5.2 点扫描之后主窗口白屏,或者卡住不重绘
现象:调用MSG_ENABLEDS后,驱动界面弹出来了,但 Qt 主窗口变成一块白板,鼠标移上去也没反应。原因是有人在扫描入口里写了阻塞等待,比如while (!finished) QApplication::processEvents();,或者在槽函数里用局部事件循环死等结果。TWAIN 驱动的界面是独立窗口,靠当前线程的消息队列泵消息,你一旦在自己的循环里反复processEvents,消息重入会让状态机乱掉。正确做法是让MSG_ENABLEDS自己阻塞到用户操作结束——多数消费级驱动内部有自己的模态循环,Qt 主窗口只是暂时失去焦点,不会假死。真需要异步,就把整个扫描流程做成状态机,用信号驱动下一步,别自己造循环。
5.3 扫出来的图整体倾斜,越往下越歪
现象:输出图像开头几行正常,越往下偏移越明显,最后完全错位。原因就是 4.2 节说的行步长算错了,用width * 通道数代替了 4 字节对齐后的值。这个 bug 在宽度正好是 4 的倍数时不会出现,换一台扫描仪或者改个分辨率就复现,特别容易被当成“偶发问题”。排查手段:打印biWidth和算出来的pitch,如果两者不相等就说明对齐生效了,必须用后者。
5.4 进纸器只出第一页就停
现象:放十张纸进去,程序只处理了一张,剩下的原样退出来。原因有两个层面。代码层面是漏了MSG_ENDXFER,或者循环条件只判断了TWRC_SUCCESS没判断pending.Count。驱动层面是CAP_FEEDERENABLED没设成功——有些机型要求在MSG_OPENDS之后、MSG_ENABLEDS之前设置,晚了就不生效。排查顺序:先确认能力项回读为真,再看循环里有没有每张都发MSG_ENDXFER,最后检查pending.Count的实际返回值。
5.5 关闭程序时崩溃,或者下次打开提示设备被占用
现象:扫描完成关窗口,进程退出时抛异常;或者第二次点扫描直接失败。原因是会话清理顺序不对,MSG_DISABLEDS、MSG_CLOSEDS、MSG_CLOSEDSM三步漏了任何一步,驱动侧的状态就停在半开状态,直到进程彻底退出才释放。更隐蔽的是异常路径——扫描中途用户点了取消,代码从break跳出去,清理调用被跳过。解决办法是把清理逻辑放进 RAII 对象的析构函数里,用两个布尔标志守卫,保证无论从哪条路径退出都会走到。
6. 多页合成 PDF 与成品验收:版面换算、体积控制和页数校验
图像都拿到手之后,合成 PDF 这一步反而不复杂,Qt5 自带QPdfWriter,不需要额外依赖。关键在于页面尺寸和分辨率的换算:把 PDF 的分辨率设成和扫描分辨率一致(比如都是 300dpi),图像就能按 1:1 像素映射到页面上,省掉一次重采样,清晰度不打折。
void buildPdf(const QVector<QImage> &pages, const QString &path) { QPdfWriter writer(path); writer.setPageSize(QPageSize(QPageSize::A4)); writer.setResolution(300); // 与扫描 dpi 对齐,避免二次缩放 writer.setPageMargins(QMarginsF(0, 0, 0, 0)); // 扫描件本身已有白边,不再加页边距 QPainter painter(&writer); for (int i = 0; i < pages.size(); ++i) { if (i > 0) writer.newPage(); const QImage &img = pages.at(i); QRect area = painter.viewport(); QSize fit = img.size().scaled(area.size(), Qt::KeepAspectRatio); QRect target(QPoint((area.width() - fit.width()) / 2, (area.height() - fit.height()) / 2), fit); painter.drawImage(target, img); } painter.end(); }setResolution(300)这一句决定了painter.viewport()的实际像素尺寸,A4 在 300dpi 下大约是 2480×3508,比多数扫描件大,所以KeepAspectRatio缩放基本不会丢细节。体积控制是另一个话题:QPdfWriter嵌图默认走无损压缩,二十页彩色 300dpi 的文档轻松上到一百多兆。我的做法是在嵌入前把QImage转成 JPEG 质量 85 再塞进QBuffer,视觉上看不出差别,体积能压到原来的五分之一到十分之一。灰度件更简单,直接转 8 位灰度再嵌,效果比彩色转灰度更好。
成品验收我固定跑两步:用qpdf --check过一遍文件结构,确认没有损坏的对象;再用QPdfDocument读回来数页数,和扫描时记录的页数对一遍。这一步拦住过好几次问题——有一次进纸器双张进纸,页数少了但流程没报错,就是靠页数校验发现的。从那以后我每次改完取图循环,都强制走一遍“页数比对 + 首页缩略图目视”,不再相信“跑通一次就没问题”。希望帮到你。
本文还有配套的精品资源,点击获取