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

PHPOffice / PHPWord / 8877757690

29 Apr 2024 11:26AM UTC coverage: 96.803% (-0.4%) from 97.217%
8877757690

Pull #2607

github

web-flow
Merge 795f2ecbb into 8b891bb68
Pull Request #2607: Replace macros with multiple elements by splitting existing text runs/paragraphs instead of replacing them

0 of 51 new or added lines in 1 file covered. (0.0%)

51 existing lines in 1 file now uncovered.

11597 of 11980 relevant lines covered (96.8%)

30.74 hits per line

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

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

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

19
namespace PhpOffice\PhpWord;
20

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

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

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

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

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

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

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

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

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

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

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

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

100
    protected static $macroClosingChars = '}';
101

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

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

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

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

139
    public function __destruct()
140
    {
141
        // ZipClass
142
        if ($this->zipClass) {
49✔
143
            try {
144
                $this->zipClass->close();
19✔
145
            } catch (Throwable $e) {
10✔
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 \PhpOffice\PhpWord\Shared\ZipArchive
158
     */
159
    public function zip()
160
    {
161
        return $this->zipClass;
1✔
162
    }
163

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

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

180
    /**
181
     * @param string $xml
182
     * @param XSLTProcessor $xsltProcessor
183
     *
184
     * @return string
185
     */
186
    protected function transformSingleXml($xml, $xsltProcessor)
187
    {
188
        if (\PHP_VERSION_ID < 80000) {
2✔
189
            $orignalLibEntityLoader = libxml_disable_entity_loader(true);
×
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);
×
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.');
×
244
        }
245

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

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

262
        return $macro;
26✔
263
    }
264

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

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

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

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

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

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

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

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

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

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

318
    /**
319
     * Replaces a search string (macro) with a set of rendered elements, splitting
320
     * surrounding texts, text runs or paragraphs before and after the macro,
321
     * depending on the types of elements to insert.
322
     *
323
     * @param \PhpOffice\PhpWord\Element\AbstractElement[] $elements
324
     */
325
    public function setElementsValue(string $search, array $elements): void
326
    {
NEW
327
        $elementsData = '';
×
NEW
328
        $hasParagraphs = false;
×
NEW
329
        foreach ($elements as $element) {
×
NEW
330
            $elementName = substr(
×
NEW
331
                get_class($element),
×
NEW
332
                (int) strrpos(get_class($element), '\\') + 1
×
NEW
333
            );
×
NEW
334
            $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
×
335

336
            // For inline elements, do not create a new paragraph.
NEW
337
            $withParagraph = Writer\Word2007\Element\Text::class !== $objectClass;
×
NEW
338
            $hasParagraphs = $hasParagraphs || $withParagraph;
×
339

NEW
340
            $xmlWriter = new XMLWriter();
×
341
            /** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $elementWriter */
NEW
342
            $elementWriter = new $objectClass($xmlWriter, $element, !$withParagraph);
×
NEW
343
            $elementWriter->write();
×
NEW
344
            $elementsData .= $xmlWriter->getData();
×
345
        }
NEW
346
        $blockType = $hasParagraphs ? 'w:p' : 'w:r';
×
NEW
347
        $where = $this->findContainingXmlBlockForMacro($search, $blockType);
×
NEW
348
        if (is_array($where)) {
×
349
            /** @phpstan-var array{start: int, end: int} $where */
NEW
350
            $block = $this->getSlice($where['start'], $where['end']);
×
NEW
351
            $parts = $hasParagraphs ? $this->splitParagraphIntoParagraphs($block) : $this->splitTextIntoTexts($block);
×
NEW
352
            $this->replaceXmlBlock($search, $parts, $blockType);
×
NEW
353
            $search = static::ensureMacroCompleted($search);
×
NEW
354
            $this->replaceXmlBlock($search, $elementsData, $blockType);
×
355
        }
356
    }
357

358
    /**
359
     * @param mixed $search
360
     * @param mixed $replace
361
     * @param int $limit
362
     */
363
    public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
364
    {
365
        if (is_array($search)) {
14✔
366
            foreach ($search as &$item) {
2✔
367
                $item = static::ensureMacroCompleted($item);
2✔
368
            }
369
            unset($item);
2✔
370
        } else {
371
            $search = static::ensureMacroCompleted($search);
12✔
372
        }
373

374
        if (is_array($replace)) {
14✔
375
            foreach ($replace as &$item) {
2✔
376
                $item = static::ensureUtf8Encoded($item);
2✔
377
            }
378
            unset($item);
2✔
379
        } else {
380
            $replace = static::ensureUtf8Encoded($replace);
12✔
381
        }
382

383
        if (Settings::isOutputEscapingEnabled()) {
14✔
384
            $xmlEscaper = new Xml();
2✔
385
            $replace = $xmlEscaper->escape($replace);
2✔
386
        }
387

388
        // convert carriage returns
389
        if (is_array($replace)) {
14✔
390
            foreach ($replace as &$item) {
2✔
391
                $item = $this->replaceCarriageReturns($item);
2✔
392
            }
393
        } else {
394
            $replace = $this->replaceCarriageReturns($replace);
12✔
395
        }
396

397
        $this->tempDocumentHeaders = $this->setValueForPart($search, $replace, $this->tempDocumentHeaders, $limit);
14✔
398
        $this->tempDocumentMainPart = $this->setValueForPart($search, $replace, $this->tempDocumentMainPart, $limit);
14✔
399
        $this->tempDocumentFooters = $this->setValueForPart($search, $replace, $this->tempDocumentFooters, $limit);
14✔
400
    }
401

402
    /**
403
     * Set values from a one-dimensional array of "variable => value"-pairs.
404
     */
405
    public function setValues(array $values): void
406
    {
407
        foreach ($values as $macro => $replace) {
3✔
408
            $this->setValue($macro, $replace);
3✔
409
        }
410
    }
411

412
    public function setCheckbox(string $search, bool $checked): void
413
    {
414
        $search = static::ensureMacroCompleted($search);
2✔
415
        $blockType = 'w:sdt';
2✔
416

417
        $where = $this->findContainingXmlBlockForMacro($search, $blockType);
2✔
418
        if (!is_array($where)) {
2✔
UNCOV
419
            return;
×
420
        }
421

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

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

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

430
        $this->replaceXmlBlock($search, $block, $blockType);
2✔
431
    }
432

433
    /**
434
     * @param string $search
435
     */
436
    public function setChart($search, Element\AbstractElement $chart): void
437
    {
UNCOV
438
        $elementName = substr(get_class($chart), strrpos(get_class($chart), '\\') + 1);
×
UNCOV
439
        $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName;
×
440

441
        // Get the next relation id
UNCOV
442
        $rId = $this->getNextRelationsIndex($this->getMainPartName());
×
UNCOV
443
        $chart->setRelationId($rId);
×
444

445
        // Define the chart filename
UNCOV
446
        $filename = "charts/chart{$rId}.xml";
×
447

448
        // Get the part writer
UNCOV
449
        $writerPart = new \PhpOffice\PhpWord\Writer\Word2007\Part\Chart();
×
UNCOV
450
        $writerPart->setElement($chart);
×
451

452
        // ContentTypes.xml
UNCOV
453
        $this->zipClass->addFromString("word/{$filename}", $writerPart->write());
×
454

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

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

463
        // Write the chart
464
        $xmlWriter = new XMLWriter();
×
UNCOV
465
        $elementWriter = new $objectClass($xmlWriter, $chart, true);
×
UNCOV
466
        $elementWriter->write();
×
467

468
        // Place it in the template
UNCOV
469
        $this->replaceXmlBlock($search, '<w:p>' . $xmlWriter->getData() . '</w:p>', 'w:p');
×
470
    }
471

472
    private function getImageArgs($varNameWithArgs)
473
    {
474
        $varElements = explode(':', $varNameWithArgs);
1✔
475
        array_shift($varElements); // first element is name of variable => remove it
1✔
476

477
        $varInlineArgs = [];
1✔
478
        // size format documentation: https://msdn.microsoft.com/en-us/library/documentformat.openxml.vml.shape%28v=office.14%29.aspx?f=255&MSPPError=-2147217396
479
        foreach ($varElements as $argIdx => $varArg) {
1✔
480
            if (strpos($varArg, '=')) { // arg=value
1✔
481
                [$argName, $argValue] = explode('=', $varArg, 2);
1✔
482
                $argName = strtolower($argName);
1✔
483
                if ($argName == 'size') {
1✔
484
                    [$varInlineArgs['width'], $varInlineArgs['height']] = explode('x', $argValue, 2);
×
485
                } else {
486
                    $varInlineArgs[strtolower($argName)] = $argValue;
1✔
487
                }
488
            } elseif (preg_match('/^([0-9]*[a-z%]{0,2}|auto)x([0-9]*[a-z%]{0,2}|auto)$/i', $varArg)) { // 60x40
1✔
UNCOV
489
                [$varInlineArgs['width'], $varInlineArgs['height']] = explode('x', $varArg, 2);
×
490
            } else { // :60:40:f
491
                switch ($argIdx) {
492
                    case 0:
1✔
493
                        $varInlineArgs['width'] = $varArg;
1✔
494

495
                        break;
1✔
496
                    case 1:
1✔
497
                        $varInlineArgs['height'] = $varArg;
1✔
498

499
                        break;
1✔
UNCOV
500
                    case 2:
×
UNCOV
501
                        $varInlineArgs['ratio'] = $varArg;
×
502

UNCOV
503
                        break;
×
504
                }
505
            }
506
        }
507

508
        return $varInlineArgs;
1✔
509
    }
510

511
    private function chooseImageDimension($baseValue, $inlineValue, $defaultValue)
512
    {
513
        $value = $baseValue;
1✔
514
        if (null === $value && isset($inlineValue)) {
1✔
515
            $value = $inlineValue;
1✔
516
        }
517
        if (!preg_match('/^([0-9\.]*(cm|mm|in|pt|pc|px|%|em|ex|)|auto)$/i', $value ?? '')) {
1✔
518
            $value = null;
×
519
        }
520
        if (null === $value) {
1✔
521
            $value = $defaultValue;
1✔
522
        }
523
        if (is_numeric($value)) {
1✔
524
            $value .= 'px';
1✔
525
        }
526

527
        return $value;
1✔
528
    }
529

530
    private function fixImageWidthHeightRatio(&$width, &$height, $actualWidth, $actualHeight): void
531
    {
532
        $imageRatio = $actualWidth / $actualHeight;
1✔
533

534
        if (($width === '') && ($height === '')) { // defined size are empty
1✔
UNCOV
535
            $width = $actualWidth . 'px';
×
536
            $height = $actualHeight . 'px';
×
537
        } elseif ($width === '') { // defined width is empty
1✔
UNCOV
538
            $heightFloat = (float) $height;
×
UNCOV
539
            $widthFloat = $heightFloat * $imageRatio;
×
UNCOV
540
            $matches = [];
×
UNCOV
541
            preg_match('/\\d([a-z%]+)$/', $height, $matches);
×
UNCOV
542
            $width = $widthFloat . $matches[1];
×
543
        } elseif ($height === '') { // defined height is empty
1✔
UNCOV
544
            $widthFloat = (float) $width;
×
UNCOV
545
            $heightFloat = $widthFloat / $imageRatio;
×
UNCOV
546
            $matches = [];
×
UNCOV
547
            preg_match('/\\d([a-z%]+)$/', $width, $matches);
×
UNCOV
548
            $height = $heightFloat . $matches[1];
×
549
        } else { // we have defined size, but we need also check it aspect ratio
550
            $widthMatches = [];
1✔
551
            preg_match('/\\d([a-z%]+)$/', $width, $widthMatches);
1✔
552
            $heightMatches = [];
1✔
553
            preg_match('/\\d([a-z%]+)$/', $height, $heightMatches);
1✔
554
            // try to fix only if dimensions are same
555
            if ($widthMatches[1] == $heightMatches[1]) {
1✔
556
                $dimention = $widthMatches[1];
1✔
557
                $widthFloat = (float) $width;
1✔
558
                $heightFloat = (float) $height;
1✔
559
                $definedRatio = $widthFloat / $heightFloat;
1✔
560

561
                if ($imageRatio > $definedRatio) { // image wider than defined box
1✔
562
                    $height = ($widthFloat / $imageRatio) . $dimention;
×
563
                } elseif ($imageRatio < $definedRatio) { // image higher than defined box
1✔
564
                    $width = ($heightFloat * $imageRatio) . $dimention;
1✔
565
                }
566
            }
567
        }
568
    }
569

570
    private function prepareImageAttrs($replaceImage, $varInlineArgs)
571
    {
572
        // get image path and size
573
        $width = null;
1✔
574
        $height = null;
1✔
575
        $ratio = null;
1✔
576

577
        // a closure can be passed as replacement value which after resolving, can contain the replacement info for the image
578
        // use case: only when a image if found, the replacement tags can be generated
579
        if (is_callable($replaceImage)) {
1✔
580
            $replaceImage = $replaceImage();
1✔
581
        }
582

583
        if (is_array($replaceImage) && isset($replaceImage['path'])) {
1✔
584
            $imgPath = $replaceImage['path'];
1✔
585
            if (isset($replaceImage['width'])) {
1✔
586
                $width = $replaceImage['width'];
1✔
587
            }
588
            if (isset($replaceImage['height'])) {
1✔
589
                $height = $replaceImage['height'];
1✔
590
            }
591
            if (isset($replaceImage['ratio'])) {
1✔
592
                $ratio = $replaceImage['ratio'];
1✔
593
            }
594
        } else {
595
            $imgPath = $replaceImage;
1✔
596
        }
597

598
        $width = $this->chooseImageDimension($width, $varInlineArgs['width'] ?? null, 115);
1✔
599
        $height = $this->chooseImageDimension($height, $varInlineArgs['height'] ?? null, 70);
1✔
600

601
        $imageData = @getimagesize($imgPath);
1✔
602
        if (!is_array($imageData)) {
1✔
UNCOV
603
            throw new Exception(sprintf('Invalid image: %s', $imgPath));
×
604
        }
605
        [$actualWidth, $actualHeight, $imageType] = $imageData;
1✔
606

607
        // fix aspect ratio (by default)
608
        if (null === $ratio && isset($varInlineArgs['ratio'])) {
1✔
609
            $ratio = $varInlineArgs['ratio'];
1✔
610
        }
611
        if (null === $ratio || !in_array(strtolower($ratio), ['', '-', 'f', 'false'])) {
1✔
612
            $this->fixImageWidthHeightRatio($width, $height, $actualWidth, $actualHeight);
1✔
613
        }
614

615
        $imageAttrs = [
1✔
616
            'src' => $imgPath,
1✔
617
            'mime' => image_type_to_mime_type($imageType),
1✔
618
            'width' => $width,
1✔
619
            'height' => $height,
1✔
620
        ];
1✔
621

622
        return $imageAttrs;
1✔
623
    }
624

625
    private function addImageToRelations($partFileName, $rid, $imgPath, $imageMimeType): void
626
    {
627
        // define templates
628
        $typeTpl = '<Override PartName="/word/media/{IMG}" ContentType="image/{EXT}"/>';
1✔
629
        $relationTpl = '<Relationship Id="{RID}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/{IMG}"/>';
1✔
630
        $newRelationsTpl = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
1✔
631
        $newRelationsTypeTpl = '<Override PartName="/{RELS}" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>';
1✔
632
        $extTransform = [
1✔
633
            'image/jpeg' => 'jpeg',
1✔
634
            'image/png' => 'png',
1✔
635
            'image/bmp' => 'bmp',
1✔
636
            'image/gif' => 'gif',
1✔
637
        ];
1✔
638

639
        // get image embed name
640
        if (isset($this->tempDocumentNewImages[$imgPath])) {
1✔
641
            $imgName = $this->tempDocumentNewImages[$imgPath];
1✔
642
        } else {
643
            // transform extension
644
            if (isset($extTransform[$imageMimeType])) {
1✔
645
                $imgExt = $extTransform[$imageMimeType];
1✔
646
            } else {
UNCOV
647
                throw new Exception("Unsupported image type $imageMimeType");
×
648
            }
649

650
            // add image to document
651
            $imgName = 'image_' . $rid . '_' . pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt;
1✔
652
            $this->zipClass->pclzipAddFile($imgPath, 'word/media/' . $imgName);
1✔
653
            $this->tempDocumentNewImages[$imgPath] = $imgName;
1✔
654

655
            // setup type for image
656
            $xmlImageType = str_replace(['{IMG}', '{EXT}'], [$imgName, $imgExt], $typeTpl);
1✔
657
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlImageType, $this->tempDocumentContentTypes) . '</Types>';
1✔
658
        }
659

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

662
        if (!isset($this->tempDocumentRelations[$partFileName])) {
1✔
663
            // create new relations file
664
            $this->tempDocumentRelations[$partFileName] = $newRelationsTpl;
1✔
665
            // and add it to content types
666
            $xmlRelationsType = str_replace('{RELS}', $this->getRelationsName($partFileName), $newRelationsTypeTpl);
1✔
667
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
1✔
668
        }
669

670
        // add image to relations
671
        $this->tempDocumentRelations[$partFileName] = str_replace('</Relationships>', $xmlImageRelation, $this->tempDocumentRelations[$partFileName]) . '</Relationships>';
1✔
672
    }
673

674
    /**
675
     * @param mixed $search
676
     * @param mixed $replace Path to image, or array("path" => xx, "width" => yy, "height" => zz)
677
     * @param int $limit
678
     */
679
    public function setImageValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
680
    {
681
        // prepare $search_replace
682
        if (!is_array($search)) {
1✔
683
            $search = [$search];
1✔
684
        }
685

686
        $replacesList = [];
1✔
687
        if (!is_array($replace) || isset($replace['path'])) {
1✔
688
            $replacesList[] = $replace;
1✔
689
        } else {
690
            $replacesList = array_values($replace);
1✔
691
        }
692

693
        $searchReplace = [];
1✔
694
        foreach ($search as $searchIdx => $searchString) {
1✔
695
            $searchReplace[$searchString] = $replacesList[$searchIdx] ?? $replacesList[0];
1✔
696
        }
697

698
        // collect document parts
699
        $searchParts = [
1✔
700
            $this->getMainPartName() => &$this->tempDocumentMainPart,
1✔
701
        ];
1✔
702
        foreach (array_keys($this->tempDocumentHeaders) as $headerIndex) {
1✔
703
            $searchParts[$this->getHeaderName($headerIndex)] = &$this->tempDocumentHeaders[$headerIndex];
1✔
704
        }
705
        foreach (array_keys($this->tempDocumentFooters) as $headerIndex) {
1✔
706
            $searchParts[$this->getFooterName($headerIndex)] = &$this->tempDocumentFooters[$headerIndex];
1✔
707
        }
708

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

713
        $i = 0;
1✔
714
        foreach ($searchParts as $partFileName => &$partContent) {
1✔
715
            $partVariables = $this->getVariablesForPart($partContent);
1✔
716

717
            foreach ($searchReplace as $searchString => $replaceImage) {
1✔
718
                $varsToReplace = array_filter($partVariables, function ($partVar) use ($searchString) {
1✔
719
                    return ($partVar == $searchString) || preg_match('/^' . preg_quote($searchString) . ':/', $partVar);
1✔
720
                });
1✔
721

722
                foreach ($varsToReplace as $varNameWithArgs) {
1✔
723
                    $varInlineArgs = $this->getImageArgs($varNameWithArgs);
1✔
724
                    $preparedImageAttrs = $this->prepareImageAttrs($replaceImage, $varInlineArgs);
1✔
725
                    $imgPath = $preparedImageAttrs['src'];
1✔
726

727
                    // get image index
728
                    $imgIndex = $this->getNextRelationsIndex($partFileName);
1✔
729
                    $rid = 'rId' . $imgIndex;
1✔
730

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

735
                    // replace variable
736
                    $varNameWithArgsFixed = static::ensureMacroCompleted($varNameWithArgs);
1✔
737
                    $matches = [];
1✔
738
                    if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed) . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) {
1✔
739
                        $wholeTag = $matches[0];
1✔
740
                        array_shift($matches);
1✔
741
                        [$openTag, $prefix, , $postfix, $closeTag] = $matches;
1✔
742
                        $replaceXml = $openTag . $prefix . $closeTag . $xmlImage . $openTag . $postfix . $closeTag;
1✔
743
                        // replace on each iteration, because in one tag we can have 2+ inline variables => before proceed next variable we need to change $partContent
744
                        $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent, $limit);
1✔
745
                    }
746

747
                    if (++$i >= $limit) {
1✔
748
                        break;
1✔
749
                    }
750
                }
751
            }
