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

PHPOffice / PHPWord / 16421226744

21 Jul 2025 03:27PM UTC coverage: 96.757%. Remained the same
16421226744

Pull #2801

github

web-flow
Merge 4e2536ab5 into 0ab0b4940
Pull Request #2801: Easy way to add a table

12501 of 12920 relevant lines covered (96.76%)

35.44 hits per line

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

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

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

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

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

142
    public function __destruct()
143
    {
144
        // ZipClass
145
        if ($this->zipClass) {
50✔
146
            try {
147
                $this->zipClass->close();
19✔
148
            } catch (Throwable $e) {
11✔
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);
19✔
175
        $partRelations = $this->zipClass->getFromName($relsFileName);
19✔
176
        if ($partRelations !== false) {
19✔
177
            $this->tempDocumentRelations[$fileName] = $partRelations;
19✔
178
        }
179

180
        return $this->fixBrokenMacros($this->zipClass->getFromName($fileName));
19✔
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) {
27✔
262
            $macro = self::$macroOpeningChars . $macro . self::$macroClosingChars;
23✔
263
        }
264

265
        return $macro;
27✔
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): void
282
    {
283
        $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1);
2✔
284
        $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
2✔
285

286
        $xmlWriter = new XMLWriter();
2✔
287
        /** @var Writer\Word2007\Element\AbstractElement $elementWriter */
288
        $elementWriter = new $objectClass($xmlWriter, $complexType, true);
2✔
289
        $elementWriter->write();
2✔
290

291
        $where = $this->findContainingXmlBlockForMacro($search, 'w:r');
2✔
292

293
        if ($where === false) {
2✔
294
            return;
×
295
        }
296

297
        $block = $this->getSlice($where['start'], $where['end']);
2✔
298
        $textParts = $this->splitTextIntoTexts($block);
2✔
299
        $this->replaceXmlBlock($search, $textParts, 'w:r');
2✔
300

301
        $search = static::ensureMacroCompleted($search);
2✔
302
        $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:r');
2✔
303
    }
304

305
    /**
306
     * @param string $search
307
     */
308
    public function setComplexBlock($search, Element\AbstractElement $complexType): void
309
    {
310
        $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1);
2✔
311
        $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
2✔
312

313
        $xmlWriter = new XMLWriter();
2✔
314
        /** @var Writer\Word2007\Element\AbstractElement $elementWriter */
315
        $elementWriter = new $objectClass($xmlWriter, $complexType, false);
2✔
316
        $elementWriter->write();
2✔
317

318
        $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:p');
2✔
319
    }
320

321
    /**
322
     * @param array<string>|string $search
323
     * @param null|array<string>|bool|float|int|string $replace
324
     * @param int $limit
325
     */
326
    public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
327
    {
328
        if (is_array($search)) {
15✔
329
            foreach ($search as &$item) {
2✔
330
                $item = static::ensureMacroCompleted($item);
2✔
331
            }
332
            unset($item);
2✔
333
        } else {
334
            $search = static::ensureMacroCompleted($search);
13✔
335
        }
336

337
        if (is_array($replace)) {
15✔
338
            foreach ($replace as &$item) {
2✔
339
                $item = static::ensureUtf8Encoded($item);
2✔
340
            }
341
            unset($item);
2✔
342
        } else {
343
            $replace = static::ensureUtf8Encoded(null === $replace ? null : (string) $replace);
13✔
344
        }
345

346
        if (Settings::isOutputEscapingEnabled()) {
15✔
347
            $xmlEscaper = new Xml();
2✔
348
            $replace = $xmlEscaper->escape($replace);
2✔
349
        }
350

351
        // convert carriage returns
352
        if (is_array($replace)) {
15✔
353
            foreach ($replace as &$item) {
2✔
354
                $item = $this->replaceCarriageReturns($item);
2✔
355
            }
356
        } else {
357
            $replace = $this->replaceCarriageReturns($replace);
13✔
358
        }
359

360
        $this->tempDocumentHeaders = $this->setValueForPart($search, $replace, $this->tempDocumentHeaders, $limit);
15✔
361
        $this->tempDocumentMainPart = $this->setValueForPart($search, $replace, $this->tempDocumentMainPart, $limit);
15✔
362
        $this->tempDocumentFooters = $this->setValueForPart($search, $replace, $this->tempDocumentFooters, $limit);
15✔
363
    }
364

365
    /**
366
     * Set values from a one-dimensional array of "variable => value"-pairs.
367
     */
