Test Failed
Push — master ( 0fc6ac...5bbac0 )
by Bingo
03:07
created

DocxDocument::save()   B

Complexity

Conditions 6
Paths 16

Size

Total Lines 36
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 6

Importance

Changes 3
Bugs 1 Features 2
Metric Value
cc 6
eloc 20
nc 16
nop 1
dl 0
loc 36
ccs 18
cts 18
cp 1
crap 6
rs 8.9777
c 3
b 1
f 2
1
<?php
2
3
namespace PhpDocxTemplate;
4
5
use DOMDocument;
6
use DOMElement;
7
use Exception;
8
use ZipArchive;
9
use RecursiveIteratorIterator;
10
use RecursiveDirectoryIterator;
11
use PhpDocxTemplate\Escaper\RegExp;
12
13
/**
14
 * Class DocxDocument
15
 *
16
 * @package PhpDocxTemplate
17
 */
18
class DocxDocument
19
{
20
    private $path;
21
    private $tmpDir;
22
    private $document;
23
    private $zipClass;
24
    private $tempDocumentMainPart;
25
    private $tempDocumentRelations = [];
26
    private $tempDocumentContentTypes = '';
27
    private $tempDocumentNewImages = [];
28
    private $skipFiles = [];
29
30
    /**
31
     * Construct an instance of Document
32
     *
33
     * @param string $path - path to the document
34
     *
35
     * @throws Exception
36
     */
37 10
    public function __construct(string $path)
38
    {
39 10
        if (file_exists($path)) {
40 10
            $this->path = $path;
41 10
            $this->tmpDir = sys_get_temp_dir() . "/" . uniqid("", true) . date("His");
42 10
            $this->zipClass = new ZipArchive();
43 10
            $this->extract();
44
        } else {
45
            throw new Exception("The template " . $path . " was not found!");
46
        }
47 10
    }
48
49
    /**
50
     * Extract (unzip) document contents
51
     */
52 10
    private function extract(): void
53
    {
54 10
        if (file_exists($this->tmpDir) && is_dir($this->tmpDir)) {
55
            $this->rrmdir($this->tmpDir);
56
        }
57
58 10
        mkdir($this->tmpDir);
59
60 10
        $this->zipClass->open($this->path);
61 10
        $this->zipClass->extractTo($this->tmpDir);
62
63 10
        $this->tempDocumentMainPart = $this->readPartWithRels($this->getMainPartName());
64
65 10
        $this->tempDocumentContentTypes = $this->zipClass->getFromName($this->getDocumentContentTypesName());
66
67
        $this->document = file_get_contents($this->tmpDir . "/word/document.xml");
68
    }
69 10
70 10
    /**
71
     * Get document main part
72
     *
73
     * @return string
74
     */
75
    public function getDocumentMainPart(): string
76
    {
77 1
        return $this->tempDocumentMainPart;
78
    }
79 1
80
    /**
81
     * Get the name of main part document (method from PhpOffice\PhpWord)
82
     *
83
     * @return string
84
     */
85
    private function getMainPartName(): string
86
    {
87 10
        $contentTypes = $this->zipClass->getFromName('[Content_Types].xml');
88
89 10
        $pattern = '~PartName="\/(word\/document.*?\.xml)" ' .
90
                   'ContentType="application\/vnd\.openxmlformats-officedocument' .
91
                   '\.wordprocessingml\.document\.main\+xml"~';
92
93 10
        $matches = [];
94
        preg_match($pattern, $contentTypes, $matches);
95 10
96 10
        return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
97
    }
98 10
99
    /**
100
     * @return string
101
     */
102
    private function getDocumentContentTypesName(): string
103
    {
104 10
        return '[Content_Types].xml';
105
    }
106 10
107
    /**
108
     * Read document part (method from PhpOffice\PhpWord)
109
     *
110
     * @param string $fileName
111
     *
112
     * @return string
113
     */
114
    private function readPartWithRels(string $fileName): string
115
    {
116 10
        $relsFileName = $this->getRelationsName($fileName);
117
        $partRelations = $this->zipClass->getFromName($relsFileName);
118 10
        if ($partRelations !== false) {
119 10
            $this->tempDocumentRelations[$fileName] = $partRelations;
120 10
        }
121 10
122
        return $this->fixBrokenMacros($this->zipClass->getFromName($fileName));
123
    }
124 10
125
    /**
126
     * Get the name of the relations file for document part (method from PhpOffice\PhpWord)
127
     *
128
     * @param string $documentPartName
129
     *
130
     * @return string
131
     */
132
    private function getRelationsName(string $documentPartName): string
133
    {
134 10
        return 'word/_rels/' . pathinfo($documentPartName, PATHINFO_BASENAME) . '.rels';
0 ignored issues
show
Bug introduced by
Are you sure pathinfo($documentPartNa...late\PATHINFO_BASENAME) of type array|string can be used in concatenation? ( Ignorable by Annotation )

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

134
        return 'word/_rels/' . /** @scrutinizer ignore-type */ pathinfo($documentPartName, PATHINFO_BASENAME) . '.rels';
Loading history...
135
    }
136 10
137
    private function getNextRelationsIndex(string $documentPartName): int
138
    {
139
        if (isset($this->tempDocumentRelations[$documentPartName])) {
140
            $candidate = substr_count($this->tempDocumentRelations[$documentPartName], '<Relationship');
141
            while (strpos($this->tempDocumentRelations[$documentPartName], 'Id="rId' . $candidate . '"') !== false) {
142
                $candidate++;
143
            }
144
145
            return $candidate;
146
        }
147
148
        return 1;
149
    }
150
151
    /**
152
     * Finds parts of broken macros and sticks them together (method from PhpOffice\PhpWord)
153
     *
154
     * @param string $documentPart
155
     *
156
     * @return string
157
     */
158
    private function fixBrokenMacros(string $documentPart): string
159
    {
160 10
        return preg_replace_callback(
161
            '/\$(?:\{|[^{$]*\>\{)[^}$]*\}/U',
162 10
            function ($match) {
163 10
                return strip_tags($match[0]);
164
            },
165
            $documentPart
166 10
        );
167 10
    }
168
169
    /**
170
     * @param string $macro
171
     *
172
     * @return string
173
     */
174
    protected static function ensureMacroCompleted(string $macro): string
175
    {
176
        if (substr($macro, 0, 2) !== '{{' && substr($macro, -1) !== '}}') {
177
            $macro = '{{' . $macro . '}}';
178
        }
179
        return $macro;
180
    }
181
182
    /**
183
     * Get the name of the header file for $index.
184
     *
185
     * @param int $index
186
     *
187
     * @return string
188
     */
189
    private function getHeaderName(int $index): string
190
    {
191
        return sprintf('word/header%d.xml', $index);
192
    }
193
194
    /**
195
     * Get the name of the footer file for $index.
196
     *
197
     * @param int $index
198
     *
199
     * @return string
200
     */
201
    private function getFooterName(int $index): string
202
    {
203
        return sprintf('word/footer%d.xml', $index);
204
    }
205
206
    /**
207
     * Find all variables in $documentPartXML.
208
     *
209
     * @param string $documentPartXML
210
     *
211
     * @return string[]
212
     */
213
    private function getVariablesForPart(string $documentPartXML): array
214
    {
215
        $matches = array();
216
        preg_match_all('/\{\{(.*?)\}\}/i', $documentPartXML, $matches);
217
        return $matches[1];
218
    }
219
220
    private function getImageArgs(string $varNameWithArgs): array
221
    {
222
        $varElements = explode(':', $varNameWithArgs);
223
        array_shift($varElements); // first element is name of variable => remove it
224
225
        $varInlineArgs = array();
226
        // size format documentation: https://msdn.microsoft.com/en-us/library/documentformat.openxml.vml.shape%28v=office.14%29.aspx?f=255&MSPPError=-2147217396
227
        foreach ($varElements as $argIdx => $varArg) {
228
            if (strpos($varArg, '=')) { // arg=value
229
                list($argName, $argValue) = explode('=', $varArg, 2);
230
                $argName = strtolower($argName);
231
                if ($argName == 'size') {
232
                    list($varInlineArgs['width'], $varInlineArgs['height']) = explode('x', $argValue, 2);
233
                } else {
234
                    $varInlineArgs[strtolower($argName)] = $argValue;
235
                }
236
            } elseif (preg_match('/^([0-9]*[a-z%]{0,2}|auto)x([0-9]*[a-z%]{0,2}|auto)$/i', $varArg)) { // 60x40
237
                list($varInlineArgs['width'], $varInlineArgs['height']) = explode('x', $varArg, 2);
238
            } else { // :60:40:f
239
                switch ($argIdx) {
240
                    case 0:
241
                        $varInlineArgs['width'] = $varArg;
242
                        break;
243
                    case 1:
244
                        $varInlineArgs['height'] = $varArg;
245
                        break;
246
                    case 2:
247
                        $varInlineArgs['ratio'] = $varArg;
248
                        break;
249
                }
250
            }
251
        }
252
253
        return $varInlineArgs;
254
    }
255
256
    /**
257
     * @param mixed $replaceImage
258
     * @param array $varInlineArgs
259
     *
260
     * @return array
261
     */
262
    private function prepareImageAttrs($replaceImage, array $varInlineArgs): array
263
    {
264
        // get image path and size
265
        $width = null;
266
        $height = null;
267
        $ratio = null;
268
269
        // a closure can be passed as replacement value which after resolving, can contain the replacement info for the image
270
        // use case: only when a image if found, the replacement tags can be generated
271
        if (is_callable($replaceImage)) {
272
            $replaceImage = $replaceImage();
273
        }
274
275
        if (is_array($replaceImage) && isset($replaceImage['path'])) {
276
            $imgPath = $replaceImage['path'];
277
            if (isset($replaceImage['width'])) {
278
                $width = $replaceImage['width'];
279
            }
280
            if (isset($replaceImage['height'])) {
281
                $height = $replaceImage['height'];
282
            }
283
            if (isset($replaceImage['ratio'])) {
284
                $ratio = $replaceImage['ratio'];
285
            }
286
        } else {
287
            $imgPath = $replaceImage;
288
        }
289
290
        $width = $this->chooseImageDimension($width, isset($varInlineArgs['width']) ? $varInlineArgs['width'] : null, 115);
291
        $height = $this->chooseImageDimension($height, isset($varInlineArgs['height']) ? $varInlineArgs['height'] : null, 70);
292
293
        $imageData = @getimagesize($imgPath);
294
        if (!is_array($imageData)) {
295
            throw new Exception(sprintf('Invalid image: %s', $imgPath));
296
        }
297
        list($actualWidth, $actualHeight, $imageType) = $imageData;
298
299
        // fix aspect ratio (by default)
300
        if (is_null($ratio) && isset($varInlineArgs['ratio'])) {
301
            $ratio = $varInlineArgs['ratio'];
302
        }
303
        if (is_null($ratio) || !in_array(strtolower($ratio), array('', '-', 'f', 'false'))) {
304
            $this->fixImageWidthHeightRatio($width, $height, $actualWidth, $actualHeight);
305
        }
306
307
        $imageAttrs = array(
308
            'src'    => $imgPath,
309
            'mime'   => image_type_to_mime_type($imageType),
310
            'width'  => $width,
311
            'height' => $height,
312
        );
313
314
        return $imageAttrs;
315
    }
316
317
    /**
318
     * @param mixed $width
319
     * @param mixed $height
320
     * @param int $actualWidth
321
     * @param int $actualHeight
322
     */
323
    private function fixImageWidthHeightRatio(&$width, &$height, int $actualWidth, int $actualHeight): void
324
    {
325
        $imageRatio = $actualWidth / $actualHeight;
326
327
        if (($width === '') && ($height === '')) { // defined size are empty
328
            $width = $actualWidth . 'px';
329
            $height = $actualHeight . 'px';
330
        } elseif ($width === '') { // defined width is empty
331
            $heightFloat = (float) $height;
332
            $widthFloat = $heightFloat * $imageRatio;
333
            $matches = array();
334
            preg_match("/\d([a-z%]+)$/", $height, $matches);
335
            $width = $widthFloat . $matches[1];
336
        } elseif ($height === '') { // defined height is empty
337
            $widthFloat = (float) $width;
338
            $heightFloat = $widthFloat / $imageRatio;
339
            $matches = array();
340
            preg_match("/\d([a-z%]+)$/", $width, $matches);
341
            $height = $heightFloat . $matches[1];
342
        } else { // we have defined size, but we need also check it aspect ratio
343
            $widthMatches = array();
344
            preg_match("/\d([a-z%]+)$/", $width, $widthMatches);
345
            $heightMatches = array();
346
            preg_match("/\d([a-z%]+)$/", $height, $heightMatches);
347
            // try to fix only if dimensions are same
348
            if ($widthMatches[1] == $heightMatches[1]) {
349
                $dimention = $widthMatches[1];
350
                $widthFloat = (float) $width;
351
                $heightFloat = (float) $height;
352
                $definedRatio = $widthFloat / $heightFloat;
353
354
                if ($imageRatio > $definedRatio) { // image wider than defined box
355
                    $height = ($widthFloat / $imageRatio) . $dimention;
356
                } elseif ($imageRatio < $definedRatio) { // image higher than defined box
357
                    $width = ($heightFloat * $imageRatio) . $dimention;
358
                }
359
            }
360
        }
361
    }
362
363
    private function chooseImageDimension(?int $baseValue, ?int $inlineValue, int $defaultValue): string
364
    {
365
        $value = $baseValue;
366
        if (is_null($value) && isset($inlineValue)) {
367
            $value = $inlineValue;
368
        }
369
        if (!preg_match('/^([0-9]*(cm|mm|in|pt|pc|px|%|em|ex|)|auto)$/i', $value)) {
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type null; however, parameter $subject of preg_match() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

369
        if (!preg_match('/^([0-9]*(cm|mm|in|pt|pc|px|%|em|ex|)|auto)$/i', /** @scrutinizer ignore-type */ $value)) {
Loading history...
370
            $value = null;
371
        }
372
        if (is_null($value)) {
373
            $value = $defaultValue;
374
        }
375
        if (is_numeric($value)) {
0 ignored issues
show
introduced by
The condition is_numeric($value) is always true.
Loading history...
376
            $value .= 'px';
377
        }
378
379
        return $value;
380
    }
381
382
    private function addImageToRelations(string $partFileName, string $rid, string $imgPath, string $imageMimeType): void
383
    {
384
        // define templates
385
        $typeTpl = '<Override PartName="/word/media/{IMG}" ContentType="image/{EXT}"/>';
386
        $relationTpl = '<Relationship Id="{RID}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/{IMG}"/>';
387
        $newRelationsTpl = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
388
        $newRelationsTypeTpl = '<Override PartName="/{RELS}" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>';
389
        $extTransform = array(
390
            'image/jpeg' => 'jpeg',
391
            'image/png'  => 'png',
392
            'image/bmp'  => 'bmp',
393
            'image/gif'  => 'gif',
394
        );
395
396
        // get image embed name
397
        if (isset($this->tempDocumentNewImages[$imgPath])) {
398
            $imgName = $this->tempDocumentNewImages[$imgPath];
399
        } else {
400
            // transform extension
401
            if (isset($extTransform[$imageMimeType])) {
402
                $imgExt = $extTransform[$imageMimeType];
403
            } else {
404
                throw new Exception("Unsupported image type $imageMimeType");
405
            }
406
407
            // add image to document
408
            $imgName = 'image_' . $rid . '_' . pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt;
0 ignored issues
show
Bug introduced by
Are you sure pathinfo($partFileName, ...late\PATHINFO_FILENAME) of type array|string can be used in concatenation? ( Ignorable by Annotation )

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

408
            $imgName = 'image_' . $rid . '_' . /** @scrutinizer ignore-type */ pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt;
Loading history...
409
            $this->tempDocumentNewImages[$imgPath] = $imgName;
410
411
            // setup type for image
412
            $xmlImageType = str_replace(array('{IMG}', '{EXT}'), array($imgName, $imgExt), $typeTpl);
413
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlImageType, $this->tempDocumentContentTypes) . '</Types>';
414
        }
415
416
        $xmlImageRelation = str_replace(array('{RID}', '{IMG}'), array($rid, $imgName), $relationTpl);
417
418
        if (!isset($this->tempDocumentRelations[$partFileName])) {
419
            // create new relations file
420
            $this->tempDocumentRelations[$partFileName] = $newRelationsTpl;
421
            // and add it to content types
422
            $xmlRelationsType = str_replace('{RELS}', $this->getRelationsName($partFileName), $newRelationsTypeTpl);
423
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
424
        }
425
426
        // add image to relations
427
        $this->tempDocumentRelations[$partFileName] = str_replace('</Relationships>', $xmlImageRelation, $this->tempDocumentRelations[$partFileName]) . '</Relationships>';
428
429
        if (!in_array($this->getDocumentContentTypesName(), $this->skipFiles)) {
430
            $this->skipFiles[] = basename($this->getDocumentContentTypesName());
431
        }
432
    }
433
434
    /**
435
     * @param mixed $search
436
     * @param mixed $replace Path to image, or array("path" => xx, "width" => yy, "height" => zz)
437
     */
438
    public function setImageValue($search, $replace): void
439
    {
440
        // prepare $search_replace
441
        if (!is_array($search)) {
442
            $search = array($search);
443
        }
444
445
        $replacesList = array();
446
        if (!is_array($replace) || isset($replace['path'])) {
447
            $replacesList[] = $replace;
448
        } else {
449
            $replacesList = array_values($replace);
450
        }
451
452
        $searchReplace = array();
453
        foreach ($search as $searchIdx => $searchString) {
454
            $searchReplace[$searchString] = isset($replacesList[$searchIdx]) ? $replacesList[$searchIdx] : $replacesList[0];
455
        }
456
457
        // collect document parts
458
        $searchParts = array(
459
            $this->getMainPartName() => &$this->tempDocumentMainPart,
460
        );
461
        // define templates
462
        // result can be verified via "Open XML SDK 2.5 Productivity Tool" (http://www.microsoft.com/en-us/download/details.aspx?id=30425)
463
        $imgTpl = '<w:rPr>
464
                        <w:noProof/>
465
                        <w:lang w:eastAsia="ru-RU"/>
466
                        </w:rPr>
467
                        <w:drawing>
468
                            <wp:inline distT="0" distB="0" distL="0" distR="0">
469
                                <wp:extent cx="{WIDTH}" cy="{HEIGHT}"/>
470
                                <wp:effectExtent l="0" t="0" r="635" b="635"/>
471
                                <wp:docPr id="{RID}" name=""/>
472
                                <wp:cNvGraphicFramePr>
473
                                    <a:graphicFrameLocks
474
                                            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
475
                                            noChangeAspect="1"/>
476
                                </wp:cNvGraphicFramePr>
477
                                <a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
478
                                    <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
479
                                        <pic:pic
480
                                                xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
481
                                            <pic:nvPicPr>
482
                                                <pic:cNvPr id="{RID}" name=""/>
483
                                                <pic:cNvPicPr/>
484
                                            </pic:nvPicPr>
485
                                            <pic:blipFill>
486
                                                <a:blip r:embed="rId{RID}"/>
487
                                                <a:stretch>
488
                                                    <a:fillRect/>
489
                                                </a:stretch>
490
                                            </pic:blipFill>
491
                                            <pic:spPr>
492
                                                <a:xfrm>
493
                                                    <a:off x="0" y="0"/>
494
                                                    <a:ext cx="952500" cy="952500"/>
495
                                                </a:xfrm>
496
                                                <a:prstGeom prst="rect">
497
                                                    <a:avLst/>
498
                                                </a:prstGeom>
499
                                            </pic:spPr>
500
                                        </pic:pic>
501
                                    </a:graphicData>
502
                                </a:graphic>
503
                            </wp:inline>
504
                        </w:drawing>';
505
506
507
        foreach ($searchParts as $partFileName => &$partContent) {
508
            $partVariables = $this->getVariablesForPart($partContent);
509
510
            foreach ($searchReplace as $searchString => $replaceImage) {
511
                $varsToReplace = array_filter($partVariables, function ($partVar) use ($searchString) {
512
                    return ($partVar == $searchString) || preg_match('/^' . preg_quote($searchString) . ':/', $partVar);
513
                });
514
515
                foreach ($varsToReplace as $varNameWithArgs) {
516
                    $varInlineArgs = $this->getImageArgs($varNameWithArgs);
517
                    $preparedImageAttrs = $this->prepareImageAttrs($replaceImage, $varInlineArgs);
518
                    $imgPath = $preparedImageAttrs['src'];
519
520
                    // get image index
521
                    $imgIndex = $this->getNextRelationsIndex($partFileName);
522
                    $rid = 'rId' . $imgIndex;
523
524
                    // replace preparations
525
                    $this->addImageToRelations($partFileName, $rid, $imgPath, $preparedImageAttrs['mime']);
526
                    $xmlImage = str_replace(array('{RID}', '{WIDTH}', '{HEIGHT}'), array($rid, $preparedImageAttrs['width'], $preparedImageAttrs['height']), $imgTpl);
527
528
                    // replace variable
529
                    $varNameWithArgsFixed = self::ensureMacroCompleted($varNameWithArgs);
530
                    $matches = array();
531
                    if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed) . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) {
532
                        $wholeTag = $matches[0];
533
                        array_shift($matches);
534
                        list($openTag, $prefix, , $postfix, $closeTag) = $matches;
535
                        $replaceXml = $openTag . $prefix . $closeTag . $xmlImage . $openTag . $postfix . $closeTag;
536
                        // replace on each iteration, because in one tag we can have 2+ inline variables => before proceed next variable we need to change $partContent
537
                        $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent);
538
                    }
539
                }
540
            }
