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

PHPOffice / PHPWord / 13461426829

21 Feb 2025 04:52PM UTC coverage: 96.905% (+0.1%) from 96.767%
13461426829

Pull #2567

github

web-flow
Merge e64a82db6 into 6ca8c9ff6
Pull Request #2567: WIP Do Not Install

300 of 307 new or added lines in 26 files covered. (97.72%)

161 existing lines in 21 files now uncovered.

12681 of 13086 relevant lines covered (96.91%)

37.43 hits per line

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

87.66
/src/PhpWord/TemplateProcessor.php
1
<?php
2

3
/**
4
 * This file is part of PHPWord - A pure PHP library for reading and writing
5
 * word processing documents.
6
 *
7
 * PHPWord is free software distributed under the terms of the GNU Lesser
8
 * General Public License version 3 as published by the Free Software Foundation.
9
 *
10
 * For the full copyright and license information, please read the LICENSE
11
 * file that was distributed with this source code. For the full list of
12
 * contributors, visit https://github.com/PHPOffice/PHPWord/contributors.
13
 *
14
 * @see         https://github.com/PHPOffice/PHPWord
15
 *
16
 * @license     http://www.gnu.org/licenses/lgpl.txt LGPL version 3
17
 */
18

19
namespace PhpOffice\PhpWord;
20

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

33
class TemplateProcessor
34
{
35
    const MAXIMUM_REPLACEMENTS_DEFAULT = -1;
36

37
    /**
38
     * ZipArchive object.
39
     *
40
     * @var mixed
41
     */
42
    protected $zipClass;
43

44
    /**
45
     * @var string Temporary document filename (with path)
46
     */
47
    protected $tempDocumentFilename;
48

49
    /**
50
     * Content of main document part (in XML format) of the temporary document.
51
     *
52
     * @var string
53
     */
54
    protected $tempDocumentMainPart;
55

56
    /**
57
     * Content of settings part (in XML format) of the temporary document.
58
     *
59
     * @var string
60
     */
61
    protected $tempDocumentSettingsPart;
62

63
    /**
64
     * Content of headers (in XML format) of the temporary document.
65
     *
66
     * @var string[]
67
     */
68
    protected $tempDocumentHeaders = [];
69

70
    /**
71
     * Content of footers (in XML format) of the temporary document.
72
     *
73
     * @var string[]
74
     */
75
    protected $tempDocumentFooters = [];
76

77
    /**
78
     * Document relations (in XML format) of the temporary document.
79
     *
80
     * @var string[]
81
     */
82
    protected $tempDocumentRelations = [];
83

84
    /**
85
     * Document content types (in XML format) of the temporary document.
86
     *
87
     * @var string
88
     */
89
    protected $tempDocumentContentTypes = '';
90

91
    /**
92
     * new inserted images list.
93
     *
94
     * @var string[]
95
     */
96
    protected $tempDocumentNewImages = [];
97

98
    protected static $macroOpeningChars = '${';
99

100
    protected static $macroClosingChars = '}';
101

102
    /**
103
     * @since 0.12.0 Throws CreateTemporaryFileException and CopyFileException instead of Exception
104
     *
105
     * @param string $documentTemplate The fully qualified template filename
106
     */
107
    public function __construct($documentTemplate)
108
    {
109
        // Temporary document filename initialization
110
        $this->tempDocumentFilename = tempnam(Settings::getTempDir(), 'PhpWord');
20✔
111
        if (false === $this->tempDocumentFilename) {
20✔
112
            throw new CreateTemporaryFileException(); // @codeCoverageIgnore
113
        }
114

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

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

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

139
    public function __destruct()
140
    {
141
        // ZipClass
142
        if ($this->zipClass) {
51✔
143
            try {
144
                $this->zipClass->close();
20✔
145
            } catch (Throwable $e) {
12✔
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);
20✔
172
        $partRelations = $this->zipClass->getFromName($relsFileName);
20✔
173
        if ($partRelations !== false) {
20✔
174
            $this->tempDocumentRelations[$fileName] = $partRelations;
20✔
175
        }
176

177
        return $this->fixBrokenMacros($this->zipClass->getFromName($fileName));
20✔
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) {
28✔
259
            $macro = self::$macroOpeningChars . $macro . self::$macroClosingChars;
24✔
260
        }
261

262
        return $macro;
28✔
263
    }
264

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

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

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

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

294
        if ($where === false) {
3✔
295
            return;
1✔
296
        }
297

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

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

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

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

325
        $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:p');
3✔
326
    }
327

328
    /**
329
     * @param mixed $search
330
     * @param mixed $replace
331
     * @param int $limit
332
     */
333
    public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
334
    {
335
        if (is_array($search)) {
15✔
336
            foreach ($search as &$item) {
2✔
337
                $item = static::ensureMacroCompleted($item);
2✔
338
            }
339
            unset($item);
2✔
340
        } else {
341
            $search = static::ensureMacroCompleted($search);
13✔
342
        }
343

344
        if (is_array($replace)) {
15✔
345
            foreach ($replace as &$item) {
2✔
346
                $item = static::ensureUtf8Encoded($item);
2✔
347
            }
348
            unset($item);
2✔
349
        } else {
350
            $replace = static::ensureUtf8Encoded($replace);
13✔
351
        }
352

353
        if (Settings::isOutputEscapingEnabled()) {
15✔
354
            $xmlEscaper = new Xml();
2✔
355
            $replace = $xmlEscaper->escape($replace);
2✔
356
        }
357

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

367
        $this->tempDocumentHeaders = $this->setValueForPart($search, $replace, $this->tempDocumentHeaders, $limit);
15✔
368
        $this->tempDocumentMainPart = $this->setValueForPart($search, $replace, $this->tempDocumentMainPart, $limit);
15✔
369
        $this->tempDocumentFooters = $this->setValueForPart($search, $replace, $this->tempDocumentFooters, $limit);
15✔
370
    }
371

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

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

387
        $where = $this->findContainingXmlBlockForMacro($search, $blockType);
2✔
388
        if (!is_array($where)) {
2✔
UNCOV
389
            return;
×
390
        }
391

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

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

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

400
        $this->replaceXmlBlock($search, $block, $blockType);
2✔
401
    }