368
    public function setValues(array $values, int $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
369
    {
370
        foreach ($values as $macro => $replace) {
3✔
371
            $this->setValue($macro, $replace, $limit);
3✔
372
        }
373
    }
374

375
    public function setCheckbox(string $search, bool $checked): void
376
    {
377
        $search = static::ensureMacroCompleted($search);
2✔
378
        $blockType = 'w:sdt';
2✔
379

380
        $where = $this->findContainingXmlBlockForMacro($search, $blockType);
2✔
381
        if (!is_array($where)) {
2✔
382
            return;
×
383
        }
384

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

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

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

393
        $this->replaceXmlBlock($search, $block, $blockType);
2✔
394
    }
395

396
    /**
397
     * @param string $search
398
     */
399
    public function setChart($search, Element\AbstractElement $chart): void
400
    {
401
        $elementName = substr(get_class($chart), strrpos(get_class($chart), '\\') + 1);
×
402
        $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
×
403

404
        // Get the next relation id
405
        $rId = $this->getNextRelationsIndex($this->getMainPartName());
×
406
        $chart->setRelationId($rId);
×
407

408
        // Define the chart filename
409
        $filename = "charts/chart{$rId}.xml";
×
410

411
        // Get the part writer
412
        $writerPart = new Writer\Word2007\Part\Chart();
×
413
        $writerPart->setElement($chart);
×
414

415
        // ContentTypes.xml
416
        $this->zipClass->addFromString("word/{$filename}", $writerPart->write());
×
417

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

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

426
        // Write the chart
427
        $xmlWriter = new XMLWriter();
×
428
        $elementWriter = new $objectClass($xmlWriter, $chart, true);
×
429
        $elementWriter->write();
×
430

431
        // Place it in the template
432
        $this->replaceXmlBlock($search, '<w:p>' . $xmlWriter->getData() . '</w:p>', 'w:p');
×
433
    }
434

435
    private function getImageArgs($varNameWithArgs)
436
    {
437
        $varElements = explode(':', $varNameWithArgs);
1✔
438
        array_shift($varElements); // first element is name of variable => remove it
1✔
439

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

458
                        break;
1✔
459
                    case 1:
1✔
460
                        $varInlineArgs['height'] = $varArg;
1✔
461

462
                        break;
1✔
463
                    case 2:
×
464
                        $varInlineArgs['ratio'] = $varArg;
×
465

466
                        break;
×
467
                }
468
            }
469
        }
470

471
        return $varInlineArgs;
1✔
472
    }
473

474
    private function chooseImageDimension($baseValue, $inlineValue, $defaultValue)
475
    {
476
        $value = $baseValue;
1✔
477
        if (null === $value && isset($inlineValue)) {
1✔
478
            $value = $inlineValue;
1✔
479
        }
480
        if (!preg_match('/^([0-9\.]*(cm|mm|in|pt|pc|px|%|em|ex|)|auto)$/i', $value ?? '')) {
1✔
481
            $value = null;
×
482
        }
483
        if (null === $value) {
1✔
484
            $value = $defaultValue;
1✔
485
        }
486
        if (is_numeric($value)) {
1✔
487
            $value .= 'px';
1✔
488
        }
489

490
        return $value;
1✔
491
    }
492

493
    private function fixImageWidthHeightRatio(&$width, &$height, $actualWidth, $actualHeight): void
494
    {
495
        $imageRatio = $actualWidth / $actualHeight;
1✔
496

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

526
                if ($imageRatio > $definedRatio) { // image wider than defined box
1✔
527
                    $height = ($widthFloat / $imageRatio) . $dimention;
×
528
                } elseif ($imageRatio < $definedRatio) { // image higher than defined box
1✔
529
                    $width = ($heightFloat * $imageRatio) . $dimention;
1✔
530
                }
531
            }
532
        }
533
    }
534

535
    private function prepareImageAttrs($replaceImage, $varInlineArgs)
536
    {
537
        // get image path and size
538
        $width = null;
1✔
539
        $height = null;
1✔
540
        $ratio = null;
1✔
541

542
        // a closure can be passed as replacement value which after resolving, can contain the replacement info for the image
543
        // use case: only when a image if found, the replacement tags can be generated
544
        if (is_callable($replaceImage)) {
1✔
545
            $replaceImage = $replaceImage();
1✔
546
        }
547

548
        if (is_array($replaceImage) && isset($replaceImage['path'])) {
1✔
549
            $imgPath = $replaceImage['path'];
1✔
550
            if (isset($replaceImage['width'])) {
1✔
551
                $width = $replaceImage['width'];
1✔
552
            }
553
            if (isset($replaceImage['height'])) {
1✔
554
                $height = $replaceImage['height'];
1✔
555
            }
556
            if (isset($replaceImage['ratio'])) {
1✔
557
                $ratio = $replaceImage['ratio'];
1✔
558
            }
559
        } else {
560
            $imgPath = $replaceImage;
1✔
561
        }
562

563
        $width = $this->chooseImageDimension($width, $varInlineArgs['width'] ?? null, 115);
1✔
564
        $height = $this->chooseImageDimension($height, $varInlineArgs['height'] ?? null, 70);
1✔
565

566
        $imageData = @getimagesize($imgPath);
1✔
567
        if (!is_array($imageData)) {
1✔
568
            throw new Exception(sprintf('Invalid image: %s', $imgPath));
×
569
        }
570
        [$actualWidth, $actualHeight, $imageType] = $imageData;
1✔
571

572
        // fix aspect ratio (by default)
573
        if (null === $ratio && isset($varInlineArgs['ratio'])) {
1✔
574
            $ratio = $varInlineArgs['ratio'];
1✔
575
        }
576
        if (null === $ratio || !in_array(strtolower($ratio), ['', '-', 'f', 'false'])) {
1✔
577
            $this->fixImageWidthHeightRatio($width, $height, $actualWidth, $actualHeight);
1✔
578
        }
579

580
        $imageAttrs = [
1✔
581
            'src' => $imgPath,
1✔
582
            'mime' => image_type_to_mime_type($imageType),
1✔
583
            'width' => $width,
1✔
584
            'height' => $height,
1✔
585
        ];
1✔
586

587
        return $imageAttrs;
1✔
588
    }
589

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

604
        // get image embed name
