ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Qt打印预览核心原理:QPrinter与QPainter实现多页绘制及PDF导出

Qt打印预览核心原理:QPrinter与QPainter实现多页绘制及PDF导出 简介这是一套基于Qt框架演示打印与预览功能的示例工程面向正在学习QPrinter、QTextDocument及打印预览组件的Qt开发者。工程采用代码与UI文件结合的方式主窗口支持输入文本并调用系统打印机输出源码中展示了如何配置纸张大小、分辨率等参数同时集成QPrintPreviewWidget/QPrintPreviewDialog预览界面并附带字体样本以观察不同样式在打印时的效果可帮助读者快速理清从文档创建、打印机配置到预览与输出的完整调用链。压缩包共8个文件包含cpp/h源代码、ui界面布局、pro工程配置、manifest清单及可直接运行的exe整体仅32KB结构精简便于对照阅读与二次修改。目前该资源已有2629人学习浏览适合需要快速上手Qt打印功能的初中级开发者作为参考。 做Qt客户端开发的同行应该都有体会打印和预览这套功能平时不起眼一旦业务提了需求——比如导报表、打单据、出PDF——临时去翻文档总要踩不少坑。我最早接触Qt的打印预览就是从这个官方例子开始的后来在好几个项目里反复改造成自己的工具类也算把QPrinter、QPainter、预览对话框这套链路摸得比较透了。这篇就把核心原理和可直接落地的写法拆开讲适合刚接触Qt打印、或者想在现有项目里快速加打印功能的开发者参考。1. 打印功能的核心类与整体选型1.1 绕不开的三个核心类Qt把打印相关的API集中在三个类里QPrinter、QPrintDialog、QPrintPreviewDialog。很多初学者一上来就被这三个类搞晕其实它们的职责非常清晰。QPrinter是打印任务的具体承载者它对应一个真实的打印设备也可以指向PDF文件负责提供页面尺寸、分辨率、方向这些最基础的信息同时它也是QPainter的绘制目标。换句话说你往QPainter上画什么QPainter就把像素交给QPrinter再由QPrinter发给打印机或者写进PDF。QPrintDialog是系统打印对话框的封装用户在里面选打印机、调份数、改纸张大小和方向确认后返回一个配置好的QPrinter。这个对话框的价值在于你不用自己去维护各种打印机驱动差异用户在系统里能看到的选项它基本都能透传。QPrintPreviewDialog则是预览对话框它内部维护了一个预览控件并且会在用户切换预览模式、缩放比例、翻页时反复发出paintRequested(QPrinter*)信号。你只需要写一个统一的绘制函数连接到这个信号就可以同时完成屏幕预览和真实打印两件事。三个类配合起来整体调用链是这样的先创建QPrinter要么弹出QPrintDialog让用户配置要么直接设置默认参数然后创建一个QPainter以QPrinter为绘图设备开始绘制绘制完成调用QPainter::end()结束任务QPrinter就会把内容交给目标设备输出。1.2 为什么所有绘制都走QPainter这是理解整个例子最关键的一环。Qt的打印预览机制之所以好用是因为它把绘制这件事从具体的输出设备中抽象出来了。无论是屏幕窗口的paintEvent、预览控件的后台缓冲、还是真实打印机QPainter都可以作为统一的入口。你可以把QPainter想象成一个画家QPrinter、QPixmap、QWidget这些设备就是画家面前不同材质的画布。同一套绘制指令画在屏幕上用于预览画在QPrinter上用于打印画在QPixmap上还可以用来生成图片代码几乎不用改动。在官方例子里作者把实际内容绘制逻辑单独抽成一个函数然后在三个地方复用Widget的paintEvent里画屏幕显示预览对话框的paintRequested信号里画预览图打印按钮的处理函数里画真实打印。这就是这套设计最精华的部分——一份绘制代码三种输出场景。这样做还有一个额外好处调试打印功能时可以先输出到PDF或者图片文件跳过真实打印机反复试错的过程。尤其是项目里涉及复杂排版时这个习惯能省下大量时间和纸张。1.3 QPrinter不接打印机也能用输出PDFQPrinter有个很容易被忽略的能力把输出目标设置成PDF文件。只要一行代码printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(output.pdf);之后所有绘制内容都会写入这个PDF文件而不是发给打印机。这个特性在开发调试阶段极其好用我可以直接在代码里生成PDF然后用阅读器查看分页和排版效果确认无误后再切回真实打印机验证。实际生产环境中打印成PDF还经常作为一种用户需求直接交付——比如财务系统里导出电子凭证、电商后台导出订单详情本质上都是把打印模块复用来生成PDF文件。所以千万不要觉得这是个鸡肋功能它是打印模块设计合理性的试金石。2. 核心实现思路从绘制到分页的完整链路2.1 先画一整页再做分页裁剪官方例子打印的内容相对简单核心是一个比喻式的分页思路内容本身是连续的、没有固定长度的一个长画卷需要按照页面高度一页一页地裁剪呈现。用代码来解释这个思路最直观。假设我们需要打印一系列文本行在printDocument(QPrinter *printer)这个统一绘制函数里第一件事是获取当前页面的可绘制区域QRectF pageRect printer-pageRect(QPrinter::DevicePixel); QPainter painter; painter.begin(printer);注意这里用的坐标系是DevicePixel也就是设备像素。打印机的设备分辨率通常很高比如300DPI甚至600DPIA4纸在300DPI下有2480x3508个像素点远大于屏幕的1920x1080。所以如果直接按像素坐标绘制文字实际打印出来字会特别小。正确处理方式是再叠加一层缩放坐标变换painter.scale(scaleFactor, scaleFactor);scaleFactor的计算逻辑是先设定一套逻辑坐标作为基准比如把页面宽度固定映射为1000个单位那么scaleFactor 1000.0 / pageRect.width()。这样无论用户选A4还是A5、横放还是竖放绘制代码里的坐标始终以1000宽为参照QPainter自动帮你完成缩放。分页判断则是这样做的qreal yOffset 0.0; int pageIndex 0; while (contentHasMore()) { if (pageIndex 0) printer-newPage(); painter.save(); painter.translate(0, -yOffset); // 绘制当前页可见范围内的内容 paintContent(painter, currentPageRange()); painter.restore(); yOffset pageRect.height() / scaleFactor; pageIndex; }每次调用printer-newPage()会结束当前页并开启新页配合painter.translate()把内容整体上移实现同一个长画卷按窗口依次截取的效果。只要绘制内容在逻辑坐标上的高度总和能计算出来这个while循环就能稳定生成正确的分页。2.2 屏幕预览和纸质输出如何保持一致很多人在做完打印功能后会遇到一个尴尬情况电脑屏幕上看起来完美打印出来却错位或者字太小。根本原因就是没有统一坐标系。屏幕预览时QPrinter的pageRect表示的是纸张在设备像素下的尺寸在QLabel或QWidget上显示时这个尺寸需要被缩放成控件的大小。而真实打印时pageRect直接对应打印机的物理像素。所以如果绘制代码里直接用了pageRect的绝对像素值同等逻辑内容在屏幕上可能占据80%宽度到打印机上却可能只占8%宽度字迹小到无法阅读。解决办法就是我上面提到的逻辑坐标系。具体做法void drawContent(QPainter *painter, const QRectF drawRect) { Q_UNUSED(drawRect) painter-save(); painter-setRenderHint(QPainter::Antialiasing, true); // 设定逻辑坐标系宽固定1200高按比例 qreal scale 1200.0 / painter-viewport().width(); painter-scale(scale, scale); // 之后所有坐标都按1200宽的体系来写 QFont font painter-font(); font.setPointSize(12); painter-setFont(font); painter-drawText(50, 50, Hello Qt Print); painter-restore(); }这里有个细节值得留意painter-viewport()在不同设备上返回的宽度不同但经过scale(1200 / width)这一步后后续绘制代码的坐标就完全被归一化了。屏幕预览时控件宽800像素它把逻辑坐标缩放到1200打印时设备宽2480像素它同样把逻辑坐标缩放到1200。两边看到的内容布局自然就是一模一样的。2.3 页边距、页眉页脚这类打印常识很多业务系统的打印需求都少不了页眉页脚、页码、公司LOGO这类元素。在Qt这套绘制机制里实现这些并不复杂关键是不要在统一绘制函数里硬编码位置。我常用的做法是在逻辑坐标系下预留边距常量比如左右边距占宽度的5%上下边距占高度的8%。绘制正文时把QPainter的裁剪区域限制在边距以内然后先绘制页眉再分页绘制正文最后在每页末尾绘制页码。// 页眉 painter-drawText(QRectF(localMargin, 0, width - 2 * localMargin, headerHeight), Qt::AlignLeft | Qt::AlignVCenter, 业务单据); // 页码 painter-drawText(QRectF(localMargin, pageBottom - footerHeight, width - 2 * localMargin, footerHeight), Qt::AlignRight | Qt::AlignVCenter, QString(第 %1 页).arg(currentPage));这里用QRectF而不是直接传坐标好处是文字对齐交给Qt内部处理不用自己计算文字宽度中文、英文、混排情况都更稳妥。3. 实操演示一个完整的打印和预览示例3.1 界面搭建与信号槽连接我用最简的方式搭一个示例界面一个QWidget作为主窗口上面放两个QPushButton一个叫打印预览一个叫直接打印。关键代码如下class PrintDemoWidget : public QWidget { Q_OBJECT public: explicit PrintDemoWidget(QWidget *parent nullptr); private slots: void doPreview(); void doPrint(); void renderPage(QPrinter *printer); private: void paintContent(QPainter *painter, int pageIndex, const QRectF drawRect); };构造函数里做好按钮布局和信号连接PrintDemoWidget::PrintDemoWidget(QWidget *parent) : QWidget(parent) { auto *previewBtn new QPushButton(tr(打印预览), this); auto *printBtn new QPushButton(tr(直接打印), this); auto *layout new QHBoxLayout(this); layout-addWidget(previewBtn); layout-addWidget(printBtn); connect(previewBtn, QPushButton::clicked, this, PrintDemoWidget::doPreview); connect(printBtn, QPushButton::clicked, this, PrintDemoWidget::doPrint); setWindowTitle(tr(Qt 打印和预览例子)); resize(800, 600); }这个界面的作用就是触发两个入口一个进预览对话框一个直接弹打印对话框。真正干活的是renderPage这个统一绘制函数。3.2 统一绘制函数的编写排版、分页、多页循环这是示例的核心部分。假设我们要打印的内容是一组模拟的业务明细行总共有80条每页显示30条那么完整流程是void PrintDemoWidget::renderPage(QPrinter *printer) { QPainter painter; if (!painter.begin(printer)) { qWarning(无法启动打印绘制); return; } QRectF pageRect printer-pageRect(QPrinter::DevicePixel); qreal pageWidth pageRect.width(); qreal pageHeight pageRect.height(); // 逻辑坐标宽度固定为1200 const qreal logicWidth 1200.0; qreal scale logicWidth / pageWidth; painter.scale(scale, scale); qreal logicHeight pageHeight * scale; // 逻辑坐标下页面高度 qreal margin 60; // 边距 qreal headerHeight 80; qreal footerHeight 50; qreal contentTop headerHeight margin; int rowsPerPage 30; int totalRows 80; int pageCount (totalRows rowsPerPage - 1) / rowsPerPage; qreal rowHeight 40; // 每行标题的高度 for (int page 0; page pageCount; page) { if (page 0) printer-newPage(); // 1. 画边框 painter.setPen(QPen(Qt::black, 2)); painter.drawRect(QRectF(margin, margin, logicWidth - 2 * margin, logicHeight - 2 * margin)); // 2. 画页眉 painter.drawText(QRectF(margin 10, margin 10, logicWidth - 2 * margin - 20, headerHeight), Qt::AlignLeft | Qt::AlignVCenter, tr(业务明细报表)); // 3. 画表头 int startRow page * rowsPerPage; int endRow qMin(startRow rowsPerPage, totalRows); qreal tableLeft margin 20; qreal tableRight logicWidth - margin - 20; qreal headerY margin headerHeight 20; painter.setPen(QPen(Qt::gray, 1)); painter.drawLine(QPointF(tableLeft, headerY), QPointF(tableRight, headerY)); painter.drawText(QRectF(tableLeft, headerY - rowHeight, tableRight - tableLeft, rowHeight), Qt::AlignLeft | Qt::AlignVCenter, tr(序号 名称 金额)); // 4. 画内容行 for (int i startRow; i endRow; i) { qreal rowY headerY (i - startRow 1) * rowHeight; painter.drawText(QRectF(tableLeft, rowY - rowHeight, tableRight - tableLeft, rowHeight), Qt::AlignLeft | Qt::AlignVCenter, QString(%1 项目-%2 %3 元) .arg(i 1).arg(i 1).arg((i 1) * 3.7, 0, f, 2)); } // 5. 画页码 painter.drawText(QRectF(margin, logicHeight - margin - footerHeight, logicWidth - 2 * margin, footerHeight), Qt::AlignRight | Qt::AlignVCenter, tr(第 %1 / %2 页).arg(page 1).arg(pageCount)); } painter.end(); }这段代码有几个容易出错的地方我列一下painter.scale(scale, scale)必须在绘制任何内容之前调用包括drawRect否则坐标参照不一致。printer-newPage()不能在第一次绘制前调用第一页默认就存在从第二页开始调用。每次newPage()之后painter的坐标变换不会自动重置所以如果后面设置了translate或其他变换要在循环末尾restore或者在循环开头重新scale。上面代码为了避免混乱没有在循环内做translate而是直接用循环变量计算每个元素的位置这样更直观。3.3 接入预览对话框和打印对话框预览对话框的接入是最简单的关键是把paintRequested信号连到统一绘制函数上void PrintDemoWidget::doPreview() { QPrinter printer(QPrinter::HighResolution); QPrintPreviewDialog preview(printer, this); connect(preview, QPrintPreviewDialog::paintRequested, this, PrintDemoWidget::renderPage); preview.exec(); }提示一点QPrintPreviewDialog::paintRequested信号带一个QPrinter*参数而我们的renderPage恰好接收QPrinter*所以连接时直接写函数指针即可无需lambda包一层。直接打印稍微多一点步骤因为要先让用户选打印机void PrintDemoWidget::doPrint() { QPrinter printer(QPrinter::HighResolution); QPrintDialog dialog(printer, this); if (dialog.exec() ! QDialog::Accepted) return; renderPage(printer); }就这样一份绘制代码已经能同时服务屏幕预览、PDF导出、真实打印三种场景。我在实际项目中还会把QPrinter::PdfFormat的设置暴露成一个下拉菜单选项比如导出PDF和打印各走各的按钮但底层调用的都是同一个renderPage维护成本几乎为零。4. 常见问题与排查技巧实录4.1 常见问题速查表我在不同项目里集成Qt打印功能时碰到过不少典型问题这里整理成表格方便大家快速定位。现象常见原因处理方式预览正常打印出来文字很小没有做逻辑坐标缩放直接用了打印机设备像素用scale把pageRect映射到固定的逻辑宽度如1200多页内容重叠或错乱newPage之后坐标变换没有重置每页begin时重新scale避免累积translate中文文字打印成方块或乱码字体设置不合适或没有嵌入字体显式设置QFont(微软雅黑)等中文字体避免默认字体打印到PDF文件打不开输出路径权限不足或代码提前结束检查painter.end()是否调用文件名是否合法预览对话框内容空白paintRequested信号没有连到正确函数确认槽函数参数类型是QPrinter*且已连接图片打印后模糊绘制图片时没有使用设备分辨率打印时使用painter-drawPixmap(rect, pixmap)的rect版本或手动缩放4.2 容易出现偏差的坐标问题坐标问题排在所有打印bug的第一位因为屏幕坐标系和设备坐标系之间的换算关系如果搞错症状往往是看起来正常但输出不对。有一个我反复强调的原则绘制函数内部永远使用逻辑坐标不要在函数里去读widget-width()或widget-height()。原因是预览对话框在用户缩放预览比例时会触发多次paintRequested如果每次绘制都依赖外部控件尺寸预览缩放时内容就跟着跑了。处理方式是在renderPage内部第一件事就是根据QPrinter的pageRect建立逻辑坐标系后续所有绘制都不再关心屏幕设备长什么样。这样无论是300DPI的真实打印机还是96DPI的屏幕预览都能得到一致的布局比例。4.3 真机打印前的排查流程我习惯在写打印功能时遵循一个固定的排查顺序极大减少了调试时间先设置成PdfFormat生成PDF文件用阅读器检查每一页的排版和分页。如果PDF是对的再连真实打印机打印排除设备驱动和纸型问题。如果PDF不对用qDebug()输出页面尺寸、scalefactor、每页可用的逻辑宽度和高度逐项核对数值。针对只有中文异常的情况检查QFont的family设置有的Linux环境默认缺中文字体需要在代码里指定可用字体。不少同事问我为什么要先打印PDF再上真机原因很简单一次PDF输出不需要消耗纸张和打印头寿命而且PDF文件可以反复查看、放大检查细节排查效率高得多。等PDF验证通过后再上打印机通常一次就成。4.4 高分屏和跨平台的一点提醒Windows下启用Qt高DPI缩放Qt5.6默认开启的AA_EnableHighDpiScaling之后屏幕坐标和逻辑坐标之间会有缩放系数但QPrinter的pageRect(QPrinter::DevicePixel)仍然返回物理像素。所以如果直接混用屏幕控件尺寸和打印机设备尺寸一定会出问题。跨平台方面Linux和macOS的默认字体不一样建议代码里不要依赖系统默认字体来排版中文显式指定字体并且在打印前设置printer.setFontEmbeddingEnabled(true)QPrinter支持的话避免换一台机器就出现字体差异。结尾打印功能写多了之后我最大的体会是别把绘制代码和具体设备绑死。一份以QPainter为唯一入口、以逻辑坐标为唯一参照系的绘制函数能同时覆盖屏幕显示、预览、打印、导出PDF四种场景这才是Qt这套接口设计的真正用法。后面如果你的项目里也要做打印和预览不妨先照着这个思路把绘制函数抽出来再接对话框整个过程会顺畅很多。最后分享一个实用习惯在renderPage里加上一个可选的调试参数比如bool debugRect false为true时把所有分页区域、边距、文本矩形都画成彩色边框这样排版问题一眼就能看出来。这个开关在真实项目排错时帮了我大忙建议留着。本文还有配套的精品资源点击获取
返回列表