541
        }
542
    }
543
544
    /**
545
     * Find and replace macros in the given XML section.
546
     *
547
     * @param mixed $search
548
     * @param mixed $replace
549
     * @param string $documentPartXML
550
     *
551
     * @return string
552
     */
553
    protected function setValueForPart($search, $replace, string $documentPartXML): string
554
    {
555
        // Note: we can't use the same function for both cases here, because of performance considerations.
556
        $regExpEscaper = new RegExp();
557
558
        return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML);
559
    }
560
561
    /**
562
     * Get document.xml contents as DOMDocument
563
     *
564
     * @return DOMDocument
565
     */
566 7
    public function getDOMDocument(): DOMDocument
567
    {
568 7
        $dom = new DOMDocument();
569
570 7
        $dom->loadXML($this->document);
571 7
        return $dom;
572
    }
573
574
    /**
575
     * Update document.xml contents
576
     *
577
     * @param DOMDocument $dom - new contents
578
     */
579 5
    public function updateDOMDocument(DOMDocument $dom): void
580
    {
581 5
        $this->document = $dom->saveXml();
582 5
        file_put_contents($this->tmpDir . "/word/document.xml", $this->document);
583 5
    }
584
585
    /**
586
     * Fix table corruption
587
     *
588
     * @param string $xml - xml to fix
589
     *
590
     * @return DOMDocument
591
     */
