QT Word模板 + QuaZIP + LibreOffice,跨平台方案实现导出.docx文件后再转为.pdf文件

发布于:2025-08-01 ⋅ 阅读:(29) ⋅ 点赞:(0)

最近在研究QT如何导出.docx文件,然后再将.docx文件转为.pdf文件;需要是跨平台的;

然而,在这方面,QT没有自带的库支持处理,第三方库也支持的比较少;

网上介绍的库有:OpenOffice、Liboffice、DuckX、docx和minidocx等,有兴趣的可以自行去了解一下;

在这里,我们不是通过代码去生成.docx文件,而是提前准备模板,通过替换模板中的文本,再导出.docx文件的方式去处理;然后如果需要,可以通过libreoffice库的命令将.docx转为.pdf。

简单介绍处理流程:

  1. 准备.docx模板,在模板上填写上需要替换的占位字符串;
  2. 通过QuaZIP将.docx文件解压缩;
  3. 修改解压缩出来的.xml文件,替换文本、图片等;
  4. 将解压出来的全部内容压缩为.docx;
  5. 通过LibreOffice库的命令将.docx转为.pdf。

(.docx 等于 .xml + zip) 我们熟悉的.docx文档,其实就是一个一个.xml文档,然后将其压缩后,就形成了。

1 准备工作

1.1 介绍使用到的工具

1.1.1 Word模板

模板就是你期望导出什么样.docx文件,先提前自己编写好,然后在还不确定填写文本的地方,使用占位字符串填写上,后面就可以通过替换占位字符串将目标文本替换上去,从而达到预期效果;

1.1.2 QuaZIP

QuaZIP 是一个基于 zlib 的跨平台 C++ 库,专为 Qt 框架设计,提供 ZIP 文件的读写功能。

这里将在项目中导入QuaZIP和ZLIB的源码,不编译成库使用,方便项目跨平台切换;

博主之前也学习QuaZIP库时,使用其简单做了一个加密压缩和解压缩zip的小案例,有兴趣的可以去看看:QT 引入Quazip和Zlib源码工程到项目中,无需编译成库,跨平台,加密压缩,带有压缩进度

1.1.3 LibreOffice

LibreOffice 是一款功能强大的办公软件,默认使用开放文档格式 (OpenDocument Format , ODF), 并支持 *.docx, *.xlsx, *.pptx 等其他格式。

它包含了 Writer, Calc, Impress, Draw, Base 以及 Math 等组件,可用于处理文本文档、电子表格、演示文稿、绘图以及公式编辑。

它可以运行于 Windows, GNU/Linux 以及 macOS 等操作系统上,并具有一致的用户体验。

对,没错,LibreOffice是一款办公软件,与Microsoft Office 和 WPS 一样,都可以处理Wrod、Excel文档等;
最最最重要的是,LibreOffice是免费的、开源的、跨平台的,并且提供了命令!所以在项目中,我们就可以使用LibreOffice的命令将.docx转为.pdf了。
当然也支持其他更多格式的转换。

1.2 项目依赖准备

1.2.1 Word模板

自己手动新建一个.docx文件,定义自己的模板;

例如,本教程将使用如下模板:
在这里插入图片描述

说明:
模板中,占位字符串用到了:
${FileHead}、${Name}、${Age}、${Description} ...等等

后面将会在代码中,将这些占位字符串替换为我们自己的字符串;
图片也可以插入然后替换,但是,文档内的图片建议(必须)是统一后缀的,例如都是.png格式,才在代码中更加方便的替换;
文档中对文本的加粗、斜体、设置颜色、背景颜色、设置字体等,替换字符串后,均使用原来设置好的样式;

对于表格,有固定行数的,也有不固定行数的(表格数据是动态的),本博客也有讲解如何处理不固定行数的表格新增问题;

1.2.2 QuaZIP

上面提到了,项目中将使用QuaZIP的源码,这里也会提供;是一个文件夹,内部包含了所有的代码;
在这里插入图片描述
将其放在项目的根路径下,即与mian.cpp文件同级路径下,然后在.pro文件中包含进来:

# 源码方式使用需要设置为静态库
DEFINES +=   QUAZIP_STATIC
include($$PWD/quazip/3rdparty/zlib.pri)
include($$PWD/quazip/quazip.pri)

1.2.3 LibreOffice 安装教程

LibreOffice非常庞大,如果使用源码安装的话,会出现各种依赖库的问题,非常麻烦,所以这里将使用官方编译好的程序进行安装。

