• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

PHPOffice / PHPWord / 17396170466

02 Sep 2025 07:14AM UTC coverage: 96.925% (+0.2%) from 96.757%
17396170466

Pull #2567

github

web-flow
Merge 3223dba6c into 0ab0b4940
Pull Request #2567: WIP Do Not Install

308 of 313 new or added lines in 28 files covered. (98.4%)

4 existing lines in 1 file now uncovered.

12704 of 13107 relevant lines covered (96.93%)

37.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.71
/src/PhpWord/TemplateProcessor.php
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

19
namespace PhpOffice\PhpWord;
20

21
use DOMDocument;
22
use PhpOffice\PhpWord\Escaper\RegExp;
23
use PhpOffice\PhpWord\Escaper\Xml;
24
use PhpOffice\PhpWord\Exception\CopyFileException;
25
use PhpOffice\PhpWord\Exception\CreateTemporaryFileException;
26
use PhpOffice\PhpWord\Exception\Exception;
27
use PhpOffice\PhpWord\Shared\Text;
28
use PhpOffice\PhpWord\Shared\XMLWriter;
29
use PhpOffice\PhpWord\Shared\ZipArchive;
30
use Throwable;
31
use XSLTProcessor;
32

33
class 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');
20✔
111
        if (false === $this->tempDocumentFilename) {
20✔
112
            throw new CreateTemporaryFileException(); // @codeCoverageIgnore
113
        }
114

115
        // Template file cloning
116
        if (false === copy($documentTemplate, $this->tempDocumentFilename)) {
20✔
117
            throw new CopyFileException($documentTemplate, $this->tempDocumentFilename); // @codeCoverageIgnore
118
        }
119

120
        // Temporary document content extraction
121
        $this->zipClass = new ZipArchive();
20✔
122
        $this->zipClass->open($this->tempDocumentFilename);
20✔
123
        $index = 1;
20✔
124
        while (false !== $this->zipClass->locateName($this->getHeaderName($index))) {
20✔
125
            $this->tempDocumentHeaders[$index] = $this->readPartWithRels($this->getHeaderName($index));
6✔
126
            ++$index;
6✔
127
        }
128
        $index = 1;
20✔
129
        while (false !== $this->zipClass->locateName($this->getFooterName($index))) {
20✔
130
            $this->tempDocumentFooters[$index] = $this->readPartWithRels($this->getFooterName($index));
8✔
131
            ++$index;
8✔
132
        }
133

134
        $this->tempDocumentMainPart = $this->readPartWithRels($this->getMainPartName());
20✔
135
        $this->tempDocumentSettingsPart = $this->readPartWithRels($this->getSettingsPartName());
20✔
136
        $tempDocumentContentTypes = $this->zipClass->getFromName($this->getDocumentContentTypesName());
20✔
137
        if (is_string($tempDocumentContentTypes)) {
20✔
138
            $this->tempDocumentContentTypes = $tempDocumentContentTypes;
20✔
139
        }
140
    }
141

142
    public function __destruct()
143
    {
144
        // ZipClass
145
        if ($this->zipClass) {
51✔
146
            try {
147
                $this->zipClass->close();
20✔
148
            } catch (Throwable $e) {
12✔
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;
2✔
165
    }
166

167
    /**
168
     * @param string $fileName
169
     *
170
     * @return string
171
     */
172
    protected function readPartWithRels($fileName)
173
    {
174
        $relsFileName = $this->getRelationsName($fileName);
20✔
175
        $partRelations = $this->zipClass->getFromName($relsFileName);
20✔
176
        if ($partRelations !== false) {
20✔
177
            $this->tempDocumentRelations[$fileName] = $partRelations;
20✔
178
        }
179

180
        return $this->fixBrokenMacros($this->zipClass->getFromName($fileName));
20✔
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) {
2✔
192
            $orignalLibEntityLoader = libxml_disable_entity_loader(true);
2✔
193
        }
194
        $domDocument = new DOMDocument();
2✔
195
        if (false === $domDocument->loadXML($xml)) {
2✔
196
            throw new Exception('Could not load the given XML document.');
1✔
197
        }
198

199
        $transformedXml = $xsltProcessor->transformToXml($domDocument);
1✔
200
        if (false === $transformedXml) {
1✔
201
            throw new Exception('Could not transform the given XML document.');
×
202
        }
203
        if (\PHP_VERSION_ID < 80000) {
1✔
204
            libxml_disable_entity_loader($orignalLibEntityLoader);
1✔
205
        }
206

207
        return $transformedXml;
1✔
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)) {
2✔
219
            foreach ($xml as &$item) {
2✔
220
                $item = $this->transformSingleXml($item, $xsltProcessor);
1✔
221
            }
222
            unset($item);
2✔
223
        } else {
224
            $xml = $this->transformSingleXml($xml, $xsltProcessor);
2✔
225
        }
226

227
        return $xml;
2✔
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();
3✔
243

244
        $xsltProcessor->importStylesheet($xslDomDocument);
3✔
245
        if (false === $xsltProcessor->setParameter($xslOptionsUri, $xslOptions)) {
3✔
246
            throw new Exception('Could not set values for the given XSL style sheet parameters.');
1✔
247
        }
248

249
        $this->tempDocumentHeaders = $this->transformXml($this->tempDocumentHeaders, $xsltProcessor);
2✔
250
        $this->tempDocumentMainPart = $this->transformXml($this->tempDocumentMainPart, $xsltProcessor);
2✔
251
        $this->tempDocumentFooters = $this->transformXml($this->tempDocumentFooters, $xsltProcessor);
1✔
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) {
28✔
262
            $macro = self::$macroOpeningChars . $macro . self::$macroClosingChars;
24✔
263
        }
264

265
        return $macro;
28✔
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) : '';
15✔
276
    }
277

278
    /**
279
     * @param string $search
280
     */
281
    public function setComplexValue($search, Element\AbstractElement $complexType, bool $multiple = false): void
282
    {
283
        $originalSearch = $search;
3✔
284
        $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1);
3✔
285
        if ($elementName === 'Section') {
3✔
NEW
286
            $elementName = 'Container';
×
287
        }
288
        $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
3✔
289

290
        $xmlWriter = new XMLWriter();
3✔
291
        /** @var Writer\Word2007\Element\AbstractElement $elementWriter */
292
        $elementWriter = new $objectClass($xmlWriter, $complexType, true);
3✔
293
        $elementWriter->write();
3✔
294

295
        $where = $this->findContainingXmlBlockForMacro($search, 'w:r');
3✔
296

297
        if ($where === false) {
3✔
298
            return;
1✔
299
        }
300

301
        $block = $this->getSlice($where['start'], $where['end']);
3✔
302
        $textParts = $this->splitTextIntoTexts($block);
3✔
303
        $this->replaceXmlBlock($search, $textParts, 'w:r');
3✔
304

305
        $search = static::ensureMacroCompleted($search);
3✔
306
        $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:r');
3✔
307
        if ($multiple === true) {
3✔
308
            $this->setComplexValue($originalSearch, $complexType, true);
1✔
309
        }
310
    }
311

