Issues (8)

src/PhpDocxTemplate.php (1 issue)

1
<?php
2
3
namespace PhpDocxTemplate;
4
5
use DOMDocument;
6
use Twig\Extension\ExtensionInterface;
7
use Twig\Loader\ArrayLoader;
8
use Twig\Environment;
9
use PhpDocxTemplate\Twig\Impl\{
10
    ImageExtension,
11
    ImageRenderer,
12
    QrCodeExtension,
13
    QrCodeRenderer
14
};
15
16
/**
17
 * Class PhpDocxTemplate
18
 *
19
 * @package PhpDocxTemplate
20
 */
21
class PhpDocxTemplate
22
{
23
    private const NEWLINE_XML = '</w:t><w:br/><w:t xml:space="preserve">';
24
    private const NEWPARAGRAPH_XML = '</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve">';
25
    private const TAB_XML = '</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t xml:space="preserve">';
26
    private const PAGE_BREAK = '</w:t><w:br w:type="page"/><w:t xml:space="preserve">';
27
28
    private const HEADER_URI = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
29
    private const FOOTER_URI = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
30
31
    private $docx;
32
    private $crcToNewMedia;
33
    private $crcToNewEmbedded;
34
    private $picToReplace;
35
    private $picMap;
36
37
    private $twigExtensions = [];
38
39
    /**
40
     * Construct an instance of PhpDocxTemplate
41
     *
42
     * @param string $path - path to the template
43
     */
44 12
    public function __construct(string $path)
45
    {
46 12
        $this->docx = new DocxDocument($path);
47 12
        $this->crcToNewMedia = [];
48 12
        $this->crcToNewEmbedded = [];
49 12
        $this->picToReplace = [];
50 12
        $this->picMap = [];
51 12
    }
52
53
    /**
54
     * Convert DOM to string
55
     *
56
     * @param DOMDocument $dom - DOM to be converted
57
     *
58
     * @return string
59
     */
60 10
    public function xmlToString(DOMDocument $dom): string
61
    {
62
        //return $el->ownerDocument->saveXML($el);
63 10
        return $dom->saveXML();
64
    }
65
66
    /**
67
     * Get document wrapper
68
     *
69
     * @return DocxDocument
70
     */
71 3
    public function getDocx(): DocxDocument
72
    {
73 3
        return $this->docx;
74
    }
75
76
    /**
77
     * Convert document.xml contents as string
78
     *
79
     * @return string
80
     */
81 9
    public function getXml(): string
82
    {
83 9
        return $this->xmlToString($this->docx->getDOMDocument());
84
    }
85
86
    /**
87
     * Write document.xml contents to file
88
     */
89
    private function writeXml(string $path): void
90
    {
91
        file_put_contents($path, $this->getXml());
92
    }
93
94
    /**
95
     * Update document.xml contents to file
96
     *
97
     * @param DOMDocument $xml - new contents
98
     */
99 7
    private function updateXml(DOMDocument $xml): void
100
    {
101 7
        $this->docx->updateDOMDocument($xml);
102 7
    }
103
104 8
    public function patchXml(string $xml): string
105
    {
106 8
        $matches = [];
107
108 8
        preg_match('/^.*?(<w:body>)/s', $xml, $matches);
109
110 8
        $beforeXml = $matches[0];
111
112 8
        preg_match('/(<\/w:body>).*?$/s', $xml, $matches);
113
114 8
        $afterXml = $matches[0];
115
116 8
        $dom = new DOMDocument();
117 8
        $dom->loadXML($xml);
118
119 8
        $elBody = $dom->getElementsByTagName('body')->item(0);
120
121 8
        $chunkXml = '';
122
123 8
        for ($itemIdx = 0; $itemIdx < $elBody->childNodes->count(); $itemIdx++) {
124 8
            $el = $elBody->childNodes->item($itemIdx);
125
126 8
            $chunkXml .= $this->patchXmlChunk($el->ownerDocument->saveXML($el));
0 ignored issues
show
The method saveXML() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

126
            $chunkXml .= $this->patchXmlChunk($el->ownerDocument->/** @scrutinizer ignore-call */ saveXML($el));

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
127
        }
128
129 8
        return sprintf('%s%s%s', $beforeXml, $chunkXml, $afterXml);
130
    }
131
132
    /**
133
     * Patch initial xml
134
     *
135
     * @param string $xml - initial xml
136
     */
137 9
    public function patchXmlChunk(string $xml): string
138
    {
139 9
        $xml = preg_replace('/(?<={)(<[^>]*>)+(?=[\{%\#])|(?<=[%\}\#])(<[^>]*>)+(?=\})/mu', '', $xml);
140 9
        $xml = preg_replace_callback(
141 9
            '/{%(?:(?!%}).)*|{#(?:(?!#}).)*|{{(?:(?!}}).)*/mu',
142 9
            array(get_class($this), 'stripTags'),
143
            $xml
144
        );
145 9
        $xml = preg_replace_callback(
146 9
            '/(<w:tc[ >](?:(?!<w:tc[ >]).)*){%\s*colspan\s+([^%]*)\s*%}(.*?<\/w:tc>)/mu',
147 9
            array(get_class($this), 'colspan'),
148
            $xml
149
        );
150 9
        $xml = preg_replace_callback(
151 9
            '/(<w:tc[ >](?:(?!<w:tc[ >]).)*){%\s*cellbg\s+([^%]*)\s*%}(.*?<\/w:tc>)/mu',
152 9
            array(get_class($this), 'cellbg'),
153
            $xml
154
        );
155
        // avoid {{r and {%r tags to strip MS xml tags too far
156
        // ensure space preservation when splitting
157 9
        $xml = preg_replace(
158 9
            '/<w:t>((?:(?!<w:t>).)*)({{r\s.*?}}|{%r\s.*?%})/mu',
159 9
            '<w:t xml:space="preserve">${1}${2}',
160
            $xml
161
        );
162 9
        $xml = preg_replace(
163 9
            '/({{r\s.*?}}|{%r\s.*?%})/mu',
164 9
            '</w:t></w:r><w:r><w:t xml:space="preserve">${1}</w:t></w:r><w:r><w:t xml:space="preserve">',
165
            $xml
166
        );
167
168
        // {%- will merge with previous paragraph text
169 9
        $xml = preg_replace(
170 9
            '/<\/w:t>(?:(?!<\/w:t>).)*?{%-/mu',
171 9
            '{%',
172
            $xml
173
        );
174
175
        // -%} will merge with next paragraph text
176 9
        $xml = preg_replace(
177 9
            '/-%}(?:(?!<w:t[ >]).)*?<w:t[^>]*?>/mu',
178 9
            '%}',
179
            $xml
180
        );
181
182
        // replace into xml code the row/paragraph/run containing
183
        // {%y xxx %} or {{y xxx}} template tag
184
        // by {% xxx %} or {{ xx }} without any surronding <w:y> tags
185 9
        $tokens = ['tr', 'tc', 'p', 'r'];
186 9
        foreach ($tokens as $token) {
187 9
            $regex = '/';
188 9
            $regex .= str_replace("%s", $token, '<w:%s[ >](?:(?!<w:%s[ >]).)*({%|{{)%s ([^}%]*(?:%}|}})).*?<\/w:%s>');
189 9
            $regex .= '/mu';
190 9
            $xml = preg_replace(
191 9
                $regex,
192 9
                '${1} ${2}',
193
                $xml
194
            );
195
        }
196
197 9
        $xml = preg_replace_callback(
198 9
            '/<w:tc[ >](?:(?!<w:tc[ >]).)*?{%\s*vm\s*%}.*?<\/w:tc[ >]/mu',
199 9
            array(get_class($this), 'vMergeTc'),
200
            $xml
201
        );
202
203 9
        $xml = preg_replace_callback(
204 9
            '/<w:tc[ >](?:(?!<w:tc[ >]).)*?{%\s*hm\s*%}.*?<\/w:tc[ >]/mu',
205 9
            array(get_class($this), 'hMergeTc'),
206
            $xml
207
        );
208
209 9
        $xml = preg_replace_callback(
210 9
            '/(?<=\{[\{%])(.*?)(?=[\}%]})/mu',
211 9
            array(get_class($this), 'cleanTags'),
212
            $xml
213
        );
214
215 9
        return $xml;
216
    }
217
218 8
    private function resolveListing(string $xml): string
219
    {
220 8
        return preg_replace_callback(
221 8
            '/<w:p\b(?:[^>]*)?>.*?<\/w:p>/mus',
222 8
            array(get_class($this), 'resolveParagraph'),
223
            $xml
224
        );
225
    }
226
227 8
    private function resolveParagraph(array $matches): string
228
    {
229 8
        preg_match("/<w:pPr>.*<\/w:pPr>/mus", $matches[0], $paragraphProperties);
230
231 8
        return preg_replace_callback(
232 8
            '/<w:r\b(?:[^>]*)?>.*?<\/w:r>/mus',
233
            function ($m) use ($paragraphProperties) {
234 8
                return $this->resolveRun($paragraphProperties[0] ?? '', $m);
235 8
            },
236 8
            $matches[0]
237
        );
238
    }
239
240 8
    private function resolveRun(string $paragraphProperties, array $matches): string
241
    {
242 8
        preg_match("/<w:rPr>.*<\/w:rPr>/mus", $matches[0], $runProperties);
243
244 8
        return preg_replace_callback(
245 8
            '/<w:t\b(?:[^>]*)?>.*?<\/w:t>/mus',
246
            function ($m) use ($paragraphProperties, $runProperties) {
247 8
                return $this->resolveText($paragraphProperties, $runProperties[0] ?? '', $m);
248 8
            },
249 8
            $matches[0]
250
        );
251
    }
252
253 8
    private function resolveText(string $paragraphProperties, string $runProperties, array $matches): string
254
    {
255 8
        $xml = str_replace(
256 8
            "\t",
257 8
            sprintf("</w:t></w:r>" .
258
                "<w:r>%s<w:tab/></w:r>" .
259 8
                "<w:r>%s<w:t xml:space=\"preserve\">", $runProperties, $runProperties),
260 8
            $matches[0]
261
        );
262
263 8
        $xml = str_replace(
264 8
            "\a",
265 8
            sprintf("</w:t></w:r></w:p>" .
266 8
                "<w:p>%s<w:r>%s<w:t xml:space=\"preserve\">", $paragraphProperties, $runProperties),
267
            $xml
268
        );
269
270 8
        $xml = str_replace("\n", sprintf("</w:t>" .
271
            "</w:r>" .
272
            "</w:p>" .
273
            "<w:p>%s" .
274
            "<w:r>%s" .
275 8
            "<w:t xml:space=\"preserve\">", $paragraphProperties, $runProperties), $xml);
276
277 8
        $xml = str_replace(
278 8
            "\f",
279 8
            sprintf("</w:t></w:r></w:p>" .
280
                "<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>" .
281 8
                "<w:p>%s<w:r>%s<w:t xml:space=\"preserve\">", $paragraphProperties, $runProperties),
282
            $xml
283
        );
284
285 8
        return $xml;
286
    }
287
288
    /**
289
     * Strip tags from matches
290
     *
291
     * @param array $matches - matches
292
     *
293
     * @return string
294
     */
295 9
    private static function stripTags(array $matches): string
296
    {
297 9
        return preg_replace('/<\/w:t>.*?(<w:t>|<w:t [^>]*>)/mu', '', $matches[0]);
298
    }
299
300
    /**
301
     * Parse colspan
302
     *
303
     * @param array $matches - matches
304
     *
305
     * @return string
306
     */
307 1
    private static function colspan(array $matches): string
308
    {
309 1
        $cellXml = $matches[1] . $matches[3];
310 1
        $cellXml = preg_replace('/<w:r[ >](?:(?!<w:r[ >]).)*<w:t><\/w:t>.*?<\/w:r>/mu', '', $cellXml);
311 1
        $cellXml = preg_replace('/<w:gridSpan[^\/]*\/>/mu', '', $cellXml, 1);
312 1
        return preg_replace(
313 1
            '/(<w:tcPr[^>]*>)/mu',
314 1
            sprintf('${1}<w:gridSpan w:val="{{%s}}"/>', $matches[2]),
315
            $cellXml
316
        );
317
    }
318
319
    /**
320
     * Parse cellbg
321
     *
322
     * @param array $matches - matches
323
     *
324
     * @return string
325
     */
326 1
    private function cellbg(array $matches): string
327
    {
328 1
        $cellXml = $matches[1] . $matches[3];
329 1
        $cellXml = preg_replace('/<w:r[ >](?:(?!<w:r[ >]).)*<w:t><\/w:t>.*?<\/w:r>/mu', '', $cellXml);
330 1
        $cellXml = preg_replace('/<w:shd[^\/]*\/>/mu', '', $cellXml, 1);
331 1
        return preg_replace(
332 1
            '/(<w:tcPr[^>]*>)/mu',
333 1
            sprintf('${1}<w:shd w:val="clear" w:color="auto" w:fill="{{%s}}"/>', $matches[2]),
334
            $cellXml
335
        );
336
    }
337
338
    /**
339
     * Parse vm
340
     *
341
     * @param array $matches - matches
342
     *
343
     * @return string
344
     */
345 1
    private function vMergeTc(array $matches): string
346
    {
347 1
        return preg_replace_callback(
348 1
            '/(<\/w:tcPr[ >].*?<w:t(?:.*?)>)(.*?)(?:{%\s*vm\s*%})(.*?)(<\/w:t>)/mu',
349 1
            array(get_class($this), 'vMerge'),
350 1
            $matches[0]
351
        );
352
    }
353
354
    /**
355
     * Continue parsing vm
356
     *
357
     * @param array $matches - matches
358
     *
359
     * @return string
360
     */
361 1
    private function vMerge(array $matches): string
362
    {
363
        return '<w:vMerge w:val="{% if loop.first %}restart{% else %}continue{% endif %}"/>' .
364 1
            $matches[1] .  // Everything between ``</w:tcPr>`` and ``<w:t>``.
365 1
            "{% if loop.first %}" .
366 1
            $matches[2] .  // Everything before ``{% vm %}``.
367 1
            $matches[3] .  // Everything after ``{% vm %}``.
368 1
            "{% endif %}" .
369 1
            $matches[4];  // ``</w:t>``.
370
    }
371
372
    /**
373
     * Parse hm
374
     *
375
     * @param array $matches - matches
376
     *
377
     * @return string
378
     */
379 1
    private function hMergeTc(array $matches): string
380
    {
381 1
        $xmlToPatch = $matches[0];
382 1
        if (strpos($xmlToPatch, 'w:gridSpan') !== false) {
383
            $xmlToPatch = preg_replace_callback(
384
                '/(w:gridSpan w:val=")(\d+)(")/mu',
385
                array(get_class($this), 'withGridspan'),
386
                $xmlToPatch
387
            );
388
            $xmlToPatch = preg_replace('/{%\s*hm\s*%}/mu', '', $xmlToPatch);
389
        } else {
390 1
            $xmlToPatch = preg_replace_callback(
391 1
                '/(<\/w:tcPr[ >].*?<w:t(?:.*?)>)(.*?)(?:{%\s*hm\s*%})(.*?)(<\/w:t>)/mu',
392 1
                array(get_class($this), 'withoutGridspan'),
393
                $xmlToPatch
394
            );
395
        }
396
397 1
        return "{% if loop.first %}" . $xmlToPatch . "{% endif %}";
398
    }
399
400
    private function withGridspan(array $matches): string
401
    {
402
        return $matches[1] . // ``w:gridSpan w:val="``.
403
            '{{ ' . $matches[2] . ' * loop.length }}' . // Content of ``w:val``, multiplied by loop length.
404
            $matches[3];  // Closing quotation mark.
405
    }
406
407 1
    private function withoutGridspan(array $matches): string
408
    {
409
        return '<w:gridSpan w:val="{{ loop.length }}"/>' .
410 1
            $matches[1] . // Everything between ``</w:tcPr>`` and ``<w:t>``.
411 1
            $matches[2] . // Everything before ``{% hm %}``.
412 1
            $matches[3] . // Everything after ``{% hm %}``.
413 1
            $matches[4]; // ``</w:t>``.
414
    }
415
416
    /**
417
     * Clean tags in matches
418
     *
419
     * @param array $matches - matches
420
     *
421
     * @return string
422
     */
423 9
    private function cleanTags(array $matches): string
424
    {
425 9
        return str_replace(
426 9
            ["&#8216;", '&lt;', '&gt;', '“', '”', "‘", "’"],
427 9
            ["'", '<', '>', '"', '"', "'", "'"],
428 9
            $matches[0]
429
        );
430
    }
431
432
    /**
433
     * Добавить пользовательское расширение
434
     *
435
     * @param ExtensionInterface $ext
436
     */
437
    public function addTwigExtensions(ExtensionInterface $ext): void
438
    {
439
        $this->twigExtensions[] = $ext;
440
    }
441
442
    /**
443
     * Render xml
444
     *
445
     * @param string $srcXml - source xml
446
     * @param array $context - data to be rendered
447
     *
448
     * @return string
449
     */
450 8
    private function renderXml(string $srcXml, array $context): string
451
    {
452 8
        $srcXml = str_replace('<w:p>', "\n<w:p>", $srcXml);
453
454 8
        $template = new Environment(new ArrayLoader([
455 8
            'index' => $srcXml,
456
        ]));
457
458
        /**
459
         * Пользовательские расширения
460
         */
461 8
        foreach ($this->twigExtensions as $ext) {
462
            $template->addExtension($ext);
463
        }
464
465 8
        $ext = new ImageExtension();
466 8
        $ext->setRenderer(
467 8
            new ImageRenderer($this)
468
        );
469 8
        $template->addExtension($ext);
470
471
472 8
        $ext = new QrCodeExtension();
473 8
        $ext->setRenderer(
474 8
            new QrCodeRenderer($this)
475
        );
476 8
        $template->addExtension($ext);
477
478 8
        $dstXml = $template->render('index', $context);
479
480 8
        $dstXml = str_replace(
481 8
            ["\n<w:p>", "{_{", '}_}', '{_%', '%_}'],
482 8
            ['<w:p>', "{{", '}}', '{%', '%}'],
483
            $dstXml
484
        );
485
486
        // fix xml after rendering
487 8
        $dstXml = preg_replace(
488 8
            '/<w:p [^>]*>(?:<w:r [^>]*><w:t [^>]*>\s*<\/w:t><\/w:r>)?(?:<w:pPr><w:ind w:left="360"\/>' .
489 8
            '<\/w:pPr>)?<w:r [^>]*>(?:<w:t\/>|<w:t [^>]*><\/w:t>|<w:t [^>]*\/>|<w:t><\/w:t>)<\/w:r><\/w:p>/mu',
490 8
            '',
491
            $dstXml
492
        );
493
494 8
        $dstXml = $this->resolveListing($dstXml);
495
496 8
        return $dstXml;
497
    }
498
499
    /**
500
     * Build xml
501
     *
502
     * @param array $context - data to be rendered
503
     *
504
     * @return string
505
     */
506 8
    public function buildXml(array $context): string
507
    {
508 8
        $xml = $this->getXml();
509 8
        $xml = $this->patchXml($xml);
510 8
        $xml = $this->renderXml($xml, $context);
511 8
        return $xml;
512
    }
513
514
    /**
515
     * Render document
516
     *
517
     * @param array $context - data to be rendered
518
     */
519 7
    public function render(array $context): void
520
    {
521 7
        $xmlSrc = $this->buildXml($context);
522 7
        $newXml = $this->docx->fixTables($xmlSrc);
523 7
        $this->updateXml($newXml);
524
525 7
        $this->renderHeaders($context);
526 7
        $this->renderFooters($context);
527 7
    }
528
529
    /**
530
     * Save document
531
     *
532
     * @param string $path - target path
533
     */
534 3
    public function save(string $path): void
535
    {
536
        //$this->preProcessing();
537 3
        $this->docx->save($path);
538
        //$this->postProcessing($path);
539 3
    }
540
541 7
    public function renderHeaders(array $context): void
542
    {
543
        $this->docx->setHeaders(array_map(function ($header) use ($context) {
544 1
            return $this->renderXml($header, $context);
545 7
        }, $this->docx->getHeaders()));
546 7
    }
547
548 7
    public function renderFooters(array $context): void
549
    {
550
        $this->docx->setFooters(array_map(function ($footer) use ($context) {
551 1
            return $this->renderXml($footer, $context);
552 7
        }, $this->docx->getFooters()));
553 7
    }
554
555
    /**
556
     * Clean everything after rendering
557
     */
558 5
    public function close(): void
559
    {
560 5
        $this->docx->close();
561 5
    }
562
}
563