乐于分享
好东西不私藏

Qt写PDF文件,为什么文字放大不失真,贴图却会模糊?

Qt写PDF文件,为什么文字放大不失真,贴图却会模糊?

使用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(0300);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 压缩]endstream

3.2 放大模糊的根本原因

PDF 中的图像对象记录了固定的像素尺寸(Width × Height)。当放大显示时:

  1. 像素数量不变:200×100 的图片始终只有 20000 个像素
  2. 渲染器插值:放大 10 倍显示时,需要填充 2000×1000 的屏幕像素,中间值靠插值算法猜测
  3. JPEG 压缩还丢了细节:默认 quality=94 的 JPEG 压缩已经丢弃了 6% 的图像信息,高频细节丢失

这和矢量内容完全不同——矢量指令在放大时是重新计算,而位图是拉伸已有像素

3.3 两种存储方式的本质对比

维度
矢量(文字/曲线)
位图(贴图)
存储内容
坐标 + 绘制指令
像素颜色矩阵
信息密度
与缩放无关
固定像素数量
放大时
重新计算,精确绘制
插值拉伸,像素不够用
文件大小
通常较小
取决于分辨率
典型 PDF 指令
mlcSBT...ET/Image /Width /Height stream...

四、解决方法

用矢量 API 替代位图

凡是能用 QPainterPath 画出来的,就不要用截图/贴图。

// 错误:截图方式——位图,放大模糊QPixmap chart = grabChartWidget();  // 截取控件截图painter.drawPixmap(100100, 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 中,矢量指令(路径、文字)存储的是数学描述,放大时重新计算;位图存储的是像素矩阵,放大时拉伸插值。能用矢量画的,就别用截图。