点击链接进入到官网:主页| LibreOffice 简体中文官方网站 - 自由免费的办公套件

然后点击下载进入到下载页面:
在这里插入图片描述
有两个选项,下载LibreOffice和国内下载镜像,如果你是Windows和Linux用户,那么可以点击国内下载镜像去下载,下载速度会很快;
因为我用的是统信UOS系统ARM架构,只能点击下载LibreOffice项去下载,下载速度会很慢;

然后点击downloadarchive
在这里插入图片描述
然后会进入到很多旧版本下载的页面,Windows用户和Linux用户可以根据自己情况下载相应版本;
但是,ARM架构的用户,只能选择最新的测试版本,因为只有最新的测试版本才提供了ARM架构的安装文件,其他正式版本都没有,我不知道为什么,这里的版本我几乎都点来看过了,都没有;有知道的朋友请告知一下,谢谢。
在这里插入图片描述
点击进来后,如果你是Linux用户和ARM架构用户,点击deb包下载,也方便安装;
如果你是Windows用户,点击win下载.msi安装文件;
在这里插入图片描述
点击进来后,如果你是Linux用户,点击x86_64;如果你是ARM架构用户,点击aarch64;
在这里插入图片描述

Windows用户根据情况选择32位系统安装包和64位系统安装包;
在这里插入图片描述
然后,不管你是点击了 aarch64 还是 x86_64 还是 x86 ,在进入的下载页面中,都点击第一个包进行下载:
ARMLinux
Windows
之后就是漫长的等待下载的过程了…

1.2.3.1 Linux和ARM

下载完成后,Linux和ARM架构用户,将压缩包解压出来,进入到全是.deb包的文件路径,打开终端,输入命令:sudo dpkg -i *.deb 开始安装;
在这里插入图片描述
程序一般安装在 /opt 文件夹下,里面会有一个libreoffice25.8的文件夹,因为我安装的是25.8版本的,所以文件夹名字是libreoffice25.8;
libreoffice25.8文件夹内,还会有一个 program/ 文件夹,这个文件夹里面就有我们要用到的程序 soffice ;
在这里插入图片描述
通过命令: /opt/libreoffice25.8/program/soffice --version 就可以查看安装的版本了;
在这里插入图片描述
然后,请记住这个这个路径,例如我的是:/opt/libreoffice25.8/program/soffice ; 在代码中需要用到!
注意,我安装的最新测试版本,soffice 是一个.sh脚本,不知道其他正式版本是不是;

1.2.3.2 Windows

双击 .msi 文件进行安装;
选择自定义安装,才可以选择安装路径;
在这里插入图片描述
然后修改安装路径,不介安装在系统盘符的,可以不用改;
在这里插入图片描述
然后继续点击下一步,直到装成功就可以了!
最后在系统菜单栏里,就可以看到相应的菜单项了,点击后就可以打开wrod或者excel文档了;

不过,请记住自己的安装路径,在安装路径下的 program/ 文件夹内,有一个 soffice.exe 可执行程序,双击后也可以打开;请记住这个路径,例如我的是:D:\libreoffice\program\soffice.exe ; 在代码中需要用到!
在这里插入图片描述

2 解压docx文档后的文件介绍

将.docx解压后,会得到一个文件夹,文件夹内部全都是.xml文件;

其中,在wrod文件夹下的document.xml文件,这个文件就相当主页面了,我们操作替换的文件也是它;
在这里插入图片描述
如下图所示:
在这里插入图片描述

在media/ 文件中,存储着Word文件插入的图片;
细心观察,图片名字为image1.png ~ image5.png;而且图片名字的序号与文档中的图片位置是一致的;
如果将文档中第三张图片删除掉,再解压出来,那么顺序就变成了image1.png ~ image4.png;文档会自动对文件进行排序命名的;

所以,根据此原理,只需要替换掉文件夹中的图片,即可实现文档的图片替换了!
在这里插入图片描述
那么图片是如何与主页面的.xml文档关联上的呢?

在 word/_rels/ 文件夹中,有一个document.xml.rels文件,文件内会有标识图片的Id值,通过这个Id值,在主页面的.xml文件中是可以搜索得到的,有兴趣的可以去搜一下!
就是这样就可以关联上了。
在这里插入图片描述

其他的我也不太懂了,就不介绍了。有兴趣的可自行了解。

3 编码