605
        if (isset($this->tempDocumentNewImages[$imgPath])) {
1✔
606
            $imgName = $this->tempDocumentNewImages[$imgPath];
1✔
607
        } else {
608
            // transform extension
609
            if (isset($extTransform[$imageMimeType])) {
1✔
610
                $imgExt = $extTransform[$imageMimeType];
1✔
611
            } else {
612
                throw new Exception("Unsupported image type $imageMimeType");
×
613
            }
614

615
            // add image to document
616
            $imgName = 'image_' . $rid . '_' . pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt;
1✔
617
            $this->zipClass->pclzipAddFile($imgPath, 'word/media/' . $imgName);
1✔
618
            $this->tempDocumentNewImages[$imgPath] = $imgName;
1✔
619

620
            // setup type for image
621
            $xmlImageType = str_replace(['{IMG}', '{EXT}'], [$imgName, $imgExt], $typeTpl);
1✔
622
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlImageType, $this->tempDocumentContentTypes) . '</Types>';
1✔
623
        }
624

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

627
        if (!isset($this->tempDocumentRelations[$partFileName])) {
1✔
628
            // create new relations file
629
            $this->tempDocumentRelations[$partFileName] = $newRelationsTpl;
1✔
630
            // and add it to content types
631
            $xmlRelationsType = str_replace('{RELS}', $this->getRelationsName($partFileName), $newRelationsTypeTpl);
1✔
632
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
1✔
633
        }
634

635
        // add image to relations
636
        $this->tempDocumentRelations[$partFileName] = str_replace('</Relationships>', $xmlImageRelation, $this->tempDocumentRelations[$partFileName]) . '</Relationships>';
1✔
637
    }
638

639
    /**
640
     * @param mixed $search
641
     * @param mixed $replace Path to image, or array("path" => xx, "width" => yy, "height" => zz)
642
     * @param int $limit
643
     */
644
    public function setImageValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
645
    {
646
        // prepare $search_replace
647
        if (!is_array($search)) {
1✔
648
            $search = [$search];
1✔
649
        }
650

651
        $replacesList = [];
1✔
652
        if (!is_array($replace) || isset($replace['path'])) {
1✔
653
            $replacesList[] = $replace;
1✔
654
        } else {
655
            $replacesList = array_values($replace);
1✔
656
        }
657

658
        $searchReplace = [];
1✔
659
        foreach ($search as $searchIdx => $searchString) {
1✔
660
            $searchReplace[$searchString] = $replacesList[$searchIdx] ?? $replacesList[0];
1✔
661
        }
662

663
        // collect document parts
664
        $searchParts = [
1✔
665
            $this->getMainPartName() => &$this->tempDocumentMainPart,
1✔
666
        ];
1✔
667
        foreach (array_keys($this->tempDocumentHeaders) as $headerIndex) {
1✔
668
            $searchParts[$this->getHeaderName($headerIndex)] = &$this->tempDocumentHeaders[$headerIndex];
1✔
669
        }
670
        foreach (array_keys($this->tempDocumentFooters) as $footerIndex) {
1✔
671
            $searchParts[$this->getFooterName($footerIndex)] = &$this->tempDocumentFooters[$footerIndex];
1✔
672
        }
673

674
        // define templates
675
        // result can be verified via "Open XML SDK 2.5 Productivity Tool" (http://www.microsoft.com/en-us/download/details.aspx?id=30425)
676
        $imgTpl = '<w:pict><v:shape type="#_x0000_t75" style="width:{WIDTH};height:{HEIGHT}" stroked="f" filled="f"><v:imagedata r:id="{RID}" o:title=""/></v:shape></w:pict>';
1✔
677

678
        $i = 0;
1✔
679
        foreach ($searchParts as $partFileName => &$partContent) {
1✔
680
            $partVariables = $this->getVariablesForPart($partContent);
1✔
681

682
            foreach ($searchReplace as $searchString => $replaceImage) {
1✔
683
                $varsToReplace = array_filter($partVariables, function ($partVar) use ($searchString) {
1✔
684
                    return ($partVar == $searchString) || preg_match('/^' . preg_quote($searchString, '/') . ':/', $partVar);
1✔
685
                });
1✔
686

687
                foreach ($varsToReplace as $varNameWithArgs) {
1✔
688
                    $varInlineArgs = $this->getImageArgs($varNameWithArgs);
1✔
689
                    $preparedImageAttrs = $this->prepareImageAttrs($replaceImage, $varInlineArgs);
1✔
690
                    $imgPath = $preparedImageAttrs['src'];
1✔
691

692
                    // get image index
693
                    $imgIndex = $this->getNextRelationsIndex($partFileName);
1✔
694
                    $rid = 'rId' . $imgIndex;
1✔
695

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

700
                    // replace variable
701
                    $varNameWithArgsFixed = static::ensureMacroCompleted($varNameWithArgs);
1✔
702
                    $matches = [];
1✔
703
                    if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed, '/') . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) {
1✔
704
                        $wholeTag = $matches[0];
1✔
705
                        array_shift($matches);
1✔
706
                        [$openTag, $prefix, , $postfix, $closeTag] = $matches;
1✔
707
                        $replaceXml = $openTag . $prefix . $closeTag . $xmlImage . $openTag . $postfix . $closeTag;
1✔
708
                        // replace on each iteration, because in one tag we can have 2+ inline variables => before proceed next variable we need to change $partContent
709
                        $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent, $limit);
1✔
710
                    }
711

712
                    if (++$i >= $limit) {
1✔
713
                        break;
1✔
714
                    }
715
                }
716
            }
717
        }
718
    }
719

