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

PHPOffice / PHPWord / 12658036797

07 Jan 2025 07:31PM UTC coverage: 96.96% (+0.05%) from 96.91%
12658036797

push

github

Progi1984
Added support for PHP 8.4

33 of 38 new or added lines in 17 files covered. (86.84%)

2 existing lines in 1 file now uncovered.

11896 of 12269 relevant lines covered (96.96%)

33.23 hits per line

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

87.5
/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
        $this->tempDocumentContentTypes = $this->zipClass->getFromName($this->getDocumentContentTypesName());
19✔
137
    }
138

139
    public function __destruct()
140
    {
141
        // ZipClass
142
        if ($this->zipClass) {
49✔
143
            try {
144
                $this->zipClass->close();
19✔
145
            } catch (Throwable $e) {
11✔
146
                // Nothing to do here.
147
            }
148
        }
149
    }
150

151
    /**
152
     * Expose zip class.
153
     *
154
     * To replace an image: $templateProcessor->zip()->AddFromString("word/media/image1.jpg", file_get_contents($file));<br>
155
     * To read a file: $templateProcessor->zip()->getFromName("word/media/image1.jpg");
156
     *
157
     * @return ZipArchive
158
     */
159
    public function zip()
160
    {
161
        return $this->zipClass;
2✔
162
    }
163

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

177
        return $this->fixBrokenMacros($this->zipClass->getFromName($fileName));
19✔
178
    }
179

180
    /**
181
     * @param string $xml
182
     * @param XSLTProcessor $xsltProcessor
183
     *
184
     * @return string
185
     */
186
    protected function transformSingleXml($xml, $xsltProcessor)
187
    {
188
        if (\PHP_VERSION_ID < 80000) {
2✔
189
            $orignalLibEntityLoader = libxml_disable_entity_loader(true);
2✔
190
        }
191
        $domDocument = new DOMDocument();
2✔
192
        if (false === $domDocument->loadXML($xml)) {
2✔
193
            throw new Exception('Could not load the given XML document.');
1✔
194
        }
195

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

204
        return $transformedXml;
1✔
205
    }
206

207
    /**
208
     * @param mixed $xml
209
     * @param XSLTProcessor $xsltProcessor
210
     *
211
     * @return mixed
212
     */
213
    protected function transformXml($xml, $xsltProcessor)
214
    {
215
        if (is_array($xml)) {
2✔
216
            foreach ($xml as &$item) {
2✔
217
                $item = $this->transformSingleXml($item, $xsltProcessor);
1✔
218
            }
219
            unset($item);
2✔
220
        } else {
221
            $xml = $this->transformSingleXml($xml, $xsltProcessor);
2✔
222
        }
223

224
        return $xml;
2✔
225
    }
226

227
    /**
228
     * Applies XSL style sheet to template's parts.
229
     *
230
     * Note: since the method doesn't make any guess on logic of the provided XSL style sheet,
231
     * make sure that output is correctly escaped. Otherwise you may get broken document.
232
     *
233
     * @param DOMDocument $xslDomDocument
234
     * @param array $xslOptions
235
     * @param string $xslOptionsUri
236
     */
237
    public function applyXslStyleSheet($xslDomDocument, $xslOptions = [], $xslOptionsUri = ''): void
238
    {
239
        $xsltProcessor = new XSLTProcessor();
3✔
240

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

246
        $this->tempDocumentHeaders = $this->transformXml($this->tempDocumentHeaders, $xsltProcessor);
2✔
247
        $this->tempDocumentMainPart = $this->transformXml($this->tempDocumentMainPart, $xsltProcessor);
2✔
248
        $this->tempDocumentFooters = $this->transformXml($this->tempDocumentFooters, $xsltProcessor);
1✔
249
    }
250

251
    /**
252
     * @param string $macro
253
     *
254
     * @return string
255
     */
256
    protected static function ensureMacroCompleted($macro)
257
    {
258
        if (substr($macro, 0, 2) !== self::$macroOpeningChars && substr($macro, -1) !== self::$macroClosingChars) {
26✔
259
            $macro = self::$macroOpeningChars . $macro . self::$macroClosingChars;
22✔
260
        }
261

262
        return $macro;
26✔
263
    }
264

265
    /**
266
     * @param ?string $subject
267
     *
268
     * @return string
269
     */
270
    protected static function ensureUtf8Encoded($subject)
271
    {
272
        return $subject ? Text::toUTF8($subject) : '';
14✔
273
    }
274

275
    /**
276
     * @param string $search
277
     */
278
    public function setComplexValue($search, Element\AbstractElement $complexType): void
279
    {
280
        $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1);
2✔
281
        $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
2✔
282

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

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

290
        if ($where === false) {
2✔
291
            return;
×
292
        }
293

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

298
        $search = static::ensureMacroCompleted($search);
2✔
299
        $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:r');
