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