720
    /**
721
     * Returns count of all variables in template.
722
     *
723
     * @return array
724
     */
725
    public function getVariableCount()
726
    {
727
        $variables = $this->getVariablesForPart($this->tempDocumentMainPart);
17✔
728

729
        foreach ($this->tempDocumentHeaders as $headerXML) {
17✔
730
            $variables = array_merge(
4✔
731
                $variables,
4✔
732
                $this->getVariablesForPart($headerXML)
4✔
733
            );
4✔
734
        }
735

736
        foreach ($this->tempDocumentFooters as $footerXML) {
17✔
737
            $variables = array_merge(
6✔
738
                $variables,
6✔
739
                $this->getVariablesForPart($footerXML)
6✔
740
            );
6✔
741
        }
742

743
        return array_count_values($variables);
17✔
744
    }
745

746
    /**
747
     * Returns array of all variables in template.
748
     *
749
     * @return string[]
750
     */
751
    public function getVariables()
752
    {
753
        return array_keys($this->getVariableCount());
14✔
754
    }
755

756
    /**
757
     * Clone a table row in a template document.
758
     *
759
     * @param string $search
760
     * @param int $numberOfClones
761
     */
762
    public function cloneRow($search, $numberOfClones): void
763
    {
764
        $search = static::ensureMacroCompleted($search);
5✔
765

766
        $tagPos = strpos($this->tempDocumentMainPart, $search);
5✔
767
        if (!$tagPos) {
5✔
768
            throw new Exception('Can not clone row, template variable not found or variable contains markup.');
1✔
769
        }
770

771
        $rowStart = $this->findRowStart($tagPos);
4✔
772
        $rowEnd = $this->findRowEnd($tagPos);
4✔
773
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
4✔
774

775
        // Check if there's a cell spanning multiple rows.
776
        if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
4✔
777
            // $extraRowStart = $rowEnd;
778
            $extraRowEnd = $rowEnd;
4✔
779
            while (true) {
4✔
780
                $extraRowStart = $this->findRowStart($extraRowEnd + 1);
4✔
781
                $extraRowEnd = $this->findRowEnd($extraRowEnd + 1);
4✔
782

783
                // If extraRowEnd is lower then 7, there was no next row found.
784
                if ($extraRowEnd < 7) {
4✔
785
                    break;
×
786
                }
787

788
                // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
789
                $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
4✔
790
                if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
4✔
791
                    !preg_match('#<w:vMerge w:val="continue"\s*/>#', $tmpXmlRow)
4✔
792
                ) {
793
                    break;
4✔
794
                }
795
                // This row was a spanned row, update $rowEnd and search for the next row.
796
                $rowEnd = $extraRowEnd;
4✔
797
            }
798
            $xmlRow = $this->getSlice($rowStart, $rowEnd);
4✔
799
        }
800

801
        $result = $this->getSlice(0, $rowStart);
4✔
802
        $result .= implode('', $this->indexClonedVariables($numberOfClones, $xmlRow));
4✔
803
        $result .= $this->getSlice($rowEnd);
4✔
804

805
        $this->tempDocumentMainPart = $result;
4✔
806
    }
807

808
    /**
809
     * Delete a table row in a template document.
810
     */
811
    public function deleteRow(string $search): void
812
    {
813
        if (self::$macroOpeningChars !== substr($search, 0, 2) && self::$macroClosingChars !== substr($search, -1)) {
1✔
814
            $search = self::$macroOpeningChars . $search . self::$macroClosingChars;
1✔
815
        }
816

817
        $tagPos = strpos($this->tempDocumentMainPart, $search);
1✔
818
        if (!$tagPos) {
1✔
819
            throw new Exception(sprintf('Can not delete row %s, template variable not found or variable contains markup.', $search));
×
820
        }
821

822
        $tableStart = $this->findTableStart($tagPos);
1✔
823
        $tableEnd = $this->findTableEnd($tagPos);
1✔
824
        $xmlTable = $this->getSlice($tableStart, $tableEnd);
1✔
825

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

829
            return;
1✔
830
        }
831

832
        $rowStart = $this->findRowStart($tagPos);
×
833
        $rowEnd = $this->findRowEnd($tagPos);
×
834
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
×
835

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

838
        // Check if there's a cell spanning multiple rows.
839
        if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
×
840
            $extraRowStart = $rowStart;
×
841
            while (true) {
×
842
                $extraRowStart = $this->findRowStart($extraRowStart + 1);
×
843
                $extraRowEnd = $this->findRowEnd($extraRowStart + 1);
×
844

845
                // If extraRowEnd is lower then 7, there was no next row found.
846
                if ($extraRowEnd < 7) {
×
847
                    break;
×
848
                }
849

850
                // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
851
                $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
×
852
                if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
×
853
                    !preg_match('#<w:vMerge w:val="continue" />#', $tmpXmlRow)
×
854
                ) {
855
                    break;
×
856
                }
857

858
                $tableStart = $this->findTableStart($extraRowEnd + 1);
×
859
                $tableEnd = $this->findTableEnd($extraRowEnd + 1);
×
860
                $xmlTable = $this->getSlice($tableStart, $tableEnd);
×
861
                if (substr_count($xmlTable, '<w:tr') === 1) {
×
862
                    $this->tempDocumentMainPart = $this->getSlice(0, $tableStart) . $this->getSlice($tableEnd);
×
863

864
                    return;
×
865
                }
866

867
                $this->tempDocumentMainPart = $this->getSlice(0, $extraRowStart) . $this->getSlice($extraRowEnd);
