Passed
Push — master ( 2bc814...c9068a )
by Bingo
03:11
created

DocxDocument::setValueForPart()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 4
c 0
b 0
f 0
nc 2
nop 4
dl 0
loc 9
ccs 0
cts 5
cp 0
crap 6
rs 10
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 const MAXIMUM_REPLACEMENTS_DEFAULT = -1;
21
    private $path;
22
    private $tmpDir;
23
    private $document;
24
    private $zipClass;
25
    private $tempDocumentMainPart;
26
    private $tempDocumentHeaders = [];
27
    private $tempDocumentFooters = [];
28
    private $tempDocumentRelations = [];
29
    private $tempDocumentContentTypes = '';
30
    private $tempDocumentNewImages = [];
31
32
    /**
33
     * Construct an instance of Document
34
     *
35
     * @param string $path - path to the document
36
     *
37
     * @throws Exception
38
     */
39 10
    public function __construct(string $path)
40
    {
41 10
        if (file_exists($path)) {
42 10
            $this->path = $path;
43 10
            $this->tmpDir = sys_get_temp_dir() . "/" . uniqid("", true) . date("His");
44 10
            $this->zipClass = new ZipArchive();
45 10
            $this->extract();
46
        } else {
47
            throw new Exception("The template " . $path . " was not found!");
48
        }
49 10
    }
50
51
    /**
52
     * Extract (unzip) document contents
53
     */
54 10
    private function extract(): void
55
    {
56 10
        if (file_exists($this->tmpDir) && is_dir($this->tmpDir)) {
57
            $this->rrmdir($this->tmpDir);
58
        }
59
60 10
        mkdir($this->tmpDir);
61
62 10
        $this->zipClass->open($this->path);
63 10
        $this->zipClass->extractTo($this->tmpDir);
64
65 10
        $index = 1;
66 10
        while (false !== $this->zipClass->locateName($this->getHeaderName($index))) {
67
            $this->tempDocumentHeaders[$index] = $this->readPartWithRels($this->getHeaderName($index));
68
            $index += 1;
69
        }
70 10
        $index = 1;
71 10
        while (false !== $this->zipClass->locateName($this->getFooterName($index))) {
72
            $this->tempDocumentFooters[$index] = $this->readPartWithRels($this->getFooterName($index));
73
            $index += 1;
74
        }
75
76 10
        $this->tempDocumentMainPart = $this->readPartWithRels($this->getMainPartName());
77
78 10
        $this->tempDocumentContentTypes = $this->zipClass->getFromName($this->getDocumentContentTypesName());
79
80
        //$this->zipClass->close();
81
82 10
        $this->document = file_get_contents($this->tmpDir . "/word/document.xml");
83 10
    }
84
85
    /**
86
     * Get document main part
87
     *
88
     * @return string
89
     */
90 1
    public function getDocumentMainPart(): string
91
    {
92 1
        return $this->tempDocumentMainPart;
93
    }
94
95
    /**
96
     * Get the name of main part document (method from PhpOffice\PhpWord)
97
     *
98
     * @return string
99
     */
100 10
    private function getMainPartName(): string
101
    {
102 10
        $contentTypes = $this->zipClass->getFromName('[Content_Types].xml');
103
104
        $pattern = '~PartName="\/(word\/document.*?\.xml)" ' .
105
                   'ContentType="application\/vnd\.openxmlformats-officedocument' .
106 10
                   '\.wordprocessingml\.document\.main\+xml"~';
107
108 10
        $matches = [];
109 10
        preg_match($pattern, $contentTypes, $matches);
110
111 10
        return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
112
    }
113
114
    /**
115
     * @return string
116
     */
117 10
    private function getDocumentContentTypesName(): string
118
    {
119 10
        return '[Content_Types].xml';
120
    }
121
122
    /**
123
     * Read document part (method from PhpOffice\PhpWord)
124
     *
125
     * @param string $fileName
126
     *
127
     * @return string
128
     */
129 10
    private function readPartWithRels(string $fileName): string