提前准备好需要替换的图片,命名可随意,但必须要与文档中的图片后缀一样!!!
例如我准备的模板文档中插入的5张图片都是.png格式,这里我也准备了5张不同内容的.png格式照片用于替换;
在这里插入图片描述

包含头文件:

#include <QFile>
#include <QDomDocument>
#include <QProcess>
#include <QDir>
#include <QDebug>
#include <QTemporaryDir>
#include <QMap>
#include <QDesktopServices>
#include <QDirIterator>
#include <QUrl>

#include "quazipfile.h"


// 存储表格数据的
struct TableValue {
    QString document;       // 文档
    QString description;    // 描述
    QString explain;        // 说明
    QString kind;           // 种类、类别
};


// 使用 RAII 包装器确保资源清理
class QuaZipRAII {
public:
    QuaZipRAII(QuaZip* zip) : m_zip(zip) {}
    ~QuaZipRAII() {
        if(m_zip && m_zip->isOpen()) {
            m_zip->close();
        }
        delete m_zip;
    }
    operator QuaZip*() { return m_zip; }
    QuaZip* operator->() { return m_zip; }
    QuaZip* get() { return m_zip; }
private:
    QuaZip* m_zip;
    Q_DISABLE_COPY(QuaZipRAII)
};

3.1 解压缩

这里使用的是QuaZIP去处理解压缩,并没有使用JlCompress去处理,使用JlCompress去解压缩会出问题;

// zipPath 参数传.docx文档路径	targetDir 参数传解压路径
bool Widget::extractFile(const QString &zipPath, const QString &targetDir)
{
    QuaZipRAII zip(new QuaZip(zipPath));
    if (!zip->open(QuaZip::mdUnzip)) {
        qWarning() << "Failed to open ZIP file:" << zipPath
                   << "Error:" << zip->getZipError();
        return false;
    }

    QDir targetDirObj(targetDir);
    if (!targetDirObj.exists() && !targetDirObj.mkpath(".")) {
        qWarning() << "Failed to create target directory:" << targetDir;
        return false;
    }

    const int blockSize = 65536;  // 64KB 块大小
    QuaZipFileInfo fileInfo;
    QuaZipFile file(zip);

    for (bool more = zip->goToFirstFile(); more; more = zip->goToNextFile()) {
        if (!zip->getCurrentFileInfo(&fileInfo)) {
            qWarning() << "Failed to get file info, skipping. Error:" << zip->getZipError();
            continue;
        }

        QString absPath = targetDirObj.absoluteFilePath(fileInfo.name);
        QFileInfo fi(absPath);

        // 创建目录结构
        if (!QDir().mkpath(fi.path())) {
            qWarning() << "Failed to create path:" << fi.path();
            continue;
        }

        // 处理目录
        if (fileInfo.name.endsWith('/')) {
            QDir dir(absPath);
            if (!dir.exists() && !dir.mkpath(".")) {
                qWarning() << "Failed to create directory:" << absPath;
            }
            continue;
        }

        // 打开文件
        if (!file.open(QIODevice::ReadOnly)) {
            qWarning() << "Failed to open ZIP entry:" << fileInfo.name
                       << "Error:" << file.getZipError();
            continue;
        }

        // 创建目标文件
        QFile outFile(absPath);
        if (!outFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
            qWarning() << "Failed to open output file:" << absPath;
            file.close();
            continue;
        }

        // 分块读写
        char buffer[blockSize];
        qint64 totalBytes = 0;
        while (!file.atEnd()) {
            qint64 bytesRead = file.read(buffer, blockSize);
            if (bytesRead <= 0) break;

            qint64 bytesWritten = outFile.write(buffer, bytesRead);
            if (bytesWritten != bytesRead) {
                qWarning() << "Write error for:" << absPath
                           << "Expected:" << bytesRead << "Actual:" << bytesWritten;
                break;
            }
            totalBytes += bytesWritten;
        }

        outFile.close();
        file.close();

        // 设置文件权限
//        if (fileInfo.externalAttr != 0) {
//            QFile::setPermissions(absPath,
//                static_cast<QFile::Permissions>(fileInfo.externalAttr >> 16));
//        }

        // 设置文件时间戳
        QDateTime dt;
        dt.setDate(QDate(fileInfo.dateTime.date().year() + 1900,
                         fileInfo.dateTime.date().month() + 1,
                         fileInfo.dateTime.date().day()));
        dt.setTime(QTime(fileInfo.dateTime.time().hour(),
                         fileInfo.dateTime.time().minute(),
                         fileInfo.dateTime.time().second()));
        QFile(absPath).setFileTime(dt, QFileDevice::FileModificationTime);

        if (totalBytes != fileInfo.uncompressedSize) {
            qWarning() << "Size mismatch for:" << absPath
                       << "Expected:" << fileInfo.uncompressedSize
                       << "Actual:" << totalBytes;
        }
    }

    return zip->getZipError() == UNZ_OK;
}

