Passed
Push — master ( c052ad...705b31 )
by Bingo
02:51
created

DocxDocument   F

Complexity

Total Complexity 128

Size/Duplication

Total Lines 754
Duplicated Lines 0 %

Test Coverage

Coverage 76.85%

Importance

Changes 5
Bugs 2 Features 3
Metric Value
eloc 341
dl 0
loc 754
ccs 269
cts 350
cp 0.7685
rs 2
c 5
b 2
f 3
wmc 128

28 Methods

Rating   Name   Duplication   Size   Complexity  
F fixTables() 0 103 29
A getCellLen() 0 17 6
A __construct() 0 9 2
A updateDOMDocument() 0 4 1
A close() 0 6 2
A rrmdir() 0 15 6
A getHeaderName() 0 3 1
A addImageToRelations() 0 49 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
C prepareImageAttrs() 0 53 14
A chooseImageDimension() 0 17 6
A getDOMDocument() 0 6 1
B setImageValue() 0 64 11
A getVariablesForPart() 0 5 1
A getNextRelationsIndex() 0 12 3
B getImageArgs() 0 34 8
A savePartWithRels() 0 6 2
A getDocumentContentTypesName() 0 3 1
A ensureMacroCompleted() 0 6 3
A getDocumentMainPart() 0 3 1
B save() 0 36 6
A extract() 0 16 3
A getFooterName() 0 3 1
B fixImageWidthHeightRatio() 0 35 8

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 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 11
    public function __construct(string $path)
38
    {
39 11
        if (file_exists($path)) {
40 11
            $this->path = $path;
41 11
            $this->tmpDir = sys_get_temp_dir() . "/" . uniqid("", true) . date("His");
42 11
            $this->zipClass = new ZipArchive();
43 11
            $this->extract();
44
        } else {
45
            throw new Exception("The template " . $path . " was not found!");
46
        }
47 11
    }
48
49
    /**
50
     * Extract (unzip) document contents
51
     */
52 11
    private function extract(): void
53
    {
54 11
        if (file_exists($this->tmpDir) && is_dir($this->tmpDir)) {
55
            $this->rrmdir($this->tmpDir);
56
        }
57
58 11
        mkdir($this->tmpDir);
59
60 11
        $this->zipClass->open($this->path);
61 11
        $this->zipClass->extractTo($this->tmpDir);
62
63 11
        $this->tempDocumentMainPart = $this->readPartWithRels($this->getMainPartName());
64
65 11
        $this->tempDocumentContentTypes = $this->zipClass->getFromName($this->getDocumentContentTypesName());
66
67 11
        $this->document = file_get_contents($this->tmpDir . "/word/document.xml");
68 11
    }
69
70
    /**
71
     * Get document main part
72
     *
73
     * @return string
74
     */
75 1
    public function getDocumentMainPart(): string
76
    {
77 1
        return $this->tempDocumentMainPart;
78
    }
79
80
    /**
81
     * Get the name of main part document (method from PhpOffice\PhpWord)
82
     *
83
     * @return string
84
     */
85 11
    private function getMainPartName(): string
86
    {
87 11
        $contentTypes = $this->zipClass->getFromName('[Content_Types].xml');
88
89
        $pattern = '~PartName="\/(word\/document.*?\.xml)" ' .
90
                   'ContentType="application\/vnd\.openxmlformats-officedocument' .
91 11
                   '\.wordprocessingml\.document\.main\+xml"~';
92
93 11
        $matches = [];
94 11
        preg_match($pattern, $contentTypes, $matches);
95
96 11
        return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml';
97
    }
98
99
    /**
100
     * @return string
101
     */
102 11
    private function getDocumentContentTypesName(): string
103
    {
104 11
        return '[Content_Types].xml';
105
    }
106
107
    /**
108
     * Read document part (method from PhpOffice\PhpWord)
109
     *
110
     * @param string $fileName
111
     *
112
     * @return string
113
     */
114 11
    private function readPartWithRels(string $fileName): string
115
    {
116 11
        $relsFileName = $this->getRelationsName($fileName);
117 11
        $partRelations = $this->zipClass->getFromName($relsFileName);
118 11
        if ($partRelations !== false) {
119 11
            $this->tempDocumentRelations[$fileName] = $partRelations;
120
        }
121
122 11
        return $this->fixBrokenMacros($this->zipClass->getFromName($fileName));
123
    }
124
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 11
    private function getRelationsName(string $documentPartName): string
133
    {
134 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

134
        return 'word/_rels/' . /** @scrutinizer ignore-type */ pathinfo($documentPartName, PATHINFO_BASENAME) . '.rels';
Loading history...
135
    }
136
137 1
    private function getNextRelationsIndex(string $documentPartName): int