130
    {
131 10
        $relsFileName = $this->getRelationsName($fileName);
132 10
        $partRelations = $this->zipClass->getFromName($relsFileName);
133 10
        if ($partRelations !== false) {
134 10
            $this->tempDocumentRelations[$fileName] = $partRelations;
135
        }
136
137 10
        return $this->fixBrokenMacros($this->zipClass->getFromName($fileName));
138
    }
139
140
    /**
141
     * Get the name of the relations file for document part (method from PhpOffice\PhpWord)
142
     *
143
     * @param string $documentPartName
144
     *
145
     * @return string
146
     */
147 10
    private function getRelationsName(string $documentPartName): string
148
    {
149 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

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

385
        if (!preg_match('/^([0-9]*(cm|mm|in|pt|pc|px|%|em|ex|)|auto)$/i', /** @scrutinizer ignore-type */ $value)) {
Loading history...
386
            $value = null;
387
        }
388
        if (is_null($value)) {
389
            $value = $defaultValue;
390
        }
391
        if (is_numeric($value)) {
0 ignored issues
show
introduced by
The condition is_numeric($value) is always true.
Loading history...
392
            $value .= 'px';
393
        }
394
395
        return $value;
396
    }
397
398
    private function addImageToRelations(string $partFileName, string $rid, string $imgPath, string $imageMimeType): void
399
    {
400
        // define templates
401
        $typeTpl = '<Override PartName="/word/media/{IMG}" ContentType="image/{EXT}"/>';
402
        $relationTpl = '<Relationship Id="{RID}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/{IMG}"/>';
403
        $newRelationsTpl = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
404
        $newRelationsTypeTpl = '<Override PartName="/{RELS}" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>';
405
        $extTransform = array(
406
            'image/jpeg' => 'jpeg',
407
            'image/png'  => 'png',
408
            'image/bmp'  => 'bmp',
409
            'image/gif'  => 'gif',
410
        );
411
412
        // get image embed name
413
        if (isset($this->tempDocumentNewImages[$imgPath])) {
414
            $imgName = $this->tempDocumentNewImages[$imgPath];
415
        } else {
416
            // transform extension
417
            if (isset($extTransform[$imageMimeType])) {
418
                $imgExt = $extTransform[$imageMimeType];
419
            } else {
420
                throw new Exception("Unsupported image type $imageMimeType");
421
            }
422
423
            // add image to document
424
            $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

424
            $imgName = 'image_' . $rid . '_' . /** @scrutinizer ignore-type */ pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt;
Loading history...
425
            $this->zipClass->pclzipAddFile($imgPath, 'word/media/' . $imgName);
426
            $this->tempDocumentNewImages[$imgPath] = $imgName;
427
428
            // setup type for image
429
            $xmlImageType = str_replace(array('{IMG}', '{EXT}'), array($imgName, $imgExt), $typeTpl);
430
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlImageType, $this->tempDocumentContentTypes) . '</Types>';
431
        }
432
433
        $xmlImageRelation = str_replace(array('{RID}', '{IMG}'), array($rid, $imgName), $relationTpl);
434
435
        if (!isset($this->tempDocumentRelations[$partFileName])) {
436
            // create new relations file
437
            $this->tempDocumentRelations[$partFileName] = $newRelationsTpl;
438
            // and add it to content types
439
            $xmlRelationsType = str_replace('{RELS}', $this->getRelationsName($partFileName), $newRelationsTypeTpl);
440
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
441
        }
442
443
        // add image to relations
444
        $this->tempDocumentRelations[$partFileName] = str_replace('</Relationships>', $xmlImageRelation, $this->tempDocumentRelations[$partFileName]) . '</Relationships>';
445
    }
446
447
    /**
448
     * @param mixed $search
449
     * @param mixed $replace Path to image, or array("path" => xx, "width" => yy, "height" => zz)
450
     * @param int $limit
451
     */
