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

最近在研究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文件中包含进来:

cpp 复制代码
# 源码方式使用需要设置为静态库
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 ,在进入的下载页面中,都点击第一个包进行下载:

之后就是漫长的等待下载的过程了...

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格式照片用于替换;

包含头文件:

cpp 复制代码
#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去解压缩会出问题;

cpp 复制代码
// 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去解压缩会出问题;

cpp 复制代码
// 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文件;

cpp 复制代码
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函数实现内容替换即可!

cpp 复制代码
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 示例(简化后的):

javascript 复制代码
<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} 即可知道表格,因为文档中的占位符唯一;

cpp 复制代码
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 使用示例

cpp 复制代码
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 文件里。

表格结构(简化):

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报表文件

相关推荐
用户805533698031 天前
不止三件套:QObject 属性系统全关键字与运行时反射!
c++·qt
xcyxiner1 天前
DicomViewer (vcpkg Windows和ubuntu编译)7
qt
Quz6 天前
QML Hello World 入门示例
qt
xcyxiner9 天前
DicomViewer (dcmtk读取dcm文件)5
qt
xcyxiner10 天前
DicomViewer (后台线程处理文件)4
qt
xcyxiner10 天前
DicomViewer (添加模型类)3
qt
xcyxiner11 天前
DicomViewer (目录调整) 2
qt
xcyxiner11 天前
dcmtk vtk vtk-dicom(gdcm) 编译(debug) v2
qt
桥田智能13 天前
桥田智能 QT-650S:面向白车身焊装的 800kg 重载快换解决方案
开发语言·qt·系统架构
weixin_3975740913 天前
PDF复杂表格的1:1还原引擎:跨页表格自动拼接技术实战
大数据·人工智能·pdf