752
        }
753
    }
754

755
    /**
756
     * Returns count of all variables in template.
757
     *
758
     * @return array
759
     */
760
    public function getVariableCount()
761
    {
762
        $variables = $this->getVariablesForPart($this->tempDocumentMainPart);
15✔
763

764
        foreach ($this->tempDocumentHeaders as $headerXML) {
15✔
765
            $variables = array_merge(
4✔
766
                $variables,
4✔
767
                $this->getVariablesForPart($headerXML)
4✔
768
            );
4✔
769
        }
770

771
        foreach ($this->tempDocumentFooters as $footerXML) {
15✔
772
            $variables = array_merge(
6✔
773
                $variables,
6✔
774
                $this->getVariablesForPart($footerXML)
6✔
775
            );
6✔
776
        }
777

778
        return array_count_values($variables);
15✔
779
    }
780

781
    /**
782
     * Returns array of all variables in template.
783
     *
784
     * @return string[]
785
     */
786
    public function getVariables()
787
    {
788
        return array_keys($this->getVariableCount());
13✔
789
    }
790

791
    /**
792
     * Clone a table row in a template document.
793
     *
794
     * @param string $search
795
     * @param int $numberOfClones
796
     */
797
    public function cloneRow($search, $numberOfClones): void
798
    {
799
        $search = static::ensureMacroCompleted($search);
5✔
800

801
        $tagPos = strpos($this->tempDocumentMainPart, $search);
5✔
802
        if (!$tagPos) {
5✔
803
            throw new Exception('Can not clone row, template variable not found or variable contains markup.');
1✔
804
        }
805

806
        $rowStart = $this->findRowStart($tagPos);
4✔
807
        $rowEnd = $this->findRowEnd($tagPos);
4✔
808
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
4✔
809

810
        // Check if there's a cell spanning multiple rows.
811
        if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
4✔
812
            // $extraRowStart = $rowEnd;
813
            $extraRowEnd = $rowEnd;
4✔
814
            while (true) {
4✔
815
                $extraRowStart = $this->findRowStart($extraRowEnd + 1);
4✔
816
                $extraRowEnd = $this->findRowEnd($extraRowEnd + 1);
4✔
817

818
                // If extraRowEnd is lower then 7, there was no next row found.
819
                if ($extraRowEnd < 7) {
4✔
UNCOV
820
                    break;
×
821
                }
822

823
                // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
824
                $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
4✔
825
                if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
4✔
826
                    !preg_match('#<w:vMerge w:val="continue"\s*/>#', $tmpXmlRow)
4✔
827
                ) {
828
                    break;
4✔
829
                }
830
                // This row was a spanned row, update $rowEnd and search for the next row.
831
                $rowEnd = $extraRowEnd;
4✔
832
            }
