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

76 阅读5分钟

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

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

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

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

屏幕截图 2024-09-07 215508.png 首先这个在PHPWord中的示例代码中是这样实现的:

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

源码链接

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

微信图片_20240907220912.jpg 这时候用户需要选择 是,然后目录会更新,页码就能正常显示出来了,效果就如同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”不生效,具体原因还不清楚。

image.png

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

2 document>TablesOfContents[1]>Update();这个就是更新目录,效果与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.wordDoc->SaveAs2(**DIR** . pdfPath, new \VARIANT(17, VT_I4));,第二个参数就是文件格式,它支持一下几种,PDF的枚举值是 17,但是直接写17会报错,需要转换一下 。参见 PHP: variant - Manual

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