No Description

TemplateProcessor.php 15KB

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