833
            $xmlRow = $this->getSlice($rowStart, $rowEnd);
4✔
834
        }
835

836
        $result = $this->getSlice(0, $rowStart);
4✔
837
        $result .= implode('', $this->indexClonedVariables($numberOfClones, $xmlRow));
4✔
838
        $result .= $this->getSlice($rowEnd);
4✔
839

840
        $this->tempDocumentMainPart = $result;
4✔
841
    }
842

843
    /**
844
     * Delete a table row in a template document.
845
     */
846
    public function deleteRow(string $search): void
847
    {
848
        if ('${' !== substr($search, 0, 2) && '}' !== substr($search, -1)) {
1✔
849
            $search = '${' . $search . '}';
1✔
850
        }
851

852
        $tagPos = strpos($this->tempDocumentMainPart, $search);
1✔
853
        if (!$tagPos) {
1✔
UNCOV
854
            throw new Exception(sprintf('Can not delete row %s, template variable not found or variable contains markup.', $search));
×
855
        }
856

857
        $tableStart = $this->findTableStart($tagPos);
1✔
858
        $tableEnd = $this->findTableEnd($tagPos);
1✔
859
        $xmlTable = $this->getSlice($tableStart, $tableEnd);
1✔
860

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

864
            return;
1✔
865
        }
866