×
868
            }
869
        }
870
    }
871

872
    /**
873
     * Clones a table row and populates it's values from a two-dimensional array in a template document.
874
     *
875
     * @param string $search
876
     * @param array $values
877
     */
878
    public function cloneRowAndSetValues($search, $values): void
879
    {
880
        $this->cloneRow($search, count($values));
2✔
881

882
        foreach ($values as $rowKey => $rowData) {
2✔
883
            $rowNumber = $rowKey + 1;
2✔
884
            foreach ($rowData as $macro => $replace) {
2✔
885
                $this->setValue($macro . '#' . $rowNumber, $replace);
2✔
886
            }
887
        }
888
    }
889

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

915
        if (isset($matches[3])) {
9✔
916
            $xmlBlock = $matches[3];
9✔
917
            if ($indexVariables) {
9✔
918
                $cloned = $this->indexClonedVariables($clones, $xmlBlock);
2✔
919
            } elseif ($variableReplacements !== null && is_array($variableReplacements)) {
7✔
920
                $cloned = $this->replaceClonedVariables($variableReplacements, $xmlBlock);
2✔
921
            } else {
922
                $cloned = [];
5✔
923
                for ($i = 1; $i <= $clones; ++$i) {
5✔
924
                    $cloned[] = $xmlBlock;
5✔
925
                }
926
            }
927

928
            if ($replace) {
9✔
929
                $this->tempDocumentMainPart = str_replace(
9✔
930
                    $matches[2] . $matches[3] . $matches[4],
9✔
931
                    implode('', $cloned),
9✔
932
                    $this->tempDocumentMainPart
9✔
933
                );
9✔
934
            }
935
        }
936

937
        return $xmlBlock;
9✔
938
    }
939

940
    /**
941
     * Replace a block.
942
     *
943
     * @param string $blockname
944
     * @param string $replacement
945
     */
946
    public function replaceBlock($blockname, $replacement): void
947
    {
948
        $matches = [];
1✔
949
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
1✔
950
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
1✔
951
        preg_match(
1✔
952
            '/(<\?xml.*)(<w:p.*>' . $escapedMacroOpeningChars . $blockname . $escapedMacroClosingChars . '<\/w:.*?p>)(.*)(<w:p.*' . $escapedMacroOpeningChars . '\/' . $blockname . $escapedMacroClosingChars . '<\/w:.*?p>)/is',
1✔
953
            $this->tempDocumentMainPart,
1✔
954
            $matches
1✔
955
        );
1✔
956

957
        if (isset($matches[3])) {
1✔
958
            $this->tempDocumentMainPart = str_replace(
1✔
959
                $matches[2] . $matches[3] . $matches[4],
1✔
960
                $replacement,
1✔
961
                $this->tempDocumentMainPart
1✔
962
            );
1✔
963
        }
964
    }
965

966
    /**
967
     * Delete a block of text.
968
     *
969
     * @param string $blockname
970
     */
971
    public function deleteBlock($blockname): void
972
    {
973
        $this->replaceBlock($blockname, '');
1✔
974
    }
975

976
    /**
977
     * Automatically Recalculate Fields on Open.
978
     *
979
     * @param bool $update
980
     */
981
    public function setUpdateFields($update = true): void
982
    {
983
        $string = $update ? 'true' : 'false';
2✔
984
        $matches = [];
2✔
985
        if (preg_match('/<w:updateFields w:val=\"(true|false|1|0|on|off)\"\/>/', $this->tempDocumentSettingsPart, $matches)) {
2✔
986
            $this->tempDocumentSettingsPart = str_replace($matches[0], '<w:updateFields w:val="' . $string . '"/>', $this->tempDocumentSettingsPart);
2✔
987
        } else {
988
            $this->tempDocumentSettingsPart = str_replace('</w:settings>', '<w:updateFields w:val="' . $string . '"/></w:settings>', $this->tempDocumentSettingsPart);
2✔
989
        }
990
    }
991

992
    /**
993
     * Saves the result document.
994
     *
995
     * @return string
996
     */
997
    public function save()
998
    {
999
        foreach ($this->tempDocumentHeaders as $index => $xml) {
11✔
1000
            $this->savePartWithRels($this->getHeaderName($index), $xml);
4✔
1001
        }
1002

1003
        $this->savePartWithRels($this->getMainPartName(), $this->tempDocumentMainPart);
11✔
1004
        $this->savePartWithRels($this->getSettingsPartName(), $this->tempDocumentSettingsPart);
11✔
1005

1006
        foreach ($this->tempDocumentFooters as $index => $xml) {
11✔
1007
            $this->savePartWithRels($this->getFooterName($index), $xml);
5✔
1008
        }
1009

1010
        $this->zipClass->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
11✔
1011

1012
        // Close zip file
1013
        if (false === $this->zipClass->close()) {
11✔
1014
            throw new Exception('Could not close zip file.'); // @codeCoverageIgnore
1015
        }
1016

1017
        return $this->tempDocumentFilename;
11✔
1018
    }
1019

1020
    /**
1021
     * @param string $fileName
1022
     * @param string $xml
1023
     */
1024
    protected function savePartWithRels($fileName, $xml): void
1025
    {
1026
        $this->zipClass->addFromString($fileName, $xml);
11✔
1027
        if (isset($this->tempDocumentRelations[$fileName])) {
11✔
1028
            $relsFileName = $this->getRelationsName($fileName);
11✔
1029
            $this->zipClass->addFromString($relsFileName, $this->tempDocumentRelations[$fileName]);
11✔
1030
        }
1031
    }