592 5
    public function fixTables(string $xml): DOMDocument
593
    {
594 5
        $dom = new DOMDocument();
595 5
        $dom->loadXML($xml);
596 5
        $tables = $dom->getElementsByTagName('tbl');
597 5
        foreach ($tables as $table) {
598 2
            $columns = [];
599 2
            $columnsLen = 0;
600 2
            $toAdd = 0;
601 2
            $tableGrid = null;
602 2
            foreach ($table->childNodes as $el) {
603 2
                if ($el->nodeName == 'w:tblGrid') {
604 2
                    $tableGrid = $el;
605 2
                    foreach ($el->childNodes as $col) {
606 2
                        if ($col->nodeName == 'w:gridCol') {
607 2
                            $columns[] = $col;
608 2
                            $columnsLen += 1;
609
                        }
610
                    }
611 2
                } elseif ($el->nodeName == 'w:tr') {
612 2
                    $cellsLen = 0;
613 2
                    foreach ($el->childNodes as $col) {
614 2
                        if ($col->nodeName == 'w:tc') {
615 2
                            $cellsLen += 1;
616
                        }
617
                    }
618 2
                    if (($columnsLen + $toAdd) < $cellsLen) {
619 2
                        $toAdd = $cellsLen - $columnsLen;
620
                    }
621
                }
622
            }
623
624
            // add columns, if necessary
625 2
            if (!is_null($tableGrid) && $toAdd > 0) {
626
                $width = 0;
627
                foreach ($columns as $col) {
628
                    if (!is_null($col->getAttribute('w:w'))) {
629
                        $width += $col->getAttribute('w:w');
630
                    }
631
                }
632
                if ($width > 0) {
633
                    $oldAverage = $width / $columnsLen;
634
                    $newAverage = round($width / ($columnsLen + $toAdd));
635
                    foreach ($columns as $col) {
636
                        $col->setAttribute('w:w', round($col->getAttribute('w:w') * $newAverage / $oldAverage));
637
                    }
638
                    while ($toAdd > 0) {
639
                        $newCol = $dom->createElement("w:gridCol");
640
                        $newCol->setAttribute('w:w', $newAverage);
641
                        $tableGrid->appendChild($newCol);
642
                        $toAdd -= 1;
643
                    }
644
                }
645
            }
646
647
            // remove columns, if necessary
648 2
            $columns = [];
649 2
            foreach ($tableGrid->childNodes as $col) {
650 2
                if ($col->nodeName == 'w:gridCol') {
651 2
                    $columns[] = $col;
652
                }
653
            }
654 2
            $columnsLen = count($columns);
655
656 2
            $cellsLen = 0;
657 2
            $cellsLenMax = 0;
658 2
            foreach ($table->childNodes as $el) {
659 2
                if ($el->nodeName == 'w:tr') {
660 2
                    $cells = [];
661 2
                    foreach ($el->childNodes as $col) {
662 2
                        if ($col->nodeName == 'w:tc') {
663 2
                            $cells[] = $col;
664
                        }
665
                    }
666 2
                    $cellsLen = $this->getCellLen($cells);
667 2
                    $cellsLenMax = max($cellsLenMax, $cellsLen);
668
                }
669
            }
670 2
            $toRemove = $cellsLen - $cellsLenMax;
671 2
            if ($toRemove > 0) {
672
                $removedWidth = 0.0;
673
                for ($i = $columnsLen - 1; ($i + 1) >= $toRemove; $i -= 1) {
674
                    $extraCol = $columns[$i];
675
                    $removedWidth += $extraCol->getAttribute('w:w');
676
                    $tableGrid->removeChild($extraCol);
677
                }
678
679
                $columnsLeft = [];
680
                foreach ($tableGrid->childNodes as $col) {
681
                    if ($col->nodeName == 'w:gridCol') {
682
                        $columnsLeft[] = $col;
683
                    }
684
                }
685
                $extraSpace = 0;
686
                if (count($columnsLeft) > 0) {
687
                    $extraSpace = $removedWidth / count($columnsLeft);
688
                }
689
                foreach ($columnsLeft as $col) {
690 2
                    $col->setAttribute('w:w', round($col->getAttribute('w:w') + $extraSpace));
691
                }
692
            }
693
        }
694 5
        return $dom;
695
    }