3.2 压缩

这里使用的是QuaZIP去处理解压缩,并没有使用JlCompress去处理,使用JlCompress去解压缩会出问题;

// zipPath 参数传目标 .docx全路径		sourceDir 参数传需要压缩的文件夹路径
bool Widget::compressFile(const QString &zipPath, const QString &sourceDir)
{
    // 使用 RAII 管理资源
    QuaZipRAII newZip(new QuaZip(zipPath));

    if (!newZip->open(QuaZip::mdCreate)) {
        qWarning() << "Failed to create ZIP file:" << zipPath
                   << "Error:" << newZip->getZipError();
        return false;
    }

    QDir sourceDirObj(sourceDir);

    QSet<QString> addedDirs;
    const int blockSize = 65536;  // 64KB 块大小

    // 递归处理目录
    QDirIterator it(sourceDir, QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot,
                    QDirIterator::Subdirectories);

    while (it.hasNext()) {
        QString filePath = it.next();
        QFileInfo fi(filePath);
        QString relativePath = sourceDirObj.relativeFilePath(filePath);

        // 处理目录
        if (fi.isDir()) {
            QString zipDirPath = relativePath + '/';
            if (!addedDirs.contains(zipDirPath)) {
                QuaZipFile zipFile(newZip);
                QuaZipNewInfo newInfo(zipDirPath);
//                newInfo.externalAttr = (fi.permissions() | 0x10) << 16; // 保留权限+目录标志

                if (!zipFile.open(QIODevice::WriteOnly, newInfo)) {
                    qWarning() << "Failed to create directory entry:" << zipDirPath
                               << "Error:" << zipFile.getZipError();
                    continue;
                }
                zipFile.close();
                addedDirs.insert(zipDirPath);
            }
            continue;
        }

        // 处理文件
        QFile sourceFile(filePath);
        if (!sourceFile.open(QIODevice::ReadOnly)) {
            qWarning() << "Failed to open source file:" << filePath;
            continue;
        }

        QuaZipFile zipFile(newZip);
        QuaZipNewInfo newInfo(relativePath, filePath);
//        newInfo.externalAttr = fi.permissions() << 16; // 保留文件权限

        if (!zipFile.open(QIODevice::WriteOnly, newInfo)) {
            qWarning() << "Failed to open ZIP entry:" << relativePath
                       << "Error:" << zipFile.getZipError();
            sourceFile.close();
            continue;
        }

        // 分块读写提高大文件处理效率
        qint64 totalBytes = 0;
        char buffer[blockSize];
        while (!sourceFile.atEnd()) {
            qint64 bytesRead = sourceFile.read(buffer, blockSize);
            if (bytesRead <= 0) break;

            qint64 bytesWritten = zipFile.write(buffer, bytesRead);
            if (bytesWritten != bytesRead) {
                qWarning() << "Write error for:" << relativePath
                           << "Expected:" << bytesRead << "Actual:" << bytesWritten;
                break;
            }
            totalBytes += bytesWritten;
        }

        zipFile.close();
        sourceFile.close();

        if (totalBytes != fi.size()) {
            qWarning() << "File size mismatch:" << relativePath
                       << "Original:" << fi.size() << "Compressed:" << totalBytes;
        }
    }

    return newZip->getZipError() == UNZ_OK;
}

3.3 docx转pdf

转换时,使用安装好的libreoffice命令,将docx转为pdf文件;