312
    /**
313
     * @param string $search
314
     */
315
    public function setComplexBlock($search, Element\AbstractElement $complexType): void
316
    {
317
        $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1);
3✔
318
        if ($elementName === 'Section') {
3✔
319
            $elementName = 'Container';
1✔
320
        }
321
        $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
3✔
322

323
        $xmlWriter = new XMLWriter();
3✔
324
        /** @var Writer\Word2007\Element\AbstractElement $elementWriter */
325
        $elementWriter = new $objectClass($xmlWriter, $complexType, false);
3✔
326
        $elementWriter->write();
3✔
327

328
        $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:p');
3✔
329
    }
330

331
    /**
332
     * @param array<string>|string $search
333
     * @param null|array<string>|bool|float|int|string $replace
334
     * @param int $limit
335
     */
336
    public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
337
    {
338
        if (is_array($search)) {
15✔
339
            foreach ($search as &$item) {
2✔
340
                $item = static::ensureMacroCompleted($item);
2✔
341
            }
342
            unset($item);
2✔
343
        } else {
344
            $search = static::ensureMacroCompleted($search);
13✔
345
        }
346

347
        if (is_array($replace)) {
15✔
348
            foreach ($replace as &$item) {
2✔
349
                $item = static::ensureUtf8Encoded($item);
2✔
350
            }
351
            unset($item);
2✔
352
        } else {
353
            $replace = static::ensureUtf8Encoded(null === $replace ? null : (string) $replace);
13✔
354
        }
355

356
        if (Settings::isOutputEscapingEnabled()) {
15✔
357
            $xmlEscaper = new Xml();
2✔
358
            $replace = $xmlEscaper->escape($replace);
2✔
359
        }
360

361
        // convert carriage returns
362
        if (is_array($replace)) {
15✔
363
            foreach ($replace as &$item) {
2✔
364
                $item = $this->replaceCarriageReturns($item);
2✔
365
            }
366
        } else {
367
            $replace = $this->replaceCarriageReturns($replace);
13✔
368
        }
369

370
        $this->tempDocumentHeaders = $this->setValueForPart($search, $replace, $this->tempDocumentHeaders, $limit);
15✔
371
        $this->tempDocumentMainPart = $this->setValueForPart($search, $replace, $this->tempDocumentMainPart, $limit);
15✔
372
        $this->tempDocumentFooters = $this->setValueForPart($search, $replace, $this->tempDocumentFooters, $limit);
15✔
373
    }
374

375
    /**
376
     * Set values from a one-dimensional array of "variable => value"-pairs.
377
     */
