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

PHPOffice / PHPWord / 13025401639

29 Jan 2025 05:48AM UTC coverage: 96.825% (-0.4%) from 97.217%
13025401639

Pull #2562

github

web-flow
Merge ebe3e5dac into 2d2759585
Pull Request #2562: TemplateProcessor SetComplexBlock/Value and Sections

6 of 7 new or added lines in 1 file covered. (85.71%)

245 existing lines in 40 files now uncovered.

12227 of 12628 relevant lines covered (96.82%)

34.56 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) {
50✔
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) {
27✔
259
            $macro = self::$macroOpeningChars . $macro . self::$macroClosingChars;
23✔
260
        }
261

262
        return $macro;
27✔
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, bool $multiple = false): void
279
    {
280
        $originalSearch = $search;
3✔
281

282
        $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1);
3✔
283
        if ($elementName === 'Section') {
3✔
NEW
284
            $elementName = 'Container';
×
285
        }
286
        $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
3✔
287

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

474
                        break;
×
475
                }
476
            }
477
        }
478

479
        return $varInlineArgs;
1✔
480
    }
481

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

498
        return $value;
1✔
499
    }
500

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

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

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

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

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

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

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

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

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

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

595
        return $imageAttrs;
1✔
596
    }
597

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

751
        return array_count_values($variables);
15✔
752
    }
753

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

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

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

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

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

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

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

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

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

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

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

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

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

837
            return;
1✔
838
        }
839

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

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

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

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

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

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

UNCOV
872
                    return;
×
873
                }
874

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

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

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

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

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

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

945
        return $xmlBlock;
9✔
946
    }
947

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1125
        return $matches[1];
18✔
1126
    }
1127

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

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

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

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

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

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

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

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

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

1199
            return $candidate;
1✔
1200
        }
1201

1202
        return 1;
1✔
1203
    }
1204

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

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

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

1235
        return $rowStart;
1✔
1236
    }
1237

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

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

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

1264
        return $rowStart;
4✔
1265
    }
1266

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

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

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

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

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

1315
        return $results;
6✔
1316
    }
1317

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

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

1345
        return $results;
2✔
1346
    }
1347

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

1364
        return $this;
5✔
1365
    }
1366

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

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

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

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

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

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

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

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

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

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

1477
        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✔
1478
    }
1479

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

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

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

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

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

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