bool Widget::convertDocxToPdf(const QString &docxPath, const QString &pdfOutputDir)
{
    QProcess process;
    QString libreOfficeCmd = "";

    // 根据自己的安装路径设置,一定要绝对路径,相对路径不行,会找不到soffice
#ifdef Q_OS_UNIX
    libreOfficeCmd = "/opt/libreoffice25.8/program/soffice"; // Linux路径
#else
    libreOfficeCmd = "D:/libreoffice/program/soffice.exe" // Windows路径
#endif

// 测试命令: 
// /opt/libreoffice25.8/program/soffice  --headless  -convert-to  pdf  --outdir  pdf文件输出路径   xxx.docx输入路径
    QStringList args = {
        "--headless",
        "--convert-to", "pdf",
        "--outdir", pdfOutputDir,
        docxPath
    };

    process.start(libreOfficeCmd, args);
    if (!process.waitForFinished(15000)) {
        qWarning() << "PDF转换超时:" << process.errorString();
        return false;
    }
    return process.exitCode() == 0;
}

3.4 替换占位符内容 和 图片

起始就是读取文件的内容后,通过QString::replace函数实现内容替换即可!

bool Widget::replaceInDocx(const QString &templatePath,
                          const QString &outputPath,
                          const QMap<QString, QString> &textReplacements,
                          const QMap<QString, QString> &imageReplacements)
{
    QTemporaryDir tempDir;
    if (!tempDir.isValid()) {
        qWarning() << "无法创建临时目录";
        return false;
    }


    // 1 解压 docx
    QString unzipPath = tempDir.path();
    if (!extractFile(templatePath, unzipPath)) {
        qWarning() << "解压失败!";
        return false;
    }

    qWarning() << unzipPath;
    // 2 替换文本内容
    QString docXmlPath = unzipPath + "/word/document.xml";
    QFile file(docXmlPath);
    if (!file.open(QIODevice::ReadOnly)) {
        qWarning() << "无法打开document.xml";
        return false;
    }

    QDomDocument doc;
    if (!doc.setContent(&file)) {
        file.close();
        qWarning() << "解析XML失败";
        return false;
    }
    file.close();

    // 文本替换
    QString xml = doc.toString();
    for (auto it = textReplacements.begin(); it != textReplacements.end(); ++it) {
        QString key = QString("${%1}").arg(it.key());
        QString value = it.value();
        xml.replace(key, value);
    }
    // 不想使用循环,也可以一个一个的替换
    xml.replace("${FileHead}", "我是表头");

    if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
        qWarning() << "无法写入document.xml";
        return false;
    }
    file.write(xml.toUtf8());
    file.close();

    // 3 替换图片
    if (!imageReplacements.isEmpty()) {
        QString mediaPath = unzipPath + "/word/media/";
        QDir mediaDir(mediaPath);

        // 确保media目录存在
        if (!mediaDir.exists()) {
            mediaDir.mkpath(".");
        }

        // 处理图片替换
        for (auto it = imageReplacements.constBegin(); it != imageReplacements.constEnd(); ++it) {
            QString placeholder = it.key();       // 占位符名称,如 "logo"
            QString imagePath = it.value();       // 新图片路径
            // 目标图片路径
            QString destPath = mediaPath + placeholder;

            // 复制图片到media目录
            if (!copyWithOverwrite(imagePath, destPath)) {
                qWarning() << "图片复制失败:" << imagePath << "->" << destPath;
                continue;
            }
        }
    }

    // 准备表格数据
    QList<TableValue> tableValueList = {
        { "文档1", "描述1", "说明1", "种类1" },
        { "文档2", "描述2", "说明1", "种类2" },
        { "文档3", "描述3", "说明1", "种类3" },
        { "文档4", "描述4", "说明1", "种类4" },
        { "文档5", "描述5", "说明1", "种类5" },
        { "文档6", "描述6", "说明1", "种类6" },
        { "末尾1", "末尾2", "末尾3", "末尾4" },
    };
    // 4 动态新增替换表格
    if (!replayTableIndex(docXmlPath, tableValueList)) {
        qWarning() << "表格数据替换失败!";
        return false;
    }

    // 5 压缩回 docx
    if (!compressFile(outputPath, unzipPath)) {
        qWarning() << "压缩失败!";
        return false;
    }

    return true;
}

3.5 动态往表格插入数据

动态往表格中插入数据,就不能使用固定好写好占位符内容了,因为不知道需要插入多少行数据;

即使知道如果是要插入512行数据,那么是不是就要定义好512个占位符内容呢?这显然不现实;

因为我们操作的对象是.xml文档,所以其实是可以通过复制一行表格内容再粘贴,即可实现插入效果的;

如果复制的是占位符内容,那么就可以先复制,再替换内容,再粘贴的方式,实现动态插入的效果;