402

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

411
        // Get the next relation id
UNCOV
412
        $rId = $this->getNextRelationsIndex($this->getMainPartName());
×
413
        $chart->setRelationId($rId);
×
414

415
        // Define the chart filename
416
        $filename = "charts/chart{$rId}.xml";
×
417

418
        // Get the part writer
UNCOV
419
        $writerPart = new Writer\Word2007\Part\Chart();
×
420
        $writerPart->setElement($chart);
×
421

422
        // ContentTypes.xml
423
        $this->zipClass->addFromString("word/{$filename}", $writerPart->write());
×
424

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

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

433
        // Write the chart
UNCOV
434
        $xmlWriter = new XMLWriter();
×
UNCOV
435
        $elementWriter = new $objectClass($xmlWriter, $chart, true);
×
436
        $elementWriter->write();
×
437

438
        // Place it in the template
UNCOV
439
        $this->replaceXmlBlock($search, '<w:p>' . $xmlWriter->getData() . '</w:p>', 'w:p');
×
440
    }
441

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

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

465
                        break;
1✔
466
                    case 1:
1✔
467
                        $varInlineArgs['height'] = $varArg;
1✔
468

469
                        break;
1✔
470
                    case 2:
×
UNCOV
471
                        $varInlineArgs['ratio'] = $varArg;
×
472

UNCOV
473
                        break;
×
474
                }
475
            }
476
        }
477

478
        return $varInlineArgs;
1✔
479
    }
480

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

497
        return $value;
1✔
498
    }
499

500
    private function fixImageWidthHeightRatio(&$width, &$height, $actualWidth, $actualHeight): void
501
    {
502
        $imageRatio = $actualWidth / $actualHeight;
1✔
503

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

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

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

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

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

570
        $width = $this->chooseImageDimension($width, $varInlineArgs['width'] ?? null, 115);
1✔
571
        $height = $this->chooseImageDimension($height, $varInlineArgs['height'] ?? null, 70);
1✔
572

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

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

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

594
        return $imageAttrs;
1✔
595
    }
596

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

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

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

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

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

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

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

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

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

665
        $searchReplace = [];
1✔
666
        foreach ($search as $searchIdx => $searchString) {
1✔
667
            $searchReplace[$searchString] = $replacesList[$searchIdx] ?? $replacesList[0];
1✔
668
        }
669

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

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

685
        $i = 0;
1✔
686
        foreach ($searchParts as $partFileName => &$partContent) {
1✔
687
            $partVariables = $this->getVariablesForPart($partContent);
1✔
688

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

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

699
                    // get image index
700
                    $imgIndex = $this->getNextRelationsIndex($partFileName);
1✔
701
                    $rid = 'rId' . $imgIndex;
1✔
702

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

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

719
                    if (++$i >= $limit) {
1✔
720
                        break;
1✔
721
                    }
722
                }
723
            }
724
        }
725
    }
726

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

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

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

750
        return array_count_values($variables);
17✔
751
    }
752

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

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

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

778
        $rowStart = $this->findRowStart($tagPos);
4✔
779
        $rowEnd = $this->findRowEnd($tagPos);
4✔
780
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
4✔
781

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

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

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

808
        $result = $this->getSlice(0, $rowStart);
4✔
809
        $result .= implode('', $this->indexClonedVariables($numberOfClones, $xmlRow));
4✔
810
        $result .= $this->getSlice($rowEnd);