UNCOV
867
        $rowStart = $this->findRowStart($tagPos);
×
UNCOV
868
        $rowEnd = $this->findRowEnd($tagPos);
×
UNCOV
869
        $xmlRow = $this->getSlice($rowStart, $rowEnd);
×
870

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

873
        // Check if there's a cell spanning multiple rows.
UNCOV
874
        if (preg_match('#<w:vMerge w:val="restart"/>#', $xmlRow)) {
×
UNCOV
875
            $extraRowStart = $rowStart;
×
UNCOV
876
            while (true) {
×
UNCOV
877
                $extraRowStart = $this->findRowStart($extraRowStart + 1);
×
UNCOV
878
                $extraRowEnd = $this->findRowEnd($extraRowStart + 1);
×
879

880
                // If extraRowEnd is lower then 7, there was no next row found.
UNCOV
881
                if ($extraRowEnd < 7) {
×
UNCOV
882
                    break;
×
883
                }
884

885
                // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row.
886
                $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd);
×
887
                if (!preg_match('#<w:vMerge/>#', $tmpXmlRow) &&
×
UNCOV
888
                    !preg_match('#<w:vMerge w:val="continue" />#', $tmpXmlRow)
×
889
                ) {
UNCOV
890
                    break;
×
891
                }
892

893
                $tableStart = $this->findTableStart($extraRowEnd + 1);
×
894
                $tableEnd = $this->findTableEnd($extraRowEnd + 1);
×
895
                $xmlTable = $this->getSlice($tableStart, $tableEnd);
×
896
                if (substr_count($xmlTable, '<w:tr') === 1) {
×
UNCOV
897
                    $this->tempDocumentMainPart = $this->getSlice(0, $tableStart) . $this->getSlice($tableEnd);
×
898

899
                    return;
×
900
                }
901

UNCOV
902
                $this->tempDocumentMainPart = $this->getSlice(0, $extraRowStart) . $this->getSlice($extraRowEnd);
×
903
            }
