感觉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 程序集。
这个是相关的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 协议》,转载必须注明作者和本文链接
com 非常不稳定 一般用 libreoffice 等支持 linux 的用 cli 处理。或者用 wps 等云处理接口
建议直接对接成熟的第三方接口服务去处理,例如wps。
不知道这个方案是否可行,提供一个思路。 搭建一个内部的在线文档平台,有那种开源的在线word或者pdf的工具。然后每天生成内容后给客户邮件里推送查看的url