452
    public function setImageValue($search, $replace, ?int $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void
453
    {
454
        // prepare $search_replace
455
        if (!is_array($search)) {
456
            $search = array($search);
457
        }
458
459
        $replacesList = array();
460
        if (!is_array($replace) || isset($replace['path'])) {
461
            $replacesList[] = $replace;
462
        } else {
463
            $replacesList = array_values($replace);
464
        }
465
466
        $searchReplace = array();
467
        foreach ($search as $searchIdx => $searchString) {
468
            $searchReplace[$searchString] = isset($replacesList[$searchIdx]) ? $replacesList[$searchIdx] : $replacesList[0];
469
        }
470
471
        // collect document parts
472
        $searchParts = array(
473
            $this->getMainPartName() => &$this->tempDocumentMainPart,
474
        );
475
        foreach (array_keys($this->tempDocumentHeaders) as $headerIndex) {
476
            $searchParts[$this->getHeaderName($headerIndex)] = &$this->tempDocumentHeaders[$headerIndex];
477
        }
478
        foreach (array_keys($this->tempDocumentFooters) as $headerIndex) {
479
            $searchParts[$this->getFooterName($headerIndex)] = &$this->tempDocumentFooters[$headerIndex];
480
        }
481
482
        // define templates
483
        // result can be verified via "Open XML SDK 2.5 Productivity Tool" (http://www.microsoft.com/en-us/download/details.aspx?id=30425)
484
        $imgTpl = '<w:pict><v:shape type="#_x0000_t75" style="width:{WIDTH};height:{HEIGHT}" stroked="f"><v:imagedata r:id="{RID}" o:title=""/></v:shape></w:pict>';
485
486
        foreach ($searchParts as $partFileName => &$partContent) {
487
            $partVariables = $this->getVariablesForPart($partContent);
488
489
            foreach ($searchReplace as $searchString => $replaceImage) {
490
                $varsToReplace = array_filter($partVariables, function ($partVar) use ($searchString) {
491
                    return ($partVar == $searchString) || preg_match('/^' . preg_quote($searchString) . ':/', $partVar);
492
                });
493
494
                foreach ($varsToReplace as $varNameWithArgs) {
495
                    $varInlineArgs = $this->getImageArgs($varNameWithArgs);
496
                    $preparedImageAttrs = $this->prepareImageAttrs($replaceImage, $varInlineArgs);
497
                    $imgPath = $preparedImageAttrs['src'];
498
499
                    // get image index
500
                    $imgIndex = $this->getNextRelationsIndex($partFileName);
501
                    $rid = 'rId' . $imgIndex;
502
503
                    // replace preparations
504
                    $this->addImageToRelations($partFileName, $rid, $imgPath, $preparedImageAttrs['mime']);
505
                    $xmlImage = str_replace(array('{RID}', '{WIDTH}', '{HEIGHT}'), array($rid, $preparedImageAttrs['width'], $preparedImageAttrs['height']), $imgTpl);
506
507
                    // replace variable
508
                    $varNameWithArgsFixed = self::ensureMacroCompleted($varNameWithArgs);
509
                    $matches = array();
510
                    if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed) . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) {
511
                        $wholeTag = $matches[0];
512
                        array_shift($matches);
513
                        list($openTag, $prefix, , $postfix, $closeTag) = $matches;
514
                        $replaceXml = $openTag . $prefix . $closeTag . $xmlImage . $openTag . $postfix . $closeTag;
515
                        // replace on each iteration, because in one tag we can have 2+ inline variables => before proceed next variable we need to change $partContent
516
                        $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent, $limit);
0 ignored issues
show
Bug introduced by
It seems like $limit can also be of type null; however, parameter $limit of PhpDocxTemplate\DocxDocument::setValueForPart() does only seem to accept integer, 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

516
                        $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent, /** @scrutinizer ignore-type */ $limit);
Loading history...
517
                    }
518
                }
519
            }
520
        }
521
    }
522
523
    /**
524
     * Find and replace macros in the given XML section.
525
     *
526
     * @param mixed $search
527
     * @param mixed $replace
528
     * @param string $documentPartXML
529
     * @param int $limit
530
     *
531
     * @return string
532
     */
533
    protected function setValueForPart($search, $replace, string $documentPartXML, int $limit): string
534
    {
535
        // Note: we can't use the same function for both cases here, because of performance considerations.
536
        if (self::MAXIMUM_REPLACEMENTS_DEFAULT === $limit) {
537
            return str_replace($search, $replace, $documentPartXML);
538
        }
539
        $regExpEscaper = new RegExp();
540
541
        return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML, $limit);