904
        }
905
    }
906

907
    /**
908
     * Clones a table row and populates it's values from a two-dimensional array in a template document.
909
     *
910
     * @param string $search
911
     * @param array $values
912
     */
913
    public function cloneRowAndSetValues($search, $values): void
914
    {
915
        $this->cloneRow($search, count($values));
2✔
916

917
        foreach ($values as $rowKey => $rowData) {
2✔
918
            $rowNumber = $rowKey + 1;
2✔
919
            foreach ($rowData as $macro => $replace) {
2✔
920
                $this->setValue($macro . '#' . $rowNumber, $replace);
2✔
921
            }
922
        }
923
    }
924

925
    /**
926
     * Clone a block.
927
     *
928
     * @param string $blockname
929
     * @param int $clones How many time the block should be cloned
930
     * @param bool $replace
931
     * @param bool $indexVariables If true, any variables inside the block will be indexed (postfixed with #1, #2, ...)
932
     * @param array $variableReplacements Array containing replacements for macros found inside the block to clone
933
     *
934
     * @return null|string
935
     */
936
    public function cloneBlock($blockname, $clones = 1, $replace = true, $indexVariables = false, $variableReplacements = null)
937
    {
938
        $xmlBlock = null;
9✔
939
        $matches = [];
9✔
940
        $escapedMacroOpeningChars = self::$macroOpeningChars;
9✔
941
        $escapedMacroClosingChars = self::$macroClosingChars;
9✔
942
        preg_match(
9✔
943
            //'/(.*((?s)<w:p\b(?:(?!<w:p\b).)*?\{{' . $blockname . '}<\/w:.*?p>))(.*)((?s)<w:p\b(?:(?!<w:p\b).)[^$]*?\{{\/' . $blockname . '}<\/w:.*?p>)/is',
944
            '/(.*((?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✔
945
            //'/(.*((?s)<w:p\b(?:(?!<w:p\b).)*?\\'. $escapedMacroOpeningChars . $blockname . '}<\/w:.*?p>))(.*)((?s)<w:p\b(?:(?!<w:p\b).)[^$]*?\\'.$escapedMacroOpeningChars.'\/' . $blockname . '}<\/w:.*?p>)/is',
946
            $this->tempDocumentMainPart,
9✔
947
            $matches
9✔
948
        );
9✔
949

950
        if (isset($matches[3])) {
9✔
951
            $xmlBlock = $matches[3];
9✔
952
            if ($indexVariables) {
9✔
953
                $cloned = $this->indexClonedVariables($clones, $xmlBlock);
2✔
954
            } elseif ($variableReplacements !== null && is_array($variableReplacements)) {
7✔
955
                $cloned = $this->replaceClonedVariables($variableReplacements, $xmlBlock);
2✔
956
            } else {
957
                $cloned = [];
5✔
958
                for ($i = 1; $i <= $clones; ++$i) {
5✔
959
                    $cloned[] = $xmlBlock;
5✔
960
                }
961
            }
962

963
            if ($replace) {
9✔
964
                $this->tempDocumentMainPart = str_replace(
9✔
965
                    $matches[2] . $matches[3] . $matches[4],
9✔
966
                    implode('', $cloned),
9✔
967
                    $this->tempDocumentMainPart
9✔
968
                );
9✔
969
            }
970
        }
971

972
        return $xmlBlock;
9✔
973
    }
974

975
    /**
976
     * Replace a block.
977
     *
978
     * @param string $blockname
979
     * @param string $replacement
980
     */
981
    public function replaceBlock($blockname, $replacement): void
982
    {
983
        $matches = [];
1✔
984
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
1✔
985
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
1✔
986
        preg_match(
1✔
987
            '/(<\?xml.*)(<w:p.*>' . $escapedMacroOpeningChars . $blockname . $escapedMacroClosingChars . '<\/w:.*?p>)(.*)(<w:p.*' . $escapedMacroOpeningChars . '\/' . $blockname . $escapedMacroClosingChars . '<\/w:.*?p>)/is',
1✔
988
            $this->tempDocumentMainPart,
1✔
989
            $matches
1✔
990
        );
1✔
991

992
        if (isset($matches[3])) {
1✔
993
            $this->tempDocumentMainPart = str_replace(
1✔
994
                $matches[2] . $matches[3] . $matches[4],
1✔
995
                $replacement,
1✔
996
                $this->tempDocumentMainPart
1✔
997
            );
1✔
998
        }
999
    }
1000

1001
    /**
1002
     * Delete a block of text.
1003
     *
1004
     * @param string $blockname
1005
     */
1006
    public function deleteBlock($blockname): void
1007
    {
1008
        $this->replaceBlock($blockname, '');
1✔
1009
    }
1010

1011
    /**
1012
     * Automatically Recalculate Fields on Open.
1013
     *
1014
     * @param bool $update
1015
     */
1016
    public function setUpdateFields($update = true): void
1017
    {
1018
        $string = $update ? 'true' : 'false';
2✔
1019
        $matches = [];
2✔
1020
        if (preg_match('/<w:updateFields w:val=\"(true|false|1|0|on|off)\"\/>/', $this->tempDocumentSettingsPart, $matches)) {
2✔
1021
            $this->tempDocumentSettingsPart = str_replace($matches[0], '<w:updateFields w:val="' . $string . '"/>', $this->tempDocumentSettingsPart);
2✔
1022
        } else {
1023
            $this->tempDocumentSettingsPart = str_replace('</w:settings>', '<w:updateFields w:val="' . $string . '"/></w:settings>', $this->tempDocumentSettingsPart);
2✔
1024
        }
1025
    }
1026

1027
    /**
1028
     * Saves the result document.
1029
     *
1030
     * @return string
1031
     */