知识点:

  • <w:tbl> 表示整个表格。
  • <w:tr> 表示表格的一行(table row)。
  • <w:tc> 是表格的一个单元格(table cell)。
  • <w:t> 是里面的文本内容。

操作流程:

在 Word 中插入一个表格(比如 4 列),写一行示例数据:
${Value1} | ${Value2} | ${Value3} | ${Value4}

在代码里找到这段表格所在的 XML,然后:

  1. 复制这一整行(<w:tr> 标签)
  2. 替换占位符
  3. 重复插入你需要的行数

表格 XML 示例(简化后的):

<w:tbl>
  <w:tr>
    <w:tc><w:p><w:r><w:t>${Value1}</w:t></w:r></w:p></w:tc>
    <w:tc><w:p><w:r><w:t>${Value2}</w:t></w:r></w:p></w:tc>
    <w:tc><w:p><w:r><w:t>${Value3}</w:t></w:r></w:p></w:tc>
    <w:tc><w:p><w:r><w:t>${Value4}</w:t></w:r></w:p></w:tc>
  </w:tr>
</w:tbl>

只需要:

  • 在 XML 中找到相应的表格 <w:tbl>
  • 在<w:tbl> 表格内找到占位符内容的行 <w:tr>
  • 在代码里复制这个 <w:tr>节点
  • 在<w:tr>一行节点内,循环遍历内部的所有子节点,即循环遍历一行内的所有单元格 然后在逐步替换单元格内的文本即可

注意,一个文档内可能会有多个表格,所以在定位表格的时候,可以获取当前表格内的所有文本,然后通过字符串判断方式是否包含指定的替换文本,从而得知当前表格是不是需要操作的表格;
例如通过明确定位 ${Value1} 即可知道表格,因为文档中的占位符唯一;

bool Widget::replayTableIndex(const QString &templatePath, QList<Widget::TableValue> TableData)
{
    QString docXmlPath = templatePath;
    QFile file(docXmlPath);
    if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return false;


    QDomDocument doc;
    if (!doc.setContent(&file)) {
        file.close();
        return false;
    }
    file.close();

    // 查找所有表格
    QDomNodeList tables = doc.elementsByTagName("w:tbl");
    if (tables.isEmpty()) return false;


    QDomElement table = { };
    for (int j = 0; j < tables.count(); j++) {
        table = tables.at(j).toElement();   // 获取表格
        QString str = table.text();         // 获取表格内的所有文本
        // 通过判断字符串内是否包含占位字符串,从而得知当前表格是否是需要替换文本的表格
        if (str.contains("${Value1}")) {    // 可以判断是占位字符串里面任意,因为他们理论上都是唯一的
            break;
        } else {
            table = { };
        }
    }

    // 判断是否找到了匹配的表格
    if (table.isNull()) {
        qWarning() << "没有找到匹配的表格!";
        return false;
    }

    // 查找表格内的所有行
    QDomNodeList rows = table.elementsByTagName("w:tr");
    if (rows.size() < 2) return false;


    QDomNode templateRow = { }; // 占位模板行
    for (int j = 0; j < rows.count(); ++j) {
        templateRow = rows.at(j);           // 获得占位模板行
        QString str = templateRow.toElement().text();         // 获取一行表格内的所有文本
        // 通过判断字符串内是否包含占位字符串,从而得知当前行是否是占位模板行
        if (str.contains("${Value1}")) {    // 可以判断是占位字符串里面任意,因为他们理论上都是唯一的
            break;
        } else {
            templateRow = { };
        }
    }

    // 判断是否找到了 占位模板行
    if (table.isNull()) {
        qWarning() << "没有找到 占位模板行!";
        return false;
    }


    // 表格动态插入行和文本替换
    for (int i = 0; i < TableData.size(); ++i) {
        // 克隆一个新的占位模板行,相当于插入了一行占位模板行
        QDomNode newRow = templateRow.cloneNode(true);
        // 替换文本
        //QString xmlString = newRow.toElement().ownerDocument().toString();

        // 临时存储站位模板行,用于动态插入
        newRow = newRow.toElement();
        // 查找一行表格内的所有单元格
        QDomNodeList cells = newRow.toElement().elementsByTagName("w:t");
        for (int j = 0; j < cells.count(); ++j) {
            QDomElement cell = cells.at(j).toElement();
            QString text = cell.text(); // 获得单元格内的文本

            if (text.contains("${Value1}")) {			// text == "${Value1}"
                text = TableData[i].document;

            } else if (text.contains("${Value2}")) {	// text == "${Value2}"
                text = TableData[i].description;

            } else if (text.contains("${Value3}")) {	// text == "${Value3}"
                text = TableData[i].explain;

            } else if (text.contains("${Value4}")) {	// text == "${Value4}"
                text = TableData[i].kind;
            }

            // 重新设置单元格里面的文本,相当于替换
            cell.firstChild().setNodeValue(text);
        }

        // 在末尾插入新的一行 占位模板行
//        table.appendChild(newRow);
        table.insertAfter(newRow, table.lastChild());
    }

    table.removeChild(templateRow); // 删除原始模板行

    // 保存 document.xml
    if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) return false;
    QTextStream out(&file);
    doc.save(out, 2);
    file.close();

    return true;
}