4✔
811

812
        $this->tempDocumentMainPart = $result;
4✔
813
    }
814

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

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

829
        $tableStart = $this->findTableStart($tagPos);
1✔
830
        $tableEnd = $this->findTableEnd($tagPos);
1✔
831
        $xmlTable = $this->getSlice($tableStart, $tableEnd);
1✔
832

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

836
            return;
1✔
837
        }
838

UNCOV
839
        $rowStart = $this->findRowStart($tagPos);
×
840
        $rowEnd = $this->findRowEnd($tagPos);
×
UNCOV
841
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
×
842

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

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

852
                // If extraRowEnd is lower then 7, there was no next row found.
UNCOV
853
                if ($extraRowEnd < 7) {
×
UNCOV
854
                    break;
×
855
                }
856

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

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

871
                    return;
×
872
                }
873

UNCOV
874
                $this->tempDocumentMainPart = $this->getSlice(0, $extraRowStart) . $this->getSlice($extraRowEnd);
×
875
            }
876
        }
877
    }
878

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

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

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

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

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

944
        return $xmlBlock;
9✔
945
    }
946

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

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

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

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

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

1010
        $this->savePartWithRels($this->getMainPartName(), $this->tempDocumentMainPart);
12✔
1011
        $this->savePartWithRels($this->getSettingsPartName(), $this->tempDocumentSettingsPart);
12✔
1012

1013
        foreach ($this->tempDocumentFooters as $index => $xml) {
12✔
1014
            $this->savePartWithRels($this->getFooterName($index), $xml);
5✔
1015
        }
1016

1017
        $this->zipClass->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
12✔
1018

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

1024
        return $this->tempDocumentFilename;
12✔
1025
    }
1026

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

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

1051
        if (file_exists($fileName)) {
9✔
1052
            unlink($fileName);
2✔
1053
        }
1054

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

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

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

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

1106
        return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML, $limit);
3✔
1107
    }
1108

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

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

1124
        return $matches[1];
20✔
1125
    }
1126

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

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

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

1150
        $matches = [];
20✔
1151
        preg_match($pattern, $contentTypes, $matches);
20✔
1152

1153
        return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
20✔
1154
    }
1155

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

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

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

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

1198
            return $candidate;
1✔
1199
        }
1200

1201
        return 1;
1✔
1202
    }
1203

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

1212
    /**
1213
     * Find the start position of the nearest table before $offset.
1214
     */
1215
    private function findTableStart(int $offset): int
1216
    {
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
            $rowStart = strrpos(
1✔
1225
                $this->tempDocumentMainPart,
1✔
1226
                '<w:tbl>',
1✔
1227
                ((strlen($this->tempDocumentMainPart) - $offset) * -1)
1✔
1228
            );
1✔
1229
        }
1230
        if (!$rowStart) {
1✔
UNCOV
1231
            throw new Exception('Can not find the start position of the table.');
×
1232
        }
1233

1234
        return $rowStart;
1✔
1235
    }
1236

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

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

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

1263
        return $rowStart;
4✔
1264
    }
1265

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

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

1292
        return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition));
13✔
1293
    }
1294

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

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

1314
        return $results;
6✔
1315
    }
1316

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

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

1344
        return $results;
2✔
1345
    }
1346

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

1363
        return $this;
5✔
1364
    }
1365

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

1393
        return ['start' => $start, 'end' => $end];
7✔
1394
    }
1395

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

1413
        return ($pos === false) ? -1 : $pos;
9✔
1414
    }
1415

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

1435
        return ($blockStart === false) ? -1 : $blockStart;
8✔
1436
    }
1437

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

1451
        return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType);
8✔
1452
    }
1453

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

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

1476
        return str_replace(['<w:r>' . $extractedStyle . '<w:t xml:space="preserve"></w:t></w:r>', '<w:r><w:t xml:space="preserve"></w:t></w:r>', '<w:t>'], ['', '', '<w:t xml:space="preserve">'], $result);
7✔
1477
    }
1478

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

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

1494
    public function setMacroOpeningChars(string $macroOpeningChars): void
1495
    {
1496
        self::$macroOpeningChars = $macroOpeningChars;
5✔
1497
    }
1498

1499
    public function setMacroClosingChars(string $macroClosingChars): void
1500
    {
1501
        self::$macroClosingChars = $macroClosingChars;
5✔
1502
    }
1503

1504
    public function setMacroChars(string $macroOpeningChars, string $macroClosingChars): void
1505
    {
1506
        self::$macroOpeningChars = $macroOpeningChars;
15✔
1507
        self::$macroClosingChars = $macroClosingChars;
15✔
1508
    }
1509

1510
    public function getTempDocumentFilename(): string
1511
    {
1512
        return $this->tempDocumentFilename;
20✔
1513
    }
1514
}
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