1032
    public function save()
1033
    {
1034
        foreach ($this->tempDocumentHeaders as $index => $xml) {
10✔
1035
            $this->savePartWithRels($this->getHeaderName($index), $xml);
4✔
1036
        }
1037

1038
        $this->savePartWithRels($this->getMainPartName(), $this->tempDocumentMainPart);
10✔
1039
        $this->savePartWithRels($this->getSettingsPartName(), $this->tempDocumentSettingsPart);
10✔
1040

1041
        foreach ($this->tempDocumentFooters as $index => $xml) {
10✔
1042
            $this->savePartWithRels($this->getFooterName($index), $xml);
5✔
1043
        }
1044

1045
        $this->zipClass->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
10✔
1046

1047
        // Close zip file
1048
        if (false === $this->zipClass->close()) {
10✔
1049
            throw new Exception('Could not close zip file.'); // @codeCoverageIgnore
1050
        }
1051

1052
        return $this->tempDocumentFilename;
10✔
1053
    }
1054

1055
    /**
1056
     * @param string $fileName
1057
     * @param string $xml
1058
     */
1059
    protected function savePartWithRels($fileName, $xml): void
1060
    {
1061
        $this->zipClass->addFromString($fileName, $xml);
10✔
1062
        if (isset($this->tempDocumentRelations[$fileName])) {
10✔
1063
            $relsFileName = $this->getRelationsName($fileName);
10✔
1064
            $this->zipClass->addFromString($relsFileName, $this->tempDocumentRelations[$fileName]);
10✔
1065
        }
1066
    }
1067

1068
    /**
1069
     * Saves the result document to the user defined file.
1070
     *
1071
     * @since 0.8.0
1072
     *
1073
     * @param string $fileName
1074
     */
1075
    public function saveAs($fileName): void
1076
    {
1077
        $tempFileName = $this->save();
9✔
1078

1079
        if (file_exists($fileName)) {
9✔
1080
            unlink($fileName);
2✔
1081
        }
1082

1083
        /*
1084
         * Note: we do not use `rename` function here, because it loses file ownership data on Windows platform.
1085
         * As a result, user cannot open the file directly getting "Access denied" message.
1086
         *
1087
         * @see https://github.com/PHPOffice/PHPWord/issues/532
1088
         */
1089
        copy($tempFileName, $fileName);
9✔
1090
        unlink($tempFileName);
9✔
1091
    }
1092

1093
    /**
1094
     * Finds parts of broken macros and sticks them together.
1095
     * Macros, while being edited, could be implicitly broken by some of the word processors.
1096
     *
1097
     * @param string $documentPart The document part in XML representation
1098
     *
1099
     * @return string
1100
     */
1101
    protected function fixBrokenMacros($documentPart)
1102
    {
1103
        $brokenMacroOpeningChars = substr(self::$macroOpeningChars, 0, 1);
25✔
1104
        $endMacroOpeningChars = substr(self::$macroOpeningChars, 1);
25✔
1105
        $macroClosingChars = self::$macroClosingChars;
25✔
1106

1107
        return preg_replace_callback(
25✔
1108
            '/\\' . $brokenMacroOpeningChars . '(?:\\' . $endMacroOpeningChars . '|[^{$]*\>\{)[^' . $macroClosingChars . '$]*\}/U',
25✔
1109
            function ($match) {
25✔
1110
                return strip_tags($match[0]);
16✔
1111
            },
25✔
1112
            $documentPart
25✔
1113
        );
25✔
1114
    }
1115

1116
    /**
1117
     * Find and replace macros in the given XML section.
1118
     *
1119
     * @param mixed $search
1120
     * @param mixed $replace
1121
     * @param array<int, string>|string $documentPartXML
1122
     * @param int $limit
1123
     *
1124
     * @return string
1125
     */
1126
    protected function setValueForPart($search, $replace, $documentPartXML, $limit)
1127
    {
1128
        // Note: we can't use the same function for both cases here, because of performance considerations.
1129
        if (self::MAXIMUM_REPLACEMENTS_DEFAULT === $limit) {
17✔
1130
            return str_replace($search, $replace, $documentPartXML);
17✔
1131
        }
1132
        $regExpEscaper = new RegExp();
2✔
1133

1134
        return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML, $limit);
2✔
1135
    }
1136

1137
    /**
1138
     * Find all variables in $documentPartXML.
1139
     *
1140
     * @param string $documentPartXML
1141
     *
1142
     * @return string[]
1143
     */
1144
    protected function getVariablesForPart($documentPartXML)
1145
    {
1146
        $matches = [];
18✔
1147
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
18✔
1148
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
18✔
1149

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

1152
        return $matches[1];
18✔
1153
    }
1154

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

1167
    /**
1168
     * 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.
1169
     *
1170
     * @return string
1171
     */
1172
    protected function getMainPartName()
1173
    {
1174
        $contentTypes = $this->zipClass->getFromName('[Content_Types].xml');
19✔
1175

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

1178
        $matches = [];
19✔
1179
        preg_match($pattern, $contentTypes, $matches);
19✔
1180

1181
        return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
19✔
1182
    }
1183

1184
    /**
1185
     * The name of the file containing the Settings part.
1186
     *
1187
     * @return string
1188
     */
1189
    protected function getSettingsPartName()
1190
    {
1191
        return 'word/settings.xml';
19✔
1192
    }
1193

1194
    /**
1195
     * Get the name of the footer file for $index.
1196
     *
1197
     * @param int $index
1198
     *
1199
     * @return string
1200
     */
1201
    protected function getFooterName($index)
1202
    {
1203
        return sprintf('word/footer%d.xml', $index);
19✔
1204
    }
1205

1206
    /**
1207
     * Get the name of the relations file for document part.
1208
     *
1209
     * @param string $documentPartName
1210
     *
1211
     * @return string
1212
     */
1213
    protected function getRelationsName($documentPartName)
1214
    {
1215
        return 'word/_rels/' . pathinfo($documentPartName, PATHINFO_BASENAME) . '.rels';
19✔
1216
    }
1217

1218
    protected function getNextRelationsIndex($documentPartName)
1219
    {
1220
        if (isset($this->tempDocumentRelations[$documentPartName])) {
1✔
1221
            $candidate = substr_count($this->tempDocumentRelations[$documentPartName], '<Relationship');
1✔
1222
            while (strpos($this->tempDocumentRelations[$documentPartName], 'Id="rId' . $candidate . '"') !== false) {
1✔
UNCOV
1223
                ++$candidate;
×
1224
            }
1225

1226
            return $candidate;
1✔
1227
        }
1228

1229
        return 1;
1✔
1230
    }