696
697
    /**
698
     * Get total cells length
699
     *
700
     * @param array $cells - cells
701
     *
702
     * @return int
703
     */
704 2
    private function getCellLen(array $cells): int
705
    {
706 2
        $total = 0;
707 2
        foreach ($cells as $cell) {
708 2
            foreach ($cell->childNodes as $tc) {
709 2
                if ($tc->nodeName == 'w:tcPr') {
710 2
                    foreach ($tc->childNodes as $span) {
711 2
                        if ($span->nodeName == 'w:gridSpan') {
712 1
                            $total += intval($span->getAttribute('w:val'));
713 2
                            break;
714
                        }
715
                    }
716 2
                    break;
717
                }
718
            }
719
        }
720 2
        return $total + 1;
721
    }
722
723
    /**
724
     * @param ZipArchive $target
725
     * @param string $fileName
726
     * @param string $xml
727 1
     */
728
    protected function savePartWithRels(ZipArchive $target, string $fileName, string $xml): void
729 1
    {
730 1
        $this->skipFiles[] = basename($fileName);
731 1
        $target->addFromString($fileName, $xml);
732 1
        if (isset($this->tempDocumentRelations[$fileName])) {
733
            $relsFileName = $this->getRelationsName($fileName);
734 1
            $this->skipFiles[] = basename($relsFileName);
735
            $target->addFromString($relsFileName, $this->tempDocumentRelations[$fileName]);
736
        }
737
    }
