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