1231

1232
    /**
1233
     * @return string
1234
     */
1235
    protected function getDocumentContentTypesName()
1236
    {
1237
        return '[Content_Types].xml';
19✔
1238
    }
1239

1240
    /**
1241
     * Find the start position of the nearest table before $offset.
1242
     */
1243
    private function findTableStart(int $offset): int
1244
    {
1245
        $rowStart = strrpos(
1✔
1246
            $this->tempDocumentMainPart,
1✔
1247
            '<w:tbl ',
1✔
1248
            ((strlen($this->tempDocumentMainPart) - $offset) * -1)
1✔
1249
        );
1✔
1250

1251
        if (!$rowStart) {
1✔
1252
            $rowStart = strrpos(
1✔
1253
                $this->tempDocumentMainPart,
1✔
1254
                '<w:tbl>',
1✔
1255
                ((strlen($this->tempDocumentMainPart) - $offset) * -1)
1✔
1256
            );
1✔
1257
        }
1258
        if (!$rowStart) {
1✔
UNCOV
1259
            throw new Exception('Can not find the start position of the table.');
×
1260
        }
1261

1262
        return $rowStart;
1✔
1263
    }
1264

1265
    /**
1266
     * Find the end position of the nearest table row after $offset.
1267
     */
1268
    private function findTableEnd(int $offset): int
1269
    {
1270
        return strpos($this->tempDocumentMainPart, '</w:tbl>', $offset) + 7;
1✔
1271
    }
1272

1273
    /**
1274
     * Find the start position of the nearest table row before $offset.
1275
     *
1276
     * @param int $offset
1277
     *
1278
     * @return int
1279
     */
1280
    protected function findRowStart($offset)
1281
    {
1282
        $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr ', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
4✔
1283

1284
        if (!$rowStart) {
4✔
1285
            $rowStart = strrpos($this->tempDocumentMainPart, '<w:tr>', ((strlen($this->tempDocumentMainPart) - $offset) * -1));
4✔
1286
        }
1287
        if (!$rowStart) {
4✔
UNCOV
1288
            throw new Exception('Can not find the start position of the row to clone.');
×
1289
        }
1290

1291
        return $rowStart;
4✔
1292
    }
1293

1294
    /**
1295
     * Find the end position of the nearest table row after $offset.
1296
     *
1297
     * @param int $offset
1298
     *
1299
     * @return int
1300
     */
1301
    protected function findRowEnd($offset)
1302
    {
1303
        return strpos($this->tempDocumentMainPart, '</w:tr>', $offset) + 7;
4✔
1304
    }
1305

1306
    /**
1307
     * Get a slice of a string.
1308
     *
1309
     * @param int $startPosition
1310
     * @param int $endPosition
1311
     *
1312
     * @return string
1313
     */
1314
    protected function getSlice($startPosition, $endPosition = 0)
1315
    {
1316
        if (!$endPosition) {
12✔
1317
            $endPosition = strlen($this->tempDocumentMainPart);
9✔
1318
        }
1319

1320
        return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition));
12✔
1321
    }
1322

1323
    /**
1324
     * Replaces variable names in cloned
1325
     * rows/blocks with indexed names.
1326
     *
1327
     * @param int $count
1328
     * @param string $xmlBlock
1329
     *
1330
     * @return string
1331
     */
1332
    protected function indexClonedVariables($count, $xmlBlock)
1333
    {
1334
        $results = [];
6✔
1335
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
6✔
1336
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
6✔
1337

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

1342
        return $results;
6✔
1343
    }
1344

1345
    /**
1346
     * Replace carriage returns with xml.
1347
     */
1348
    public function replaceCarriageReturns(string $string): string
1349
    {
1350
        return str_replace(["\r\n", "\r", "\n"], '</w:t><w:br/><w:t>', $string);
14✔
1351
    }
1352

1353
    /**
1354
     * Replaces variables with values from array, array keys are the variable names.
1355
     *
1356
     * @param array $variableReplacements
1357
     * @param string $xmlBlock
1358
     *
1359
     * @return string[]
1360
     */
1361
    protected function replaceClonedVariables($variableReplacements, $xmlBlock)
1362
    {
1363
        $results = [];
2✔
1364
        foreach ($variableReplacements as $replacementArray) {
2✔
1365
            $localXmlBlock = $xmlBlock;
2✔
1366
            foreach ($replacementArray as $search => $replacement) {
2✔
1367
                $localXmlBlock = $this->setValueForPart(self::ensureMacroCompleted($search), $replacement, $localXmlBlock, self::MAXIMUM_REPLACEMENTS_DEFAULT);
2✔
1368
            }
1369
            $results[] = $localXmlBlock;
2✔
1370
        }
1371

1372
        return $results;
2✔
1373
    }
1374

1375
    /**
1376
     * Replace an XML block surrounding a macro with a new block.
1377
     *
1378
     * @param string $macro Name of macro
1379
     * @param string $block New block content
1380
     * @param string $blockType XML tag type of block
1381
     *
1382
     * @return \PhpOffice\PhpWord\TemplateProcessor Fluent interface
1383
     */
1384
    public function replaceXmlBlock($macro, $block, $blockType = 'w:p')
1385
    {
1386
        $where = $this->findContainingXmlBlockForMacro($macro, $blockType);
4✔
1387
        if (is_array($where)) {
4✔
1388
            $this->tempDocumentMainPart = $this->getSlice(0, $where['start']) . $block . $this->getSlice($where['end']);
4✔
1389
        }
1390

1391
        return $this;
4✔
1392
    }
1393

1394
    /**
1395
     * Find start and end of XML block containing the given macro
1396
     * e.g. <w:p>...${macro}...</w:p>.
1397
     *
1398
     * Note that only the first instance of the macro will be found
1399
     *
1400
     * @param string $macro Name of macro
1401
     * @param string $blockType XML tag for block
1402
     *
1403
     * @return bool|int[] FALSE if not found, otherwise array with start and end
1404
     */
1405
    protected function findContainingXmlBlockForMacro($macro, $blockType = 'w:p')
1406
    {
1407
        $macroPos = $this->findMacro($macro);
8✔
1408
        if (0 > $macroPos) {
8✔
1409
            return false;
2✔
1410
        }
1411
        $start = $this->findXmlBlockStart($macroPos, $blockType);
7✔
1412
        if (0 > $start) {
7✔
1413
            return false;
1✔
1414
        }
1415
        $end = $this->findXmlBlockEnd($start, $blockType);
7✔
1416
        //if not found or if resulting string does not contain the macro we are searching for
1417
        if (0 > $end || strstr($this->getSlice($start, $end), $macro) === false) {
7✔
1418
            return false;
1✔
1419
        }
1420

1421
        return ['start' => $start, 'end' => $end];
6✔
1422
    }
