Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.94% covered (warning)
86.94%
466 / 536
75.81% covered (warning)
75.81%
47 / 62
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemplateProcessor
86.94% covered (warning)
86.94%
466 / 536
75.81% covered (warning)
75.81%
47 / 62
285.32
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
58.06% covered (warning)
58.06%
18 / 31
0.00% covered (danger)
0.00%
0 / 1
22.62
 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 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 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 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 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 . (!empty($matches) ? $matches[1] : 'px');
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 . (!empty($matches) ? $matches[1] : 'px');
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 (!empty($widthMatches)
516                && !empty($heightMatches)
517                && $widthMatches[1] == $heightMatches[1]) {
518                $dimention = $widthMatches[1];
519                $widthFloat = (float) $width;
520                $heightFloat = (float) $height;
521                $definedRatio = $widthFloat / $heightFloat;
522
523                if ($imageRatio > $definedRatio) { // image wider than defined box
524                    $height = ($widthFloat / $imageRatio) . $dimention;
525                } elseif ($imageRatio < $definedRatio) { // image higher than defined box
526                    $width = ($heightFloat * $imageRatio) . $dimention;
527                }
528            }
529        }
530    }
531
532    private function prepareImageAttrs($replaceImage, $varInlineArgs)
533    {
534        // get image path and size
535        $width = null;
536        $height = null;
537        $ratio = null;
538
539        // a closure can be passed as replacement value which after resolving, can contain the replacement info for the image
540        // use case: only when a image if found, the replacement tags can be generated
541        if (is_callable($replaceImage)) {
542            $replaceImage = $replaceImage();
543        }
544
545        if (is_array($replaceImage) && isset($replaceImage['path'])) {
546            $imgPath = $replaceImage['path'];
547            if (isset($replaceImage['width'])) {
548                $width = $replaceImage['width'];
549            }
550            if (isset($replaceImage['height'])) {
551                $height = $replaceImage['height'];
552            }
553            if (isset($replaceImage['ratio'])) {
554                $ratio = $replaceImage['ratio'];
555            }
556        } else {
557            $imgPath = $replaceImage;
558        }
559
560        $width = $this->chooseImageDimension($width, $varInlineArgs['width'] ?? null, 115);
561        $height = $this->chooseImageDimension($height, $varInlineArgs['height'] ?? null, 70);
562
563        $imageData = @getimagesize($imgPath);
564        if (!is_array($imageData)) {
565            throw new Exception(sprintf('Invalid image: %s', $imgPath));
566        }
567        [$actualWidth, $actualHeight, $imageType] = $imageData;
568
569        // fix aspect ratio (by default)
570        if (null === $ratio && isset($varInlineArgs['ratio'])) {
571            $ratio = $varInlineArgs['ratio'];
572        }
573        if (null === $ratio || !in_array(strtolower($ratio), ['', '-', 'f', 'false'])) {
574            $this->fixImageWidthHeightRatio($width, $height, $actualWidth, $actualHeight);
575        }
576
577        $imageAttrs = [
578            'src' => $imgPath,
579            'mime' => image_type_to_mime_type($imageType),
580            'width' => $width,
581            'height' => $height,
582        ];
583
584        return $imageAttrs;
585    }
586
587    private function addImageToRelations($partFileName, $rid, $imgPath, $imageMimeType): void
588    {
589        // define templates
590        $typeTpl = '<Override PartName="/word/media/{IMG}" ContentType="image/{EXT}"/>';
591        $relationTpl = '<Relationship Id="{RID}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/{IMG}"/>';
592        $newRelationsTpl = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
593        $newRelationsTypeTpl = '<Override PartName="/{RELS}" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>';
594        $extTransform = [
595            'image/jpeg' => 'jpeg',
596            'image/png' => 'png',
597            'image/bmp' => 'bmp',
598            'image/gif' => 'gif',
599        ];
600
601        // get image embed name
602        if (isset($this->tempDocumentNewImages[$imgPath])) {
603            $imgName = $this->tempDocumentNewImages[$imgPath];
604        } else {
605            // transform extension
606            if (isset($extTransform[$imageMimeType])) {
607                $imgExt = $extTransform[$imageMimeType];
608            } else {
609                throw new Exception("Unsupported image type $imageMimeType");
610            }
611
612            // add image to document
613            $imgName = 'image_' . $rid . '_' . pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt;
614            $this->zipClass->pclzipAddFile($imgPath, 'word/media/' . $imgName);
615            $this->tempDocumentNewImages[$imgPath] = $imgName;
616
617            // setup type for image
618            $xmlImageType = str_replace(['{IMG}', '{EXT}'], [$imgName, $imgExt], $typeTpl);
619            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlImageType, $this->tempDocumentContentTypes) . '</Types>';
620        }
621
622        $xmlImageRelation = str_replace(['{RID}', '{IMG}'], [$rid, $imgName], $relationTpl);
623
624        if (!isset($this->tempDocumentRelations[$partFileName])) {
625            // create new relations file
626            $this->tempDocumentRelations[$partFileName] = $newRelationsTpl;
627            // and add it to content types
628            $xmlRelationsType = str_replace('{RELS}', $this->getRelationsName($partFileName), $newRelationsTypeTpl);
629            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
630        }
631
632        // add image to relations
633        $this->tempDocumentRelations[$partFileName] = str_replace('</Relationships>', $xmlImageRelation, $this->tempDocumentRelations[$partFileName]) . '</Relationships>';
634    }
635
636    /**
637     * @param mixed $search
638     * @param mixed $replace Path to image, or array("path" => xx, "width" => yy, "height" => zz)
639     * @param int $limit
640     */
641    public function setImageValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
642    {
643        // prepare $search_replace
644        if (!is_array($search)) {
645            $search = [$search];
646        }
647
648        $replacesList = [];
649        if (!is_array($replace) || isset($replace['path'])) {
650            $replacesList[] = $replace;
651        } else {
652            $replacesList = array_values($replace);
653        }
654
655        $searchReplace = [];
656        foreach ($search as $searchIdx => $searchString) {
657            $searchReplace[$searchString] = $replacesList[$searchIdx] ?? $replacesList[0];
658        }
659
660        // collect document parts
661        $searchParts = [
662            $this->getMainPartName() => &$this->tempDocumentMainPart,
663        ];
664        foreach (array_keys($this->tempDocumentHeaders) as $headerIndex) {
665            $searchParts[$this->getHeaderName($headerIndex)] = &$this->tempDocumentHeaders[$headerIndex];
666        }
667        foreach (array_keys($this->tempDocumentFooters) as $footerIndex) {
668            $searchParts[$this->getFooterName($footerIndex)] = &$this->tempDocumentFooters[$footerIndex];
669        }
670
671        // define templates
672        // result can be verified via "Open XML SDK 2.5 Productivity Tool" (http://www.microsoft.com/en-us/download/details.aspx?id=30425)
673        $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>';
674
675        $i = 0;
676        foreach ($searchParts as $partFileName => &$partContent) {
677            $partVariables = $this->getVariablesForPart($partContent);
678
679            foreach ($searchReplace as $searchString => $replaceImage) {
680                $varsToReplace = array_filter($partVariables, function ($partVar) use ($searchString) {
681                    return ($partVar == $searchString) || preg_match('/^' . preg_quote($searchString) . ':/', $partVar);
682                });
683
684                foreach ($varsToReplace as $varNameWithArgs) {
685                    $varInlineArgs = $this->getImageArgs($varNameWithArgs);
686                    $preparedImageAttrs = $this->prepareImageAttrs($replaceImage, $varInlineArgs);
687                    $imgPath = $preparedImageAttrs['src'];
688
689                    // get image index
690                    $imgIndex = $this->getNextRelationsIndex($partFileName);
691                    $rid = 'rId' . $imgIndex;
692
693                    // replace preparations
694                    $this->addImageToRelations($partFileName, $rid, $imgPath, $preparedImageAttrs['mime']);
695                    $xmlImage = str_replace(['{RID}', '{WIDTH}', '{HEIGHT}'], [$rid, $preparedImageAttrs['width'], $preparedImageAttrs['height']], $imgTpl);
696
697                    // replace variable
698                    $varNameWithArgsFixed = static::ensureMacroCompleted($varNameWithArgs);
699                    $matches = [];
700                    if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed) . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) {
701                        $wholeTag = $matches[0];
702                        array_shift($matches);
703                        [$openTag, $prefix, , $postfix, $closeTag] = $matches;
704                        $replaceXml = $openTag . $prefix . $closeTag . $xmlImage . $openTag . $postfix . $closeTag;
705                        // replace on each iteration, because in one tag we can have 2+ inline variables => before proceed next variable we need to change $partContent
706                        $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent, $limit);
707                    }
708
709                    if (++$i >= $limit) {
710                        break;
711                    }
712                }
713            }
714        }
715    }
716
717    /**
718     * Returns count of all variables in template.
719     *
720     * @return array
721     */
722    public function getVariableCount()
723    {
724        $variables = $this->getVariablesForPart($this->tempDocumentMainPart);
725
726        foreach ($this->tempDocumentHeaders as $headerXML) {
727            $variables = array_merge(
728                $variables,
729                $this->getVariablesForPart($headerXML)
730            );
731        }
732
733        foreach ($this->tempDocumentFooters as $footerXML) {
734            $variables = array_merge(
735                $variables,
736                $this->getVariablesForPart($footerXML)
737            );
738        }
739
740        return array_count_values($variables);
741    }
742
743    /**
744     * Returns array of all variables in template.
745     *
746     * @return string[]
747     */
748    public function getVariables()
749    {
750        return array_keys($this->getVariableCount());
751    }
752
753    /**
754     * Clone a table row in a template document.
755     *
756     * @param string $search
757     * @param int $numberOfClones
758     */
759    public function cloneRow($search, $numberOfClones): void
760    {
761        $search = static::ensureMacroCompleted($search);
762
763        $tagPos = strpos($this->tempDocumentMainPart, $search);
764        if (!$tagPos) {
765            throw new Exception('Can not clone row, template variable not found or variable contains markup.');
766        }
767
768        $rowStart = $this->findRowStart($tagPos);
769        $rowEnd = $this->findRowEnd($tagPos);
770        $xmlRow = $this->getSlice($rowStart, $rowEnd);
771
772        // Check if there's a cell spanning multiple rows.
773        if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
774            // $extraRowStart = $rowEnd;
775            $extraRowEnd = $rowEnd;
776            while (true) {
777                $extraRowStart = $this->findRowStart($extraRowEnd + 1);
778                $extraRowEnd = $this->findRowEnd($extraRowEnd + 1);
779
780                // If extraRowEnd is lower then 7, there was no next row found.
781                if ($extraRowEnd < 7) {
782                    break;
783                }
784
785                // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
786                $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
787                if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
788                    !preg_match('#<w:vMerge w:val="continue"\s*/>#', $tmpXmlRow)
789                ) {
790                    break;
791                }
792                // This row was a spanned row, update $rowEnd and search for the next row.
793                $rowEnd = $extraRowEnd;
794            }
795            $xmlRow = $this->getSlice($rowStart, $rowEnd);
796        }
797
798        $result = $this->getSlice(0, $rowStart);
799        $result .= implode('', $this->indexClonedVariables($numberOfClones, $xmlRow));
800        $result .= $this->getSlice($rowEnd);
801
802        $this->tempDocumentMainPart = $result;
803    }
804
805    /**
806     * Delete a table row in a template document.
807     */
808    public function deleteRow(string $search): void
809    {
810        if (self::$macroOpeningChars !== substr($search, 0, 2) && self::$macroClosingChars !== substr($search, -1)) {
811            $search = self::$macroOpeningChars . $search . self::$macroClosingChars;
812        }
813
814        $tagPos = strpos($this->tempDocumentMainPart, $search);
815        if (!$tagPos) {
816            throw new Exception(sprintf('Can not delete row %s, template variable not found or variable contains markup.', $search));
817        }
818
819        $tableStart = $this->findTableStart($tagPos);
820        $tableEnd = $this->findTableEnd($tagPos);
821        $xmlTable = $this->getSlice($tableStart, $tableEnd);
822
823        if (substr_count($xmlTable, '<w:tr') === 1) {
824            $this->tempDocumentMainPart = $this->getSlice(0, $tableStart) . $this->getSlice($tableEnd);
825
826            return;
827        }
828
829        $rowStart = $this->findRowStart($tagPos);
830        $rowEnd = $this->findRowEnd($tagPos);
831        $xmlRow = $this->getSlice($rowStart, $rowEnd);
832
833        $this->tempDocumentMainPart = $this->getSlice(0, $rowStart) . $this->getSlice($rowEnd);
834
835        // Check if there's a cell spanning multiple rows.
836        if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
837            $extraRowStart = $rowStart;
838            while (true) {
839                $extraRowStart = $this->findRowStart($extraRowStart + 1);
840                $extraRowEnd = $this->findRowEnd($extraRowStart + 1);
841
842                // If extraRowEnd is lower then 7, there was no next row found.
843                if ($extraRowEnd < 7) {
844                    break;
845                }
846
847                // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
848                $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
849                if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
850                    !preg_match('#<w:vMerge w:val="continue" />#', $tmpXmlRow)
851                ) {
852                    break;
853                }
854
855                $tableStart = $this->findTableStart($extraRowEnd + 1);
856                $tableEnd = $this->findTableEnd($extraRowEnd + 1);
857                $xmlTable = $this->getSlice($tableStart, $tableEnd);
858                if (substr_count($xmlTable, '<w:tr') === 1) {
859                    $this->tempDocumentMainPart = $this->getSlice(0, $tableStart) . $this->getSlice($tableEnd);
860
861                    return;
862                }
863
864                $this->tempDocumentMainPart = $this->getSlice(0, $extraRowStart) . $this->getSlice($extraRowEnd);
865            }
866        }
867    }
868
869    /**
870     * Clones a table row and populates it's values from a two-dimensional array in a template document.
871     *
872     * @param string $search
873     * @param array $values
874     */
875    public function cloneRowAndSetValues($search, $values): void
876    {
877        $this->cloneRow($search, count($values));
878
879        foreach ($values as $rowKey => $rowData) {
880            $rowNumber = $rowKey + 1;
881            foreach ($rowData as $macro => $replace) {
882                $this->setValue($macro . '#' . $rowNumber, $replace);
883            }
884        }
885    }
886
887    /**
888     * Clone a block.
889     *
890     * @param string $blockname
891     * @param int $clones How many time the block should be cloned
892     * @param bool $replace
893     * @param bool $indexVariables If true, any variables inside the block will be indexed (postfixed with #1, #2, ...)
894     * @param array $variableReplacements Array containing replacements for macros found inside the block to clone
895     *
896     * @return null|string
897     */
898    public function cloneBlock($blockname, $clones = 1, $replace = true, $indexVariables = false, $variableReplacements = null)
899    {
900        $xmlBlock = null;
901        $matches = [];
902        $escapedMacroOpeningChars = self::$macroOpeningChars;
903        $escapedMacroClosingChars = self::$macroClosingChars;
904        preg_match(
905            //'/(.*((?s)<w:p\b(?:(?!<w:p\b).)*?\{{' . $blockname . '}<\/w:.*?p>))(.*)((?s)<w:p\b(?:(?!<w:p\b).)[^$]*?\{{\/' . $blockname . '}<\/w:.*?p>)/is',
906            '/(.*((?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',
907            //'/(.*((?s)<w:p\b(?:(?!<w:p\b).)*?\\'. $escapedMacroOpeningChars . $blockname . '}<\/w:.*?p>))(.*)((?s)<w:p\b(?:(?!<w:p\b).)[^$]*?\\'.$escapedMacroOpeningChars.'\/' . $blockname . '}<\/w:.*?p>)/is',
908            $this->tempDocumentMainPart,
909            $matches
910        );
911
912        if (isset($matches[3])) {
913            $xmlBlock = $matches[3];
914            if ($indexVariables) {
915                $cloned = $this->indexClonedVariables($clones, $xmlBlock);
916            } elseif ($variableReplacements !== null && is_array($variableReplacements)) {
917                $cloned = $this->replaceClonedVariables($variableReplacements, $xmlBlock);
918            } else {
919                $cloned = [];
920                for ($i = 1; $i <= $clones; ++$i) {
921                    $cloned[] = $xmlBlock;
922                }
923            }
924
925            if ($replace) {
926                $this->tempDocumentMainPart = str_replace(
927                    $matches[2] . $matches[3] . $matches[4],
928                    implode('', $cloned),
929                    $this->tempDocumentMainPart
930                );
931            }
932        }
933
934        return $xmlBlock;
935    }
936
937    /**
938     * Replace a block.
939     *
940     * @param string $blockname
941     * @param string $replacement
942     */
943    public function replaceBlock($blockname, $replacement): void
944    {
945        $matches = [];
946        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
947        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
948        preg_match(
949            '/(<\?xml.*)(<w:p.*>' . $escapedMacroOpeningChars . $blockname . $escapedMacroClosingChars . '<\/w:.*?p>)(.*)(<w:p.*' . $escapedMacroOpeningChars . '\/' . $blockname . $escapedMacroClosingChars . '<\/w:.*?p>)/is',
950            $this->tempDocumentMainPart,
951            $matches
952        );
953
954        if (isset($matches[3])) {
955            $this->tempDocumentMainPart = str_replace(
956                $matches[2] . $matches[3] . $matches[4],
957                $replacement,
958                $this->tempDocumentMainPart
959            );
960        }
961    }
962
963    /**
964     * Delete a block of text.
965     *
966     * @param string $blockname
967     */
968    public function deleteBlock($blockname): void
969    {
970        $this->replaceBlock($blockname, '');
971    }
972
973    /**
974     * Automatically Recalculate Fields on Open.
975     *
976     * @param bool $update
977     */
978    public function setUpdateFields($update = true): void
979    {
980        $string = $update ? 'true' : 'false';
981        $matches = [];
982        if (preg_match('/<w:updateFields w:val=\"(true|false|1|0|on|off)\"\/>/', $this->tempDocumentSettingsPart, $matches)) {
983            $this->tempDocumentSettingsPart = str_replace($matches[0], '<w:updateFields w:val="' . $string . '"/>', $this->tempDocumentSettingsPart);
984        } else {
985            $this->tempDocumentSettingsPart = str_replace('</w:settings>', '<w:updateFields w:val="' . $string . '"/></w:settings>', $this->tempDocumentSettingsPart);
986        }
987    }
988
989    /**
990     * Saves the result document.
991     *
992     * @return string
993     */
994    public function save()
995    {
996        foreach ($this->tempDocumentHeaders as $index => $xml) {
997            $this->savePartWithRels($this->getHeaderName($index), $xml);
998        }
999
1000        $this->savePartWithRels($this->getMainPartName(), $this->tempDocumentMainPart);
1001        $this->savePartWithRels($this->getSettingsPartName(), $this->tempDocumentSettingsPart);
1002
1003        foreach ($this->tempDocumentFooters as $index => $xml) {
1004            $this->savePartWithRels($this->getFooterName($index), $xml);
1005        }
1006
1007        $this->zipClass->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
1008
1009        // Close zip file
1010        if (false === $this->zipClass->close()) {
1011            throw new Exception('Could not close zip file.'); // @codeCoverageIgnore
1012        }
1013
1014        return $this->tempDocumentFilename;
1015    }
1016
1017    /**
1018     * @param string $fileName
1019     * @param string $xml
1020     */
1021    protected function savePartWithRels($fileName, $xml): void
1022    {
1023        $this->zipClass->addFromString($fileName, $xml);
1024        if (isset($this->tempDocumentRelations[$fileName])) {
1025            $relsFileName = $this->getRelationsName($fileName);
1026            $this->zipClass->addFromString($relsFileName, $this->tempDocumentRelations[$fileName]);
1027        }
1028    }
1029
1030    /**
1031     * Saves the result document to the user defined file.
1032     *
1033     * @since 0.8.0
1034     *
1035     * @param string $fileName
1036     */
1037    public function saveAs($fileName): void
1038    {
1039        $tempFileName = $this->save();
1040
1041        if (file_exists($fileName)) {
1042            unlink($fileName);
1043        }
1044
1045        /*
1046         * Note: we do not use `rename` function here, because it loses file ownership data on Windows platform.
1047         * As a result, user cannot open the file directly getting "Access denied" message.
1048         *
1049         * @see https://github.com/PHPOffice/PHPWord/issues/532
1050         */
1051        copy($tempFileName, $fileName);
1052        unlink($tempFileName);
1053    }
1054
1055    /**
1056     * Finds parts of broken macros and sticks them together.
1057     * Macros, while being edited, could be implicitly broken by some of the word processors.
1058     *
1059     * @param string $documentPart The document part in XML representation
1060     *
1061     * @return string
1062     */
1063    protected function fixBrokenMacros($documentPart)
1064    {
1065        $brokenMacroOpeningChars = substr(self::$macroOpeningChars, 0, 1);
1066        $endMacroOpeningChars = substr(self::$macroOpeningChars, 1);
1067        $macroClosingChars = self::$macroClosingChars;
1068
1069        return preg_replace_callback(
1070            '/\\' . $brokenMacroOpeningChars . '(?:\\' . $endMacroOpeningChars . '|[^{$]*\>\{)[^' . $macroClosingChars . '$]*\}/U',
1071            function ($match) {
1072                return strip_tags($match[0]);
1073            },
1074            $documentPart
1075        );
1076    }
1077
1078    /**
1079     * Find and replace macros in the given XML section.
1080     *
1081     * @param mixed $search
1082     * @param mixed $replace
1083     * @param array<int, string>|string $documentPartXML
1084     * @param int $limit
1085     *
1086     * @return string
1087     */
1088    protected function setValueForPart($search, $replace, $documentPartXML, $limit)
1089    {
1090        // Note: we can't use the same function for both cases here, because of performance considerations.
1091        if (self::MAXIMUM_REPLACEMENTS_DEFAULT === $limit) {
1092            return str_replace($search, $replace, $documentPartXML);
1093        }
1094        $regExpEscaper = new RegExp();
1095
1096        return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML, $limit);
1097    }
1098
1099    /**
1100     * Find all variables in $documentPartXML.
1101     *
1102     * @param string $documentPartXML
1103     *
1104     * @return string[]
1105     */
1106    protected function getVariablesForPart($documentPartXML)
1107    {
1108        $matches = [];
1109        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
1110        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
1111
1112        preg_match_all("/$escapedMacroOpeningChars(.*?)$escapedMacroClosingChars/i", $documentPartXML, $matches);
1113
1114        return $matches[1];
1115    }
1116
1117    /**
1118     * Get the name of the header file for $index.
1119     *
1120     * @param int $index
1121     *
1122     * @return string
1123     */
1124    protected function getHeaderName($index)
1125    {
1126        return sprintf('word/header%d.xml', $index);
1127    }
1128
1129    /**
1130     * 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.
1131     *
1132     * @return string
1133     */
1134    protected function getMainPartName()
1135    {
1136        $contentTypes = $this->zipClass->getFromName('[Content_Types].xml');
1137
1138        $pattern = '~PartName="\/(word\/document.*?\.xml)" ContentType="application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document\.main\+xml"~';
1139
1140        $matches = [];
1141        preg_match($pattern, $contentTypes, $matches);
1142
1143        return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
1144    }
1145
1146    /**
1147     * The name of the file containing the Settings part.
1148     *
1149     * @return string
1150     */
1151    protected function getSettingsPartName()
1152    {
1153        return 'word/settings.xml';
1154    }
1155
1156    /**
1157     * Get the name of the footer file for $index.
1158     *
1159     * @param int $index
1160     *
1161     * @return string
1162     */
1163    protected function getFooterName($index)
1164    {
1165        return sprintf('word/footer%d.xml', $index);
1166    }
1167
1168    /**
1169     * Get the name of the relations file for document part.
1170     *
1171     * @param string $documentPartName
1172     *
1173     * @return string
1174     */
1175    protected function getRelationsName($documentPartName)
1176    {
1177        return 'word/_rels/' . pathinfo($documentPartName, PATHINFO_BASENAME) . '.rels';
1178    }
1179
1180    protected function getNextRelationsIndex($documentPartName)
1181    {
1182        if (isset($this->tempDocumentRelations[$documentPartName])) {
1183            $candidate = substr_count($this->tempDocumentRelations[$documentPartName], '<Relationship');
1184            while (strpos($this->tempDocumentRelations[$documentPartName], 'Id="rId' . $candidate . '"') !== false) {
1185                ++$candidate;
1186            }
1187
1188            return $candidate;
1189        }
1190
1191        return 1;
1192    }
1193
1194    /**
1195     * @return string
1196     */
1197    protected function getDocumentContentTypesName()
1198    {
1199        return '[Content_Types].xml';
1200    }
1201
1202    /**
1203     * Find the start position of the nearest table before $offset.
1204     */
1205    private function findTableStart(int $offset): int
1206    {
1207        $rowStart = strrpos(
1208            $this->tempDocumentMainPart,
1209            '<w:tbl ',
1210            ((strlen($this->tempDocumentMainPart) - $offset) * -1)
1211        );
1212
1213        if (!$rowStart) {
1214            $rowStart = strrpos(
1215                $this->tempDocumentMainPart,
1216                '<w:tbl>',
1217                ((strlen($this->tempDocumentMainPart) - $offset) * -1)
1218            );
1219        }
1220        if (!$rowStart) {
1221            throw new Exception('Can not find the start position of the table.');
1222        }
1223
1224        return $rowStart;
1225    }
1226
1227    /**
1228     * Find the end position of the nearest table row after $offset.
1229     */
1230    private function findTableEnd(int $offset): int
1231    {
1232        return strpos($this->tempDocumentMainPart, '</w:tbl>', $offset) + 7;
1233    }
1234
1235    /**
1236     * Find the start position of the nearest table row before $offset.
1237     *
1238     * @param int $offset
1239     *
1240     * @return int
1241     */
1242    protected function findRowStart($offset)
1243    {
1244        $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr ', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
1245
1246        if (!$rowStart) {
1247            $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr>', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
1248        }
1249        if (!$rowStart) {
1250            throw new Exception('Can not find the start position of the row to clone.');
1251        }
1252
1253        return $rowStart;
1254    }
1255
1256    /**
1257     * Find the end position of the nearest table row after $offset.
1258     *
1259     * @param int $offset
1260     *
1261     * @return int
1262     */
1263    protected function findRowEnd($offset)
1264    {
1265        return strpos($this->tempDocumentMainPart, '</w:tr>', $offset) + 7;
1266    }
1267
1268    /**
1269     * Get a slice of a string.
1270     *
1271     * @param int $startPosition
1272     * @param int $endPosition
1273     *
1274     * @return string
1275     */
1276    protected function getSlice($startPosition, $endPosition = 0)
1277    {
1278        if (!$endPosition) {
1279            $endPosition = strlen($this->tempDocumentMainPart);
1280        }
1281
1282        return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition));
1283    }
1284
1285    /**
1286     * Replaces variable names in cloned
1287     * rows/blocks with indexed names.
1288     *
1289     * @param int $count
1290     * @param string $xmlBlock
1291     *
1292     * @return array<string>
1293     */
1294    protected function indexClonedVariables($count, $xmlBlock)
1295    {
1296        $results = [];
1297        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
1298        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
1299
1300        for ($i = 1; $i <= $count; ++$i) {
1301            $results[] = preg_replace("/$escapedMacroOpeningChars([^:]*?)(:.*?)?$escapedMacroClosingChars/", self::$macroOpeningChars . '\1#' . $i . '\2' . self::$macroClosingChars, $xmlBlock);
1302        }
1303
1304        return $results;
1305    }
1306
1307    /**
1308     * Replace carriage returns with xml.
1309     */
1310    public function replaceCarriageReturns(string $string): string
1311    {
1312        return str_replace(["\r\n", "\r", "\n"], '</w:t><w:br/><w:t>', $string);
1313    }
1314
1315    /**
1316     * Replaces variables with values from array, array keys are the variable names.
1317     *
1318     * @param array $variableReplacements
1319     * @param string $xmlBlock
1320     *
1321     * @return string[]
1322     */
1323    protected function replaceClonedVariables($variableReplacements, $xmlBlock)
1324    {
1325        $results = [];
1326        foreach ($variableReplacements as $replacementArray) {
1327            $localXmlBlock = $xmlBlock;
1328            foreach ($replacementArray as $search => $replacement) {
1329                $localXmlBlock = $this->setValueForPart(self::ensureMacroCompleted($search), $replacement, $localXmlBlock, self::MAXIMUM_REPLACEMENTS_DEFAULT);
1330            }
1331            $results[] = $localXmlBlock;
1332        }
1333
1334        return $results;
1335    }
1336
1337    /**
1338     * Replace an XML block surrounding a macro with a new block.
1339     *
1340     * @param string $macro Name of macro
1341     * @param string $block New block content
1342     * @param string $blockType XML tag type of block
1343     *
1344     * @return TemplateProcessor Fluent interface
1345     */
1346    public function replaceXmlBlock($macro, $block, $blockType = 'w:p')
1347    {
1348        $where = $this->findContainingXmlBlockForMacro($macro, $blockType);
1349        if (is_array($where)) {
1350            $this->tempDocumentMainPart = $this->getSlice(0, $where['start']) . $block . $this->getSlice($where['end']);
1351        }
1352
1353        return $this;
1354    }
1355
1356    /**
1357     * Find start and end of XML block containing the given macro
1358     * e.g. <w:p>...${macro}...</w:p>.
1359     *
1360     * Note that only the first instance of the macro will be found
1361     *
1362     * @param string $macro Name of macro
1363     * @param string $blockType XML tag for block
1364     *
1365     * @return bool|int[] FALSE if not found, otherwise array with start and end
1366     */
1367    protected function findContainingXmlBlockForMacro($macro, $blockType = 'w:p')
1368    {
1369        $macroPos = $this->findMacro($macro);
1370        if (0 > $macroPos) {
1371            return false;
1372        }
1373        $start = $this->findXmlBlockStart($macroPos, $blockType);
1374        if (0 > $start) {
1375            return false;
1376        }
1377        $end = $this->findXmlBlockEnd($start, $blockType);
1378        //if not found or if resulting string does not contain the macro we are searching for
1379        if (0 > $end || strstr($this->getSlice($start, $end), $macro) === false) {
1380            return false;
1381        }
1382
1383        return ['start' => $start, 'end' => $end];
1384    }
1385
1386    /**
1387     * Find the position of (the start of) a macro.
1388     *
1389     * Returns -1 if not found, otherwise position of opening $
1390     *
1391     * Note that only the first instance of the macro will be found
1392     *
1393     * @param string $search Macro name
1394     * @param int $offset Offset from which to start searching
1395     *
1396     * @return int -1 if macro not found
1397     */
1398    protected function findMacro($search, $offset = 0)
1399    {
1400        $search = static::ensureMacroCompleted($search);
1401        $pos = strpos($this->tempDocumentMainPart, $search, $offset);
1402
1403        return ($pos === false) ? -1 : $pos;
1404    }
1405
1406    /**
1407     * Find the start position of the nearest XML block start before $offset.
1408     *
1409     * @param int $offset    Search position
1410     * @param string  $blockType XML Block tag
1411     *
1412     * @return int -1 if block start not found
1413     */
1414    protected function findXmlBlockStart($offset, $blockType)
1415    {
1416        $reverseOffset = (strlen($this->tempDocumentMainPart) - $offset) * -1;
1417        // first try XML tag with attributes
1418        $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . ' ', $reverseOffset);
1419        // if not found, or if found but contains the XML tag without attribute
1420        if (false === $blockStart || strrpos($this->getSlice($blockStart, $offset), '<' . $blockType . '>')) {
1421            // also try XML tag without attributes
1422            $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . '>', $reverseOffset);
1423        }
1424
1425        return ($blockStart === false) ? -1 : $blockStart;
1426    }
1427
1428    /**
1429     * Find the nearest block end position after $offset.
1430     *
1431     * @param int $offset    Search position
1432     * @param string  $blockType XML Block tag
1433     *
1434     * @return int -1 if block end not found
1435     */
1436    protected function findXmlBlockEnd($offset, $blockType)
1437    {
1438        $blockEndStart = strpos($this->tempDocumentMainPart, '</' . $blockType . '>', $offset);
1439        // return position of end of tag if found, otherwise -1
1440
1441        return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType);
1442    }
1443
1444    /**
1445     * Splits a w:r/w:t into a list of w:r where each ${macro} is in a separate w:r.
1446     *
1447     * @param string $text
1448     *
1449     * @return string
1450     */
1451    protected function splitTextIntoTexts($text)
1452    {
1453        if (!$this->textNeedsSplitting($text)) {
1454            return $text;
1455        }
1456        $matches = [];
1457        if (preg_match('/(<w:rPr.*<\/w:rPr>)/i', $text, $matches)) {
1458            $extractedStyle = $matches[0];
1459        } else {
1460            $extractedStyle = '';
1461        }
1462
1463        $unformattedText = preg_replace('/>\s+</', '><', $text);
1464        $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);
1465
1466        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);
1467    }
1468
1469    /**
1470     * Returns true if string contains a macro that is not in it's own w:r.
1471     *
1472     * @param string $text
1473     *
1474     * @return bool
1475     */
1476    protected function textNeedsSplitting($text)
1477    {
1478        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
1479        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
1480
1481        return 1 === preg_match('/[^>]' . $escapedMacroOpeningChars . '|' . $escapedMacroClosingChars . '[^<]/i', $text);
1482    }
1483
1484    public function setMacroOpeningChars(string $macroOpeningChars): void
1485    {
1486        self::$macroOpeningChars = $macroOpeningChars;
1487    }
1488
1489    public function setMacroClosingChars(string $macroClosingChars): void
1490    {
1491        self::$macroClosingChars = $macroClosingChars;
1492    }
1493
1494    public function setMacroChars(string $macroOpeningChars, string $macroClosingChars): void
1495    {
1496        self::$macroOpeningChars = $macroOpeningChars;
1497        self::$macroClosingChars = $macroClosingChars;
1498    }
1499
1500    public function getTempDocumentFilename(): string
1501    {
1502        return $this->tempDocumentFilename;
1503    }
1504}