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