738
739
    /**
740
     * Save the document to the target path
741 1
     *
742
     * @param string $path - target path
743 1
     */
744
    public function save(string $path): void
745 1
    {
746 1
        $rootPath = realpath($this->tmpDir);
747
748 1
        $zip = new ZipArchive();
749 1
        $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
750
751 1
        $this->savePartWithRels($zip, $this->getMainPartName(), $this->tempDocumentMainPart);
752 1
        $zip->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
753 1
754
        foreach ($this->tempDocumentNewImages as $imgPath => $imgName) {
755
            $zip->addFile($imgPath, 'word/media/' . $imgName);
756 1
        }
757 1
758 1
        $files = new RecursiveIteratorIterator(
759 1
            new RecursiveDirectoryIterator($rootPath),
760 1
            RecursiveIteratorIterator::LEAVES_ONLY
761
        );
762
763
        foreach ($files as $name => $file) {
764 1
            if (!$file->isDir()) {
765
                $filePath = $file->getRealPath();
766 1
                $relativePath = substr($filePath, strlen($rootPath) + 1);
767 1
                if (!in_array(basename($filePath), $this->skipFiles)) {
768
                    $zip->addFile($filePath, $relativePath);
769
                }
770 1
            }
771 1
        }
772
773
        $zip->close();
774
775
        if (isset($this->zipClass)) {
776
            $this->zipClass->close();
777
        }
778 6
779
        $this->rrmdir($this->tmpDir);
780 6
    }
781 6
782 6
    /**
783 6
     * Remove recursively directory
784 6
     *
785 6
     * @param string $dir - target directory
786
     */
787 6
    private function rrmdir(string $dir): void
788
    {
789
        $objects = scandir($dir);
790
        if (is_array($objects)) {
0 ignored issues
show
introduced by
The condition is_array($objects) is always true.
Loading history...
791 6
            foreach ($objects as $object) {
792 6
                if ($object != "." && $object != "..") {
793
                    if (filetype($dir . "/" . $object) == "dir") {
794 6
                        $this->rrmdir($dir . "/" . $object);
795
                    } else {
796
                        unlink($dir . "/" . $object);
797
                    }
798
                }
799 5
            }
800
            reset($objects);
801 5
            rmdir($dir);
802 5
        }
803
    }
804 5
805 5
    /**
806
     * Close document
807
     */
808
    public function close(): void
809
    {
810
        if (isset($this->zipClass)) {
811
            $this->zipClass->close();
812
        }
813
        $this->rrmdir($this->tmpDir);
814
    }
815
}
816