378
    public function setValues(array $values, int $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
379
    {
380
        foreach ($values as $macro => $replace) {
3✔
381
            $this->setValue($macro, $replace, $limit);
3✔
382
        }
383
    }
384

385
    public function setCheckbox(string $search, bool $checked): void
386
    {
387
        $search = static::ensureMacroCompleted($search);
2✔
388
        $blockType = 'w:sdt';
2✔
389

390
        $where = $this->findContainingXmlBlockForMacro($search, $blockType);
2✔
391
        if (!is_array($where)) {
2✔
392
            return;
×
393
        }
394

395
        $block = $this->getSlice($where['start'], $where['end']);
2✔
396

397
        $val = $checked ? '1' : '0';
2✔
398
        $block = preg_replace('/(<w14:checked w14:val=)".*?"(\/>)/', '$1"' . $val . '"$2', $block);
2✔
399

400
        $text = $checked ? '☒' : '☐';
2✔
401
        $block = preg_replace('/(<w:t>).*?(<\/w:t>)/', '$1' . $text . '$2', $block);
2✔
402

403
        $this->replaceXmlBlock($search, $block, $blockType);
2✔
404
    }
405

406
    /**
407
     * @param string $search
408
     */
409
    public function setChart($search, Element\AbstractElement $chart): void
410
    {
411
        $elementName = substr(get_class($chart), strrpos(get_class($chart), '\\') + 1);
×
412
        $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
×
413

414
        // Get the next relation id
415
        $rId = $this->getNextRelationsIndex($this->getMainPartName());
×
416
        $chart->setRelationId($rId);
×
417

418
        // Define the chart filename
419
        $filename = "charts/chart{$rId}.xml";
×
420

421
        // Get the part writer
422
        $writerPart = new Writer\Word2007\Part\Chart();
×
423
        $writerPart->setElement($chart);
×
424

425
        // ContentTypes.xml
426
        $this->zipClass->addFromString("word/{$filename}", $writerPart->write());
×
427

428
        // add chart to content type
429
        $xmlRelationsType = "<Override PartName=\"/word/{$filename}\" ContentType=\"application/vnd.openxmlformats-officedocument.drawingml.chart+xml\"/>";
×
430
        $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
×
431

432
        // Add the chart to relations
433
        $xmlChartRelation = "<Relationship Id=\"rId{$rId}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart\" Target=\"charts/chart{$rId}.xml\"/>";
×
434
        $this->tempDocumentRelations[$this->getMainPartName()] = str_replace('</Relationships>', $xmlChartRelation, $this->tempDocumentRelations[$this->getMainPartName()]) . '</Relationships>';
×
435

436
        // Write the chart
437
        $xmlWriter = new XMLWriter();
×
438
        $elementWriter = new $objectClass($xmlWriter, $chart, true);
×
439
        $elementWriter->write();
×
440

441
        // Place it in the template
442
        $this->replaceXmlBlock($search, '<w:p>' . $xmlWriter->getData() . '</w:p>', 'w:p');
×
443
    }
444

445
    private function getImageArgs($varNameWithArgs)
446
    {
447
        $varElements = explode(':', $varNameWithArgs);
1✔
448
        array_shift($varElements); // first element is name of variable => remove it
1✔
449

450
        $varInlineArgs = [];
1✔
451
        // size format documentation: https://msdn.microsoft.com/en-us/library/documentformat.openxml.vml.shape%28v=office.14%29.aspx?f=255&MSPPError=-2147217396
452
        foreach ($varElements as $argIdx => $varArg) {
1✔
453
            if (strpos($varArg, '=')) { // arg=value
1✔
454
                [$argName, $argValue] = explode('=', $varArg, 2);
1✔
455
                $argName = strtolower($argName);
1✔
456
                if ($argName == 'size') {
1✔
457
                    [$varInlineArgs['width'], $varInlineArgs['height']] = explode('x', $argValue, 2);
×
458
                } else {
459
                    $varInlineArgs[strtolower($argName)] = $argValue;
1✔
460
                }
461
            } elseif (preg_match('/^([0-9]*[a-z%]{0,2}|auto)x([0-9]*[a-z%]{0,2}|auto)$/i', $varArg)) { // 60x40
1✔
462
                [$varInlineArgs['width'], $varInlineArgs['height']] = explode('x', $varArg, 2);
×
463
            } else { // :60:40:f
464
                switch ($argIdx) {
465
                    case 0:
1✔
466
                        $varInlineArgs['width'] = $varArg;
1✔
467

468
                        break;
1✔
469
                    case 1:
1✔
470
                        $varInlineArgs['height'] = $varArg;
1✔
471

472
                        break;
1✔
473
                    case 2:
×
474
                        $varInlineArgs['ratio'] = $varArg;
×
475

476
                        break;
×
477
                }
478
            }
479
        }
480

481
        return $varInlineArgs;
1✔
482
    }
483

484
    private function chooseImageDimension($baseValue, $inlineValue, $defaultValue)
485
    {
486
        $value = $baseValue;
1✔
487
        if (null === $value && isset($inlineValue)) {
1✔
488
            $value = $inlineValue;
1✔
489
        }
490
        if (!preg_match('/^([0-9\.]*(cm|mm|in|pt|pc|px|%|em|ex|)|auto)$/i', $value ?? '')) {
1✔
491
            $value = null;
×
492
        }
493
        if (null === $value) {
1✔
494
            $value = $defaultValue;
1✔
495
        }
496
        if (is_numeric($value)) {
1✔
497
            $value .= 'px';
1✔
498
        }
499

500
        return $value;
1✔
501
    }
502

503
    private function fixImageWidthHeightRatio(&$width, &$height, $actualWidth, $actualHeight): void
504
    {
505
        $imageRatio = $actualWidth / $actualHeight;
1✔
506

507
        if (($width === '') && ($height === '')) { // defined size are empty
1✔
508
            $width = $actualWidth . 'px';
×
509
            $height = $actualHeight . 'px';
×
510
        } elseif ($width === '') { // defined width is empty
1✔
511
            $heightFloat = (float) $height;
×
512
            $widthFloat = $heightFloat * $imageRatio;
×
513
            $matches = [];
×
514
            preg_match('/\\d([a-z%]+)$/', $height, $matches);
×
515
            $width = $widthFloat . (!empty($matches) ? $matches[1] : 'px');
×
516
        } elseif ($height === '') { // defined height is empty
1✔
517
            $widthFloat = (float) $width;
×
518
            $heightFloat = $widthFloat / $imageRatio;
×
519
            $matches = [];
×
520
            preg_match('/\\d([a-z%]+)$/', $width, $matches);
×
521
            $height = $heightFloat . (!empty($matches) ? $matches[1] : 'px');
×
522
        } else { // we have defined size, but we need also check it aspect ratio
523
            $widthMatches = [];
1✔
524
            preg_match('/\\d([a-z%]+)$/', $width, $widthMatches);
1✔
525
            $heightMatches = [];
1✔
526
            preg_match('/\\d([a-z%]+)$/', $height, $heightMatches);
1✔
527
            // try to fix only if dimensions are same
528
            if (!empty($widthMatches)
1✔
529
                && !empty($heightMatches)
1✔
530
                && $widthMatches[1] == $heightMatches[1]) {
1✔
531
                $dimention = $widthMatches[1];
1✔
532
                $widthFloat = (float) $width;
1✔
533
                $heightFloat = (float) $height;
1✔
534
                $definedRatio = $widthFloat / $heightFloat;
1✔
535

536
                if ($imageRatio > $definedRatio) { // image wider than defined box
1✔
537
                    $height = ($widthFloat / $imageRatio) . $dimention;
×
538
                } elseif ($imageRatio < $definedRatio) { // image higher than defined box
1✔
539
                    $width = ($heightFloat * $imageRatio) . $dimention;
1✔
540
                }
541
            }
542
        }
543
    }
544

545
    private function prepareImageAttrs($replaceImage, $varInlineArgs)
546
    {
547
        // get image path and size
548
        $width = null;
1✔
549
        $height = null;
1✔
550
        $ratio = null;
1✔
551

552
        // a closure can be passed as replacement value which after resolving, can contain the replacement info for the image
553
        // use case: only when a image if found, the replacement tags can be generated
554
        if (is_callable($replaceImage)) {
1✔
555
            $replaceImage = $replaceImage();
1✔
556
        }
557

558
        if (is_array($replaceImage) && isset($replaceImage['path'])) {
1✔
559
            $imgPath = $replaceImage['path'];
1✔
560
            if (isset($replaceImage['width'])) {
1✔
561
                $width = $replaceImage['width'];
1✔
562
            }
563
            if (isset($replaceImage['height'])) {
1✔
564
                $height = $replaceImage['height'];
1✔
565
            }
566
            if (isset($replaceImage['ratio'])) {
1✔
567
                $ratio = $replaceImage['ratio'];
1✔
568
            }
569
        } else {
570
            $imgPath = $replaceImage;
1✔
571
        }
572

573
        $width = $this->chooseImageDimension($width, $varInlineArgs['width'] ?? null, 115);
1✔
574
        $height = $this->chooseImageDimension($height, $varInlineArgs['height'] ?? null, 70);
1✔
575

576
        $imageData = @getimagesize($imgPath);
1✔
577
        if (!is_array($imageData)) {
1✔
578
            throw new Exception(sprintf('Invalid image: %s', $imgPath));
×
579
        }
580
        [$actualWidth, $actualHeight, $imageType] = $imageData;
1✔
581

582
        // fix aspect ratio (by default)
583
        if (null === $ratio && isset($varInlineArgs['ratio'])) {
1✔
584
            $ratio = $varInlineArgs['ratio'];
1✔
585
        }
586
        if (null === $ratio || !in_array(strtolower($ratio), ['', '-', 'f', 'false'])) {
1✔
587
            $this->fixImageWidthHeightRatio($width, $height, $actualWidth, $actualHeight);
1✔
588
        }
589

590
        $imageAttrs = [
1✔
591
            'src' => $imgPath,
1✔
592
            'mime' => image_type_to_mime_type($imageType),
1✔
593
            'width' => $width,
1✔
594
            'height' => $height,
1✔
595
        ];
1✔
596

597
        return $imageAttrs;
1✔
598
    }
599

600
    private function addImageToRelations($partFileName, $rid, $imgPath, $imageMimeType): void
601
    {
602
        // define templates
603
        $typeTpl = '<Override PartName="/word/media/{IMG}" ContentType="image/{EXT}"/>';
1✔
604
        $relationTpl = '<Relationship Id="{RID}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/{IMG}"/>';
1✔
605
        $newRelationsTpl = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
1✔
606
        $newRelationsTypeTpl = '<Override PartName="/{RELS}" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>';
1✔
607
        $extTransform = [
1✔
608
            'image/jpeg' => 'jpeg',
1✔
609
            'image/png' => 'png',
1✔
610
            'image/bmp' => 'bmp',
1✔
611
            'image/gif' => 'gif',
1✔
612
        ];
1✔
613

614
        // get image embed name
615
        if (isset($this->tempDocumentNewImages[$imgPath])) {
1✔
616
            $imgName = $this->tempDocumentNewImages[$imgPath];
1✔
617
        } else {
618
            // transform extension
619
            if (isset($extTransform[$imageMimeType])) {
1✔
620
                $imgExt = $extTransform[$imageMimeType];
1✔
621
            } else {
622
                throw new Exception("Unsupported image type $imageMimeType");
×
623
            }
624

625
            // add image to document
626
            $imgName = 'image_' . $rid . '_' . pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt;
1✔
627
            $this->zipClass->pclzipAddFile($imgPath, 'word/media/' . $imgName);
1✔
628
            $this->tempDocumentNewImages[$imgPath] = $imgName;
1✔
629

630
            // setup type for image
631
            $xmlImageType = str_replace(['{IMG}', '{EXT}'], [$imgName, $imgExt], $typeTpl);
1✔
632
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlImageType, $this->tempDocumentContentTypes) . '</Types>';
1✔
633
        }
634

635
        $xmlImageRelation = str_replace(['{RID}', '{IMG}'], [$rid, $imgName], $relationTpl);
1✔
636

637
        if (!isset($this->tempDocumentRelations[$partFileName])) {
1✔
638
            // create new relations file
639
            $this->tempDocumentRelations[$partFileName] = $newRelationsTpl;
1✔
640
            // and add it to content types
641
            $xmlRelationsType = str_replace('{RELS}', $this->getRelationsName($partFileName), $newRelationsTypeTpl);
1✔
642
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
1✔
643
        }
644

645
        // add image to relations
646
        $this->tempDocumentRelations[$partFileName] = str_replace('</Relationships>', $xmlImageRelation, $this->tempDocumentRelations[$partFileName]) . '</Relationships>';
1✔
647
    }