1423

1424
    /**
1425
     * Find the position of (the start of) a macro.
1426
     *
1427
     * Returns -1 if not found, otherwise position of opening $
1428
     *
1429
     * Note that only the first instance of the macro will be found
1430
     *
1431
     * @param string $search Macro name
1432
     * @param int $offset Offset from which to start searching
1433
     *
1434
     * @return int -1 if macro not found
1435
     */
1436
    protected function findMacro($search, $offset = 0)
1437
    {
1438
        $search = static::ensureMacroCompleted($search);
8✔
1439
        $pos = strpos($this->tempDocumentMainPart, $search, $offset);
8✔
1440

1441
        return ($pos === false) ? -1 : $pos;
8✔
1442
    }
1443

1444
    /**
1445
     * Find the start position of the nearest XML block start before $offset.
1446
     *
1447
     * @param int $offset    Search position
1448
     * @param string  $blockType XML Block tag
1449
     *
1450
     * @return int -1 if block start not found
1451
     */
1452
    protected function findXmlBlockStart($offset, $blockType)
1453
    {
1454
        $reverseOffset = (strlen($this->tempDocumentMainPart) - $offset) * -1;
7✔
1455
        // first try XML tag with attributes
1456
        $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . ' ', $reverseOffset);
7✔
1457
        // if not found, or if found but contains the XML tag without attribute
1458
        if (false === $blockStart || strrpos($this->getSlice($blockStart, $offset), '<' . $blockType . '>')) {
7✔
1459
            // also try XML tag without attributes
1460
            $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . '>', $reverseOffset);
7✔
1461
        }
1462

1463
        return ($blockStart === false) ? -1 : $blockStart;
7✔
1464
    }
1465

1466
    /**
1467
     * Find the nearest block end position after $offset.
1468
     *
1469
     * @param int $offset    Search position
1470
     * @param string  $blockType XML Block tag
1471
     *
1472
     * @return int -1 if block end not found
1473
     */
1474
    protected function findXmlBlockEnd($offset, $blockType)
1475
    {
1476
        $blockEndStart = strpos($this->tempDocumentMainPart, '</' . $blockType . '>', $offset);
7✔
1477
        // return position of end of tag if found, otherwise -1
1478

1479
        return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType);
7✔
1480
    }
1481

1482
    /**
1483
     * Splits a w:r/w:t into a list of w:r where each ${macro} is in a separate w:r.
1484
     *
1485
     * @param string $text
1486
     *
1487
     * @return string
1488
     */
1489
    protected function splitTextIntoTexts($text)
1490
    {
1491
        if (!$this->textNeedsSplitting($text)) {
6✔
1492
            return $text;
4✔
1493
        }
1494
        $matches = [];
6✔
1495
        if (preg_match('/(<w:rPr.*<\/w:rPr>)/i', $text, $matches)) {
6✔
1496
            $extractedStyle = $matches[0];
4✔
1497
        } else {
1498
            $extractedStyle = '';
2✔
1499
        }
1500

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

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

1507
    /**
1508
     * Splits a w:p into a list of w:p where each ${macro} is in a separate w:p.
1509
     */
1510
    public function splitParagraphIntoParagraphs(string $paragraph): string
1511
    {
NEW
1512
        $matches = [];
×
NEW
1513
        if (1 === preg_match('/(<w:pPr.*<\/w:pPr>)/i', $paragraph, $matches)) {
×
NEW
1514
            $extractedStyle = $matches[0];
×
1515
        } else {
NEW
1516
            $extractedStyle = '';
×
1517
        }
NEW
1518
        if (null === $paragraph = preg_replace('/>\s+</', '><', $paragraph)) {
×
NEW
UNCOV
1519
            throw new Exception('Error processing PhpWord document.');
×
1520
        }
NEW
UNCOV
1521
        $result = str_replace(
×
NEW
1522
            [
×
NEW
1523
                '${',
×
NEW
1524
                '}',
×
NEW
1525
            ],
×
NEW
1526
            [
×
NEW
1527
                '</w:t></w:r></w:p><w:p>' . $extractedStyle . '<w:r><w:t xml:space="preserve">${',
×
NEW
1528
                '}</w:t></w:r></w:p><w:p>' . $extractedStyle . '<w:r><w:t xml:space="preserve">',
×
NEW
1529
            ],
×
NEW
1530
            $paragraph
×
NEW
1531
        );
×
1532

1533
        // Remove empty paragraphs that might have been created before/after the
1534
        // macro.
NEW
1535
        $result = str_replace(
×
NEW
1536
            [
×
NEW
1537
                '<w:p>' . $extractedStyle . '<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>',
×
NEW
1538
                '<w:p><w:r><w:t xml:space="preserve"></w:t></w:r></w:p>',
×
NEW
1539
            ],
×
NEW
1540
            [
×
NEW
1541
                '',
×
NEW
1542
                '',
×
NEW
1543
            ],
×
NEW
1544
            $result
×
NEW
1545
        );
×
1546

NEW
1547
        return $result;
×
1548
    }
1549

1550
    /**
1551
     * Returns true if string contains a macro that is not in it's own w:r.
1552
     *
1553
     * @param string $text
1554
     *
1555
     * @return bool
1556
     */
1557
    protected function textNeedsSplitting($text)
1558
    {
1559
        $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars);
6✔
1560
        $escapedMacroClosingChars = preg_quote(self::$macroClosingChars);
6✔
1561

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

1565
    public function setMacroOpeningChars(string $macroOpeningChars): void
1566
    {
1567
        self::$macroOpeningChars = $macroOpeningChars;
5✔
1568
    }
1569

1570
    public function setMacroClosingChars(string $macroClosingChars): void
1571
    {
1572
        self::$macroClosingChars = $macroClosingChars;
5✔
1573
    }
1574

1575
    public function setMacroChars(string $macroOpeningChars, string $macroClosingChars): void
1576
    {
1577
        self::$macroOpeningChars = $macroOpeningChars;
15✔
1578
        self::$macroClosingChars = $macroClosingChars;
15✔
1579
    }
1580

1581
    public function getTempDocumentFilename(): string
1582
    {
1583
        return $this->tempDocumentFilename;
19✔
1584
    }
1585
}
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