Test Setup Failed
Pull Request — master (#7)
by
unknown
08:47
created

DocxDocument   F

Complexity

Total Complexity 125

Size/Duplication

Total Lines 731
Duplicated Lines 0 %

Test Coverage

Coverage 76.55%

Importance

Changes 6
Bugs 2 Features 3
Metric Value
eloc 321
c 6
b 2
f 3
dl 0
loc 731
ccs 258
cts 337
cp 0.7655
rs 2
wmc 125

29 Methods

Rating   Name   Duplication   Size   Complexity  
A getHeaderName() 0 3 1
A addImageToRelations() 0 53 5
A setValueForPart() 0 6 1
A readPartWithRels() 0 9 2
A fixBrokenMacros() 0 8 1
A getRelationsName() 0 3 1
A getMainPartName() 0 12 2
F fixTables() 0 103 29
C prepareImageAttrs() 0 53 14
A chooseImageDimension() 0 14 5
A getDOMDocument() 0 6 1
B setImageValue() 0 66 11
A getNextRelationsIndex() 0 12 3
A getVariablesForPart() 0 5 1
B getImageArgs() 0 34 8
A savePartWithRels() 0 9 3
A getCellLen() 0 17 6
A getDocumentContentTypesName() 0 3 1
A ensureMacroCompleted() 0 6 3
A close() 0 6 2
A getDocumentMainPart() 0 3 1
A save() 0 30 5
A __construct() 0 9 2
A extract() 0 16 3
A rrmdir() 0 15 6
A getFooterName() 0 3 1
A updateDOMDocument() 0 4 1
A getImageTemplate() 0 3 1
A fixImageWidthHeightRatio() 0 13 5

How to fix   Complexity   

Complex Class

Complex classes like DocxDocument often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DocxDocument, and based on these observations, apply Extract Interface, too.

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

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

343
        if (!preg_match('/^([0-9]*(cm|mm|in|pt|pc|px|%|em|ex|)|auto)$/i', /** @scrutinizer ignore-type */ $value)) {
Loading history...
344 1
            $value = null;
345 1
        }
346 1
        if (is_null($value)) {
347
            $value = $defaultValue;
348 1
        }
349 1
350 1
        return $value;
351 1
    }
352 1
353
    public function addImageToRelations(string $partFileName, string $rid, string $imgPath, string $imageMimeType): void
354 1
    {
355 1
        // define templates
356 1
        $typeTpl = '<Override PartName="/word/media/{IMG}" ContentType="image/{EXT}"/>';
357 1
        $relationTpl = '<Relationship Id="{RID}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/{IMG}"/>';
358
        $newRelationsTpl = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" . '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
359
        $newRelationsTypeTpl = '<Override PartName="/{RELS}" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>';
360
        $extTransform = array(
361 1
            'image/jpeg' => 'jpeg',
362
            'image/png'  => 'png',
363 1
            'image/bmp'  => 'bmp',
364
            'image/gif'  => 'gif',
365 1
        );
366 1
        //tempDocumentRelations
367
368
        // get image embed name
369 1
        if (isset($this->tempDocumentNewImages[$imgPath])) {
370
            $imgName = $this->tempDocumentNewImages[$imgPath];
371
        } else {
372 1
            // transform extension
373
            if (isset($extTransform[$imageMimeType])) {
374
                $imgExt = $extTransform[$imageMimeType];
375 1
            } else {
376 1
                throw new Exception("Unsupported image type $imageMimeType");
377
            }
378
379 1
            // add image to document
380
            $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

380
            $imgName = 'image_' . $rid . '_' . /** @scrutinizer ignore-type */ pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt;
Loading history...
381
            $this->tempDocumentNewImages[$imgPath] = $imgName;
382 1
383
            $targetDir = sprintf('%s%sword%smedia', $this->tmpDir, DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR);
384
            if (!file_exists($targetDir)) {
385 1
                mkdir($targetDir, 0777, true);
386 1
            }
387 1
            copy($imgPath, sprintf('%s%s%s', $targetDir, DIRECTORY_SEPARATOR, $imgName));
388 1
389
            // setup type for image
390 1
            $xmlImageType = str_replace(array('{IMG}', '{EXT}'), array($imgName, $imgExt), $typeTpl);
391
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlImageType, $this->tempDocumentContentTypes) . '</Types>';
392
        }
393
394
        $xmlImageRelation = str_replace(array('{RID}', '{IMG}'), array($rid, $imgName), $relationTpl);
