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

PHPOffice / PHPWord / 12236342093

09 Dec 2024 01:11PM UTC coverage: 96.862% (-0.06%) from 96.918%
12236342093

Pull #2715

github

web-flow
Merge f009665f7 into feadceb1f
Pull Request #2715: Delete Function Added

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

1 existing line in 1 file now uncovered.

11885 of 12270 relevant lines covered (96.86%)

33.21 hits per line

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

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

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

19
namespace PhpOffice\PhpWord;
20

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

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

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

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

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

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

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

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

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

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

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

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

100
    protected static $macroClosingChars = '}';
101

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

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

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

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

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

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

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

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

180
    /**
181
     * @param string $xml
182
     * @param XSLTProcessor $xsltProcessor
183
     *
184
     * @return string
185
     */
186
    protected function transformSingleXml($xml, $xsltProcessor)
187
    {
188
        if (\PHP_VERSION_ID < 80000) {
2✔
189
            $orignalLibEntityLoader = libxml_disable_entity_loader(true);
×
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
     * @param mixed $search
320
     * @param mixed $replace
321
     * @param int $limit
322
     */
323
    public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
324
    {
325
        if (is_array($search)) {
14✔
326
            foreach ($search as &$item) {
2✔
327
                $item = static::ensureMacroCompleted($item);
2✔
328
            }
329
            unset($item);
2✔
330
        } else {
331
            $search = static::ensureMacroCompleted($search);
12✔
332
        }
333

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

463
                        break;
×
464
                }
465
            }
466
        }
467

468
        return $varInlineArgs;
1✔
469
    }
470

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

487
        return $value;
1✔
488
    }
489

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

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

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

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

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

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

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

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

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

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

582
        return $imageAttrs;
1✔
583
    }
584

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

738
        return array_count_values($variables);
15✔
739
    }
740

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

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

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

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

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

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

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

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

800
        $this->tempDocumentMainPart = $result;
4✔
801
    }
802

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

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

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

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

824
            return;
1✔
825
        }
826

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

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

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

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

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

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

859
                    return;
×
860
                }
861

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

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

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

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

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

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

932
        return $xmlBlock;
9✔
933
    }
934

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

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

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

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

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

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

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

1005
        $this->zipClass->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
11✔
1006

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

1012
        return $this->tempDocumentFilename;
11✔
1013
    }
1014

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

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

1039
        if (file_exists($fileName)) {
9✔
1040
            unlink($fileName);
2✔
1041
        }
1042

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

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

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

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

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

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

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

1112
        return $matches[1];
18✔
1113
    }
1114

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

1127
    /**
1128
     * 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.
1129
     *
1130
     * @return string
1131
     */
1132
    protected function getMainPartName()
1133
    {
1134
        $contentTypes = $this->zipClass->getFromName('[Content_Types].xml');
19✔
1135

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

1138
        $matches = [];
19✔
1139
        preg_match($pattern, $contentTypes, $matches);
19✔
1140

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

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

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

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

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

1186
            return $candidate;
1✔
1187
        }
1188

1189
        return 1;
1✔
1190
    }
1191

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

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

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

1222
        return $rowStart;
1✔
1223
    }
1224

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

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

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

1251
        return $rowStart;
4✔
1252
    }
1253

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

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

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

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

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

1302
        return $results;
6✔
1303
    }
1304

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

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

1332
        return $results;
2✔
1333
    }
1334

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

1351
        return $this;
4✔
1352
    }
1353

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

1381
        return ['start' => $start, 'end' => $end];
6✔
1382
    }
1383

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

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

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

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

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

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

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

1461
        $unformattedText = preg_replace('/>\s+</', '><', $text);
6✔
1462
        $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✔
1463

1464
        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✔
1465
    }
1466

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

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

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

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

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

1498
    public function getTempDocumentFilename(): string
1499
    {
1500
        return $this->tempDocumentFilename;
19✔
1501
    }
1502

1503
    /**
1504
     * Delete Table 
1505
     * 
1506
     * Below function will take $search Parameter as an Input and remove Respective table from format
1507
     * 
1508
     * @param string $search
1509
     * return void
1510
     */
1511
    public function deleteTable(string $search): void
1512
    {
NEW
1513
        $search = self::ensureMacroCompleted($search);
×
NEW
1514
        $tagPos = strpos($this->tempDocumentMainPart, $search);
×
NEW
1515
        if ($tagPos) {
×
NEW
1516
            $tableStart = $this->findTableStart($tagPos);
×
NEW
1517
            $tableEnd = $this->findTableEnd($tagPos);
×
1518
    
1519
            // Delete the entire table
NEW
1520
            $this->tempDocumentMainPart = $this->getSlice(0, $tableStart) . $this->getSlice($tableEnd);
×
1521
        }
1522
    }
1523
}
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