感觉PHPWord不够用的时候怎么办,例如生成目录没有页码、转换PDF格式错乱

#

最近又遇到了两个需求发觉PHPWord已经实现起来有些困难了,我们有一项业务是每天将资讯信息从数据库中查找出来,生成一份word格式的日报文档,以附件形式发送到订阅的客户邮箱中;但是最近业务部门提了两个需求,让开发的同事犯了难,来找我帮忙。

需求一:在日报的的开头生成一份目录,用户点击可以直接跳转

需求二:日报附件的word改成pdf格式推送

这两个需求起初一看都很简单,但是实际都不太好实现。首先需求一,也就是我们在word中很常见的目录是这样的,标题部分没问题,但是红框标注的页面部分是难点:

首先这个在PHPWord中的示例代码中是这样实现的:

<?php
// 其他代码 略去
$phpWord->getSettings()->setUpdateFields(true);
// 其他代码 略去
$toc = $section->addTOC($fontStyle12);

源码链接

这里面核心就这两句,首先是addToc方法,Toc即“Table of contents”,这个方法的参数很少,基本都是和样式相关的,如果直接调用这个方法,得到的目录打开后,会发现只有目录标题而没有页码的。所以这里面的另一行代码其实更关键:$phpWord->getSettings()->setUpdateFields(true);,这行的功能就是,生成完word文件之后,用户首次用Word程序打开会看到一个提示弹窗:

这时候用户需要选择 是,然后目录会更新,页码就能正常显示出来了,效果就如同word程序里面的【引用】【更新目录】。但是这样体验就很差, 这个弹窗默认选中的是否,极易忽略,如果不点是就没有页码。其次经过测试这个弹窗只在Office Word中会正常弹出,在国内是用广泛的WPS上就没有了,自然也就没有页码。做这类文档生成常常需要兼容WPS和Office Word,就像前端页面兼容不同的浏览器一样,两个软件经常会有一些微妙的区别。

所以这个时候,需要重新回想一下PHPWord的原理,为何会导致PHPWord只能生成目录标题,生成页码会这么难呢。PHPWord生成Word的核心原理其实就是写OOXML,按照OOXML的语法生成XML而已,最终打包的文件实际是xml文件。所以我们假如打开源码中的Writer目录,就能看到好多操作XML的代码,例如src/PhpWord/Writer/Word2007/Element/TOC.php:

<?php
// more code
class TOC extends AbstractElement
{
    /**
     * Write element.
     */
    public function write(): void
    {
        $xmlWriter = $this->getXmlWriter();
        $element = $this->getElement();
        if (!$element instanceof TOCElement) {
            return;
        }

        $titles = $element->getTitles();
        $writeFieldMark = true;

        foreach ($titles as $title) {
            $this->writeTitle($xmlWriter, $element, $title, $writeFieldMark);
            if ($writeFieldMark) {
                $writeFieldMark = false;
            }
        }

        $xmlWriter->startElement('w:p');
        $xmlWriter->startElement('w:r');
        $xmlWriter->startElement('w:fldChar');
        $xmlWriter->writeAttribute('w:fldCharType', 'end');
        $xmlWriter->endElement();
        $xmlWriter->endElement();
        $xmlWriter->endElement();
    }

    /**
     * Write title.
     */
    private function writeTitle(XMLWriter $xmlWriter, TOCElement $element, Title $title, bool $writeFieldMark): void
    {
       // 略去
    }

    /**
     * Write style.
     */
    private function writeStyle(XMLWriter $xmlWriter, TOCElement $element, int $indent): void
    {
     // 略去
    }

    /**
     * Write TOC Field.
     */
    private function writeFieldMark(XMLWriter $xmlWriter, TOCElement $element): void
    {
       // 略去
    }
}

仔细看也会会发现源码中有生成标题有生成样式的,根本就没有写入页码的部分。

PHPWord生成OOXML,再经由word渲染就成了Word文档了,这个过程跟前端写入HTML在浏览器渲染是很像的。其实问题就是在于生成XML的时候,是一种流水账一样的写入方式,程序是无法判断文档的位置也就是页码的,也就很难得到这个页码,所以核心问题在于PHPWord无法渲染。众所周知渲染程序往往是最难写的,例如像CSS解释器等。能渲染Word文档的只有Word或Wps,所以如果我们能像Puppeteer调动浏览器一样调用Word程序的话问题就简单多了。

这时候我们使用PHP COM组件,注意这个ddl支持Windows平台。官方介绍:

COM 是 Component Object Model 的缩写;它是 DCE RPC(公开标准)之上的面向对象层(和相关服务),定义了通用的调用转换,任一语言编写的代码都可以与另外的任一语言(前提是这些语言可以 COM 感知)编写的代码进行互相调用与交互。代码不仅可以用任何语言编写,并且不需要是同一个执行文件的一部分;代码可以从 DLL 载入,或者从相同机器的另外一个进程中找到,或者使用 DCOM(分布式 COM),或者从远程机器的另外一个进程中找到,所有的这些都不要代码知道组件在哪里。

有个 COM 子集叫做 OLE 自动化,包含一组允许松散绑定 COM 对象的 COM 接口,因此可以在运行是对其自省(introspected)和调用,而无需了解编译时这些对象的工作原理。PHP COM 扩展利用 OLE 自动化接口,允许从脚本中创建和调用兼容对象。从技术上,这应该称为“OLE Automation Extension for PHP”(PHP OLE 自动化扩展),因为并非所有的 COM 对象用于 OLE 兼容。