648

649
    /**
650
     * @param mixed $search
651
     * @param mixed $replace Path to image, or array("path" => xx, "width" => yy, "height" => zz)
652
     * @param int $limit
653
     */
654
    public function setImageValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
655
    {
656
        // prepare $search_replace
657
        if (!is_array($search)) {
1✔
658
            $search = [$search];
1✔
659
        }
660

661
        $replacesList = [];
1✔
662
        if (!is_array($replace) || isset($replace['path'])) {
1✔
663
            $replacesList[] = $replace;
1✔
664
        } else {
665
            $replacesList = array_values($replace);
1✔
666
        }
667

668
        $searchReplace = [];
1✔
669
        foreach ($search as $searchIdx => $searchString) {
1✔
670
            $searchReplace[$searchString] = $replacesList[$searchIdx] ?? $replacesList[0];
1✔
671
        }
672

673
        // collect document parts
674
        $searchParts = [
1✔
675
            $this->getMainPartName() => &$this->tempDocumentMainPart,
1✔
676
        ];
1✔
677
        foreach (array_keys($this->tempDocumentHeaders) as $headerIndex) {
1✔
678
            $searchParts[$this->getHeaderName($headerIndex)] = &$this->tempDocumentHeaders[$headerIndex];
1✔
679
        }
680
        foreach (array_keys($this->tempDocumentFooters) as $footerIndex) {
1✔
681
            $searchParts[$this->getFooterName($footerIndex)] = &$this->tempDocumentFooters[$footerIndex];
1✔
682
        }
683

684
        // define templates
685
        // result can be verified via "Open XML SDK 2.5 Productivity Tool" (http://www.microsoft.com/en-us/download/details.aspx?id=30425)
686
        $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>';
1✔
687

688
        $i = 0;
1✔
689
        foreach ($searchParts as $partFileName => &$partContent) {
1✔
690
            $partVariables = $this->getVariablesForPart($partContent);
1✔
691

692
            foreach ($searchReplace as $searchString => $replaceImage) {
1✔
693
                $varsToReplace = array_filter($partVariables, function ($partVar) use ($searchString) {
1✔
694
                    return ($partVar == $searchString) || preg_match('/^' . preg_quote($searchString, '/') . ':/', $partVar);
1✔
695
                });
1✔
696

697
                foreach ($varsToReplace as $varNameWithArgs) {
1✔
698
                    $varInlineArgs = $this->getImageArgs($varNameWithArgs);
1✔
699
                    $preparedImageAttrs = $this->prepareImageAttrs($replaceImage, $varInlineArgs);
1✔
700
                    $imgPath = $preparedImageAttrs['src'];
1✔
701

702
                    // get image index
703
                    $imgIndex = $this->getNextRelationsIndex($partFileName);
1✔
704
                    $rid = 'rId' . $imgIndex;
1✔
705

706
                    // replace preparations
707
                    $this->addImageToRelations($partFileName, $rid, $imgPath, $preparedImageAttrs['mime']);
1✔
708
                    $xmlImage = str_replace(['{RID}', '{WIDTH}', '{HEIGHT}'], [$rid, $preparedImageAttrs['width'], $preparedImageAttrs['height']], $imgTpl);
1✔
709

710
                    // replace variable
711
                    $varNameWithArgsFixed = static::ensureMacroCompleted($varNameWithArgs);
1✔
712
                    $matches = [];
1✔
713
                    if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed, '/') . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) {
1✔
714
                        $wholeTag = $matches[0];
1✔
715
                        array_shift($matches);
1✔
716
                        [$openTag, $prefix, , $postfix, $closeTag] = $matches;
1✔
717
                        $replaceXml = $openTag . $prefix . $closeTag . $xmlImage . $openTag . $postfix . $closeTag;
1✔
718
                        // replace on each iteration, because in one tag we can have 2+ inline variables => before proceed next variable we need to change $partContent
719
                        $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent, $limit);
1✔
720
                    }
721

722
                    if (++$i >= $limit) {
1✔
723
                        break;
1✔
724
                    }
725
                }
726
            }
727
        }
728
    }
729

730
    /**
731
     * Returns count of all variables in template.
732
     *
733
     * @return array
734
     */
735
    public function getVariableCount()
736
    {
737
        $variables = $this->getVariablesForPart($this->tempDocumentMainPart);
17✔
738

739
        foreach ($this->tempDocumentHeaders as $headerXML) {
17✔
740
            $variables = array_merge(
4✔
741
                $variables,
4✔
742
                $this->getVariablesForPart($headerXML)
4✔
743
            );
4✔
744
        }
745

746
        foreach ($this->tempDocumentFooters as $footerXML) {
17✔
747
            $variables = array_merge(
6✔
748
                $variables,
6✔
749
                $this->getVariablesForPart($footerXML)
6✔
750
            );
6✔
751
        }
752

753
        return array_count_values($variables);
17✔
754
    }
755

756
    /**
757
     * Returns array of all variables in template.
758
     *
759
     * @return string[]
760
     */
761
    public function getVariables()
762
    {
763
        return array_keys($this->getVariableCount());
14✔
764
    }
765

766
    /**
767
     * Clone a table row in a template document.
768
     *
769
     * @param string $search
770
     * @param int $numberOfClones
771
     */
772
    public function cloneRow($search, $numberOfClones): void