1032

1033
    /**
1034
     * Saves the result document to the user defined file.
1035
     *
1036
     * @since 0.8.0
1037
     *
1038
     * @param string $fileName
1039
     */
1040
    public function saveAs($fileName): void
1041
    {
1042
        $tempFileName = $this->save();
9✔
1043

1044
        if (file_exists($fileName)) {
9✔
1045
            unlink($fileName);
2✔
1046
        }
1047

1048
        /*
1049
         * Note: we do not use `rename` function here, because it loses file ownership data on Windows platform.
1050
         * As a result, user cannot open the file directly getting "Access denied" message.
1051
         *
1052
         * @see https://github.com/PHPOffice/PHPWord/issues/532
1053
         */
1054
        copy($tempFileName, $fileName);
9✔
1055
        unlink($tempFileName);
9✔
1056
    }
1057

1058
    /**
1059
     * Finds parts of broken macros and sticks them together.
1060
     * Macros, while being edited, could be implicitly broken by some of the word processors.
1061
     *
1062
     * @param string $documentPart The document part in XML representation
1063
     *
1064
     * @return string
1065
     */
1066
    protected function fixBrokenMacros($documentPart)
1067
    {
1068
        $brokenMacroOpeningChars = substr(self::$macroOpeningChars, 0, 1);
27✔
1069
        $endMacroOpeningChars = substr(self::$macroOpeningChars, 1);
27✔
1070
        $macroClosingChars = self::$macroClosingChars;
27✔
1071

1072
        return preg_replace_callback(
27✔
1073
            '/\\' . $brokenMacroOpeningChars . '(?:\\' . $endMacroOpeningChars . '|[^{$]*\>\{)[^' . $macroClosingChars . '$]*\}/U',
27✔
1074
            function ($match) {
27✔
1075
                return strip_tags($match[0]);
18✔
1076
            },
27✔
1077
            $documentPart
27✔
1078
        );
27✔
1079
    }
1080

1081
    /**
1082
     * Find and replace macros in the given XML section.
1083
     *
1084
     * @param array<string>|string $search
1085
     * @param array<string>|string $replace
1086
     * @param array<int, string>|string $documentPartXML
1087
     * @param int $limit
1088
     *
1089
     * @return ($documentPartXML is string ? string : array<string>)
1090
     */
1091
    protected function setValueForPart($search, $replace, $documentPartXML, $limit)
1092
    {
1093
        // Note: we can't use the same function for both cases here, because of performance considerations.
1094
        if (self::MAXIMUM_REPLACEMENTS_DEFAULT === $limit) {
18✔
1095
            return str_replace($search, $replace, $documentPartXML);
18✔
1096
        }
1097
        $regExpEscaper = new RegExp();
3✔
1098

1099
        return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML, $limit);
3✔
1100
    }
1101

1102
    /**
1103
     * Find all variables in $documentPartXML.
1104
     *
1105
     * @param string $documentPartXML
1106
     *
1107
     * @return string[]
1108
     */
1109
    protected function getVariablesForPart($documentPartXML)
1110
    {
1111
        $matches = [];
20✔
1112
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
20✔
1113
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
20✔
1114

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

1117
        return $matches[1];
20✔
1118
    }
1119

1120
    /**
1121
     * Get the name of the header file for $index.
1122
     *
1123
     * @param int $index
1124
     *
1125
     * @return string
1126
     */
1127
    protected function getHeaderName($index)
1128
    {
1129
        return sprintf('word/header%d.xml', $index);
19✔
1130
    }
1131

1132
    /**
1133
     * Usually, the name of main part document will be 'document.xml'. However, some .docx files (possibly those from Office 365, experienced also on documents from Word Online created from blank templates) have file 'document22.xml' in their zip archive instead of 'document.xml'. This method searches content types file to correctly determine the file name.
1134
     *
1135
     * @return string
1136
     */
1137
    protected function getMainPartName()
1138
    {
1139
        $contentTypes = $this->zipClass->getFromName('[Content_Types].xml');
19✔
1140

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

1143
        $matches = [];
19✔
1144
        preg_match($pattern, $contentTypes, $matches);
19✔
1145

1146
        return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
19✔
1147
    }
1148

1149
    /**
1150
     * The name of the file containing the Settings part.
1151
     *
1152
     * @return string
1153
     */
1154
    protected function getSettingsPartName()
1155
    {
1156
        return 'word/settings.xml';
19✔
1157
    }
1158

1159
    /**
1160
     * Get the name of the footer file for $index.
1161
     *
1162
     * @param int $index
1163
     *
1164
     * @return string
1165
     */
1166
    protected function getFooterName($index)
1167
    {
1168
        return sprintf('word/footer%d.xml', $index);
19✔
1169
    }
1170

1171
    /**
1172
     * Get the name of the relations file for document part.
1173
     *
1174
     * @param string $documentPartName
1175
     *
1176
     * @return string
1177
     */
1178
    protected function getRelationsName($documentPartName)
1179
    {
1180
        return 'word/_rels/' . pathinfo($documentPartName, PATHINFO_BASENAME) . '.rels';
19✔
1181
    }
1182

1183
    protected function getNextRelationsIndex($documentPartName)