现在,为什么以及何时应该使用 COM?COM 是在 Windows 平台上将组件和应用结合在一起的主要方法之一;使用 COM 可以启动 Microsoft Word,填充文档模板并将结果保存为 Word 文档,然后将其发送给网站的访客。可以使用 COM 为网络执行管理任务和配置 IIS;这些只是最常见的用途;还可以使用 COM 做更多的事情。

此外,支持使用 Microsoft 提供的 COM 互操作层来实例化和创建 .NET 程序集。

PHP文档

这个是相关的API文档

Microsoft.Office.Interop.Word Namespace | Microsoft Learn

这个文档非常的长,其实我们需要重点关注的是Selection部分

所以关于需求一,生成动态目录,实现的代码大概是这样的:

<?php

$word = new COM("Word.Application") or die("Unable to instantiate Word");
$word->Visible = false;

// 创建新文档
$document = $word->Documents->Add();
// 创建新文档
$document = $word->Documents->Add();

// 插入目录
$word->Selection->HomeKey();
$word->Selection->TypeParagraph();
$document->TablesOfContents->Add(
    $word->Selection->Range,
    true,
    1,
    3
);

// 插入第一章并设置为 Heading 1 样式
$word->Selection->TypeText("Chapter 1: Introduction");
$word->Selection->Style=-2;  // 设置样式为Heading 1
$word->Selection->TypeParagraph();
$word->Selection->TypeText("This is the introduction...");
$word->Selection->TypeParagraph();

// 插入第一章并设置为 Heading 1 样式
$word->Selection->TypeText("Chapter 2: Introduction");
$word->Selection->Style=-2;  // 设置样式为Heading 1
$word->Selection->TypeParagraph();
$word->Selection->TypeText("This is the introduction...");
$word->Selection->TypeParagraph();

// 更新目录以确保是最新的
$document->TablesOfContents[1]->Update();
// 保存文档
$savePath ="C:\\Users\\win10\\dev\\code\\document_with_toc.docx";
$document->SaveAs2($savePath);
// 关闭Word应用
$word->Quit();
$word = null;

简单理解这份代码,整体的API设计跟PHPWord很像的也是流水账一样写入。这里可以将Word想象成浏览器,就跟Puppeteer调动浏览器的代码差不多,首先是第一行就是启动Word应用,然后打开不同的文档,就像浏览器打开不同的标签页。其中$word->Visible = false; 就是是否需要可视化,如果是true的话可以看到word程序被打开写入内容的整个过程。

有几点需要注意:

1.$word->Selection->Style=-2; 意思是将这句话设置为标题1,其实就是TypeText()方法是写入的是纯文字,不包含任何样式的,回想PHPWrod中的addText其实也是这个类似的API设计。这里写成-2 是对应word内嵌样式的的枚举值,这个枚举值经测试只能-2才有效,直接“Heading 2”不生效,具体原因还不清楚。

文档链接 WdBuiltinStyle Enum (Microsoft.Office.Interop.Word) | Microsoft Learn

2 $document->TablesOfContents[1]->Update();这个就是更新目录,效果与$phpWord->getSettings()->setUpdateFields(true);是一样的,但是这个必须最后执行,因为内容全写完成之后再计算页码比较合理。这样最终生成的word文档,用户打开页码就都是计算好的了,也就没有弹窗了,因为我们自己渲染过了。

需求二,将Word保存成PDF:

<?php

        // 创建 COM 对象
        $word = new \COM("Word.Application") or die("无法创建 Word 对象");

        // 设置 Word 应用程序不可见
        $word->Visible = false;

        // 打开 Word 文档
        $wordDoc = $word->Documents->Open($path);

        // 生成 PDF 文件路径
        $bare_name = str_replace(".docx", "", $file_name);
        $pdfPath = "./out_files/" . $bare_name . '.pdf';
        // 将 Word 文档保存为 PDF 格式
        $wordDoc->SaveAs2(__DIR__ . $pdfPath, new \VARIANT(17, VT_I4));  // 17 表示 PDF 格式

        // 关闭 Word 文档
        $wordDoc->Close(false);
        unset($wordDoc); // 释放文档对象

        // 关闭 Word 应用程序
        $word->Quit();
        unset($word); // 释放 Word 应用程序对象

转换PDF的核心也是渲染,PHPWord中原生的转PDF的思路就是通过Mpdf或者domPDf等渲染引擎得到PDF。但是效果都不太好,如果是简单的PDF还可以,但是像我们这种样式复杂的生成的pdf往往样式有些错乱,而且还往往需要再安装补充一些中文字体。而使用这种方式,得到的PDF和WORD样式是高度一致的,几乎一样。

这里面需要注意的saveAS2方法,$wordDoc->SaveAs2(DIR . $pdfPath, new \VARIANT(17, VT_I4));,第二个参数就是文件格式,它支持一下几种,PDF的枚举值是 17,但是直接写17会报错,需要转换一下 。参见 PHP: variant - Manual

WdSaveFormat Enum (Microsoft.Office.Interop.Word) | Microsoft Learn

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 8

com 非常不稳定 一般用 libreoffice 等支持 linux 的用 cli 处理。或者用 wps 等云处理接口

1周前 评论
nicowang (楼主) 1周前
wslsq (作者) 6天前
wslsq (作者) 6天前
nicowang (楼主) 6天前

建议直接对接成熟的第三方接口服务去处理,例如wps。

libreoffice基本功能可以,但有些样式和颜色PDF转出来有一点问题。

6天前 评论
nicowang (楼主) 4天前
lovewei (作者) 4天前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!