TemplateProcessor.php 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318
  1. <?php
  2. /**
  3. * This file is part of PHPWord - A pure PHP library for reading and writing
  4. * word processing documents.
  5. *
  6. * PHPWord is free software distributed under the terms of the GNU Lesser
  7. * General Public License version 3 as published by the Free Software Foundation.
  8. *
  9. * For the full copyright and license information, please read the LICENSE
  10. * file that was distributed with this source code. For the full list of
  11. * contributors, visit https://github.com/PHPOffice/PHPWord/contributors.
  12. *
  13. * @see https://github.com/PHPOffice/PHPWord
  14. * @copyright 2010-2018 PHPWord contributors
  15. * @license http://www.gnu.org/licenses/lgpl.txt LGPL version 3
  16. */
  17. namespace PhpOffice\PhpWord;
  18. use PhpOffice\PhpWord\Escaper\RegExp;
  19. use PhpOffice\PhpWord\Escaper\Xml;
  20. use PhpOffice\PhpWord\Exception\CopyFileException;
  21. use PhpOffice\PhpWord\Exception\CreateTemporaryFileException;
  22. use PhpOffice\PhpWord\Exception\Exception;
  23. use PhpOffice\PhpWord\Shared\Text;
  24. use PhpOffice\PhpWord\Shared\XMLWriter;
  25. use PhpOffice\PhpWord\Shared\ZipArchive;
  26. class TemplateProcessor
  27. {
  28. const MAXIMUM_REPLACEMENTS_DEFAULT = -1;
  29. /**
  30. * ZipArchive object.
  31. *
  32. * @var mixed
  33. */
  34. protected $zipClass;
  35. /**
  36. * @var string Temporary document filename (with path)
  37. */
  38. protected $tempDocumentFilename;
  39. /**
  40. * Content of main document part (in XML format) of the temporary document
  41. *
  42. * @var string
  43. */
  44. protected $tempDocumentMainPart;
  45. /**
  46. * Content of settings part (in XML format) of the temporary document
  47. *
  48. * @var string
  49. */
  50. protected $tempDocumentSettingsPart;
  51. /**
  52. * Content of headers (in XML format) of the temporary document
  53. *
  54. * @var string[]
  55. */
  56. protected $tempDocumentHeaders = array();
  57. /**
  58. * Content of footers (in XML format) of the temporary document
  59. *
  60. * @var string[]
  61. */
  62. protected $tempDocumentFooters = array();
  63. /**
  64. * Document relations (in XML format) of the temporary document.
  65. *
  66. * @var string[]
  67. */
  68. protected $tempDocumentRelations = array();
  69. /**
  70. * Document content types (in XML format) of the temporary document.
  71. *
  72. * @var string
  73. */
  74. protected $tempDocumentContentTypes = '';
  75. /**
  76. * new inserted images list
  77. *
  78. * @var string[]
  79. */
  80. protected $tempDocumentNewImages = array();
  81. /**
  82. * @since 0.12.0 Throws CreateTemporaryFileException and CopyFileException instead of Exception
  83. *
  84. * @param string $documentTemplate The fully qualified template filename
  85. *
  86. * @throws \PhpOffice\PhpWord\Exception\CreateTemporaryFileException
  87. * @throws \PhpOffice\PhpWord\Exception\CopyFileException
  88. */
  89. public function __construct($documentTemplate)
  90. {
  91. // Temporary document filename initialization
  92. $this->tempDocumentFilename = tempnam(Settings::getTempDir(), 'PhpWord');
  93. if (false === $this->tempDocumentFilename) {
  94. throw new CreateTemporaryFileException(); // @codeCoverageIgnore
  95. }
  96. // Template file cloning
  97. if (false === copy($documentTemplate, $this->tempDocumentFilename)) {
  98. throw new CopyFileException($documentTemplate, $this->tempDocumentFilename); // @codeCoverageIgnore
  99. }
  100. // Temporary document content extraction
  101. $this->zipClass = new ZipArchive();
  102. $this->zipClass->open($this->tempDocumentFilename);
  103. $index = 1;
  104. while (false !== $this->zipClass->locateName($this->getHeaderName($index))) {
  105. $this->tempDocumentHeaders[$index] = $this->readPartWithRels($this->getHeaderName($index));
  106. $index++;
  107. }
  108. $index = 1;
  109. while (false !== $this->zipClass->locateName($this->getFooterName($index))) {
  110. $this->tempDocumentFooters[$index] = $this->readPartWithRels($this->getFooterName($index));
  111. $index++;
  112. }
  113. $this->tempDocumentMainPart = $this->readPartWithRels($this->getMainPartName());
  114. $this->tempDocumentSettingsPart = $this->readPartWithRels($this->getSettingsPartName());
  115. $this->tempDocumentContentTypes = $this->zipClass->getFromName($this->getDocumentContentTypesName());
  116. }
  117. /**
  118. * Expose zip class
  119. *
  120. * To replace an image: $templateProcessor->zip()->AddFromString("word/media/image1.jpg", file_get_contents($file));<br>
  121. * To read a file: $templateProcessor->zip()->getFromName("word/media/image1.jpg");
  122. *
  123. * @return \PhpOffice\PhpWord\Shared\ZipArchive
  124. */
  125. public function zip()
  126. {
  127. return $this->zipClass;
  128. }
  129. /**
  130. * @param string $fileName
  131. *
  132. * @return string
  133. */
  134. protected function readPartWithRels($fileName)
  135. {
  136. $relsFileName = $this->getRelationsName($fileName);
  137. $partRelations = $this->zipClass->getFromName($relsFileName);
  138. if ($partRelations !== false) {
  139. $this->tempDocumentRelations[$fileName] = $partRelations;
  140. }
  141. return $this->fixBrokenMacros($this->zipClass->getFromName($fileName));
  142. }
  143. /**
  144. * @param string $xml
  145. * @param \XSLTProcessor $xsltProcessor
  146. *
  147. * @throws \PhpOffice\PhpWord\Exception\Exception
  148. *
  149. * @return string
  150. */
  151. protected function transformSingleXml($xml, $xsltProcessor)
  152. {
  153. if (\PHP_VERSION_ID < 80000) {
  154. $orignalLibEntityLoader = libxml_disable_entity_loader(true);
  155. }
  156. $domDocument = new \DOMDocument();
  157. if (false === $domDocument->loadXML($xml)) {
  158. throw new Exception('Could not load the given XML document.');
  159. }
  160. $transformedXml = $xsltProcessor->transformToXml($domDocument);
  161. if (false === $transformedXml) {
  162. throw new Exception('Could not transform the given XML document.');
  163. }
  164. if (\PHP_VERSION_ID < 80000) {
  165. libxml_disable_entity_loader($orignalLibEntityLoader);
  166. }
  167. return $transformedXml;
  168. }
  169. /**
  170. * @param mixed $xml
  171. * @param \XSLTProcessor $xsltProcessor
  172. *
  173. * @return mixed
  174. */
  175. protected function transformXml($xml, $xsltProcessor)
  176. {
  177. if (is_array($xml)) {
  178. foreach ($xml as &$item) {
  179. $item = $this->transformSingleXml($item, $xsltProcessor);
  180. }
  181. unset($item);
  182. } else {
  183. $xml = $this->transformSingleXml($xml, $xsltProcessor);
  184. }
  185. return $xml;
  186. }
  187. /**
  188. * Applies XSL style sheet to template's parts.
  189. *
  190. * Note: since the method doesn't make any guess on logic of the provided XSL style sheet,
  191. * make sure that output is correctly escaped. Otherwise you may get broken document.
  192. *
  193. * @param \DOMDocument $xslDomDocument
  194. * @param array $xslOptions
  195. * @param string $xslOptionsUri
  196. *
  197. * @throws \PhpOffice\PhpWord\Exception\Exception
  198. */
  199. public function applyXslStyleSheet($xslDomDocument, $xslOptions = array(), $xslOptionsUri = '')
  200. {
  201. $xsltProcessor = new \XSLTProcessor();
  202. $xsltProcessor->importStylesheet($xslDomDocument);
  203. if (false === $xsltProcessor->setParameter($xslOptionsUri, $xslOptions)) {
  204. throw new Exception('Could not set values for the given XSL style sheet parameters.');
  205. }
  206. $this->tempDocumentHeaders = $this->transformXml($this->tempDocumentHeaders, $xsltProcessor);
  207. $this->tempDocumentMainPart = $this->transformXml($this->tempDocumentMainPart, $xsltProcessor);
  208. $this->tempDocumentFooters = $this->transformXml($this->tempDocumentFooters, $xsltProcessor);
  209. }
  210. /**
  211. * @param string $macro
  212. *
  213. * @return string
  214. */
  215. protected static function ensureMacroCompleted($macro)
  216. {
  217. if (substr($macro, 0, 2) !== '${' && substr($macro, -1) !== '}') {
  218. $macro = '${' . $macro . '}';
  219. }
  220. return $macro;
  221. }
  222. /**
  223. * @param string $subject
  224. *
  225. * @return string
  226. */
  227. protected static function ensureUtf8Encoded($subject)
  228. {
  229. if (!Text::isUTF8($subject)) {
  230. $subject = utf8_encode($subject);
  231. }
  232. return $subject;
  233. }
  234. /**
  235. * @param string $search
  236. * @param \PhpOffice\PhpWord\Element\AbstractElement $complexType
  237. */
  238. public function setComplexValue($search, \PhpOffice\PhpWord\Element\AbstractElement $complexType)
  239. {
  240. $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1);
  241. $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
  242. $xmlWriter = new XMLWriter();
  243. /** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $elementWriter */
  244. $elementWriter = new $objectClass($xmlWriter, $complexType, true);
  245. $elementWriter->write();
  246. $where = $this->findContainingXmlBlockForMacro($search, 'w:r');
  247. if ($where === false) {
  248. return;
  249. }
  250. $block = $this->getSlice($where['start'], $where['end']);
  251. $textParts = $this->splitTextIntoTexts($block);
  252. $this->replaceXmlBlock($search, $textParts, 'w:r');
  253. $search = static::ensureMacroCompleted($search);
  254. $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:r');
  255. }
  256. /**
  257. * @param string $search
  258. * @param \PhpOffice\PhpWord\Element\AbstractElement $complexType
  259. */
  260. public function setComplexBlock($search, \PhpOffice\PhpWord\Element\AbstractElement $complexType)
  261. {
  262. $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1);
  263. $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
  264. $xmlWriter = new XMLWriter();
  265. /** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $elementWriter */
  266. $elementWriter = new $objectClass($xmlWriter, $complexType, false);
  267. $elementWriter->write();
  268. $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:p');
  269. }
  270. /**
  271. * @param mixed $search
  272. * @param mixed $replace
  273. * @param int $limit
  274. */
  275. public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT)
  276. {
  277. if (is_array($search)) {
  278. foreach ($search as &$item) {
  279. $item = static::ensureMacroCompleted($item);
  280. }
  281. unset($item);
  282. } else {
  283. $search = static::ensureMacroCompleted($search);
  284. }
  285. if (is_array($replace)) {
  286. foreach ($replace as &$item) {
  287. $item = static::ensureUtf8Encoded($item);
  288. }
  289. unset($item);
  290. } else {
  291. $replace = static::ensureUtf8Encoded($replace);
  292. }
  293. if (Settings::isOutputEscapingEnabled()) {
  294. $xmlEscaper = new Xml();
  295. $replace = $xmlEscaper->escape($replace);
  296. }
  297. $this->tempDocumentHeaders = $this->setValueForPart($search, $replace, $this->tempDocumentHeaders, $limit);
  298. $this->tempDocumentMainPart = $this->setValueForPart($search, $replace, $this->tempDocumentMainPart, $limit);
  299. $this->tempDocumentFooters = $this->setValueForPart($search, $replace, $this->tempDocumentFooters, $limit);
  300. }
  301. /**
  302. * Set values from a one-dimensional array of "variable => value"-pairs.
  303. *
  304. * @param array $values
  305. */
  306. public function setValues(array $values)
  307. {
  308. foreach ($values as $macro => $replace) {
  309. $this->setValue($macro, $replace);
  310. }
  311. }
  312. /**
  313. * @param string $search
  314. * @param \PhpOffice\PhpWord\Element\AbstractElement $complexType
  315. */
  316. public function setChart($search, \PhpOffice\PhpWord\Element\AbstractElement $chart)
  317. {
  318. $elementName = substr(get_class($chart), strrpos(get_class($chart), '\\') + 1);
  319. $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
  320. // Get the next relation id
  321. $rId = $this->getNextRelationsIndex($this->getMainPartName());
  322. $chart->setRelationId($rId);
  323. // Define the chart filename
  324. $filename = "charts/chart{$rId}.xml";
  325. // Get the part writer
  326. $writerPart = new \PhpOffice\PhpWord\Writer\Word2007\Part\Chart();
  327. $writerPart->setElement($chart);
  328. // ContentTypes.xml
  329. $this->zipClass->addFromString("word/{$filename}", $writerPart->write());
  330. // add chart to content type
  331. $xmlRelationsType = "<Override PartName=\"/word/{$filename}\" ContentType=\"application/vnd.openxmlformats-officedocument.drawingml.chart+xml\"/>";
  332. $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
  333. // Add the chart to relations
  334. $xmlChartRelation = "<Relationship Id=\"rId{$rId}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart\" Target=\"charts/chart{$rId}.xml\"/>";
  335. $this->tempDocumentRelations[$this->getMainPartName()] = str_replace('</Relationships>', $xmlChartRelation, $this->tempDocumentRelations[$this->getMainPartName()]) . '</Relationships>';
  336. // Write the chart
  337. $xmlWriter = new XMLWriter();
  338. $elementWriter = new $objectClass($xmlWriter, $chart, true);
  339. $elementWriter->write();
  340. // Place it in the template
  341. $this->replaceXmlBlock($search, '<w:p>' . $xmlWriter->getData() . '</w:p>', 'w:p');
  342. }
  343. private function getImageArgs($varNameWithArgs)
  344. {
  345. $varElements = explode(':', $varNameWithArgs);
  346. array_shift($varElements); // first element is name of variable => remove it
  347. $varInlineArgs = array();
  348. // size format documentation: https://msdn.microsoft.com/en-us/library/documentformat.openxml.vml.shape%28v=office.14%29.aspx?f=255&MSPPError=-2147217396
  349. foreach ($varElements as $argIdx => $varArg) {
  350. if (strpos($varArg, '=')) { // arg=value
  351. list($argName, $argValue) = explode('=', $varArg, 2);
  352. $argName = strtolower($argName);
  353. if ($argName == 'size') {
  354. list($varInlineArgs['width'], $varInlineArgs['height']) = explode('x', $argValue, 2);
  355. } else {
  356. $varInlineArgs[strtolower($argName)] = $argValue;
  357. }
  358. } elseif (preg_match('/^([0-9]*[a-z%]{0,2}|auto)x([0-9]*[a-z%]{0,2}|auto)$/i', $varArg)) { // 60x40
  359. list($varInlineArgs['width'], $varInlineArgs['height']) = explode('x', $varArg, 2);
  360. } else { // :60:40:f
  361. switch ($argIdx) {
  362. case 0:
  363. $varInlineArgs['width'] = $varArg;
  364. break;
  365. case 1:
  366. $varInlineArgs['height'] = $varArg;
  367. break;
  368. case 2:
  369. $varInlineArgs['ratio'] = $varArg;
  370. break;
  371. }
  372. }
  373. }
  374. return $varInlineArgs;
  375. }
  376. private function chooseImageDimension($baseValue, $inlineValue, $defaultValue)
  377. {
  378. $value = $baseValue;
  379. if (is_null($value) && isset($inlineValue)) {
  380. $value = $inlineValue;
  381. }
  382. if (!preg_match('/^([0-9]*(cm|mm|in|pt|pc|px|%|em|ex|)|auto)$/i', $value)) {
  383. $value = null;
  384. }
  385. if (is_null($value)) {
  386. $value = $defaultValue;
  387. }
  388. if (is_numeric($value)) {
  389. $value .= 'px';
  390. }
  391. return $value;
  392. }
  393. private function fixImageWidthHeightRatio(&$width, &$height, $actualWidth, $actualHeight)
  394. {
  395. $imageRatio = $actualWidth / $actualHeight;
  396. if (($width === '') && ($height === '')) { // defined size are empty
  397. $width = $actualWidth . 'px';
  398. $height = $actualHeight . 'px';
  399. } elseif ($width === '') { // defined width is empty
  400. $heightFloat = (float) $height;
  401. $widthFloat = $heightFloat * $imageRatio;
  402. $matches = array();
  403. preg_match("/\d([a-z%]+)$/", $height, $matches);
  404. $width = $widthFloat . $matches[1];
  405. } elseif ($height === '') { // defined height is empty
  406. $widthFloat = (float) $width;
  407. $heightFloat = $widthFloat / $imageRatio;
  408. $matches = array();
  409. preg_match("/\d([a-z%]+)$/", $width, $matches);
  410. $height = $heightFloat . $matches[1];
  411. } else { // we have defined size, but we need also check it aspect ratio
  412. $widthMatches = array();
  413. preg_match("/\d([a-z%]+)$/", $width, $widthMatches);
  414. $heightMatches = array();
  415. preg_match("/\d([a-z%]+)$/", $height, $heightMatches);
  416. // try to fix only if dimensions are same
  417. if ($widthMatches[1] == $heightMatches[1]) {
  418. $dimention = $widthMatches[1];
  419. $widthFloat = (float) $width;
  420. $heightFloat = (float) $height;
  421. $definedRatio = $widthFloat / $heightFloat;
  422. if ($imageRatio > $definedRatio) { // image wider than defined box
  423. $height = ($widthFloat / $imageRatio) . $dimention;
  424. } elseif ($imageRatio < $definedRatio) { // image higher than defined box
  425. $width = ($heightFloat * $imageRatio) . $dimention;
  426. }
  427. }
  428. }
  429. }
  430. private function prepareImageAttrs($replaceImage, $varInlineArgs)
  431. {
  432. // get image path and size
  433. $width = null;
  434. $height = null;
  435. $ratio = null;
  436. // a closure can be passed as replacement value which after resolving, can contain the replacement info for the image
  437. // use case: only when a image if found, the replacement tags can be generated
  438. if (is_callable($replaceImage)) {
  439. $replaceImage = $replaceImage();
  440. }
  441. if (is_array($replaceImage) && isset($replaceImage['path'])) {
  442. $imgPath = $replaceImage['path'];
  443. if (isset($replaceImage['width'])) {
  444. $width = $replaceImage['width'];
  445. }
  446. if (isset($replaceImage['height'])) {
  447. $height = $replaceImage['height'];
  448. }
  449. if (isset($replaceImage['ratio'])) {
  450. $ratio = $replaceImage['ratio'];
  451. }
  452. } else {
  453. $imgPath = $replaceImage;
  454. }
  455. $width = $this->chooseImageDimension($width, isset($varInlineArgs['width']) ? $varInlineArgs['width'] : null, 115);
  456. $height = $this->chooseImageDimension($height, isset($varInlineArgs['height']) ? $varInlineArgs['height'] : null, 70);
  457. $imageData = @getimagesize($imgPath);
  458. if (!is_array($imageData)) {
  459. throw new Exception(sprintf('Invalid image: %s', $imgPath));
  460. }
  461. list($actualWidth, $actualHeight, $imageType) = $imageData;
  462. // fix aspect ratio (by default)
  463. if (is_null($ratio) && isset($varInlineArgs['ratio'])) {
  464. $ratio = $varInlineArgs['ratio'];
  465. }
  466. if (is_null($ratio) || !in_array(strtolower($ratio), array('', '-', 'f', 'false'))) {
  467. $this->fixImageWidthHeightRatio($width, $height, $actualWidth, $actualHeight);
  468. }
  469. $imageAttrs = array(
  470. 'src' => $imgPath,
  471. 'mime' => image_type_to_mime_type($imageType),
  472. 'width' => $width,
  473. 'height' => $height,
  474. );
  475. return $imageAttrs;
  476. }
  477. private function addImageToRelations($partFileName, $rid, $imgPath, $imageMimeType)
  478. {
  479. // define templates
  480. $typeTpl = '<Override PartName="/word/media/{IMG}" ContentType="image/{EXT}"/>';
  481. $relationTpl = '<Relationship Id="{RID}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/{IMG}"/>';
  482. $newRelationsTpl = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
  483. $newRelationsTypeTpl = '<Override PartName="/{RELS}" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>';
  484. $extTransform = array(
  485. 'image/jpeg' => 'jpeg',
  486. 'image/png' => 'png',
  487. 'image/bmp' => 'bmp',
  488. 'image/gif' => 'gif',
  489. );
  490. // get image embed name
  491. if (isset($this->tempDocumentNewImages[$imgPath])) {
  492. $imgName = $this->tempDocumentNewImages[$imgPath];
  493. } else {
  494. // transform extension
  495. if (isset($extTransform[$imageMimeType])) {
  496. $imgExt = $extTransform[$imageMimeType];
  497. } else {
  498. throw new Exception("Unsupported image type $imageMimeType");
  499. }
  500. // add image to document
  501. $imgName = 'image_' . $rid . '_' . pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt;
  502. $this->zipClass->pclzipAddFile($imgPath, 'word/media/' . $imgName);
  503. $this->tempDocumentNewImages[$imgPath] = $imgName;
  504. // setup type for image
  505. $xmlImageType = str_replace(array('{IMG}', '{EXT}'), array($imgName, $imgExt), $typeTpl);
  506. $this->tempDocumentContentTypes = str_replace('</Types>', $xmlImageType, $this->tempDocumentContentTypes) . '</Types>';
  507. }
  508. $xmlImageRelation = str_replace(array('{RID}', '{IMG}'), array($rid, $imgName), $relationTpl);
  509. if (!isset($this->tempDocumentRelations[$partFileName])) {
  510. // create new relations file
  511. $this->tempDocumentRelations[$partFileName] = $newRelationsTpl;
  512. // and add it to content types
  513. $xmlRelationsType = str_replace('{RELS}', $this->getRelationsName($partFileName), $newRelationsTypeTpl);
  514. $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
  515. }
  516. // add image to relations
  517. $this->tempDocumentRelations[$partFileName] = str_replace('</Relationships>', $xmlImageRelation, $this->tempDocumentRelations[$partFileName]) . '</Relationships>';
  518. }
  519. /**
  520. * @param mixed $search
  521. * @param mixed $replace Path to image, or array("path" => xx, "width" => yy, "height" => zz)
  522. * @param int $limit
  523. */
  524. public function setImageValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT)
  525. {
  526. // prepare $search_replace
  527. if (!is_array($search)) {
  528. $search = array($search);
  529. }
  530. $replacesList = array();
  531. if (!is_array($replace) || isset($replace['path'])) {
  532. $replacesList[] = $replace;
  533. } else {
  534. $replacesList = array_values($replace);
  535. }
  536. $searchReplace = array();
  537. foreach ($search as $searchIdx => $searchString) {
  538. $searchReplace[$searchString] = isset($replacesList[$searchIdx]) ? $replacesList[$searchIdx] : $replacesList[0];
  539. }
  540. // collect document parts
  541. $searchParts = array(
  542. $this->getMainPartName() => &$this->tempDocumentMainPart,
  543. );
  544. foreach (array_keys($this->tempDocumentHeaders) as $headerIndex) {
  545. $searchParts[$this->getHeaderName($headerIndex)] = &$this->tempDocumentHeaders[$headerIndex];
  546. }
  547. foreach (array_keys($this->tempDocumentFooters) as $headerIndex) {
  548. $searchParts[$this->getFooterName($headerIndex)] = &$this->tempDocumentFooters[$headerIndex];
  549. }
  550. // define templates
  551. // result can be verified via "Open XML SDK 2.5 Productivity Tool" (http://www.microsoft.com/en-us/download/details.aspx?id=30425)
  552. $imgTpl = '<w:pict><v:shape type="#_x0000_t75" style="width:{WIDTH};height:{HEIGHT}" stroked="f"><v:imagedata r:id="{RID}" o:title=""/></v:shape></w:pict>';
  553. $i = 0;
  554. foreach ($searchParts as $partFileName => &$partContent) {
  555. $partVariables = $this->getVariablesForPart($partContent);
  556. foreach ($searchReplace as $searchString => $replaceImage) {
  557. $varsToReplace = array_filter($partVariables, function ($partVar) use ($searchString) {
  558. return ($partVar == $searchString) || preg_match('/^' . preg_quote($searchString) . ':/', $partVar);
  559. });
  560. foreach ($varsToReplace as $varNameWithArgs) {
  561. $varInlineArgs = $this->getImageArgs($varNameWithArgs);
  562. $preparedImageAttrs = $this->prepareImageAttrs($replaceImage, $varInlineArgs);
  563. $imgPath = $preparedImageAttrs['src'];
  564. // get image index
  565. $imgIndex = $this->getNextRelationsIndex($partFileName);
  566. $rid = 'rId' . $imgIndex;
  567. // replace preparations
  568. $this->addImageToRelations($partFileName, $rid, $imgPath, $preparedImageAttrs['mime']);
  569. $xmlImage = str_replace(array('{RID}', '{WIDTH}', '{HEIGHT}'), array($rid, $preparedImageAttrs['width'], $preparedImageAttrs['height']), $imgTpl);
  570. // replace variable
  571. $varNameWithArgsFixed = static::ensureMacroCompleted($varNameWithArgs);
  572. $matches = array();
  573. if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed) . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) {
  574. $wholeTag = $matches[0];
  575. array_shift($matches);
  576. list($openTag, $prefix, , $postfix, $closeTag) = $matches;
  577. $replaceXml = $openTag . $prefix . $closeTag . $xmlImage . $openTag . $postfix . $closeTag;
  578. // replace on each iteration, because in one tag we can have 2+ inline variables => before proceed next variable we need to change $partContent
  579. $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent, $limit);
  580. }
  581. if (++$i >= $limit) {
  582. break;
  583. }
  584. }
  585. }
  586. }
  587. }
  588. /**
  589. * Returns count of all variables in template.
  590. *
  591. * @return array
  592. */
  593. public function getVariableCount()
  594. {
  595. $variables = $this->getVariablesForPart($this->tempDocumentMainPart);
  596. foreach ($this->tempDocumentHeaders as $headerXML) {
  597. $variables = array_merge(
  598. $variables,
  599. $this->getVariablesForPart($headerXML)
  600. );
  601. }
  602. foreach ($this->tempDocumentFooters as $footerXML) {
  603. $variables = array_merge(
  604. $variables,
  605. $this->getVariablesForPart($footerXML)
  606. );
  607. }
  608. return array_count_values($variables);
  609. }
  610. /**
  611. * Returns array of all variables in template.
  612. *
  613. * @return string[]
  614. */
  615. public function getVariables()
  616. {
  617. return array_keys($this->getVariableCount());
  618. }
  619. /**
  620. * Clone a table row in a template document.
  621. *
  622. * @param string $search
  623. * @param int $numberOfClones
  624. *
  625. * @throws \PhpOffice\PhpWord\Exception\Exception
  626. */
  627. public function cloneRow($search, $numberOfClones)
  628. {
  629. $search = static::ensureMacroCompleted($search);
  630. $tagPos = strpos($this->tempDocumentMainPart, $search);
  631. if (!$tagPos) {
  632. throw new Exception('Can not clone row, template variable not found or variable contains markup.');
  633. }
  634. $rowStart = $this->findRowStart($tagPos);
  635. $rowEnd = $this->findRowEnd($tagPos);
  636. $xmlRow = $this->getSlice($rowStart, $rowEnd);
  637. // Check if there's a cell spanning multiple rows.
  638. if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
  639. // $extraRowStart = $rowEnd;
  640. $extraRowEnd = $rowEnd;
  641. while (true) {
  642. $extraRowStart = $this->findRowStart($extraRowEnd + 1);
  643. $extraRowEnd = $this->findRowEnd($extraRowEnd + 1);
  644. // If extraRowEnd is lower then 7, there was no next row found.
  645. if ($extraRowEnd < 7) {
  646. break;
  647. }
  648. // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
  649. $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
  650. if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
  651. !preg_match('#<w:vMerge w:val="continue"\s*/>#', $tmpXmlRow)
  652. ) {
  653. break;
  654. }
  655. // This row was a spanned row, update $rowEnd and search for the next row.
  656. $rowEnd = $extraRowEnd;
  657. }
  658. $xmlRow = $this->getSlice($rowStart, $rowEnd);
  659. }
  660. $result = $this->getSlice(0, $rowStart);
  661. $result .= implode($this->indexClonedVariables($numberOfClones, $xmlRow));
  662. $result .= $this->getSlice($rowEnd);
  663. $this->tempDocumentMainPart = $result;
  664. }
  665. /**
  666. * Clones a table row and populates it's values from a two-dimensional array in a template document.
  667. *
  668. * @param string $search
  669. * @param array $values
  670. */
  671. public function cloneRowAndSetValues($search, $values)
  672. {
  673. $this->cloneRow($search, count($values));
  674. foreach ($values as $rowKey => $rowData) {
  675. $rowNumber = $rowKey + 1;
  676. foreach ($rowData as $macro => $replace) {
  677. $this->setValue($macro . '#' . $rowNumber, $replace);
  678. }
  679. }
  680. }
  681. /**
  682. * Clone a block.
  683. *
  684. * @param string $blockname
  685. * @param int $clones How many time the block should be cloned
  686. * @param bool $replace
  687. * @param bool $indexVariables If true, any variables inside the block will be indexed (postfixed with #1, #2, ...)
  688. * @param array $variableReplacements Array containing replacements for macros found inside the block to clone
  689. *
  690. * @return string|null
  691. */
  692. public function cloneBlock($blockname, $clones = 1, $replace = true, $indexVariables = false, $variableReplacements = null)
  693. {
  694. $xmlBlock = null;
  695. $matches = array();
  696. preg_match(
  697. '/(.*((?s)<w:p\b(?:(?!<w:p\b).)*?\${' . $blockname . '}<\/w:.*?p>))(.*)((?s)<w:p\b(?:(?!<w:p\b).)[^$]*?\${\/' . $blockname . '}<\/w:.*?p>)/is',
  698. $this->tempDocumentMainPart,
  699. $matches
  700. );
  701. if (isset($matches[3])) {
  702. $xmlBlock = $matches[3];
  703. if ($indexVariables) {
  704. $cloned = $this->indexClonedVariables($clones, $xmlBlock);
  705. } elseif ($variableReplacements !== null && is_array($variableReplacements)) {
  706. $cloned = $this->replaceClonedVariables($variableReplacements, $xmlBlock);
  707. } else {
  708. $cloned = array();
  709. for ($i = 1; $i <= $clones; $i++) {
  710. $cloned[] = $xmlBlock;
  711. }
  712. }
  713. if ($replace) {
  714. $this->tempDocumentMainPart = str_replace(
  715. $matches[2] . $matches[3] . $matches[4],
  716. implode('', $cloned),
  717. $this->tempDocumentMainPart
  718. );
  719. }
  720. }
  721. return $xmlBlock;
  722. }
  723. /**
  724. * Replace a block.
  725. *
  726. * @param string $blockname
  727. * @param string $replacement
  728. */
  729. public function replaceBlock($blockname, $replacement)
  730. {
  731. $matches = array();
  732. preg_match(
  733. '/(<\?xml.*)(<w:p.*>\${' . $blockname . '}<\/w:.*?p>)(.*)(<w:p.*\${\/' . $blockname . '}<\/w:.*?p>)/is',
  734. $this->tempDocumentMainPart,
  735. $matches
  736. );
  737. if (isset($matches[3])) {
  738. $this->tempDocumentMainPart = str_replace(
  739. $matches[2] . $matches[3] . $matches[4],
  740. $replacement,
  741. $this->tempDocumentMainPart
  742. );
  743. }
  744. }
  745. /**
  746. * Delete a block of text.
  747. *
  748. * @param string $blockname
  749. */
  750. public function deleteBlock($blockname)
  751. {
  752. $this->replaceBlock($blockname, '');
  753. }
  754. /**
  755. * Automatically Recalculate Fields on Open
  756. *
  757. * @param bool $update
  758. */
  759. public function setUpdateFields($update = true)
  760. {
  761. $string = $update ? 'true' : 'false';
  762. $matches = array();
  763. if (preg_match('/<w:updateFields w:val=\"(true|false|1|0|on|off)\"\/>/', $this->tempDocumentSettingsPart, $matches)) {
  764. $this->tempDocumentSettingsPart = str_replace($matches[0], '<w:updateFields w:val="' . $string . '"/>', $this->tempDocumentSettingsPart);
  765. } else {
  766. $this->tempDocumentSettingsPart = str_replace('</w:settings>', '<w:updateFields w:val="' . $string . '"/></w:settings>', $this->tempDocumentSettingsPart);
  767. }
  768. }
  769. /**
  770. * Saves the result document.
  771. *
  772. * @throws \PhpOffice\PhpWord\Exception\Exception
  773. *
  774. * @return string
  775. */
  776. public function save()
  777. {
  778. foreach ($this->tempDocumentHeaders as $index => $xml) {
  779. $this->savePartWithRels($this->getHeaderName($index), $xml);
  780. }
  781. $this->savePartWithRels($this->getMainPartName(), $this->tempDocumentMainPart);
  782. $this->savePartWithRels($this->getSettingsPartName(), $this->tempDocumentSettingsPart);
  783. foreach ($this->tempDocumentFooters as $index => $xml) {
  784. $this->savePartWithRels($this->getFooterName($index), $xml);
  785. }
  786. $this->zipClass->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
  787. // Close zip file
  788. if (false === $this->zipClass->close()) {
  789. throw new Exception('Could not close zip file.'); // @codeCoverageIgnore
  790. }
  791. return $this->tempDocumentFilename;
  792. }
  793. /**
  794. * @param string $fileName
  795. * @param string $xml
  796. */
  797. protected function savePartWithRels($fileName, $xml)
  798. {
  799. $this->zipClass->addFromString($fileName, $xml);
  800. if (isset($this->tempDocumentRelations[$fileName])) {
  801. $relsFileName = $this->getRelationsName($fileName);
  802. $this->zipClass->addFromString($relsFileName, $this->tempDocumentRelations[$fileName]);
  803. }
  804. }
  805. /**
  806. * Saves the result document to the user defined file.
  807. *
  808. * @since 0.8.0
  809. *
  810. * @param string $fileName
  811. */
  812. public function saveAs($fileName)
  813. {
  814. $tempFileName = $this->save();
  815. if (file_exists($fileName)) {
  816. unlink($fileName);
  817. }
  818. /*
  819. * Note: we do not use `rename` function here, because it loses file ownership data on Windows platform.
  820. * As a result, user cannot open the file directly getting "Access denied" message.
  821. *
  822. * @see https://github.com/PHPOffice/PHPWord/issues/532
  823. */
  824. copy($tempFileName, $fileName);
  825. unlink($tempFileName);
  826. }
  827. /**
  828. * Finds parts of broken macros and sticks them together.
  829. * Macros, while being edited, could be implicitly broken by some of the word processors.
  830. *
  831. * @param string $documentPart The document part in XML representation
  832. *
  833. * @return string
  834. */
  835. protected function fixBrokenMacros($documentPart)
  836. {
  837. return preg_replace_callback(
  838. '/\$(?:\{|[^{$]*\>\{)[^}$]*\}/U',
  839. function ($match) {
  840. return strip_tags($match[0]);
  841. },
  842. $documentPart
  843. );
  844. }
  845. /**
  846. * Find and replace macros in the given XML section.
  847. *
  848. * @param mixed $search
  849. * @param mixed $replace
  850. * @param string $documentPartXML
  851. * @param int $limit
  852. *
  853. * @return string
  854. */
  855. protected function setValueForPart($search, $replace, $documentPartXML, $limit)
  856. {
  857. // Note: we can't use the same function for both cases here, because of performance considerations.
  858. if (self::MAXIMUM_REPLACEMENTS_DEFAULT === $limit) {
  859. return str_replace($search, $replace, $documentPartXML);
  860. }
  861. $regExpEscaper = new RegExp();
  862. return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML, $limit);
  863. }
  864. /**
  865. * Find all variables in $documentPartXML.
  866. *
  867. * @param string $documentPartXML
  868. *
  869. * @return string[]
  870. */
  871. protected function getVariablesForPart($documentPartXML)
  872. {
  873. $matches = array();
  874. preg_match_all('/\$\{(.*?)}/i', $documentPartXML, $matches);
  875. return $matches[1];
  876. }
  877. /**
  878. * Get the name of the header file for $index.
  879. *
  880. * @param int $index
  881. *
  882. * @return string
  883. */
  884. protected function getHeaderName($index)
  885. {
  886. return sprintf('word/header%d.xml', $index);
  887. }
  888. /**
  889. * Usually, the name of main part document will be 'document.xml'. However, some .docx files (possibly those from Office 365, experienced also on documents from Word Online created from blank templates) have file 'document22.xml' in their zip archive instead of 'document.xml'. This method searches content types file to correctly determine the file name.
  890. *
  891. * @return string
  892. */
  893. protected function getMainPartName()
  894. {
  895. $contentTypes = $this->zipClass->getFromName('[Content_Types].xml');
  896. $pattern = '~PartName="\/(word\/document.*?\.xml)" ContentType="application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document\.main\+xml"~';
  897. $matches = array();
  898. preg_match($pattern, $contentTypes, $matches);
  899. return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
  900. }
  901. /**
  902. * The name of the file containing the Settings part
  903. *
  904. * @return string
  905. */
  906. protected function getSettingsPartName()
  907. {
  908. return 'word/settings.xml';
  909. }
  910. /**
  911. * Get the name of the footer file for $index.
  912. *
  913. * @param int $index
  914. *
  915. * @return string
  916. */
  917. protected function getFooterName($index)
  918. {
  919. return sprintf('word/footer%d.xml', $index);
  920. }
  921. /**
  922. * Get the name of the relations file for document part.
  923. *
  924. * @param string $documentPartName
  925. *
  926. * @return string
  927. */
  928. protected function getRelationsName($documentPartName)
  929. {
  930. return 'word/_rels/' . pathinfo($documentPartName, PATHINFO_BASENAME) . '.rels';
  931. }
  932. protected function getNextRelationsIndex($documentPartName)
  933. {
  934. if (isset($this->tempDocumentRelations[$documentPartName])) {
  935. $candidate = substr_count($this->tempDocumentRelations[$documentPartName], '<Relationship');
  936. while (strpos($this->tempDocumentRelations[$documentPartName], 'Id="rId' . $candidate . '"') !== false) {
  937. $candidate++;
  938. }
  939. return $candidate;
  940. }
  941. return 1;
  942. }
  943. /**
  944. * @return string
  945. */
  946. protected function getDocumentContentTypesName()
  947. {
  948. return '[Content_Types].xml';
  949. }
  950. /**
  951. * Find the start position of the nearest table row before $offset.
  952. *
  953. * @param int $offset
  954. *
  955. * @throws \PhpOffice\PhpWord\Exception\Exception
  956. *
  957. * @return int
  958. */
  959. protected function findRowStart($offset)
  960. {
  961. $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr ', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
  962. if (!$rowStart) {
  963. $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr>', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
  964. }
  965. if (!$rowStart) {
  966. throw new Exception('Can not find the start position of the row to clone.');
  967. }
  968. return $rowStart;
  969. }
  970. /**
  971. * Find the end position of the nearest table row after $offset.
  972. *
  973. * @param int $offset
  974. *
  975. * @return int
  976. */
  977. protected function findRowEnd($offset)
  978. {
  979. return strpos($this->tempDocumentMainPart, '</w:tr>', $offset) + 7;
  980. }
  981. /**
  982. * Get a slice of a string.
  983. *
  984. * @param int $startPosition
  985. * @param int $endPosition
  986. *
  987. * @return string
  988. */
  989. protected function getSlice($startPosition, $endPosition = 0)
  990. {
  991. if (!$endPosition) {
  992. $endPosition = strlen($this->tempDocumentMainPart);
  993. }
  994. return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition));
  995. }
  996. /**
  997. * Replaces variable names in cloned
  998. * rows/blocks with indexed names
  999. *
  1000. * @param int $count
  1001. * @param string $xmlBlock
  1002. *
  1003. * @return string
  1004. */
  1005. protected function indexClonedVariables($count, $xmlBlock)
  1006. {
  1007. $results = array();
  1008. for ($i = 1; $i <= $count; $i++) {
  1009. $results[] = preg_replace('/\$\{([^:]*?)(:.*?)?\}/', '\${\1#' . $i . '\2}', $xmlBlock);
  1010. }
  1011. return $results;
  1012. }
  1013. /**
  1014. * Raplaces variables with values from array, array keys are the variable names
  1015. *
  1016. * @param array $variableReplacements
  1017. * @param string $xmlBlock
  1018. *
  1019. * @return string[]
  1020. */
  1021. protected function replaceClonedVariables($variableReplacements, $xmlBlock)
  1022. {
  1023. $results = array();
  1024. foreach ($variableReplacements as $replacementArray) {
  1025. $localXmlBlock = $xmlBlock;
  1026. foreach ($replacementArray as $search => $replacement) {
  1027. $localXmlBlock = $this->setValueForPart(self::ensureMacroCompleted($search), $replacement, $localXmlBlock, self::MAXIMUM_REPLACEMENTS_DEFAULT);
  1028. }
  1029. $results[] = $localXmlBlock;
  1030. }
  1031. return $results;
  1032. }
  1033. /**
  1034. * Replace an XML block surrounding a macro with a new block
  1035. *
  1036. * @param string $macro Name of macro
  1037. * @param string $block New block content
  1038. * @param string $blockType XML tag type of block
  1039. * @return \PhpOffice\PhpWord\TemplateProcessor Fluent interface
  1040. */
  1041. public function replaceXmlBlock($macro, $block, $blockType = 'w:p')
  1042. {
  1043. $where = $this->findContainingXmlBlockForMacro($macro, $blockType);
  1044. if (is_array($where)) {
  1045. $this->tempDocumentMainPart = $this->getSlice(0, $where['start']) . $block . $this->getSlice($where['end']);
  1046. }
  1047. return $this;
  1048. }
  1049. /**
  1050. * Find start and end of XML block containing the given macro
  1051. * e.g. <w:p>...${macro}...</w:p>
  1052. *
  1053. * Note that only the first instance of the macro will be found
  1054. *
  1055. * @param string $macro Name of macro
  1056. * @param string $blockType XML tag for block
  1057. * @return bool|int[] FALSE if not found, otherwise array with start and end
  1058. */
  1059. protected function findContainingXmlBlockForMacro($macro, $blockType = 'w:p')
  1060. {
  1061. $macroPos = $this->findMacro($macro);
  1062. if (0 > $macroPos) {
  1063. return false;
  1064. }
  1065. $start = $this->findXmlBlockStart($macroPos, $blockType);
  1066. if (0 > $start) {
  1067. return false;
  1068. }
  1069. $end = $this->findXmlBlockEnd($start, $blockType);
  1070. //if not found or if resulting string does not contain the macro we are searching for
  1071. if (0 > $end || strstr($this->getSlice($start, $end), $macro) === false) {
  1072. return false;
  1073. }
  1074. return array('start' => $start, 'end' => $end);
  1075. }
  1076. /**
  1077. * Find the position of (the start of) a macro
  1078. *
  1079. * Returns -1 if not found, otherwise position of opening $
  1080. *
  1081. * Note that only the first instance of the macro will be found
  1082. *
  1083. * @param string $search Macro name
  1084. * @param int $offset Offset from which to start searching
  1085. * @return int -1 if macro not found
  1086. */
  1087. protected function findMacro($search, $offset = 0)
  1088. {
  1089. $search = static::ensureMacroCompleted($search);
  1090. $pos = strpos($this->tempDocumentMainPart, $search, $offset);
  1091. return ($pos === false) ? -1 : $pos;
  1092. }
  1093. /**
  1094. * Find the start position of the nearest XML block start before $offset
  1095. *
  1096. * @param int $offset Search position
  1097. * @param string $blockType XML Block tag
  1098. * @return int -1 if block start not found
  1099. */
  1100. protected function findXmlBlockStart($offset, $blockType)
  1101. {
  1102. $reverseOffset = (strlen($this->tempDocumentMainPart) - $offset) * -1;
  1103. // first try XML tag with attributes
  1104. $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . ' ', $reverseOffset);
  1105. // if not found, or if found but contains the XML tag without attribute
  1106. if (false === $blockStart || strrpos($this->getSlice($blockStart, $offset), '<' . $blockType . '>')) {
  1107. // also try XML tag without attributes
  1108. $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . '>', $reverseOffset);
  1109. }
  1110. return ($blockStart === false) ? -1 : $blockStart;
  1111. }
  1112. /**
  1113. * Find the nearest block end position after $offset
  1114. *
  1115. * @param int $offset Search position
  1116. * @param string $blockType XML Block tag
  1117. * @return int -1 if block end not found
  1118. */
  1119. protected function findXmlBlockEnd($offset, $blockType)
  1120. {
  1121. $blockEndStart = strpos($this->tempDocumentMainPart, '</' . $blockType . '>', $offset);
  1122. // return position of end of tag if found, otherwise -1
  1123. return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType);
  1124. }
  1125. /**
  1126. * Splits a w:r/w:t into a list of w:r where each ${macro} is in a separate w:r
  1127. *
  1128. * @param string $text
  1129. * @return string
  1130. */
  1131. protected function splitTextIntoTexts($text)
  1132. {
  1133. if (!$this->textNeedsSplitting($text)) {
  1134. return $text;
  1135. }
  1136. $matches = array();
  1137. if (preg_match('/(<w:rPr.*<\/w:rPr>)/i', $text, $matches)) {
  1138. $extractedStyle = $matches[0];
  1139. } else {
  1140. $extractedStyle = '';
  1141. }
  1142. $unformattedText = preg_replace('/>\s+</', '><', $text);
  1143. $result = str_replace(array('${', '}'), array('</w:t></w:r><w:r>' . $extractedStyle . '<w:t xml:space="preserve">${', '}</w:t></w:r><w:r>' . $extractedStyle . '<w:t xml:space="preserve">'), $unformattedText);
  1144. return str_replace(array('<w:r>' . $extractedStyle . '<w:t xml:space="preserve"></w:t></w:r>', '<w:r><w:t xml:space="preserve"></w:t></w:r>', '<w:t>'), array('', '', '<w:t xml:space="preserve">'), $result);
  1145. }
  1146. /**
  1147. * Returns true if string contains a macro that is not in it's own w:r
  1148. *
  1149. * @param string $text
  1150. * @return bool
  1151. */
  1152. protected function textNeedsSplitting($text)
  1153. {
  1154. return preg_match('/[^>]\${|}[^<]/i', $text) == 1;
  1155. }
  1156. }