138
    {
139 1
        if (isset($this->tempDocumentRelations[$documentPartName])) {
140 1
            $candidate = substr_count($this->tempDocumentRelations[$documentPartName], '<Relationship');
141 1
            while (strpos($this->tempDocumentRelations[$documentPartName], 'Id="rId' . $candidate . '"') !== false) {
142
                $candidate++;
143
            }
144
145 1
            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 11
    private function fixBrokenMacros(string $documentPart): string
159
    {
160 11
        return preg_replace_callback(
161 11
            '/\$(?:\{|[^{$]*\>\{)[^}$]*\}/U',
162
            function ($match) {
163
                return strip_tags($match[0]);
164 11
            },
165 11
            $documentPart
166
        );
167
    }
168
169
    /**
170
     * @param string $macro
171
     *
172
     * @return string
173
     */
174 1
    protected static function ensureMacroCompleted(string $macro): string
175
    {
176 1
        if (substr($macro, 0, 2) !== '{{' && substr($macro, -1) !== '}}') {
177 1
            $macro = '{{' . $macro . '}}';
178
        }
179 1
        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 1
    private function getVariablesForPart(string $documentPartXML): array
214
    {
215 1
        $matches = array();
216 1
        preg_match_all('/\{\{(.*?)\}\}/i', $documentPartXML, $matches);
217 1
        return $matches[1];
218
    }
219
220 1
    private function getImageArgs(string $varNameWithArgs): array
221
    {
222 1
        $varElements = explode(':', $varNameWithArgs);
223 1
        array_shift($varElements); // first element is name of variable => remove it
224
225 1
        $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 1
        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 1
        return $varInlineArgs;
254
    }
255
256
    /**
257
     * @param mixed $replaceImage
258
     * @param array $varInlineArgs
259
     *
260
     * @return array
261
     */
262 1
    private function prepareImageAttrs($replaceImage, array $varInlineArgs): array
263
    {
264
        // get image path and size
265 1
        $width = null;
266 1
        $height = null;
267 1
        $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 1
        if (is_callable($replaceImage)) {
272
            $replaceImage = $replaceImage();
273
        }
274
275 1
        if (is_array($replaceImage) && isset($replaceImage['path'])) {
276 1
            $imgPath = $replaceImage['path'];
277 1
            if (isset($replaceImage['width'])) {
278 1
                $width = $replaceImage['width'];
279
            }
280 1
            if (isset($replaceImage['height'])) {
281 1
                $height = $replaceImage['height'];
282
            }
283 1
            if (isset($replaceImage['ratio'])) {
284 1
                $ratio = $replaceImage['ratio'];
285
            }
286
        } else {
287
            $imgPath = $replaceImage;
288
        }
289
290 1
        $width = $this->chooseImageDimension($width, isset($varInlineArgs['width']) ? $varInlineArgs['width'] : null, 115);
291 1
        $height = $this->chooseImageDimension($height, isset($varInlineArgs['height']) ? $varInlineArgs['height'] : null, 70);
292
293 1
        $imageData = @getimagesize($imgPath);
294 1
        if (!is_array($imageData)) {
295
            throw new Exception(sprintf('Invalid image: %s', $imgPath));
296
        }
297 1
        list($actualWidth, $actualHeight, $imageType) = $imageData;
298
299
        // fix aspect ratio (by default)
300 1
        if (is_null($ratio) && isset($varInlineArgs['ratio'])) {
301
            $ratio = $varInlineArgs['ratio'];
302
        }
303 1
        if (is_null($ratio) || !in_array(strtolower($ratio), array('', '-', 'f', 'false'))) {
304 1
            $this->fixImageWidthHeightRatio($width, $height, $actualWidth, $actualHeight);
305
        }
306
307
        $imageAttrs = array(
308 1
            'src'    => $imgPath,
309 1
            'mime'   => image_type_to_mime_type($imageType),
310 1
            'width'  => $width,
311 1
            'height' => $height,
312
        );
313
314 1
        return $imageAttrs;
315
    }
316
317
    /**
318
     * @param mixed $width
319
     * @param mixed $height
320
     * @param int $actualWidth
321
     * @param int $actualHeight
322
     */
323 1
    private function fixImageWidthHeightRatio(&$width, &$height, int $actualWidth, int $actualHeight): void
324
    {
325 1
        $imageRatio = $actualWidth / $actualHeight;
326
327 1
        if (($width === '') && ($height === '')) { // defined size are empty
328
            $width = $actualWidth . 'px';
329
            $height = $actualHeight . 'px';
330 1
        } 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 1
        } 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 1
            $widthMatches = array();
344 1
            preg_match("/\d([a-z%]+)$/", $width, $widthMatches);
345 1
            $heightMatches = array();
346 1
            preg_match("/\d([a-z%]+)$/", $height, $heightMatches);
347
            // try to fix only if dimensions are same
348 1
            if ($widthMatches[1] == $heightMatches[1]) {
349 1
                $dimention = $widthMatches[1];
350 1
                $widthFloat = (float) $width;
351 1
                $heightFloat = (float) $height;
352 1
                $definedRatio = $widthFloat / $heightFloat;
353
354 1
                if ($imageRatio > $definedRatio) { // image wider than defined box
355
                    $height = ($widthFloat / $imageRatio) . $dimention;
356 1
                } elseif ($imageRatio < $definedRatio) { // image higher than defined box
357
                    $width = ($heightFloat * $imageRatio) . $dimention;
358
                }
359
            }
360
        }
361 1
    }
362
363 1
    private function chooseImageDimension(?int $baseValue, ?int $inlineValue, int $defaultValue): string
364
    {
365 1
        $value = $baseValue;
366 1
        if (is_null($value) && isset($inlineValue)) {
367
            $value = $inlineValue;
368
        }
369 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

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