使用QPdfWriter导出PDF文件,打开PDF文件时,我们会发现文字放大不失真,贴图却会模糊?为了解决这个问题,必须了解QPdfWriter的工作原理,下面详细解说一下。
一、一个直观的现象
用 QPdfWriter 生成 PDF 后,用阅读器放大查看:
文字——无论放大多少倍,笔画边缘始终锐利 曲线/波形——放大后线条依然光滑,没有锯齿 贴图——放大后出现模糊、马赛克方块
这不是 PDF 阅读器的问题,而是内容在 PDF 中的存储方式决定的。下面从源码层面讲清楚原因。
二、文本和曲线为什么放大不失真
核心原因:存储的是数学描述,不是像素
当你在 QPdfWriter 上调用 drawText() 或 drawPath() 时,写入 PDF 文件的不是一张张像素图片,而是一串数学指令——坐标、矢量、字体轮廓。PDF 渲染器在每次显示时,根据当前缩放级别实时计算每个像素应该是什么颜色,因此永远不会出现"像素不够用"的情况。
这就像存储"一条从 (0,0) 到 (100,50) 的直线"和存储"一张 100×50 像素的直线截图"的区别——前者可以在任意分辨率下精确绘制,后者放大就是方块。
2.1 曲线的矢量存储过程
当你绘制一条曲线:
QPainterPath wave;wave.moveTo(0, 300);for (int x = 0; x <= 800; x += 2) { wave.lineTo(x, 300 + 100 * qSin(x * M_PI / 200));}painter.drawPath(wave);QPdfEngine 调用 QPdf::generatePath() 将 QPainterPath 转换为 PDF 路径指令:
QByteArray QPdf::generatePath(const QPainterPath &path, const QTransform &matrix, PathFlags flags){ QByteArray result;ByteStream s(&result);for (int i = 0; i < path.elementCount(); ++i) {const QPainterPath::Element &elm = path.elementAt(i);switch (elm.type) {case QPainterPath::MoveToElement: s << matrix.map(QPointF(elm.x, elm.y)) << "m\n"; // movetobreak;case QPainterPath::LineToElement: s << matrix.map(QPointF(elm.x, elm.y)) << "l\n"; // linetobreak;case QPainterPath::CurveToElement: s << matrix.map(QPointF(elm.x, elm.y)) << matrix.map(QPointF(path.elementAt(i+1).x, path.elementAt(i+1).y)) << matrix.map(QPointF(path.elementAt(i+2).x, path.elementAt(i+2).y)) << "c\n"; // 三次贝塞尔曲线 curveto i += 2;break; } } s << op; // S(描边) f(填充) B(填充+描边)return result;}最终写入 PDF 内容流的内容长这样:
0 300 m ← 移动到起点 (0, 300)2 298.43 l ← 画直线到 (2, 298.43)4 296.87 l ← 画直线到 (4, 296.87)6 295.05 l ← ...8 293.07 l...798 298.43 l800 300 lS ← 描边每个坐标都是浮点数精度的数学值。放大 10 倍时,PDF 渲染器把所有坐标乘以 10 重新绘制;放大 100 倍时同理。曲线始终由这些精确坐标点连接而成,所以无论多大都清晰。
贝塞尔曲线更优雅
如果是平滑曲线(如 QPainterPath::cubicTo()),生成的 PDF 指令是 c(curveto):
50 300 100 100 150 300 c ← 三次贝塞尔曲线一条指令就定义了一条光滑曲线,连折线都不是——放大后甚至比原始采样点还平滑。
2.2 文字的矢量存储过程
当你调用 drawText(),QPdfEngine 走的是 drawTextItem() 路径:
voidQPdfEnginePrivate::drawTextItem(const QPointF &p, const QTextItemInt &ti){ QFontEngine *fe = ti.fontEngine; QFontEngine::FaceId face_id = fe->faceId();// 第一步:将字体轮廓嵌入 PDF QFontSubset *font = fonts.value(face_id, 0);if (!font) { font = new QFontSubset(fe, requestObject()); // 提取字体子集 } fonts.insert(face_id, font);// 第二步:输出文本对象 *currentPage << "BT\n"// Begin Text << "/F" << font->object_id << size << "Tf "// 设置字体和字号 << stretch << "0 0 -1 0 0 Tm\n"; // 文本矩阵// 第三步:逐字形输出for (int i = 0; i < glyphs.size(); ++i) { qreal x = positions[i].x.toReal(); qreal y = positions[i].y.toReal();int g = font->addGlyph(glyphs[i]); *currentPage << x - last_x << last_y - y << "Td <" << QPdf::toHex((ushort)g, buf) << "> Tj\n"; } *currentPage << "ET\n"; // End Text}文字不失真的关键在于三件事:
① 字体轮廓嵌入
QFontSubset 将字体的矢量轮廓(Type 1 或 TrueType 格式)嵌入 PDF。字体文件中存储的是每个字符的贝塞尔曲线轮廓——比如字母 "A" 由两条斜线和一条横线组成,每条线都有精确的数学描述。PDF 渲染器在任意缩放级别下,根据这些轮廓重新光栅化字形,所以笔画永远锐利。
嵌入的是子集——只包含文档中实际使用的字符,所以不会导致文件过大。
② 字形索引定位
每个字符不是以像素图片存储,而是以字形索引(glyph ID)存储。PDF 中的 Tj 操作符输出的是字形编号,渲染器根据嵌入的字体数据查找对应的矢量轮廓。
③ 浮点坐标定位
每个字形的位置通过 Td(text offset)指令以浮点坐标给出:
12.5 -0.3 Td <0041> Tj ← 偏移(12.5, -0.3),输出字形 0x41(字母 A)字符间距在任何缩放下都精确。
2.3 所有矢量绘制操作的统一路径
从源码可以清晰看到,几乎所有基本绘制操作最终都归约为路径绘制:
drawPoints() → 构造极短线段路径(0.001像素) → drawPath()drawLines() → 构造 QPainterPath → drawPath()drawRects() → 输出 re 指令 或构造路径 → drawPath()drawPolygon() → 构造 QPainterPath → drawPath()drawEllipse() → 构造贝塞尔曲线近似路径 → drawPath()drawPath() → generatePath() → PDF 矢量指令它们全部输出矢量指令,全部放大不失真。
三、贴图为什么放大会模糊
核心原因:存储的是像素矩阵,信息量有上限
当你在 QPdfWriter 上调用 drawImage() 或 drawPixmap() 时,写入 PDF 的是一张像素网格——每个像素记录一个颜色值。一张 200×100 的图片只有 20000 个像素的信息量,放大到 2000×1000 显示时,中间 1980000 个像素是渲染器猜出来的(插值算法),自然会模糊。
3.1 源码中的位图处理流程
intQPdfEnginePrivate::addImage(const QImage &img, bool *bitmap, bool lossless, qint64 serial_no){ QImage image = img;// 强制转换为 RGB32 或 ARGB32 像素格式if (format != QImage::Format_RGB32 && format != QImage::Format_ARGB32) { image = image.convertToFormat(QImage::Format_ARGB32); }int w = image.width(); // 像素宽度int h = image.height(); // 像素高度// 默认使用 JPEG 有损压缩(quality=94)if (QImageWriter::supportedImageFormats().contains("jpeg") && !grayscale && !lossless) {QBuffer buffer(&imageData);QImageWriter writer(&buffer, "jpeg"); writer.setQuality(94); // ← JPEG 质量 94%,有损压缩 writer.write(image); dct = true; // DCTDecode 过滤器 } else {// 无损模式:逐像素提取 RGB 值 imageData.resize(3 * w * h); // ← 原始像素矩阵for (int y = 0; y < h; ++y) {const QRgb *rgb = (const QRgb *)image.constScanLine(y);for (int x = 0; x < w; ++x) { *(data++) = qRed(*rgb); *(data++) = qGreen(*rgb); *(data++) = qBlue(*rgb); } } }}最终写入 PDF 的图像对象:
<</Type /XObject/Subtype /Image/Width 200 ← 固定像素宽度/Height 100 ← 固定像素高度/BitsPerComponent 8/ColorSpace /DeviceRGB/Filter /DCTDecode ← JPEG 压缩>>stream[200×100×3 字节的像素数据,经过 JPEG 压缩]endstream3.2 放大模糊的根本原因
PDF 中的图像对象记录了固定的像素尺寸(Width × Height)。当放大显示时:
像素数量不变:200×100 的图片始终只有 20000 个像素 渲染器插值:放大 10 倍显示时,需要填充 2000×1000 的屏幕像素,中间值靠插值算法猜测 JPEG 压缩还丢了细节:默认 quality=94 的 JPEG 压缩已经丢弃了 6% 的图像信息,高频细节丢失
这和矢量内容完全不同——矢量指令在放大时是重新计算,而位图是拉伸已有像素。
3.3 两种存储方式的本质对比
mlcSBT...ET | /Image /Width /Height stream... |
四、解决方法
用矢量 API 替代位图
凡是能用 QPainterPath 画出来的,就不要用截图/贴图。
// 错误:截图方式——位图,放大模糊QPixmap chart = grabChartWidget(); // 截取控件截图painter.drawPixmap(100, 100, chart);// 正确:矢量绘制——放大不失真QPainterPath curve;curve.moveTo(0, baseline);for (int i = 0; i < data.size(); ++i) { curve.lineTo(i * xStep, baseline - data[i] * yScale);}painter.drawPath(curve);五、总结
QPdfWriter 中,矢量指令(路径、文字)存储的是数学描述,放大时重新计算;位图存储的是像素矩阵,放大时拉伸插值。能用矢量画的,就别用截图。
夜雨聆风