3.6 使用示例

void Widget::generateReport()
{
    // 文本替换数据
    QMap<QString, QString> textData {
        {"Name", "Jtom"},
        {"Age", "111"},
        {"Description", "这是一段描述信息ABCdef123"},
        {"Table1", "固定表格的数据1"},
        {"Table2", "其他其他"},
        {"Table3", "啦啦啦"},
        {"Special1", "我很特别"},
        {"Special2", "俺也一样"},
        {"Special3", "我知道"}
    };

    // 图片替换数据 (Word中的图片名字 -> 图片路径)
    QMap<QString, QString> imageData {
        {"image1.png", "Image/1.png"},
        {"image2.png", "Image/2.png"},
        {"image3.png", "Image/3.png"},
        {"image4.png", "Image/图片2.png"},
        {"image5.png", "Image/图片3.png"}
    };

    QString currentPath = QCoreApplication::applicationDirPath();
    QString templatePath = currentPath + "/input.docx";		// 输入.docx路径
    QString outputDocx = currentPath + "/report.docx";		// 输出.docx路径
    QString outputPdf = currentPath + "/report.pdf";		// 输出.pdf路径

    if (replaceInDocx(templatePath, outputDocx, textData, imageData)) {
        qDebug() << "DOCX 创建成功: " << outputDocx;

        if (convertDocxToPdf(outputDocx, QFileInfo(outputPdf).absolutePath())) {
            qDebug() << "PDF 创建成功: " << outputPdf;
            // 打开生成的PDF
            QDesktopServices::openUrl(QUrl::fromLocalFile(outputPdf));
        } else {
            qWarning() << "PDF 生成失败";
        }
    } else {
        qWarning() << "报告生成失败";
    }
}

运行后的PDF如下图所示:
在这里插入图片描述

3.7 背景知识

.docx = ZIP 压缩包 + XML 文件

.docx 文件其实是 ZIP 压缩文件,里面的正文存在 word/document.xml 文件里。

表格结构(简化):

<w:tbl>            ← 表格开始
  <w:tr>           ← 表格的一行(Table Row)
    <w:tc>         ← 单元格(Table Cell)
      <w:p><w:r><w:t>内容</w:t></w:r></w:p>
    </w:tc>
    ...
  </w:tr>
  <w:tr>...        ← 第二行
  </w:tr>
</w:tbl>
  • <w:tr> 表示表格的 一整行(新增需要复制这一整段,删除也需要移除这一整段)。

  • <w:tc> 表示每个单元格。

  • <w:t> 是最终的文本内容,你的 ${} 占位符一定出现在 <w:t> 标签中。

Word中插入的图片,如果插入的图片后缀都是一样的,例如都是.png格式,那么Word会依据文档中的图片顺序,将图片重新命名为:image1.png,…,imagen.png;

即使将中间某一张图片删除掉了,Wrod也会重新排序命名的;

所以,只要确保插入的图片格式一样,就可以实现图片的简单替换!

4 总结

其实这种方案也算是取巧的一种,需要很多库的支持,并且处理起来也挺繁琐;
但是也是一种可行的方案,目前Qt、C++方面对Word的支持还是很少的!

按照教程,安装LibreOffice,准备好.docx模板,QuaZIP库源码在下面工程代码中提供了。
剩下的就是编码处理流程了,我提供的模板也只是测试模板,更加复杂的样式模板可以自行去测试一下,应该也是没有问题的!

项目源码:https://gitee.com/ygt777/Qt_Wrod_QuaZIP_LibreOffice.git

最后,学习参考:C++/Qt导出动态数据生成Word、PDF报表文件


网站公告

今日签到

点亮在社区的每一天
去签到