TemplateProcessor.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  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. * @link https://github.com/PHPOffice/PHPWord
  14. * @copyright 2010-2014 PHPWord contributors
  15. * @license http://www.gnu.org/licenses/lgpl.txt LGPL version 3
  16. */
  17. namespace PhpOffice\PhpWord;
  18. use PhpOffice\PhpWord\Exception\CopyFileException;
  19. use PhpOffice\PhpWord\Exception\CreateTemporaryFileException;
  20. use PhpOffice\PhpWord\Exception\Exception;
  21. use PhpOffice\PhpWord\Shared\String;
  22. use PhpOffice\PhpWord\Shared\ZipArchive;
  23. class TemplateProcessor
  24. {
  25. /**
  26. * ZipArchive object.
  27. *
  28. * @var mixed
  29. */
  30. private $zipClass;
  31. /**
  32. * @var string Temporary document filename (with path).
  33. */
  34. private $temporaryDocumentFilename;
  35. /**
  36. * Content of main document part (in XML format) of the temporary document.
  37. *
  38. * @var string
  39. */
  40. private $temporaryDocumentMainPart;
  41. /**
  42. * Content of headers (in XML format) of the temporary document.
  43. *
  44. * @var string[]
  45. */
  46. private $temporaryDocumentHeaders = array();
  47. /**
  48. * Content of footers (in XML format) of the temporary document.
  49. *
  50. * @var string[]
  51. */
  52. private $temporaryDocumentFooters = array();
  53. /**
  54. * @since 0.12.0 Throws CreateTemporaryFileException and CopyFileException instead of Exception.
  55. *
  56. * @param string $documentTemplate The fully qualified template filename.
  57. * @throws \PhpOffice\PhpWord\Exception\CreateTemporaryFileException
  58. * @throws \PhpOffice\PhpWord\Exception\CopyFileException
  59. */
  60. public function __construct($documentTemplate)
  61. {
  62. // Temporary document filename initialization
  63. $this->temporaryDocumentFilename = tempnam(Settings::getTempDir(), 'PhpWord');
  64. if (false === $this->temporaryDocumentFilename) {
  65. throw new CreateTemporaryFileException();
  66. }
  67. // Template file cloning
  68. if (false === copy($documentTemplate, $this->temporaryDocumentFilename)) {
  69. throw new CopyFileException($documentTemplate, $this->temporaryDocumentFilename);
  70. }
  71. // Temporary document content extraction
  72. $this->zipClass = new ZipArchive();
  73. $this->zipClass->open($this->temporaryDocumentFilename);
  74. $index = 1;
  75. while ($this->zipClass->locateName($this->getHeaderName($index)) !== false) {
  76. $this->temporaryDocumentHeaders[$index] = $this->zipClass->getFromName($this->getHeaderName($index));
  77. $index++;
  78. }
  79. $index = 1;
  80. while ($this->zipClass->locateName($this->getFooterName($index)) !== false) {
  81. $this->temporaryDocumentFooters[$index] = $this->zipClass->getFromName($this->getFooterName($index));
  82. $index++;
  83. }
  84. $this->temporaryDocumentMainPart = $this->zipClass->getFromName('word/document.xml');
  85. }
  86. /**
  87. * Applies XSL style sheet to template's parts.
  88. *
  89. * @param \DOMDocument $xslDOMDocument
  90. * @param array $xslOptions
  91. * @param string $xslOptionsURI
  92. * @return void
  93. * @throws \PhpOffice\PhpWord\Exception\Exception
  94. */
  95. public function applyXslStyleSheet($xslDOMDocument, $xslOptions = array(), $xslOptionsURI = '')
  96. {
  97. $xsltProcessor = new \XSLTProcessor();
  98. $xsltProcessor->importStylesheet($xslDOMDocument);
  99. if (false === $xsltProcessor->setParameter($xslOptionsURI, $xslOptions)) {
  100. throw new Exception('Could not set values for the given XSL style sheet parameters.');
  101. }
  102. $xmlDOMDocument = new \DOMDocument();
  103. if (false === $xmlDOMDocument->loadXML($this->temporaryDocumentMainPart)) {
  104. throw new Exception('Could not load XML from the given template.');
  105. }
  106. $xmlTransformed = $xsltProcessor->transformToXml($xmlDOMDocument);
  107. if (false === $xmlTransformed) {
  108. throw new Exception('Could not transform the given XML document.');
  109. }
  110. $this->temporaryDocumentMainPart = $xmlTransformed;
  111. }
  112. /**
  113. * @param mixed $search
  114. * @param mixed $replace
  115. * @param integer $limit
  116. * @return void
  117. */
  118. public function setValue($search, $replace, $limit = -1)
  119. {
  120. foreach ($this->temporaryDocumentHeaders as $index => $headerXML) {
  121. $this->temporaryDocumentHeaders[$index] = $this->setValueForPart($this->temporaryDocumentHeaders[$index], $search, $replace, $limit);
  122. }
  123. $this->temporaryDocumentMainPart = $this->setValueForPart($this->temporaryDocumentMainPart, $search, $replace, $limit);
  124. foreach ($this->temporaryDocumentFooters as $index => $headerXML) {
  125. $this->temporaryDocumentFooters[$index] = $this->setValueForPart($this->temporaryDocumentFooters[$index], $search, $replace, $limit);
  126. }
  127. }
  128. /**
  129. * Returns array of all variables in template.
  130. *
  131. * @return string[]
  132. */
  133. public function getVariables()
  134. {
  135. $variables = $this->getVariablesForPart($this->temporaryDocumentMainPart);
  136. foreach ($this->temporaryDocumentHeaders as $headerXML) {
  137. $variables = array_merge($variables, $this->getVariablesForPart($headerXML));
  138. }
  139. foreach ($this->temporaryDocumentFooters as $footerXML) {
  140. $variables = array_merge($variables, $this->getVariablesForPart($footerXML));
  141. }
  142. return array_unique($variables);
  143. }
  144. /**
  145. * Clone a table row in a template document.
  146. *
  147. * @param string $search
  148. * @param integer $numberOfClones
  149. * @return void
  150. * @throws \PhpOffice\PhpWord\Exception\Exception
  151. */
  152. public function cloneRow($search, $numberOfClones)
  153. {
  154. if (substr($search, 0, 2) !== '${' && substr($search, -1) !== '}') {
  155. $search = '${' . $search . '}';
  156. }
  157. $tagPos = strpos($this->temporaryDocumentMainPart, $search);
  158. if (!$tagPos) {
  159. throw new Exception("Can not clone row, template variable not found or variable contains markup.");
  160. }
  161. $rowStart = $this->findRowStart($tagPos);
  162. $rowEnd = $this->findRowEnd($tagPos);
  163. $xmlRow = $this->getSlice($rowStart, $rowEnd);
  164. // Check if there's a cell spanning multiple rows.
  165. if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
  166. // $extraRowStart = $rowEnd;
  167. $extraRowEnd = $rowEnd;
  168. while (true) {
  169. $extraRowStart = $this->findRowStart($extraRowEnd + 1);
  170. $extraRowEnd = $this->findRowEnd($extraRowEnd + 1);
  171. // If extraRowEnd is lower then 7, there was no next row found.
  172. if ($extraRowEnd < 7) {
  173. break;
  174. }
  175. // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
  176. $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
  177. if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
  178. !preg_match('#<w:vMerge w:val="continue" />#', $tmpXmlRow)) {
  179. break;
  180. }
  181. // This row was a spanned row, update $rowEnd and search for the next row.
  182. $rowEnd = $extraRowEnd;
  183. }
  184. $xmlRow = $this->getSlice($rowStart, $rowEnd);
  185. }
  186. $result = $this->getSlice(0, $rowStart);
  187. for ($i = 1; $i <= $numberOfClones; $i++) {
  188. $result .= preg_replace('/\$\{(.*?)\}/', '\${\\1#' . $i . '}', $xmlRow);
  189. }
  190. $result .= $this->getSlice($rowEnd);
  191. $this->temporaryDocumentMainPart = $result;
  192. }
  193. /**
  194. * Clone a block.
  195. *
  196. * @param string $blockname
  197. * @param integer $clones
  198. * @param boolean $replace
  199. * @return string|null
  200. */
  201. public function cloneBlock($blockname, $clones = 1, $replace = true)
  202. {
  203. $xmlBlock = null;
  204. preg_match(
  205. '/(<\?xml.*)(<w:p.*>\${' . $blockname . '}<\/w:.*?p>)(.*)(<w:p.*\${\/' . $blockname . '}<\/w:.*?p>)/is',
  206. $this->temporaryDocumentMainPart,
  207. $matches
  208. );
  209. if (isset($matches[3])) {
  210. $xmlBlock = $matches[3];
  211. $cloned = array();
  212. for ($i = 1; $i <= $clones; $i++) {
  213. $cloned[] = $xmlBlock;
  214. }
  215. if ($replace) {
  216. $this->temporaryDocumentMainPart = str_replace(
  217. $matches[2] . $matches[3] . $matches[4],
  218. implode('', $cloned),
  219. $this->temporaryDocumentMainPart
  220. );
  221. }
  222. }
  223. return $xmlBlock;
  224. }
  225. /**
  226. * Replace a block.
  227. *
  228. * @param string $blockname
  229. * @param string $replacement
  230. * @return void
  231. */
  232. public function replaceBlock($blockname, $replacement)
  233. {
  234. preg_match(
  235. '/(<\?xml.*)(<w:p.*>\${' . $blockname . '}<\/w:.*?p>)(.*)(<w:p.*\${\/' . $blockname . '}<\/w:.*?p>)/is',
  236. $this->temporaryDocumentMainPart,
  237. $matches
  238. );
  239. if (isset($matches[3])) {
  240. $this->temporaryDocumentMainPart = str_replace(
  241. $matches[2] . $matches[3] . $matches[4],
  242. $replacement,
  243. $this->temporaryDocumentMainPart
  244. );
  245. }
  246. }
  247. /**
  248. * Delete a block of text.
  249. *
  250. * @param string $blockname
  251. * @return void
  252. */
  253. public function deleteBlock($blockname)
  254. {
  255. $this->replaceBlock($blockname, '');
  256. }
  257. /**
  258. * Saves the result document.
  259. *
  260. * @return string
  261. * @throws \PhpOffice\PhpWord\Exception\Exception
  262. */
  263. public function save()
  264. {
  265. foreach ($this->temporaryDocumentHeaders as $index => $headerXML) {
  266. $this->zipClass->addFromString($this->getHeaderName($index), $this->temporaryDocumentHeaders[$index]);
  267. }
  268. $this->zipClass->addFromString('word/document.xml', $this->temporaryDocumentMainPart);
  269. foreach ($this->temporaryDocumentFooters as $index => $headerXML) {
  270. $this->zipClass->addFromString($this->getFooterName($index), $this->temporaryDocumentFooters[$index]);
  271. }
  272. // Close zip file
  273. if (false === $this->zipClass->close()) {
  274. throw new Exception('Could not close zip file.');
  275. }
  276. return $this->temporaryDocumentFilename;
  277. }
  278. /**
  279. * Saves the result document to the user defined file.
  280. *
  281. * @since 0.8.0
  282. *
  283. * @param string $fileName
  284. * @return void
  285. */
  286. public function saveAs($fileName)
  287. {
  288. $tempFileName = $this->save();
  289. if (file_exists($fileName)) {
  290. unlink($fileName);
  291. }
  292. rename($tempFileName, $fileName);
  293. }
  294. /**
  295. * Find and replace placeholders in the given XML section.
  296. *
  297. * @param string $documentPartXML
  298. * @param string $search
  299. * @param string $replace
  300. * @param integer $limit
  301. * @return string
  302. */
  303. protected function setValueForPart($documentPartXML, $search, $replace, $limit)
  304. {
  305. $pattern = '|\$\{([^\}]+)\}|U';
  306. preg_match_all($pattern, $documentPartXML, $matches);
  307. foreach ($matches[0] as $value) {
  308. $valueCleaned = preg_replace('/<[^>]+>/', '', $value);
  309. $valueCleaned = preg_replace('/<\/[^>]+>/', '', $valueCleaned);
  310. $documentPartXML = str_replace($value, $valueCleaned, $documentPartXML);
  311. }
  312. if (substr($search, 0, 2) !== '${' && substr($search, -1) !== '}') {
  313. $search = '${' . $search . '}';
  314. }
  315. if (!String::isUTF8($replace)) {
  316. $replace = utf8_encode($replace);
  317. }
  318. $regExpDelim = '/';
  319. $escapedSearch = preg_quote($search, $regExpDelim);
  320. return preg_replace("{$regExpDelim}{$escapedSearch}{$regExpDelim}u", $replace, $documentPartXML, $limit);
  321. }
  322. /**
  323. * Find all variables in $documentPartXML.
  324. *
  325. * @param string $documentPartXML
  326. * @return string[]
  327. */
  328. protected function getVariablesForPart($documentPartXML)
  329. {
  330. preg_match_all('/\$\{(.*?)}/i', $documentPartXML, $matches);
  331. return $matches[1];
  332. }
  333. /**
  334. * Get the name of the footer file for $index.
  335. *
  336. * @param integer $index
  337. * @return string
  338. */
  339. private function getFooterName($index)
  340. {
  341. return sprintf('word/footer%d.xml', $index);
  342. }
  343. /**
  344. * Get the name of the header file for $index.
  345. *
  346. * @param integer $index
  347. * @return string
  348. */
  349. private function getHeaderName($index)
  350. {
  351. return sprintf('word/header%d.xml', $index);
  352. }
  353. /**
  354. * Find the start position of the nearest table row before $offset.
  355. *
  356. * @param integer $offset
  357. * @return integer
  358. * @throws \PhpOffice\PhpWord\Exception\Exception
  359. */
  360. private function findRowStart($offset)
  361. {
  362. $rowStart = strrpos($this->temporaryDocumentMainPart, '<w:tr ', ((strlen($this->temporaryDocumentMainPart) - $offset) * -1));
  363. if (!$rowStart) {
  364. $rowStart = strrpos($this->temporaryDocumentMainPart, '<w:tr>', ((strlen($this->temporaryDocumentMainPart) - $offset) * -1));
  365. }
  366. if (!$rowStart) {
  367. throw new Exception('Can not find the start position of the row to clone.');
  368. }
  369. return $rowStart;
  370. }
  371. /**
  372. * Find the end position of the nearest table row after $offset.
  373. *
  374. * @param integer $offset
  375. * @return integer
  376. */
  377. private function findRowEnd($offset)
  378. {
  379. return strpos($this->temporaryDocumentMainPart, '</w:tr>', $offset) + 7;
  380. }
  381. /**
  382. * Get a slice of a string.
  383. *
  384. * @param integer $startPosition
  385. * @param integer $endPosition
  386. * @return string
  387. */
  388. private function getSlice($startPosition, $endPosition = 0)
  389. {
  390. if (!$endPosition) {
  391. $endPosition = strlen($this->temporaryDocumentMainPart);
  392. }
  393. return substr($this->temporaryDocumentMainPart, $startPosition, ($endPosition - $startPosition));
  394. }
  395. }