395
396
        if (!isset($this->tempDocumentRelations[$partFileName])) {
397
            // create new relations file
398 1
            $this->tempDocumentRelations[$partFileName] = $newRelationsTpl;
399
            // and add it to content types
400
            $xmlRelationsType = str_replace('{RELS}', $this->getRelationsName($partFileName), $newRelationsTypeTpl);
401
            $this->tempDocumentContentTypes = str_replace('</Types>', $xmlRelationsType, $this->tempDocumentContentTypes) . '</Types>';
402 1
        }
403 1
404
        // add image to relations
405
        $this->tempDocumentRelations[$partFileName] = str_replace('</Relationships>', $xmlImageRelation, $this->tempDocumentRelations[$partFileName]) . '</Relationships>';
406
    }
407
408
    /**
409 1
     * @param mixed $search
410 1
     * @param mixed $replace Path to image, or array("path" => xx, "width" => yy, "height" => zz)
411
     */
412
    public function setImageValue($search, $replace): void
413 1
    {
414 1
        // prepare $search_replace
415
        if (!is_array($search)) {
416
            $search = array($search);
417 1
        }
418
419 1
        $replacesList = array();
420
        if (!is_array($replace) || isset($replace['path'])) {
421
            $replacesList[] = $replace;
422
        } else {
423
            $replacesList = array_values($replace);
424
        }
425
426
        $searchReplace = array();
427
        foreach ($search as $searchIdx => $searchString) {
428 1
            $searchReplace[$searchString] = isset($replacesList[$searchIdx]) ? $replacesList[$searchIdx] : $replacesList[0];
429
        }
430 1
431 1
        // collect document parts
432
        $searchParts = array(
433 1
            $this->getMainPartName() => &$this->tempDocumentMainPart,
434
        );
435
        // define templates
436
        // result can be verified via "Open XML SDK 2.5 Productivity Tool" (http://www.microsoft.com/en-us/download/details.aspx?id=30425)
437
        $imgTpl = $this->getImageTemplate();
438
439 1
        foreach ($searchParts as $partFileName => &$partContent) {
440
            $partVariables = $this->getVariablesForPart($partContent);
441
442 1
            foreach ($searchReplace as $searchString => $replaceImage) {
443
                $varsToReplace = array_filter($partVariables, function ($partVar) use ($searchString) {
444
                    return ($partVar == $searchString) || preg_match('/^' . preg_quote($searchString) . ':/', $partVar);
445
                });
446 1
447 1
                foreach ($varsToReplace as $varNameWithArgs) {
448
                    $varInlineArgs = $this->getImageArgs($varNameWithArgs);
449
450 1
                    $preparedImageAttrs = $this->prepareImageAttrs($replaceImage, $varInlineArgs);
451
                    $imgPath = $preparedImageAttrs['src'];
452
453 1
                    // get image index
454 1
                    $imgIndex = $this->getNextRelationsIndex($partFileName);
455 1
                    $rid = 'rId' . $imgIndex;
456
457
                    // replace preparations
458
                    $this->addImageToRelations($partFileName, $rid, $imgPath, $preparedImageAttrs['mime']);
459
                    $xmlImage = str_replace(array('{RID}', '{WIDTH}', '{HEIGHT}'), array($rid, $preparedImageAttrs['width'], $preparedImageAttrs['height']), $imgTpl);
460 1
461
                    // replace variable
462
                    $varNameWithArgsFixed = self::ensureMacroCompleted($varNameWithArgs);
463
                    $matches = array();
464 1
                    if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed) . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) {
465
                        $wholeTag = $matches[0];
466 1
                        $before = str_replace($varNameWithArgsFixed, '', $wholeTag);
467 1
                        array_shift($matches);
468
                        list($openTag, $prefix, , $postfix, $closeTag) = $matches;
469 1
                        $replaceXml = $before . $openTag . $prefix . $closeTag . $xmlImage . $openTag . $postfix . $closeTag;
470
                        // replace on each iteration, because in one tag we can have 2+ inline variables => before proceed next variable we need to change $partContent
471 1
                        $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent);
472 1
                    }
473
                }
474 1
            }
475 1
        }
476
477 1
        $this->document = $this->tempDocumentMainPart;
478 1
    }
479
480
    public function getImageTemplate(): string
481 1
    {
482 1
        return '<w:pict><v:shape xmlns:v="urn:schemas-microsoft-com:vml" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:o="urn:schemas-microsoft-com:office:office" type="#_x0000_t75" style="width:{WIDTH};height:{HEIGHT}" stroked="f"><v:imagedata r:id="{RID}" o:title=""/></v:shape></w:pict>';
483
    }
484
485 1
    /**
486 1
     * Find and replace macros in the given XML section.
487
     *
488
     * @param mixed $search
489 1
     * @param mixed $replace
490 1
     * @param string $documentPartXML
491 1
     *
492 1
     * @return string
493 1
     */
