提交第一个PR

背景

工作任务有个需求,需要将运营登记的报告导出到word中,然后这个word是实现同事编排好的,里面的一些内容需要进行动态替换,替换内容包裹文本图片和图标,最容易想到的就是模板替换通过占位符替换想要的内容,因为web服务是go实现的,所以找了go相关的库没有找到合适的,所以通过php的库PHPWord起了个服务来实现模板替换。

问题

通过PHPWord的官网文档使用了下Template processing 基本可以满足功能需求,但是没办法将html内容转化为word的内容,如果是图片的话直接显示空白,刚开始以为是自己使用的问题,经过几番尝试应该是库本身实现的问题,然而如果不用模板替换的话,PHPWord本身是可以将html转成实际文档内容的,于是想自己实现一个Template Processing上的方法来渲染html图片顺便提交个PR

阅读源码

1.先看一个最简单的案例

1
2
3
4
5
6
7
8
//读取模板
$templateProcessor = new \PhpOffice\PhpWord\TemplateProcessor('tpl_vuln.docx');
//替换内容
$templateProcessor->setValue("CompanyLogo",$content);
//保存路径
$pathToSave = 'TemplateTransformed.docx';
//保存
$templateProcessor->saveAs($pathToSave);

2.如何读取的模板文件。

刚开始看源代码的时候实际有点懵逼的,代码都看得懂但是不理解为啥要这么做,然后通过gpt查询了一下word操作的本质原理理解了其实word就是一个zip文件包,他的语法格式类似html这种文本标记语言,但是叫做openxml,操作word就是操作openxml,然后打包成zip包改下后缀名变成.docx,但是早期的word就是个二进制文件了,不在讨论范围。

从TemplateProcessor构造函数中可以看出

1
2
3
4
5
6
7
8
// Temporary document content extraction
$this->zipClass = new ZipArchive();
$this->zipClass->open($this->tempDocumentFilename);
$index = 1;
while (false !== $this->zipClass->locateName($this->getHeaderName($index))) {
$this->tempDocumentHeaders[$index] = $this->readPartWithRels($this->getHeaderName($index));
++$index;
}

3.理解word解压之后的目录结构

1
2
[Content_Types].xml customXml           word
_rels docProps

主要文件[Content_Types].xml, 一个word又一个一个part组成,这些part的内容类型都标记在这个文件夹底下,如标记document.xml 这个文件类型。

1
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>

还有一些标记的扩展类型等:

1
2
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>

主要文件_rels,这个文件夹底下的.rels标记这个资源和part之间的关系,

这个是_rels底下的.rels的内容

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/>
</Relationships>

这个标记了word/document.xml是officeDocument也可以理解为入口文件。

主要文件word底下的_rels文件夹,这个文件标记了图片资源等对应的映射关系。

如一张图片:

1
<Relationship Id="rId16" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image4.png"/>

该xml描述了图片的路径Target在”media/image4.png”下,然后document.xml用<a:blip r:embed=”rId16”/>语法就可以引入图片,跟html中<img src=”hptt://**.png”的方式不太一样。

到此就可以基本了解ooxml的基本加载原理,其实还有很多知识点可以在官网查阅 :

Office Open XML - Anatomy of an OOXML WordProcessingML File

或者直接unzip解压一个docx文件,看下目录结构自己修改应用下就知道大致原理了,目前知道这个资源加载的逻辑就可以帮助我们实现将html的图片解析导入word当中。

4.继续阅读源码:

在查阅资料当中有看到 Html::addHtml 这个官方提供的方法可以添加html元素到文档当中,但是经过我几番尝试,在TemplateProcessor这个类中都没办法正确导出图片,代码如下:

1
2
3
4
5
6
7
8
9
10
$htmlCtn = ' <img  width="400"  src="https://t7.baidu.com/it/u=4198287529,2774471735&fm=193&f=GIF" />';
$table = new Table(array('borderSize' => 1, 'borderColor' => 'black', 'width' => 6000, 'unit' => TblWidth::TWIP));
$table->addRow();
$cell = $table->addCell(300);
Html::addHtml($cell, $htmlCtn);
$cell->addText("1111");
$templateProcessor = new \PhpOffice\PhpWord\TemplateProcessor('tpl_vuln.docx');
$templateProcessor->setComplexBlock("CompanyLogo", $table );
$pathToSave = 'TemplateTransformed.docx';
$templateProcessor->saveAs($pathToSave);

代码调用并没有报错,table的文字内容是可以输出的,但是图片就只会占用位置,不会显示图片。

先拿table这个元素来说,研究了下setComplexBlock这个方法,他只是通过’PhpOffice\PhpWord\Writer\Word2007\Element\Table’这个类生成对应的ooxml内容,然后在通过XMLWriter输出,在替换document.xml的占位符。

1
2
3
4
5
6
7
8
9
10
11
12
public function setComplexBlock($search, Element\AbstractElement $complexType): void
{
$elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1);
$objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;

$xmlWriter = new XMLWriter();
/** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $elementWriter */
$elementWriter = new $objectClass($xmlWriter, $complexType, false);
$elementWriter->write();

$this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:p');
}

看完前面介绍的ooxml文档运行的资源加载原理,也就理解为什么图片一直没办法加载了,因为图片对应的资源并没有写入,图片的Relationships也没写入到.rels.xml中,那么实现图片加载的思路就是将图片资源和关系写入到zip包当中。

实现图片加载

思路

我想着利用最少得代码实现功能,最好能复用原有phpword的功能,将html内容生成一个PhpWord,然后从PhpWord读取出image资源文件,写入到新的模板文件当中,在修改rels关系,这样便可以实现加载html图片。

第一步:将html加载到一个新的PhpWord当中

1
2
3
$phpWord = new PhpWord();
$section = $phpWord->addSection();
Html::addHtml($section,$htmlContent,$fullHtml);

第二歩:将图片资源打入模板的zip中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$obj = new Word2007($phpWord);
$refClass = new \ReflectionClass(Word2007::class);
$addFilesToPackage = $refClass->getMethod('addFilesToPackage');
$addFilesToPackage->setAccessible(true);
$sectionMedia = Media::getElements('section');
//add image to zip
if (!empty($sectionMedia)) {
//insert image to zip
$res = $addFilesToPackage->invoke($obj,$zip, $sectionMedia);
$registerContentTypes = $refClass->getMethod('registerContentTypes');
$registerContentTypes->setAccessible(true);
$registerContentTypes->invoke($obj,$sectionMedia);

$relationships = $refClass->getProperty('relationships');
$relationships->setAccessible(true);
$tmpRelationships = [];
foreach ($sectionMedia as $element) {
$tmpRelationships[] = $element;
}
$relationships->setValue($obj,$tmpRelationships);
}

第三步:替换rels.xml中的关系,还有document.xml中的文本替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
$documentWriterPart = $obj->getWriterPart("Document");
$relsDocumentWriterPart = $obj->getWriterPart("RelsDocument");
$documentXml = $documentWriterPart->write();
$relsDocumentXml = $relsDocumentWriterPart->write();
// Load the XML string into a SimpleXMLElement
$xml = simplexml_load_string($documentXml);
// Extract content between <w:body> tags
$bodyContent = $xml->xpath('//w:body/*');
// Output the extracted content
$documentBodyStr = "";
foreach ($bodyContent as $element) {
$documentBodyStr .= $element->asXML();
}
//replace html content r:id vaule avoid rid conflict
$rIdsElement = $xml->xpath('//*[@r:id]');
$rIdValuesMap = [];
if ($rIdsElement){
foreach ($rIdsElement as $idEle){
$rid = (string)$idEle->attributes('r', true)->id;
$rIdValuesMap[$rid] = $rid;
}
}
if (!empty($rIdValuesMap )){
foreach ($rIdValuesMap as $rid => $value){
$replactVulue = $rid."-1";
$rIdValuesMap[$rid] = $replactVulue;
$documentBodyStr = str_replace($rid,$replactVulue,$documentBodyStr);
}
}
//replace document.xml
$this->replaceXmlBlock($search, $documentBodyStr, 'w:p');

$xml = simplexml_load_string($relsDocumentXml);
// Register the namespace
$xml->registerXPathNamespace('ns', 'http://schemas.openxmlformats.org/package/2006/relationships');
// Use XPath to find all Relationship nodes
$RelationshipXmls = $xml->xpath('//ns:Relationship');
$RelationshipStr = "";
foreach ($RelationshipXmls as $relationshipXml){
$rid = (string)$relationshipXml->attributes();
if (isset($rIdValuesMap[$rid])){
$tmpStr = $relationshipXml->asXML();
$tmpStr = str_replace($rid,$rIdValuesMap[$rid],$tmpStr);
$RelationshipStr .= $tmpStr;
}
}
//add relation to document.xml.rels
if ($RelationshipStr){
$relsFileName = $this->getRelationsName($this->getMainPartName());
$content = $this->tempDocumentRelations[$this->getMainPartName()];
$endStr = "</Relationships>";
$replaceValue = $RelationshipStr.$endStr;
$content = str_replace($endStr,$replaceValue,$content);
$this->tempDocumentRelations[$this->getMainPartName()] = $content ;
}

逻辑不算复杂,比较难得是理解业务逻辑,在通过反射调用一些Word2007私有方法,在替换一下文本字符串,即可实现最终的图片加载。

提交PR

经过测试功正常满足需求,于是fork了phpword的官方仓库,在创建一个新的分支修改代码之后push到远程分支,这样就能创建一个pr,具体的操作流程网上已经有很多文章写的很好了,就不在重复了。

这个是我提交的PR https://github.com/PHPOffice/PHPWord/pull/2547 目前还没被合并,有和贡献者讨论了一些问题,但是仓库的管理员过了好久都还没review,但是有需要的小伙伴可以直接拉取这个分支实现功能。

其他

还有一些TemplateProcessor使用过程中的问题,官方文档没有详细解释,通过stackoverflow最终找到解决方法记录一下。

问题1:TemplateProcessor 中的setValue文本中如果存在\n没办法换行

将\n替换为
即可,$ctn = str_replace(”\n”,”
”,$ctn)

问题2: Html::addHtml($cell, $htmlCtn);这个方法添加的图片没办法设置宽高,设置了也不生效。

其实是支持宽高的,只是不用填写单位, 把单位去掉即可。