773
    {
774
        $search = static::ensureMacroCompleted($search);
5✔
775

776
        $tagPos = strpos($this->tempDocumentMainPart, $search);
5✔
777
        if (!$tagPos) {
5✔
778
            throw new Exception('Can not clone row, template variable not found or variable contains markup.');
1✔
779
        }
780

781
        $rowStart = $this->findRowStart($tagPos);
4✔
782
        $rowEnd = $this->findRowEnd($tagPos);
4✔
783
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
4✔
784

785
        // Check if there's a cell spanning multiple rows.
786
        if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
4✔
787
            // $extraRowStart = $rowEnd;
788
            $extraRowEnd = $rowEnd;
4✔
789
            while (true) {
4✔
790
                $extraRowStart = $this->findRowStart($extraRowEnd + 1);
4✔
791
                $extraRowEnd = $this->findRowEnd($extraRowEnd + 1);
4✔
792

793
                // If extraRowEnd is lower then 7, there was no next row found.
794
                if ($extraRowEnd < 7) {
4✔
795
                    break;
×
796
                }
797

798
                // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
799
                $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
4✔
800
                if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
4✔
801
                    !preg_match('#<w:vMerge w:val="continue"\s*/>#', $tmpXmlRow)
4✔
802
                ) {
803
                    break;
4✔
804
                }
805
                // This row was a spanned row, update $rowEnd and search for the next row.
806
                $rowEnd = $extraRowEnd;
4✔
807
            }
808
            $xmlRow = $this->getSlice($rowStart, $rowEnd);
4✔
809
        }
810

811
        $result = $this->getSlice(0, $rowStart);
4✔
812
        $result .= implode('', $this->indexClonedVariables($numberOfClones, $xmlRow));
4✔
813
        $result .= $this->getSlice($rowEnd);
4✔
814

815
        $this->tempDocumentMainPart = $result;
4✔
816
    }
817

818
    /**
819
     * Delete a table row in a template document.
820
     */
821
    public function deleteRow(string $search): void
822
    {
823
        if (self::$macroOpeningChars !== substr($search, 0, 2) && self::$macroClosingChars !== substr($search, -1)) {
1✔
824
            $search = self::$macroOpeningChars . $search . self::$macroClosingChars;
1✔
825
        }
826

827
        $tagPos = strpos($this->tempDocumentMainPart, $search);
1✔
828
        if (!$tagPos) {
1✔
829
            throw new Exception(sprintf('Can not delete row %s, template variable not found or variable contains markup.', $search));
×
830
        }
831

832
        $tableStart = $this->findTableStart($tagPos);
1✔
833
        $tableEnd = $this->findTableEnd($tagPos);
1✔
834
        $xmlTable = $this->getSlice($tableStart, $tableEnd);
1✔
835

836
        if (substr_count($xmlTable, '<w:tr') === 1) {
1✔
837
            $this->tempDocumentMainPart = $this->getSlice(0, $tableStart) . $this->getSlice($tableEnd);
1✔
838

839
            return;
1✔
840
        }
841

842
        $rowStart = $this->findRowStart($tagPos);
×
843
        $rowEnd = $this->findRowEnd($tagPos);
×
844
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
×
845

846
        $this->tempDocumentMainPart = $this->getSlice(0, $rowStart) . $this->getSlice($rowEnd);
×
847

848
        // Check if there's a cell spanning multiple rows.
849
        if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
×
850
            $extraRowStart = $rowStart;
×
851
            while (true) {
×
852
                $extraRowStart = $this->findRowStart($extraRowStart + 1);
×
853
                $extraRowEnd = $this->findRowEnd($extraRowStart + 1);
×
854

855
                // If extraRowEnd is lower then 7, there was no next row found.
856
                if ($extraRowEnd < 7) {
×
857
                    break;
×
858
                }
859

860
                // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
861
                $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
×
862
                if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
×
863
                    !preg_match('#<w:vMerge w:val="continue" />#', $tmpXmlRow)
×
864
                ) {
865
                    break;
×
866
                }
867

868
                $tableStart = $this->findTableStart($extraRowEnd + 1);
×
869
                $tableEnd = $this->findTableEnd($extraRowEnd + 1);
×
870
                $xmlTable = $this->getSlice($tableStart, $tableEnd);
×
871
                if (substr_count($xmlTable, '<w:tr') === 1) {
×
872
                    $this->tempDocumentMainPart = $this->getSlice(0, $tableStart) . $this->getSlice($tableEnd);
×
873

874
                    return;
×
875
                }
876

877
                $this->tempDocumentMainPart = $this->getSlice(0, $extraRowStart) . $this->getSlice($extraRowEnd);
×
878
            }
879
        }
880
    }
881

882
    /**
883
     * Clones a table row and populates it's values from a two-dimensional array in a template document.
884
     *
885
     * @param string $search
886
     * @param array $values
887
     */
888
    public function cloneRowAndSetValues($search, $values): void
889
    {
890
        $this->cloneRow($search, count($values));
2✔
891

892
        foreach ($values as $rowKey => $rowData) {
2✔
893
            $rowNumber = $rowKey + 1;
2✔
894
            foreach ($rowData as $macro => $replace) {
2✔
895
                $this->setValue($macro . '#' . $rowNumber, $replace);
2✔
896
            }
897
        }
898
    }
899

900
    /**
901
     * Clone a block.
902
     *
903
     * @param string $blockname
904
     * @param int $clones How many time the block should be cloned
905
     * @param bool $replace
906
     * @param bool $indexVariables If true, any variables inside the block will be indexed (postfixed with #1, #2, ...)
907
     * @param array $variableReplacements Array containing replacements for macros found inside the block to clone
908
     *
909
     * @return null|string
910
     */
911
    public function cloneBlock($blockname, $clones = 1, $replace = true, $indexVariables = false, $variableReplacements = null)
912
    {
913
        $xmlBlock = null;
9✔
914
        $matches = [];
9✔
915
        $escapedMacroOpeningChars = self::$macroOpeningChars;
9✔
916
        $escapedMacroClosingChars = self::$macroClosingChars;
9✔
917
        preg_match(
9✔
918
            //'/(.*((?s)<w:p\b(?:(?!<w:p\b).)*?\{{' . $blockname . '}<\/w:.*?p>))(.*)((?s)<w:p\b(?:(?!<w:p\b).)[^$]*?\{{\/' . $blockname . '}<\/w:.*?p>)/is',
919
            '/(.*((?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',
9✔
920
            //'/(.*((?s)<w:p\b(?:(?!<w:p\b).)*?\\'. $escapedMacroOpeningChars . $blockname . '}<\/w:.*?p>))(.*)((?s)<w:p\b(?:(?!<w:p\b).)[^$]*?\\'.$escapedMacroOpeningChars.'\/' . $blockname . '}<\/w:.*?p>)/is',
921
            $this->tempDocumentMainPart,
9✔
922
            $matches
9✔
923
        );
9✔
924

925
        if (isset($matches[3])) {
9✔
926
            $xmlBlock = $matches[3];
9✔
927
            if ($indexVariables) {
9✔
928
                $cloned = $this->indexClonedVariables($clones, $xmlBlock);
2✔
929
            } elseif ($variableReplacements !== null && is_array($variableReplacements)) {
7✔
930
                $cloned = $this->replaceClonedVariables($variableReplacements, $xmlBlock);
2✔
931
            } else {
932
                $cloned = [];
5✔
933
                for ($i = 1; $i <= $clones; ++$i) {
5✔
934
                    $cloned[] = $xmlBlock;
5✔
935
                }
936
            }
937

938
            if ($replace) {
9✔
939
                $this->tempDocumentMainPart = str_replace(
9✔
940
                    $matches[2] . $matches[3] . $matches[4],
9✔
941
                    implode('', $cloned),
9✔
942
                    $this->tempDocumentMainPart
9✔
943
                );
9✔
944
            }
945
        }
946

947
        return $xmlBlock;
9✔
948
    }