1184
    {
1185
        if (isset($this->tempDocumentRelations[$documentPartName])) {
1✔
1186
            $candidate = substr_count($this->tempDocumentRelations[$documentPartName], '<Relationship');
1✔
1187
            while (strpos($this->tempDocumentRelations[$documentPartName], 'Id="rId' . $candidate . '"') !== false) {
1✔
1188
                ++$candidate;
×
1189
            }
1190

1191
            return $candidate;
1✔
1192
        }
1193

1194
        return 1;
1✔
1195
    }
1196

1197
    /**
1198
     * @return string
1199
     */
1200
    protected function getDocumentContentTypesName()
1201
    {
1202
        return '[Content_Types].xml';
19✔
1203
    }
1204

1205
    /**
1206
     * Find the start position of the nearest table before $offset.
1207
     */
1208
    private function findTableStart(int $offset): int
1209
    {
1210
        $rowStart = strrpos(
1✔
1211
            $this->tempDocumentMainPart,
1✔
1212
            '<w:tbl ',
1✔
1213
            ((strlen($this->tempDocumentMainPart) - $offset) * -1)
1✔
1214
        );
1✔
1215

1216
        if (!$rowStart) {
1✔
1217
            $rowStart = strrpos(
1✔
1218
                $this->tempDocumentMainPart,
1✔
1219
                '<w:tbl>',
1✔
1220
                ((strlen($this->tempDocumentMainPart) - $offset) * -1)
1✔
1221
            );
1✔
1222
        }
1223
        if (!$rowStart) {
1✔
1224
            throw new Exception('Can not find the start position of the table.');
×
1225
        }
1226

1227
        return $rowStart;
1✔
1228
    }
1229

1230
    /**
1231
     * Find the end position of the nearest table row after $offset.
1232
     */
1233
    private function findTableEnd(int $offset): int
1234
    {
1235
        return strpos($this->tempDocumentMainPart, '</w:tbl>', $offset) + 7;
1✔
1236
    }
1237

1238
    /**
1239
     * Find the start position of the nearest table row before $offset.
1240
     *
1241
     * @param int $offset
1242
     *
1243
     * @return int
1244
     */
1245
    protected function findRowStart($offset)
1246
    {
1247
        $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr ', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
4✔
1248

1249
        if (!$rowStart) {
4✔
1250
            $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr>', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
4✔
1251
        }
1252
        if (!$rowStart) {
4✔
1253
            throw new Exception('Can not find the start position of the row to clone.');
×
1254
        }
1255

1256
        return $rowStart;
4✔
1257
    }
1258

1259
    /**
1260
     * Find the end position of the nearest table row after $offset.
1261
     *
1262
     * @param int $offset
1263
     *
1264
     * @return int
1265
     */
1266
    protected function findRowEnd($offset)
1267
    {
1268
        return strpos($this->tempDocumentMainPart, '</w:tr>', $offset) + 7;
4✔
1269
    }
1270

1271
    /**
1272
     * Get a slice of a string.
1273
     *
1274
     * @param int $startPosition
1275
     * @param int $endPosition
1276
     *
1277
     * @return string
1278
     */
1279
    protected function getSlice($startPosition, $endPosition = 0)
1280
    {
1281
        if (!$endPosition) {
12✔
1282
            $endPosition = strlen($this->tempDocumentMainPart);
9✔
1283
        }
1284

1285
        return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition));
12✔
1286
    }
1287

1288
    /**
1289
     * Replaces variable names in cloned
1290
     * rows/blocks with indexed names.
1291
     *
1292
     * @param int $count
1293
     * @param string $xmlBlock
1294
     *
1295
     * @return array<string>
1296
     */
1297
    protected function indexClonedVariables($count, $xmlBlock)
1298
    {
1299
        $results = [];
6✔
1300
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
6✔
1301
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
6✔
1302

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

1307
        return $results;
6✔
1308
    }
1309

1310
    /**
1311
     * Replace carriage returns with xml.
1312
     */
1313
    public function replaceCarriageReturns(string $string): string
1314
    {
1315
        return str_replace(["\r\n", "\r", "\n"], '</w:t><w:br/><w:t>', $string);
15✔
1316
    }
1317

1318
    /**
1319
     * Replaces variables with values from array, array keys are the variable names.
1320
     *
1321
     * @param array $variableReplacements
1322
     * @param string $xmlBlock
1323
     *
1324
     * @return string[]
1325
     */
1326
    protected function replaceClonedVariables($variableReplacements, $xmlBlock)
1327
    {
1328
        $results = [];
2✔
1329
        foreach ($variableReplacements as $replacementArray) {
2✔
1330
            $localXmlBlock = $xmlBlock;
2✔
1331
            foreach ($replacementArray as $search => $replacement) {
2✔
1332
                $localXmlBlock = $this->setValueForPart(self::ensureMacroCompleted($search), $replacement, $localXmlBlock, self::MAXIMUM_REPLACEMENTS_DEFAULT);
2✔
1333
            }
1334
            $results[] = $localXmlBlock;
2✔
1335
        }
1336

1337
        return $results;
2✔
1338
    }
1339

1340
    /**
1341
     * Replace an XML block surrounding a macro with a new block.
1342
     *
1343
     * @param string $macro Name of macro
1344
     * @param string $block New block content
1345
     * @param string $blockType XML tag type of block
1346
     *
1347
     * @return TemplateProcessor Fluent interface
1348
     */
1349
    public function replaceXmlBlock($macro, $block, $blockType = 'w:p')