494 1
    protected function setValueForPart($search, $replace, string $documentPartXML): string
495 1
    {
496
        // Note: we can't use the same function for both cases here, because of performance considerations.
497 1
        $regExpEscaper = new RegExp();
498
499
        return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML);
500
    }
501
502
    /**
503 1
     * Get document.xml contents as DOMDocument
504 1
     *
505
     * @return DOMDocument
506 1
     */
507
    public function getDOMDocument(): DOMDocument
508 1
    {
509
        $dom = new DOMDocument();
510
511
        $dom->loadXML($this->document);
512
        return $dom;
513
    }
514
515
    /**
516
     * Update document.xml contents
517
     *
518
     * @param DOMDocument $dom - new contents
519
     */
520 1
    public function updateDOMDocument(DOMDocument $dom): void
521
    {
522
        $this->document = $dom->saveXml();
523 1
        file_put_contents(sprintf('%s%sword%sdocument.xml', $this->tmpDir, DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR), $this->document);
524
    }
525 1
526
    /**
527
     * Fix table corruption
528
     *
529
     * @param string $xml - xml to fix
530
     *
531
     * @return DOMDocument
532
     */
533 8
    public function fixTables(string $xml): DOMDocument
534
    {
535 8
        $dom = new DOMDocument();
536
        $dom->loadXML($xml);
537 8
        $tables = $dom->getElementsByTagName('tbl');
538 8
        foreach ($tables as $table) {
539
            $columns = [];
540
            $columnsLen = 0;
541
            $toAdd = 0;
542
            $tableGrid = null;
543
            foreach ($table->childNodes as $el) {
544
                if ($el->nodeName == 'w:tblGrid') {
545
                    $tableGrid = $el;
546 6
                    foreach ($el->childNodes as $col) {
547
                        if ($col->nodeName == 'w:gridCol') {
548 6
                            $columns[] = $col;
549 6
                            $columnsLen += 1;
550 6
                        }
551
                    }
552
                } elseif ($el->nodeName == 'w:tr') {
553
                    $cellsLen = 0;
554
                    foreach ($el->childNodes as $col) {
555
                        if ($col->nodeName == 'w:tc') {
556
                            $cellsLen += 1;
557
                        }
558
                    }
559 6
                    if (($columnsLen + $toAdd) < $cellsLen) {
560
                        $toAdd = $cellsLen - $columnsLen;
561 6
                    }
562 6
                }
563 6
            }
564 6
565 3
            // add columns, if necessary
566 3
            if (!is_null($tableGrid) && $toAdd > 0) {
567 3
                $width = 0;
568 3
                foreach ($columns as $col) {
569 3
                    if (!is_null($col->getAttribute('w:w'))) {
570 3
                        $width += $col->getAttribute('w:w');
571 3
                    }
572 3
                }
573 3
                if ($width > 0) {
574 3
                    $oldAverage = $width / $columnsLen;
575 3
                    $newAverage = round($width / ($columnsLen + $toAdd));
576
                    foreach ($columns as $col) {
577
                        $col->setAttribute('w:w', round($col->getAttribute('w:w') * $newAverage / $oldAverage));
578 3
                    }
579 3
                    while ($toAdd > 0) {
580 3
                        $newCol = $dom->createElement("w:gridCol");
581 3
                        $newCol->setAttribute('w:w', $newAverage);
582 3
                        $tableGrid->appendChild($newCol);
583
                        $toAdd -= 1;
584
                    }
585 3
                }
586 3
            }
587
588
            // remove columns, if necessary
589
            $columns = [];
590
            foreach ($tableGrid->childNodes as $col) {
591
                if ($col->nodeName == 'w:gridCol') {
592 3
                    $columns[] = $col;
593
                }
594
            }
595
            $columnsLen = count($columns);
596
597
            $cellsLen = 0;
598
            $cellsLenMax = 0;
599
            foreach ($table->childNodes as $el) {
600
                if ($el->nodeName == 'w:tr') {
601
                    $cells = [];
602
                    foreach ($el->childNodes as $col) {
603
                        if ($col->nodeName == 'w:tc') {
604
                            $cells[] = $col;
605
                        }
606
                    }
607
                    $cellsLen = $this->getCellLen($cells);
608
                    $cellsLenMax = max($cellsLenMax, $cellsLen);
609
                }
610
            }
611
            $toRemove = $cellsLen - $cellsLenMax;
612
            if ($toRemove > 0) {
613
                $removedWidth = 0.0;
614
                for ($i = $columnsLen - 1; ($i + 1) >= $toRemove; $i -= 1) {
615 3
                    $extraCol = $columns[$i];
616 3
                    $removedWidth += $extraCol->getAttribute('w:w');
617 3
                    $tableGrid->removeChild($extraCol);
618 3
                }
619
620
                $columnsLeft = [];
621 3
                foreach ($tableGrid->childNodes as $col) {
622
                    if ($col->nodeName == 'w:gridCol') {
623 3
                        $columnsLeft[] = $col;
624 3
                    }
625 3
                }
626 3
                $extraSpace = 0;
627 3
                if (count($columnsLeft) > 0) {
628 3
                    $extraSpace = $removedWidth / count($columnsLeft);
629 3
                }
630 3
                foreach ($columnsLeft as $col) {
631
                    $col->setAttribute('w:w', round($col->getAttribute('w:w') + $extraSpace));
632
                }
633 3
            }
634 3
        }
635
        return $dom;
636
    }