949

950
    /**
951
     * Replace a block.
952
     *
953
     * @param string $blockname
954
     * @param string $replacement
955
     */
956
    public function replaceBlock($blockname, $replacement): void
957
    {
958
        $matches = [];
1✔
959
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
1✔
960
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
1✔
961
        preg_match(
1✔
962
            '/(<\?xml.*)(<w:p.*>' . $escapedMacroOpeningChars . $blockname . $escapedMacroClosingChars . '<\/w:.*?p>)(.*)(<w:p.*' . $escapedMacroOpeningChars . '\/' . $blockname . $escapedMacroClosingChars . '<\/w:.*?p>)/is',
1✔
963
            $this->tempDocumentMainPart,
1✔
964
            $matches
1✔
965
        );
1✔
966

967
        if (isset($matches[3])) {
1✔
968
            $this->tempDocumentMainPart = str_replace(
1✔
969
                $matches[2] . $matches[3] . $matches[4],
1✔
970
                $replacement,
1✔
971
                $this->tempDocumentMainPart
1✔
972
            );
1✔
973
        }
974
    }
975

976
    /**
977
     * Delete a block of text.
978
     *
979
     * @param string $blockname
980
     */
981
    public function deleteBlock($blockname): void
982
    {
983
        $this->replaceBlock($blockname, '');
1✔
984
    }
985

986
    /**
987
     * Automatically Recalculate Fields on Open.
988
     *
989
     * @param bool $update
990
     */
991
    public function setUpdateFields($update = true): void
992
    {
993
        $string = $update ? 'true' : 'false';
2✔
994
        $matches = [];
2✔
995
        if (preg_match('/<w:updateFields w:val=\"(true|false|1|0|on|off)\"\/>/', $this->tempDocumentSettingsPart, $matches)) {
2✔
996
            $this->tempDocumentSettingsPart = str_replace($matches[0], '<w:updateFields w:val="' . $string . '"/>', $this->tempDocumentSettingsPart);
2✔
997
        } else {
998
            $this->tempDocumentSettingsPart = str_replace('</w:settings>', '<w:updateFields w:val="' . $string . '"/></w:settings>', $this->tempDocumentSettingsPart);
2✔
999
        }
1000
    }
1001

1002
    /**
1003
     * Saves the result document.
1004
     *
1005
     * @return string
1006
     */
1007
    public function save()
1008
    {
1009
        foreach ($this->tempDocumentHeaders as $index => $xml) {
12✔
1010
            $this->savePartWithRels($this->getHeaderName($index), $xml);
4✔
1011
        }
1012

1013
        $this->savePartWithRels($this->getMainPartName(), $this->tempDocumentMainPart);
12✔
1014
        $this->savePartWithRels($this->getSettingsPartName(), $this->tempDocumentSettingsPart);
12✔
1015

1016
        foreach ($this->tempDocumentFooters as $index => $xml) {
12✔
1017
            $this->savePartWithRels($this->getFooterName($index), $xml);
5✔
1018
        }
1019

1020
        $this->zipClass->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
12✔
1021

1022
        // Close zip file
1023
        if (false === $this->zipClass->close()) {
12✔
1024
            throw new Exception('Could not close zip file.'); // @codeCoverageIgnore
1025
        }
1026

1027
        return $this->tempDocumentFilename;
12✔
1028
    }
1029

1030
    /**
1031
     * @param string $fileName
1032
     * @param string $xml
1033
     */
1034
    protected function savePartWithRels($fileName, $xml): void
1035
    {
1036
        $this->zipClass->addFromString($fileName, $xml);
12✔
1037
        if (isset($this->tempDocumentRelations[$fileName])) {
12✔
1038
            $relsFileName = $this->getRelationsName($fileName);
12✔
1039
            $this->zipClass->addFromString($relsFileName, $this->tempDocumentRelations[$fileName]);
12✔
1040
        }
1041
    }
1042

1043
    /**
1044
     * Saves the result document to the user defined file.
1045
     *
1046
     * @since 0.8.0
1047
     *
1048
     * @param string $fileName
1049
     */
1050
    public function saveAs($fileName): void
1051
    {
1052
        $tempFileName = $this->save();
9✔
1053

1054
        if (file_exists($fileName)) {
9✔
1055
            unlink($fileName);
2✔
1056
        }
1057

1058
        /*
1059
         * Note: we do not use `rename` function here, because it loses file ownership data on Windows platform.
1060
         * As a result, user cannot open the file directly getting "Access denied" message.
1061
         *
1062
         * @see https://github.com/PHPOffice/PHPWord/issues/532
1063
         */
1064
        copy($tempFileName, $fileName);
9✔
1065
        unlink($tempFileName);
9✔
1066
    }
1067

1068
    /**
1069
     * Finds parts of broken macros and sticks them together.
1070
     * Macros, while being edited, could be implicitly broken by some of the word processors.
1071
     *
1072
     * @param string $documentPart The document part in XML representation
1073
     *
1074
     * @return string
1075
     */
1076
    protected function fixBrokenMacros($documentPart)
1077
    {
1078
        $brokenMacroOpeningChars = substr(self::$macroOpeningChars, 0, 1);
28✔
1079
        $endMacroOpeningChars = substr(self::$macroOpeningChars, 1);
28✔
1080
        $macroClosingChars = self::$macroClosingChars;
28✔
1081

1082
        return preg_replace_callback(
28✔
1083
            '/\\' . $brokenMacroOpeningChars . '(?:\\' . $endMacroOpeningChars . '|[^{$]*\>\{)[^' . $macroClosingChars . '$]*\}/U',
28✔
1084
            function ($match) {
28✔
1085
                return strip_tags($match[0]);
19✔
1086
            },
28✔
1087
            $documentPart
28✔
1088
        );
28✔
1089
    }
1090

1091
    /**
1092
     * Find and replace macros in the given XML section.
1093
     *
1094
     * @param array<string>|string $search
1095
     * @param array<string>|string $replace
1096
     * @param array<int, string>|string $documentPartXML
1097
     * @param int $limit
1098
     *
1099
     * @return ($documentPartXML is string ? string : array<string>)
1100
     */
1101
    protected function setValueForPart($search, $replace, $documentPartXML, $limit)
1102
    {
1103
        // Note: we can't use the same function for both cases here, because of performance considerations.
1104
        if (self::MAXIMUM_REPLACEMENTS_DEFAULT === $limit) {
18✔
1105
            return str_replace($search, $replace, $documentPartXML);
18✔
1106
        }
1107
        $regExpEscaper = new RegExp();
3✔
1108

1109
        return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML, $limit);
3✔
1110
    }
1111

1112
    /**
1113
     * Find all variables in $documentPartXML.
1114
     *
1115
     * @param string $documentPartXML
1116
     *
1117
     * @return string[]
1118
     */