542
    }
543
544
    /**
545
     * Get document.xml contents as DOMDocument
546
     *
547
     * @return DOMDocument
548
     */
549 7
    public function getDOMDocument(): DOMDocument
550
    {
551 7
        $dom = new DOMDocument();
552 7
        $dom->loadXML($this->document);
553 7
        return $dom;
554
    }
555
556
    /**
557
     * Update document.xml contents
558
     *
559
     * @param DOMDocument $dom - new contents
560
     */
561 5
    public function updateDOMDocument(DOMDocument $dom): void
562
    {
563 5
        $this->document = $dom->saveXml();
564 5
        file_put_contents($this->tmpDir . "/word/document.xml", $this->document);
565 5
    }
566
567
    /**
568
     * Fix table corruption
569
     *
570
     * @param string $xml - xml to fix
571
     *
572
     * @return DOMDocument
573
     */
574 5
    public function fixTables(string $xml): DOMDocument
575
    {
576 5
        $dom = new DOMDocument();
577 5
        $dom->loadXML($xml);
578 5
        $tables = $dom->getElementsByTagName('tbl');
579 5
        foreach ($tables as $table) {
580 2
            $columns = [];
581 2
            $columnsLen = 0;
582 2
            $toAdd = 0;
583 2
            $tableGrid = null;
584 2
            foreach ($table->childNodes as $el) {
585 2
                if ($el->nodeName == 'w:tblGrid') {
586 2
                    $tableGrid = $el;
587 2
                    foreach ($el->childNodes as $col) {
588 2
                        if ($col->nodeName == 'w:gridCol') {
589 2
                            $columns[] = $col;
590 2
                            $columnsLen += 1;
591
                        }
592
                    }
593 2
                } elseif ($el->nodeName == 'w:tr') {
594 2
                    $cellsLen = 0;
595 2
                    foreach ($el->childNodes as $col) {
596 2
                        if ($col->nodeName == 'w:tc') {
597 2
                            $cellsLen += 1;
598
                        }
599
                    }
600 2
                    if (($columnsLen + $toAdd) < $cellsLen) {
601 2
                        $toAdd = $cellsLen - $columnsLen;
602
                    }
603
                }
604
            }
605
606
            // add columns, if necessary
607 2
            if (!is_null($tableGrid) && $toAdd > 0) {
608
                $width = 0;
609
                foreach ($columns as $col) {
610
                    if (!is_null($col->getAttribute('w:w'))) {
611
                        $width += $col->getAttribute('w:w');
612
                    }
613
                }
614
                if ($width > 0) {
615
                    $oldAverage = $width / $columnsLen;
616
                    $newAverage = round($width / ($columnsLen + $toAdd));
617
                    foreach ($columns as $col) {
618
                        $col->setAttribute('w:w', round($col->getAttribute('w:w') * $newAverage / $oldAverage));
619
                    }
620
                    while ($toAdd > 0) {
621
                        $newCol = $dom->createElement("w:gridCol");
622
                        $newCol->setAttribute('w:w', $newAverage);
623
                        $tableGrid->appendChild($newCol);
624
                        $toAdd -= 1;
625
                    }
626
                }
627
            }
628
629
            // remove columns, if necessary
630 2
            $columns = [];
631 2
            foreach ($tableGrid->childNodes as $col) {
632 2
                if ($col->nodeName == 'w:gridCol') {
633 2
                    $columns[] = $col;
634
                }
635
            }
636 2
            $columnsLen = count($columns);
637
638 2
            $cellsLen = 0;
639 2
            $cellsLenMax = 0;
640 2
            foreach ($table->childNodes as $el) {
641 2
                if ($el->nodeName == 'w:tr') {
642 2
                    $cells = [];
643 2
                    foreach ($el->childNodes as $col) {
644 2
                        if ($col->nodeName == 'w:tc') {
645 2
                            $cells[] = $col;
646
                        }
647
                    }
648 2
                    $cellsLen = $this->getCellLen($cells);
649 2
                    $cellsLenMax = max($cellsLenMax, $cellsLen);
650
                }
651
            }
652 2
            $toRemove = $cellsLen - $cellsLenMax;
653 2
            if ($toRemove > 0) {
654
                $removedWidth = 0.0;
655
                for ($i = $columnsLen - 1; ($i + 1) >= $toRemove; $i -= 1) {
656
                    $extraCol = $columns[$i];
657
                    $removedWidth += $extraCol->getAttribute('w:w');
658
                    $tableGrid->removeChild($extraCol);
659
                }
660
661
                $columnsLeft = [];
662
                foreach ($tableGrid->childNodes as $col) {
663
                    if ($col->nodeName == 'w:gridCol') {
664
                        $columnsLeft[] = $col;
665
                    }
666
                }
667
                $extraSpace = 0;
668
                if (count($columnsLeft) > 0) {
669
                    $extraSpace = $removedWidth / count($columnsLeft);
670
                }
671
                foreach ($columnsLeft as $col) {
672 2
                    $col->setAttribute('w:w', round($col->getAttribute('w:w') + $extraSpace));
673
                }
674
            }
675
        }
676 5
        return $dom;
677
    }