637 3
638 3
    /**
639
     * Get total cells length
640
     *
641
     * @param array $cells - cells
642
     *
643
     * @return int
644
     */
645
    private function getCellLen(array $cells): int
646
    {
647
        $total = 0;
648
        foreach ($cells as $cell) {
649
            foreach ($cell->childNodes as $tc) {
650
                if ($tc->nodeName == 'w:tcPr') {
651
                    foreach ($tc->childNodes as $span) {
652
                        if ($span->nodeName == 'w:gridSpan') {
653
                            $total += intval($span->getAttribute('w:val'));
654
                            break;
655
                        }
656
                    }
657 3
                    break;
658
                }
659
            }
660
        }
661 6
        return $total + 1;
662
    }
663
664
    /**
665
     * @param string $fileName
666
     */
667
    protected function savePartWithRels(string $fileName): void
668
    {
669
        if (isset($this->tempDocumentRelations[$fileName])) {
670
            $relsFileName = $this->getRelationsName($fileName);
671 3
            $targetDir = dirname($this->tmpDir . DIRECTORY_SEPARATOR . $relsFileName);
672
            if (!file_exists($targetDir)) {
673 3
                mkdir($targetDir, 0777, true);
674 3
            }
675 3
            file_put_contents($this->tmpDir . DIRECTORY_SEPARATOR . $relsFileName, $this->tempDocumentRelations[$fileName]);
676 3
        }
677 3
    }
678 3
679 1
    /**
680 3
     * Save the document to the target path
681
     *
682
     * @param string $path - target path
683 3
     */
684
    public function save(string $path): void
685
    {
686
        $rootPath = realpath($this->tmpDir);
687 3
688
        $zip = new ZipArchive();
689
        $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
690
691
        $this->savePartWithRels($this->getMainPartName());
692
        file_put_contents($this->tmpDir . DIRECTORY_SEPARATOR . $this->getDocumentContentTypesName(), $this->tempDocumentContentTypes);
693
694
        $files = new RecursiveIteratorIterator(
695 2
            new RecursiveDirectoryIterator($rootPath),
696
            RecursiveIteratorIterator::LEAVES_ONLY
697 2
        );
698 2
699 2
        foreach ($files as $name => $file) {
700 2
            $filePath = $file->getRealPath();
701
            if (file_exists($filePath) && is_file($filePath)) {
702 2
                $relativePath = substr($filePath, strlen($rootPath) + 1);
703
                $zip->addFile($filePath, $relativePath);
704
            }
705
        }
706
707
        $zip->close();
708
709 2
        if (isset($this->zipClass)) {
710
            $this->zipClass->close();
711 2
        }
712
713 2
        $this->rrmdir($this->tmpDir);
714 2
    }
715
716 2
    /**
717 2
     * Remove recursively directory
718
     *
719 2
     * @param string $dir - target directory
720 1
     */
721
    private function rrmdir(string $dir): void
722
    {
723 2
        $objects = scandir($dir);
724 2
        if (is_array($objects)) {
0 ignored issues
show
introduced by
The condition is_array($objects) is always true.
Loading history...
725 2
            foreach ($objects as $object) {
726
                if ($object != "." && $object != "..") {
727
                    if (filetype($dir . DIRECTORY_SEPARATOR . $object) == "dir") {
728 2
                        $this->rrmdir($dir . DIRECTORY_SEPARATOR . $object);
729 2
                    } else {
730 2
                        unlink($dir . DIRECTORY_SEPARATOR . $object);
731 2
                    }
732 2
                }
733 2
            }
734
            reset($objects);
735
            rmdir($dir);
736
        }
737
    }
738 2
739
    /**
740 2
     * Close document
741 2
     */
742
    public function close(): void
743
    {
744 2
        if (isset($this->zipClass)) {
745 2
            $this->zipClass->close();
746
        }
747
        $this->rrmdir($this->tmpDir);
748
    }
749
}
750