1119
    protected function getVariablesForPart($documentPartXML)
1120
    {
1121
        $matches = [];
20✔
1122
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
20✔
1123
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
20✔
1124

1125
        preg_match_all("/$escapedMacroOpeningChars(.*?)$escapedMacroClosingChars/i", $documentPartXML, $matches);
20✔
1126

1127
        return $matches[1];
20✔
1128
    }
1129

1130
    /**
1131
     * Get the name of the header file for $index.
1132
     *
1133
     * @param int $index
1134
     *
1135
     * @return string
1136
     */
1137
    protected function getHeaderName($index)
1138
    {
1139
        return sprintf('word/header%d.xml', $index);
20✔
1140
    }
1141

1142
    /**
1143
     * 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.
1144
     *
1145
     * @return string
1146
     */
1147
    protected function getMainPartName()
1148
    {
1149
        $contentTypes = $this->zipClass->getFromName('[Content_Types].xml');
20✔
1150

1151
        $pattern = '~PartName="\/(word\/document.*?\.xml)" ContentType="application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document\.main\+xml"~';
20✔
1152

1153
        $matches = [];
20✔
1154
        preg_match($pattern, $contentTypes, $matches);
20✔
1155

1156
        return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
20✔
1157
    }
1158

1159
    /**
1160
     * The name of the file containing the Settings part.
1161
     *
1162
     * @return string
1163
     */
1164
    protected function getSettingsPartName()
1165
    {
1166
        return 'word/settings.xml';
20✔
1167
    }
1168

1169
    /**
1170
     * Get the name of the footer file for $index.
1171
     *
1172
     * @param int $index
1173
     *
1174
     * @return string
1175
     */
1176
    protected function getFooterName($index)
1177
    {
1178
        return sprintf('word/footer%d.xml', $index);
20✔
1179
    }
1180

1181
    /**
1182
     * Get the name of the relations file for document part.
1183
     *
1184
     * @param string $documentPartName
1185
     *
1186
     * @return string
1187
     */
1188
    protected function getRelationsName($documentPartName)
1189
    {
1190
        return 'word/_rels/' . pathinfo($documentPartName, PATHINFO_BASENAME) . '.rels';
20✔
1191
    }
1192

1193
    protected function getNextRelationsIndex($documentPartName)
1194
    {
1195
        if (isset($this->tempDocumentRelations[$documentPartName])) {
1✔
1196
            $candidate = substr_count($this->tempDocumentRelations[$documentPartName], '<Relationship');
1✔
1197
            while (strpos($this->tempDocumentRelations[$documentPartName], 'Id="rId' . $candidate . '"') !== false) {
1✔
1198
                ++$candidate;
×
1199
            }
1200

1201
            return $candidate;
1✔
1202
        }
1203

1204
        return 1;
1✔
1205
    }
1206

1207
    /**
1208
     * @return string
1209
     */
1210
    protected function getDocumentContentTypesName()
1211
    {
1212
        return '[Content_Types].xml';
20✔
1213
    }
1214

1215
    /**
1216
     * Find the start position of the nearest table before $offset.
1217
     */
1218
    private function findTableStart(int $offset): int
1219
    {
1220
        $rowStart = strrpos(
1✔
1221
            $this->tempDocumentMainPart,
1✔
1222
            '<w:tbl ',
1✔
1223
            ((strlen($this->tempDocumentMainPart) - $offset) * -1)
1✔
1224
        );
1✔
1225

1226
        if (!$rowStart) {
1✔
1227
            $rowStart = strrpos(
1✔
1228
                $this->tempDocumentMainPart,
1✔
1229
                '<w:tbl>',
1✔
1230
                ((strlen($this->tempDocumentMainPart) - $offset) * -1)
1✔
1231
            );
1✔
1232
        }
1233
        if (!$rowStart) {
1✔
1234
            throw new Exception('Can not find the start position of the table.');
×
1235
        }
1236

1237
        return $rowStart;
1✔
1238
    }
1239

1240
    /**
1241
     * Find the end position of the nearest table row after $offset.
1242
     */
1243
    private function findTableEnd(int $offset): int
1244
    {
1245
        return strpos($this->tempDocumentMainPart, '</w:tbl>', $offset) + 7;
1✔
1246
    }
1247

1248
    /**
1249
     * Find the start position of the nearest table row before $offset.
1250
     *
1251
     * @param int $offset
1252
     *
1253
     * @return int
1254
     */
1255
    protected function findRowStart($offset)
1256
    {
1257
        $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr ', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
4✔
1258

1259
        if (!$rowStart) {
4✔
1260
            $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr>', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
4✔
1261
        }
1262
        if (!$rowStart) {
4✔
1263
            throw new Exception('Can not find the start position of the row to clone.');
×
1264
        }
1265

1266
        return $rowStart;
4✔
1267
    }
1268

1269
    /**
1270
     * Find the end position of the nearest table row after $offset.
1271
     *
1272
     * @param int $offset
1273
     *
1274
     * @return int
1275
     */
1276
    protected function findRowEnd($offset)
1277
    {
1278
        return strpos($this->tempDocumentMainPart, '</w:tr>', $offset) + 7;
4✔
1279
    }
1280

1281
    /**
1282
     * Get a slice of a string.
1283
     *
1284
     * @param int $startPosition
1285
     * @param int $endPosition
1286
     *
1287
     * @return string
1288
     */
1289
    protected function getSlice($startPosition, $endPosition = 0)
1290
    {
1291
        if (!$endPosition) {
13✔
1292
            $endPosition = strlen($this->tempDocumentMainPart);
10✔
1293
        }
1294

1295
        return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition));
13✔
1296
    }
1297

1298
    /**
1299
     * Replaces variable names in cloned
1300
     * rows/blocks with indexed names.
1301
     *
1302
     * @param int $count
1303
     * @param string $xmlBlock
1304
     *
1305
     * @return array<string>
1306
     */
1307
    protected function indexClonedVariables($count, $xmlBlock)
1308
    {
1309
        $results = [];
6✔
1310
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
6✔
1311
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
6✔
1312

1313
        for ($i = 1; $i <= $count; ++$i) {
6✔
1314
            $results[] = preg_replace("/$escapedMacroOpeningChars([^:]*?)(:.*?)?$escapedMacroClosingChars/", self::$macroOpeningChars . '\1#' . $i . '\2' . self::$macroClosingChars, $xmlBlock);
6✔
1315
        }
1316

1317
        return $results;
6✔
1318
    }
1319

1320
    /**
1321
     * Replace carriage returns with xml.
1322
     */
1323
    public function replaceCarriageReturns(string $string): string
1324
    {
1325
        return str_replace(["\r\n", "\r", "\n"], '</w:t><w:br/><w:t>', $string);
15✔
1326
    }
1327

1328
    /**
1329
     * Replaces variables with values from array, array keys are the variable names.
1330
     *
1331
     * @param array $variableReplacements
1332
     * @param string $xmlBlock
1333
     *
1334
     * @return string[]
1335
     */
