提交第一个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 $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 (); $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' );if (!empty ($sectionMedia )) { $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 ();$xml = simplexml_load_string ($documentXml );$bodyContent = $xml ->xpath ('//w:body/*' );$documentBodyStr = "" ;foreach ($bodyContent as $element ) { $documentBodyStr .= $element ->asXML (); } $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 ); } } $this ->replaceXmlBlock ($search , $documentBodyStr , 'w:p' );$xml = simplexml_load_string ($relsDocumentXml );$xml ->registerXPathNamespace ('ns' , 'http://schemas.openxmlformats.org/package/2006/relationships' );$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 ; } } 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);这个方法添加的图片没办法设置宽高,设置了也不生效。
其实是支持宽高的,只是不用填写单位, 把单位去掉即可。