2✔
300
    }
301

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

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

315
        $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:p');
2✔
316
    }
317

318
    /**
319
     * @param mixed $search
320
     * @param mixed $replace
321
     * @param int $limit
322
     */
323
    public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
324
    {
325
        if (is_array($search)) {
14✔
326
            foreach ($search as &$item) {
2✔
327
                $item = static::ensureMacroCompleted($item);
2✔
328
            }
329
            unset($item);
2✔
330
        } else {
331
            $search = static::ensureMacroCompleted($search);
12✔
332
        }
333

334
        if (is_array($replace)) {
14✔
335
            foreach ($replace as &$item) {
2✔
336
                $item = static::ensureUtf8Encoded($item);
2✔
337
            }
338
            unset($item);
2✔
339
        } else {
340
            $replace = static::ensureUtf8Encoded($replace);
12✔
341
        }
342

343
        if (Settings::isOutputEscapingEnabled()) {
14✔
344
            $xmlEscaper = new Xml();
2✔
345
            $replace = $xmlEscaper->escape($replace);
2✔
346
        }
347

348
        // convert carriage returns
349
        if (is_array($replace)) {
14✔
350
            foreach ($replace as &$item) {
2✔
351
                $item = $this->replaceCarriageReturns($item);
2✔
352
            }
353
        } else {
354
            $replace = $this->replaceCarriageReturns($replace);
12✔
355
        }
356

357
        $this->tempDocumentHeaders = $this->setValueForPart($search, $replace, $this->tempDocumentHeaders, $limit);
14✔
358
        $this->tempDocumentMainPart = $this->setValueForPart($search, $replace, $this->tempDocumentMainPart, $limit);
14✔
359
        $this->tempDocumentFooters = $this->setValueForPart($search, $replace, $this->tempDocumentFooters, $limit);
14✔
360
    }
361

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

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

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

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

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

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

390
        $this->replaceXmlBlock($search, $block, $blockType);
2✔
391
    }
392

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

401
        // Get the next relation id
402
        $rId = $this->getNextRelationsIndex($this->getMainPartName());
×
403
        $chart->setRelationId($rId);
×
404

405
        // Define the chart filename
406
        $filename = "charts/chart{$rId}.xml";
×
407

408
        // Get the part writer
NEW
409
        $writerPart = new Writer\Word2007\Part\Chart();
×
410
        $writerPart->setElement($chart);
×
411

412
        // ContentTypes.xml
413
        $this->zipClass->addFromString("word/{$filename}", $writerPart->write());
×
414

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

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

423
        // Write the chart
424
        $xmlWriter = new XMLWriter();
×
425
        $elementWriter = new $objectClass($xmlWriter, $chart, true);
×
426
        $elementWriter->write();
×
427

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

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

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

455
                        break;
1✔
456
                    case 1:
1✔
457
                        $varInlineArgs['height'] = $varArg;
1✔
458

459
                        break;
1✔
460
                    case 2:
×
461
                        $varInlineArgs['ratio'] = $varArg;
×
462

463
                        break;
×
464
                }
465
            }
466
        }
467

468
        return $varInlineArgs;
1✔
469
    }
470

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

487
        return $value;
1✔
488
    }
489

490
    private function fixImageWidthHeightRatio(&$width, &$height, $actualWidth, $actualHeight): void
491
    {
492
        $imageRatio = $actualWidth / $actualHeight;
1✔
493

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

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

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

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

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

560
        $width = $this->chooseImageDimension($width, $varInlineArgs['width'] ?? null, 115);
1✔
561
        $height = $this->chooseImageDimension($height, $varInlineArgs['height'] ?? null, 70);
1✔
562

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

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

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

584
        return $imageAttrs;
1✔
585
    }
586

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

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

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

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

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

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

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

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

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

655
        $searchReplace = [];
1✔
656
        foreach ($search as $searchIdx => $searchString) {
1✔
657
            $searchReplace[$searchString] = $replacesList[$searchIdx] ?? $replacesList[0];
1✔
658
        }
659

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

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

675
        $i = 0;
1✔
676
        foreach ($searchParts as $partFileName => &$partContent) {
1✔
677
            $partVariables = $this->getVariablesForPart($partContent);
1✔
678

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

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

689
                    // get image index
690
                    $imgIndex = $this->getNextRelationsIndex($partFileName);
1✔
691
                    $rid = 'rId' . $imgIndex;
1✔
692

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

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

709
                    if (++$i >= $limit) {
1✔
710
                        break;
1✔
711
                    }
712
                }
713
            }
714
        }
715
    }
716

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

726
        foreach ($this->tempDocumentHeaders as $headerXML) {
15✔
727
            $variables = array_merge(
4✔
728
                $variables,
4✔
729
                $this->getVariablesForPart($headerXML)
4✔
730
            );
4✔
731
        }
732