1350
    {
1351
        $where = $this->findContainingXmlBlockForMacro($macro, $blockType);
4✔
1352
        if (is_array($where)) {
4✔
1353
            $this->tempDocumentMainPart = $this->getSlice(0, $where['start']) . $block . $this->getSlice($where['end']);
4✔
1354
        }
1355

1356
        return $this;
4✔
1357
    }
1358

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

1386
        return ['start' => $start, 'end' => $end];
6✔
1387
    }
1388

1389
    /**
1390
     * Find the position of (the start of) a macro.
1391
     *
1392
     * Returns -1 if not found, otherwise position of opening $
1393
     *
1394
     * Note that only the first instance of the macro will be found
1395
     *
1396
     * @param string $search Macro name
1397
     * @param int $offset Offset from which to start searching
1398
     *
1399
     * @return int -1 if macro not found
1400
     */
1401
    protected function findMacro($search, $offset = 0)
1402
    {
1403
        $search = static::ensureMacroCompleted($search);
8✔
1404
        $pos = strpos($this->tempDocumentMainPart, $search, $offset);
8✔
1405

1406
        return ($pos === false) ? -1 : $pos;
8✔
1407
    }
1408

1409
    /**
1410
     * Find the start position of the nearest XML block start before $offset.
1411
     *
1412
     * @param int $offset    Search position
1413
     * @param string  $blockType XML Block tag
1414
     *
1415
     * @return int -1 if block start not found
1416
     */
1417
    protected function findXmlBlockStart($offset, $blockType)
1418
    {
1419
        $reverseOffset = (strlen($this->tempDocumentMainPart) - $offset) * -1;
7✔
1420
        // first try XML tag with attributes
1421
        $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . ' ', $reverseOffset);
7✔
1422
        // if not found, or if found but contains the XML tag without attribute
1423
        if (false === $blockStart || strrpos($this->getSlice($blockStart, $offset), '<' . $blockType . '>')) {
7✔
1424
            // also try XML tag without attributes
1425
            $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . '>', $reverseOffset);
7✔
1426
        }
1427

1428
        return ($blockStart === false) ? -1 : $blockStart;
7✔
1429
    }
1430

1431
    /**
1432
     * Find the nearest block end position after $offset.
1433
     *
1434
     * @param int $offset    Search position
1435
     * @param string  $blockType XML Block tag
1436
     *
1437
     * @return int -1 if block end not found
1438
     */
1439
    protected function findXmlBlockEnd($offset, $blockType)
1440
    {
1441
        $blockEndStart = strpos($this->tempDocumentMainPart, '</' . $blockType . '>', $offset);
7✔
1442
        // return position of end of tag if found, otherwise -1
1443

1444
        return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType);
7✔
1445
    }
1446

1447
    /**
1448
     * Splits a w:r/w:t into a list of w:r where each ${macro} is in a separate w:r.
1449
     *
1450
     * @param string $text
1451
     *
1452
     * @return string
1453
     */
1454
    protected function splitTextIntoTexts($text)
1455
    {
1456
        if (!$this->textNeedsSplitting($text)) {
6✔
1457
            return $text;
4✔
1458
        }
1459
        $matches = [];
6✔
1460
        if (preg_match('/(<w:rPr.*<\/w:rPr>)/i', $text, $matches)) {
6✔
1461
            $extractedStyle = $matches[0];
4✔
1462
        } else {
1463
            $extractedStyle = '';
2✔
1464
        }
1465

1466
        $unformattedText = preg_replace('/>\s+</', '><', $text);
6✔
1467
        $result = str_replace([self::$macroOpeningChars, self::$macroClosingChars], ['</w:t></w:r><w:r>' . $extractedStyle . '<w:t xml:space="preserve">' . self::$macroOpeningChars, self::$macroClosingChars . '</w:t></w:r><w:r>' . $extractedStyle . '<w:t xml:space="preserve">'], $unformattedText);
6✔
1468

1469
        return str_replace(['<w:r>' . $extractedStyle . '<w:t xml:space="preserve"></w:t></w:r>', '<w:r><w:t xml:space="preserve"></w:t></w:r>', '<w:t>'], ['', '', '<w:t xml:space="preserve">'], $result);
6✔
1470
    }
1471

1472
    /**
1473
     * Returns true if string contains a macro that is not in it's own w:r.
1474
     *
1475
     * @param string $text
1476
     *
1477
     * @return bool
1478
     */
1479
    protected function textNeedsSplitting($text)
1480
    {
1481
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
6✔
1482
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
6✔
1483

1484
        return 1 === preg_match('/[^>]' . $escapedMacroOpeningChars . '|' . $escapedMacroClosingChars . '[^<]/i', $text);
6✔
1485
    }
1486

1487
    public function setMacroOpeningChars(string $macroOpeningChars): void
1488
    {
1489
        self::$macroOpeningChars = $macroOpeningChars;
5✔
1490
    }
1491

1492
    public function setMacroClosingChars(string $macroClosingChars): void
1493
    {
1494
        self::$macroClosingChars = $macroClosingChars;
5✔
1495
    }
1496

1497
    public function setMacroChars(string $macroOpeningChars, string $macroClosingChars): void
1498
    {
1499
        self::$macroOpeningChars = $macroOpeningChars;
15✔
1500
        self::$macroClosingChars = $macroClosingChars;
15✔
1501
    }
1502

1503
    public function getTempDocumentFilename(): string
1504
    {
1505
        return $this->tempDocumentFilename;
19✔
1506
    }
1507
}
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

© 2026 Coveralls, Inc