678
679
    /**
680
     * Get total cells length
681
     *
682
     * @param array $cells - cells
683
     *
684
     * @return int
685
     */
686 2
    private function getCellLen(array $cells): int
687
    {
688 2
        $total = 0;
689 2
        foreach ($cells as $cell) {
690 2
            foreach ($cell->childNodes as $tc) {
691 2
                if ($tc->nodeName == 'w:tcPr') {
692 2
                    foreach ($tc->childNodes as $span) {
693 2
                        if ($span->nodeName == 'w:gridSpan') {
694 1
                            $total += intval($span->getAttribute('w:val'));
695 2
                            break;
696
                        }
697
                    }
698 2
                    break;
699
                }
700
            }
701
        }
702 2
        return $total + 1;
703
    }
704
705
    /**
706
     * Save the document to the target path
707
     *
708
     * @param string $path - target path
709
     */
710 1
    public function save(string $path): void
711
    {
712 1
        $rootPath = realpath($this->tmpDir);
713
714 1
        $zip = new ZipArchive();
715 1
        $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
716
717 1
        $files = new RecursiveIteratorIterator(
718 1
            new RecursiveDirectoryIterator($rootPath),
719 1
            RecursiveIteratorIterator::LEAVES_ONLY
720
        );
721
722 1
        foreach ($files as $name => $file) {
723 1
            if (!$file->isDir()) {
724 1
                $filePath = $file->getRealPath();
725 1
                $relativePath = substr($filePath, strlen($rootPath) + 1);
726 1
                $zip->addFile($filePath, $relativePath);
727
            }
728
        }
729
730 1
        $zip->close();
731
732 1
        if (isset($this->zipClass)) {
733 1
            $this->zipClass->close();
734
        }
735
736 1
        $this->rrmdir($this->tmpDir);
737 1
    }
738
739
    /**
740
     * Remove recursively directory
741
     *
742
     * @param string $dir - target directory
743
     */
744 6
    private function rrmdir(string $dir): void
745
    {
746 6
        $objects = scandir($dir);
747 6
        if (is_array($objects)) {
0 ignored issues
show
introduced by
The condition is_array($objects) is always true.
Loading history...
748 6
            foreach ($objects as $object) {
749 6
                if ($object != "." && $object != "..") {
750 6
                    if (filetype($dir . "/" . $object) == "dir") {
751 6
                        $this->rrmdir($dir . "/" . $object);
752
                    } else {
753 6
                        unlink($dir . "/" . $object);
754
                    }
755
                }
756
            }
757 6
            reset($objects);
758 6
            rmdir($dir);
759
        }
760 6
    }
761
762
    /**
763
     * Close document
764
     */
765 5
    public function close(): void
766
    {
767 5
        if (isset($this->zipClass)) {
768 5
            $this->zipClass->close();
769
        }
770 5
        $this->rrmdir($this->tmpDir);
771 5
    }
772
}
773