733
        foreach ($this->tempDocumentFooters as $footerXML) {
15✔
734
            $variables = array_merge(
6✔
735
                $variables,
6✔
736
                $this->getVariablesForPart($footerXML)
6✔
737
            );
6✔
738
        }
739

740
        return array_count_values($variables);
15✔
741
    }
742

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

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

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

768
        $rowStart = $this->findRowStart($tagPos);
4✔
769
        $rowEnd = $this->findRowEnd($tagPos);
4✔
770
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
4✔
771

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

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

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

798
        $result = $this->getSlice(0, $rowStart);
4✔
799
        $result .= implode('', $this->indexClonedVariables($numberOfClones, $xmlRow));
4✔
800
        $result .= $this->getSlice($rowEnd);
4✔
801

802
        $this->tempDocumentMainPart = $result;
4✔
803
    }
804

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

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

819
        $tableStart = $this->findTableStart($tagPos);
1✔
820
        $tableEnd = $this->findTableEnd($tagPos);
1✔
821
        $xmlTable = $this->getSlice($tableStart, $tableEnd);
1✔
822

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

826
            return;
1✔
827
        }
828

829
        $rowStart = $this->findRowStart($tagPos);
×
830
        $rowEnd = $this->findRowEnd($tagPos);
×
831
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
×
832

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

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

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

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

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

861
                    return;
×
862
                }
863

864
                $this->tempDocumentMainPart = $this->getSlice(0, $extraRowStart) . $this->getSlice($extraRowEnd);
×
865
            }
866
        }
867
    }
868

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

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

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

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

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

934
        return $xmlBlock;
9✔
935
    }
936

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

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

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

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

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

1000
        $this->savePartWithRels($this->getMainPartName(), $this->tempDocumentMainPart);
11✔
1001
        $this->savePartWithRels($this->getSettingsPartName(), $this->tempDocumentSettingsPart);
11✔
1002

1003
        foreach ($this->tempDocumentFooters as $index => $xml) {
11✔
1004
            $this->savePartWithRels($this->getFooterName($index), $xml);
5✔
1005
        }
1006

1007
        $this->zipClass->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
11✔
1008

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

1014
        return $this->tempDocumentFilename;
11✔
1015
    }
1016

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

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

1041
        if (file_exists($fileName)) {
9✔
1042
            unlink($fileName);
2✔
1043
        }
1044

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

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

1069
        return preg_replace_callback(
25✔
1070
            '/\\' . $brokenMacroOpeningChars . '(?:\\' . $endMacroOpeningChars . '|[^{$]*\>\{)[^' . $macroClosingChars . '$]*\}/U',
25✔
1071
            function ($match) {
25✔
1072
                return strip_tags($match[0]);
16✔
1073
            },
25✔
1074
            $documentPart
25✔
1075
        );
25✔
1076
    }
1077

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

1096
        return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML, $limit);
2✔
1097
    }
1098

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

1112
        preg_match_all("/$escapedMacroOpeningChars(.*?)$escapedMacroClosingChars/i", $documentPartXML, $matches);
18✔
1113

1114
        return $matches[1];
18✔
1115
    }
1116

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

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

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

1140
        $matches = [];
19✔
1141
        preg_match($pattern, $contentTypes, $matches);
19✔
1142

1143
        return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
19✔
1144
    }
1145

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

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

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

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

1188
            return $candidate;
1✔
1189
        }
1190

1191
        return 1;
1✔
1192
    }
1193

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

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

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

1224
        return $rowStart;
1✔
1225
    }
1226

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

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

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

1253
        return $rowStart;
4✔
1254
    }
1255

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

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

1282
        return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition));
12✔
1283
    }
1284

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

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

1304
        return $results;
6✔
1305
    }
1306

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

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

1334
        return $results;
2✔
1335
    }
1336

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

1353
        return $this;
4✔
1354
    }
1355

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

1383
        return ['start' => $start, 'end' => $end];
6✔
1384
    }
1385

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

1403
        return ($pos === false) ? -1 : $pos;
8✔
1404
    }
1405

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

1425
        return ($blockStart === false) ? -1 : $blockStart;
7✔
1426
    }
1427

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

1441
        return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType);
7✔
1442
    }
1443

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

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

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

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

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

1484
    public function setMacroOpeningChars(string $macroOpeningChars): void
1485
    {
1486
        self::$macroOpeningChars = $macroOpeningChars;
5✔
1487
    }
1488

1489
    public function setMacroClosingChars(string $macroClosingChars): void
1490
    {
1491
        self::$macroClosingChars = $macroClosingChars;
5✔
1492
    }
1493

1494
    public function setMacroChars(string $macroOpeningChars, string $macroClosingChars): void
1495
    {
1496
        self::$macroOpeningChars = $macroOpeningChars;
15✔
1497
        self::$macroClosingChars = $macroClosingChars;
15✔
1498
    }
1499

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