1336
    protected function replaceClonedVariables($variableReplacements, $xmlBlock)
1337
    {
1338
        $results = [];
2✔
1339
        foreach ($variableReplacements as $replacementArray) {
2✔
1340
            $localXmlBlock = $xmlBlock;
2✔
1341
            foreach ($replacementArray as $search => $replacement) {
2✔
1342
                $localXmlBlock = $this->setValueForPart(self::ensureMacroCompleted($search), $replacement, $localXmlBlock, self::MAXIMUM_REPLACEMENTS_DEFAULT);
2✔
1343
            }
1344
            $results[] = $localXmlBlock;
2✔
1345
        }
1346

1347
        return $results;
2✔
1348
    }
1349

1350
    /**
1351
     * Replace an XML block surrounding a macro with a new block.
1352
     *
1353
     * @param string $macro Name of macro
1354
     * @param string $block New block content
1355
     * @param string $blockType XML tag type of block
1356
     *
1357
     * @return TemplateProcessor Fluent interface
1358
     */
1359
    public function replaceXmlBlock($macro, $block, $blockType = 'w:p')
1360
    {
1361
        $where = $this->findContainingXmlBlockForMacro($macro, $blockType);
5✔
1362
        if (is_array($where)) {
5✔
1363
            $this->tempDocumentMainPart = $this->getSlice(0, $where['start']) . $block . $this->getSlice($where['end']);
5✔
1364
        }
1365

1366
        return $this;
5✔
1367
    }
1368

1369
    /**
1370
     * Find start and end of XML block containing the given macro
1371
     * e.g. <w:p>...${macro}...</w:p>.
1372
     *
1373
     * Note that only the first instance of the macro will be found
1374
     *
1375
     * @param string $macro Name of macro
1376
     * @param string $blockType XML tag for block
1377
     *
1378
     * @return bool|int[] FALSE if not found, otherwise array with start and end
1379
     */
1380
    protected function findContainingXmlBlockForMacro($macro, $blockType = 'w:p')
1381
    {
1382
        $macroPos = $this->findMacro($macro);
9✔
1383
        if (0 > $macroPos) {
9✔
1384
            return false;
3✔
1385
        }
1386
        $start = $this->findXmlBlockStart($macroPos, $blockType);
8✔
1387
        if (0 > $start) {
8✔
1388
            return false;
1✔
1389
        }
1390
        $end = $this->findXmlBlockEnd($start, $blockType);
8✔
1391
        //if not found or if resulting string does not contain the macro we are searching for
1392
        if (0 > $end || strstr($this->getSlice($start, $end), $macro) === false) {
8✔
1393
            return false;
1✔
1394
        }
1395

1396
        return ['start' => $start, 'end' => $end];
7✔
1397
    }
1398

1399
    /**
1400
     * Find the position of (the start of) a macro.
1401
     *
1402
     * Returns -1 if not found, otherwise position of opening $
1403
     *
1404
     * Note that only the first instance of the macro will be found
1405
     *
1406
     * @param string $search Macro name
1407
     * @param int $offset Offset from which to start searching
1408
     *
1409
     * @return int -1 if macro not found
1410
     */
1411
    protected function findMacro($search, $offset = 0)
1412
    {
1413
        $search = static::ensureMacroCompleted($search);
9✔
1414
        $pos = strpos($this->tempDocumentMainPart, $search, $offset);
9✔
1415

1416
        return ($pos === false) ? -1 : $pos;
9✔
1417
    }
1418

1419
    /**
1420
     * Find the start position of the nearest XML block start before $offset.
1421
     *
1422
     * @param int $offset    Search position
1423
     * @param string  $blockType XML Block tag
1424
     *
1425
     * @return int -1 if block start not found
1426
     */
1427
    protected function findXmlBlockStart($offset, $blockType)
1428
    {
1429
        $reverseOffset = (strlen($this->tempDocumentMainPart) - $offset) * -1;
8✔
1430
        // first try XML tag with attributes
1431
        $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . ' ', $reverseOffset);
8✔
1432
        // if not found, or if found but contains the XML tag without attribute
1433
        if (false === $blockStart || strrpos($this->getSlice($blockStart, $offset), '<' . $blockType . '>')) {
8✔
1434
            // also try XML tag without attributes
1435
            $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . '>', $reverseOffset);
8✔
1436
        }
1437

1438
        return ($blockStart === false) ? -1 : $blockStart;
8✔
1439
    }
1440

1441
    /**
1442
     * Find the nearest block end position after $offset.
1443
     *
1444
     * @param int $offset    Search position
1445
     * @param string  $blockType XML Block tag
1446
     *
1447
     * @return int -1 if block end not found
1448
     */
1449
    protected function findXmlBlockEnd($offset, $blockType)
1450
    {
1451
        $blockEndStart = strpos($this->tempDocumentMainPart, '</' . $blockType . '>', $offset);
8✔
1452
        // return position of end of tag if found, otherwise -1
1453

1454
        return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType);
8✔
1455
    }
1456

1457
    /**
1458
     * Splits a w:r/w:t into a list of w:r where each ${macro} is in a separate w:r.
1459
     *
1460
     * @param string $text
1461
     *
1462
     * @return string
1463
     */
1464
    protected function splitTextIntoTexts($text)
1465
    {
1466
        if (!$this->textNeedsSplitting($text)) {
7✔
1467
            return $text;
4✔
1468
        }
1469
        $matches = [];
7✔
1470
        if (preg_match('/(<w:rPr.*<\/w:rPr>)/i', $text, $matches)) {
7✔
1471
            $extractedStyle = $matches[0];
4✔
1472
        } else {
1473
            $extractedStyle = '';
3✔
1474
        }
1475

1476
        $unformattedText = preg_replace('/>\s+</', '><', $text);
7✔
1477
        $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);
7✔
1478

1479
        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);
7✔
1480
    }
1481

1482
    /**
1483
     * Returns true if string contains a macro that is not in it's own w:r.
1484
     *
1485
     * @param string $text
1486
     *
1487
     * @return bool
1488
     */
1489
    protected function textNeedsSplitting($text)
1490
    {
1491
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
7✔
1492
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
7✔
1493

1494
        return 1 === preg_match('/[^>]' . $escapedMacroOpeningChars . '|' . $escapedMacroClosingChars . '[^<]/i', $text);
7✔
1495
    }
1496

1497
    public function setMacroOpeningChars(string $macroOpeningChars): void
1498
    {
1499
        self::$macroOpeningChars = $macroOpeningChars;
5✔
1500
    }
1501

1502
    public function setMacroClosingChars(string $macroClosingChars): void
1503
    {
1504
        self::$macroClosingChars = $macroClosingChars;
5✔
1505
    }
1506

1507
    public function setMacroChars(string $macroOpeningChars, string $macroClosingChars): void
1508
    {
1509
        self::$macroOpeningChars = $macroOpeningChars;
15✔
1510
        self::$macroClosingChars = $macroClosingChars;
15✔
1511
    }
1512

1513
    public function getTempDocumentFilename(): string
1514
    {
1515
        return $this->tempDocumentFilename;
20✔
1516
    }
1517
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc