Passed
Pull Request — master (#29)
by
unknown
01:53
created

DocxMustache::getAllFilesFromDocx()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 14
rs 9.7998
c 0
b 0
f 0
cc 3
nc 1
nop 0
1
<?php
2
3
namespace WrkLst\DocxMustache;
4
5
use Exception;
6
use Illuminate\Support\Facades\Log;
7
8
//Custom DOCX template class to change content based on mustache templating engine.
9
class DocxMustache
10
{
11
    public $items;
12
    public $word_doc;
13
    public $template_file_name;
14
    public $template_file;
15
    public $local_path;
16
    public $storageDisk;
17
    public $storagePathPrefix;
18
    public $zipper;
19
    public $imageManipulation;
20
    public $verbose;
21
    
22
    private $filelist;
23
    private $fileWhitelist = [
24
        'word/document.xml',
25
        'word/endnotes.xml',
26
        'word/footer*.xml',
27
        'word/footnotes.xml',
28
        'word/header*.xml'
29
    ];
30
31
    public function __construct($items, $local_template_file)
32
    {
33
        $this->items = $items;
34
        $this->template_file_name = basename($local_template_file);
35
        $this->template_file = $local_template_file;
36
        $this->word_doc = false;
37
        $this->zipper = new \Wrklst\Zipper\Zipper();
38
39
        //name of disk for storage
40
        $this->storageDisk = 'local';
41
42
        //prefix within your storage path
43
        $this->storagePathPrefix = 'app/';
44
45
        //if you use img urls that support manipulation via parameter
46
        $this->imageManipulation = ''; //'&w=1800';
47
48
        $this->verbose = false;
49
    }
50
51
    public function Execute($dpi = 72)
52
    {
53
        $this->CopyTmplate();
54
        $this->getAllFilesFromDocx();
55
        foreach($this->filelist as $file) {
56
            $this->doInplaceMustache($file);
57
        }
58
        $this->ReadTeamplate($dpi);
59
    }
60
61
    /**
62
     * @param string $file
63
     */
64
    public function StoragePath($file)
65
    {
66
        return storage_path($file);
67
    }
68
69
    /**
70
     * @param string $msg
71
     */
72
    protected function Log($msg)
73
    {
74
        //introduce logging method here to keep track of process
75
        // can be overwritten in extended class to log with custom preocess logger
76
        if ($this->verbose) {
77
            Log::error($msg);
78
        }
79
    }
80
81
    public function CleanUpTmpDirs()
82
    {
83
        $now = time();
84
        $isExpired = ($now - (60 * 240));
85
        $disk = \Storage::disk($this->storageDisk);
86
        $all_dirs = $disk->directories($this->storagePathPrefix.'DocxMustache');
87
        foreach ($all_dirs as $dir) {
88
            //delete dirs older than 20min
89
            if ($disk->lastModified($dir) < $isExpired) {
90
                $disk->deleteDirectory($dir);
91
            }
92
        }
93
    }
94
95
    public function GetTmpDir()
96
    {
97
        $this->CleanUpTmpDirs();
98
        $path = $this->storagePathPrefix.'DocxMustache/'.uniqid($this->template_file).'/';
99
        \File::makeDirectory($this->StoragePath($path), 0775, true);
100
101
        return $path;
102
    }
103
104
    public function getAllFilesFromDocx() {
105
        $filelist = [];
106
        $fileWhitelist = $this->fileWhitelist;
107
        $this->zipper
108
            ->make($this->StoragePath($this->local_path.$this->template_file_name))
109
            ->getRepository()->each(function ($file, $stats) use ($fileWhitelist, &$filelist) {
0 ignored issues
show
Unused Code introduced by
The parameter $stats is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
110
                foreach($fileWhitelist as $pattern) {
111
                    if(fnmatch($pattern, $file)) {
112
                        $filelist[] = $file;
113
                    }
114
                }
115
            });
116
        $this->filelist = $filelist;
117
    }
118
119
    public function doInplaceMustache($file) {
120
        $tempFileContent = $this->zipper
121
                            ->make($this->StoragePath($this->local_path.$this->template_file_name))
122
                            ->getFileContent($file);
123
        $tempFileContent = MustacheRender::render($this->items, $tempFileContent);
124
        $tempFileContent = HtmlConversion::convert($tempFileContent);
125
        $this->zipper->addString($file,$tempFileContent);
126
        $this->zipper->close();
127
    }
128
    
129
    public function CopyTmplate()
130
    {
131
        $this->Log('Get Copy of Template');
132
        $this->local_path = $this->GetTmpDir();
133
        \Storage::disk($this->storageDisk)->copy($this->storagePathPrefix.$this->template_file, $this->local_path.$this->template_file_name);
134
    }
135
136
    protected function exctractOpenXmlFile($file)
137
    {
138
        $this->zipper
139
            ->make($this->StoragePath($this->local_path.$this->template_file_name))
140
            ->extractTo($this->StoragePath($this->local_path), [$file], \Wrklst\Zipper\Zipper::WHITELIST);
141
    }
142
143
    protected function ReadOpenXmlFile($file, $type = 'file')
144
    {
145
        $this->exctractOpenXmlFile($file);
146
        if ($type == 'file') {
147
            if ($file_contents = \Storage::disk($this->storageDisk)->get($this->local_path.$file)) {
148
                return $file_contents;
149
            } else {
150
                throw new Exception('Cannot not read file '.$file);
151
            }
152
        } else {
153
            if ($xml_object = simplexml_load_file($this->StoragePath($this->local_path.$file))) {
154
                return $xml_object;
155
            } else {
156
                throw new Exception('Cannot load XML Object from file '.$file);
157
            }
158
        }
159
    }
160
161
    protected function SaveOpenXmlFile($file, $folder, $content)
162
    {
163
        \Storage::disk($this->storageDisk)
164
            ->put($this->local_path.$file, $content);
165
        //add new content to word doc
166
        if ($folder) {
167
            $this->zipper->folder($folder)
168
                ->add($this->StoragePath($this->local_path.$file));
169
        } else {
170
            $this->zipper
171
                ->add($this->StoragePath($this->local_path.$file));
172
        }
173
    }
174
175
    protected function SaveOpenXmlObjectToFile($xmlObject, $file, $folder)
176
    {
177
        if ($xmlString = $xmlObject->asXML()) {
178
            $this->SaveOpenXmlFile($file, $folder, $xmlString);
179
        } else {
180
            throw new Exception('Cannot generate xml for '.$file);
181
        }
182
    }
183
184
    public function ReadTeamplate($dpi)
185
    {
186
        $this->Log('Analyze Template');
187
        //get the main document out of the docx archive
188
        $this->word_doc = $this->ReadOpenXmlFile('word/document.xml', 'file');
189
190
        $this->Log('Merge Data into Template');
191
192
        $this->word_doc = MustacheRender::render($this->items, $this->word_doc);
193
194
        $this->word_doc = HtmlConversion::convert($this->word_doc);
195
196
        $this->ImageReplacer($dpi);
197
198
        $this->Log('Compact Template with Data');
199
200
        $this->SaveOpenXmlFile('word/document.xml', 'word', $this->word_doc);
201
        $this->zipper->close();
202
    }
203
204
    protected function AddContentType($imageCt = 'jpeg')
205
    {
206
        $ct_file = $this->ReadOpenXmlFile('[Content_Types].xml', 'object');
207
208
        if (!($ct_file instanceof \Traversable)) {
209
            throw new Exception('Cannot traverse through [Content_Types].xml.');
210
        }
211
212
        //check if content type for jpg has been set
213
        $i = 0;
214
        $ct_already_set = false;
215
        foreach ($ct_file as $ct) {
216
            if ((string) $ct_file->Default[$i]['Extension'] == $imageCt) {
0 ignored issues
show
Bug introduced by
Accessing Default on the interface Traversable suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeClass $object) {
        $a = $object->a;
    }
    
Loading history...
217
                $ct_already_set = true;
218
            }
219
            $i++;
220
        }
221
222
        //if content type for jpg has not been set, add it to xml
223
        // and save xml to file and add it to the archive
224
        if (!$ct_already_set) {
225
            $sxe = $ct_file->addChild('Default');
226
            $sxe->addAttribute('Extension', $imageCt);
227
            $sxe->addAttribute('ContentType', 'image/'.$imageCt);
228
            $this->SaveOpenXmlObjectToFile($ct_file, '[Content_Types].xml', false);
229
        }
230
    }
231
232
    protected function FetchReplaceableImages(&$main_file, $ns)
233
    {
234
        //set up basic arrays to keep track of imgs
235
        $imgs = [];
236
        $imgs_replaced = []; // so they can later be removed from media and relation file.
237
        $newIdCounter = 1;
238
239
        //iterate through all drawing containers of the xml document
240
        foreach ($main_file->xpath('//w:drawing') as $k=>$drawing) {
241
            //figure out if there is a URL saved in the description field of the img
242
            $img_url = $this->AnalyseImgUrlString($drawing->children($ns['wp'])->xpath('wp:docPr')[0]->attributes()['descr']);
243
            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->xpath('wp:docPr')[0]->attributes()['descr'] = $img_url['rest'];
244
245
            //if there is a url, save this img as a img to be replaced
246
            if ($img_url['valid']) {
247
                $ueid = 'wrklstId'.$newIdCounter;
248
                $wasId = (string) $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])->graphic->graphicData->children($ns['pic'])->pic->blipFill->children($ns['a'])->blip->attributes($ns['r'])['embed'];
249
250
                //get dimensions
251
                $cx = (int) $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])->graphic->graphicData->children($ns['pic'])->pic->spPr->children($ns['a'])->xfrm->ext->attributes()['cx'];
252
                $cy = (int) $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])->graphic->graphicData->children($ns['pic'])->pic->spPr->children($ns['a'])->xfrm->ext->attributes()['cy'];
253
254
                //remember img as being replaced
255
                $imgs_replaced[$wasId] = $wasId;
256
257
                //set new img id
258
                $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])->graphic->graphicData->children($ns['pic'])->pic->blipFill->children($ns['a'])->blip->attributes($ns['r'])['embed'] = $ueid;
259
260
                $imgs[] = [
261
                    'cx'     => (int) $cx,
262
                    'cy'     => (int) $cy,
263
                    'wasId'  => $wasId,
264
                    'id'     => $ueid,
265
                    'url'    => $img_url['url'],
266
                    'path'    => $img_url['path'],
267
                    'mode'    => $img_url['mode'],
268
                ];
269
270
                $newIdCounter++;
271
            }
272
        }
273
274
        return [
275
            'imgs'          => $imgs,
276
            'imgs_replaced' => $imgs_replaced,
277
        ];
278
    }
279
280
    protected function RemoveReplaceImages($imgs_replaced, &$rels_file)
281
    {
282
        //TODO: check if the same img is used at a different position int he file as well, as otherwise broken images are produced.
283
        //iterate through replaced images and clean rels files from them
284
        foreach ($imgs_replaced as $img_replaced) {
285
            $i = 0;
286
            foreach ($rels_file as $rel) {
287
                if ((string) $rel->attributes()['Id'] == $img_replaced) {
288
                    $this->zipper->remove('word/'.(string) $rel->attributes()['Target']);
289
                    unset($rels_file->Relationship[$i]);
290
                }
291
                $i++;
292
            }
293
        }
294
    }
295
296
    protected function InsertImages($ns, &$imgs, &$rels_file, &$main_file, $dpi)
297
    {
298
        $docimage = new DocImage();
299
        $allowed_imgs = $docimage->AllowedContentTypeImages();
300
        $image_i = 1;
301
        //iterate through replacable images
302
        foreach ($imgs as $k=>$img) {
303
            $this->Log('Merge Images into Template - '.round($image_i / count($imgs) * 100).'%');
304
            //get file type of img and test it against supported imgs
305
            if ($imgageData = $docimage->GetImageFromUrl($img['mode'] == 'url' ? $img['url'] : $img['path'], $img['mode'] == 'url' ? $this->imageManipulation : '')) {
306
                $imgs[$k]['img_file_src'] = str_replace('wrklstId', 'wrklst_image', $img['id']).$allowed_imgs[$imgageData['mime']];
307
                $imgs[$k]['img_file_dest'] = str_replace('wrklstId', 'wrklst_image', $img['id']).'.jpeg';
308
309
                $resampled_img = $docimage->ResampleImage($this, $imgs, $k, $imgageData['data'], $dpi);
310
311
                $sxe = $rels_file->addChild('Relationship');
312
                $sxe->addAttribute('Id', $img['id']);
313
                $sxe->addAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image');
314
                $sxe->addAttribute('Target', 'media/'.$imgs[$k]['img_file_dest']);
315
316
                foreach ($main_file->xpath('//w:drawing') as $k=>$drawing) {
317
                    if (null !== $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
318
                        ->graphic->graphicData->children($ns['pic'])->pic->blipFill &&
319
                        $img['id'] == $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
320
                        ->graphic->graphicData->children($ns['pic'])->pic->blipFill->children($ns['a'])
321
                        ->blip->attributes($ns['r'])['embed']) {
322
                        $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
323
                            ->graphic->graphicData->children($ns['pic'])->pic->spPr->children($ns['a'])
324
                            ->xfrm->ext->attributes()['cx'] = $resampled_img['width_emus'];
325
                        $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
326
                            ->graphic->graphicData->children($ns['pic'])->pic->spPr->children($ns['a'])
327
                            ->xfrm->ext->attributes()['cy'] = $resampled_img['height_emus'];
328
                        //anchor images
329
                        if (isset($main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->anchor)) {
330
                            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->anchor->extent->attributes()['cx'] = $resampled_img['width_emus'];
331
                            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->anchor->extent->attributes()['cy'] = $resampled_img['height_emus'];
332
                        }
333
                        //inline images
334
                        elseif (isset($main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->inline)) {
335
                            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->inline->extent->attributes()['cx'] = $resampled_img['width_emus'];
336
                            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->inline->extent->attributes()['cy'] = $resampled_img['height_emus'];
337
                        }
338
339
                        break;
340
                    }
341
                }
342
            }
343
            $image_i++;
344
        }
345
    }
346
347
    protected function ImageReplacer($dpi)
348
    {
349
        $this->Log('Load XML Document to Merge Images');
350
351
        //load main doc xml
352
        libxml_use_internal_errors(true);
353
        $main_file = simplexml_load_string($this->word_doc);
354
355
        if (gettype($main_file) == 'object') {
356
            $this->Log('Merge Images into Template');
357
358
            //get all namespaces of the document
359
            $ns = $main_file->getNamespaces(true);
360
361
            $replaceableImage = $this->FetchReplaceableImages($main_file, $ns);
362
            $imgs = $replaceableImage['imgs'];
363
            $imgs_replaced = $replaceableImage['imgs_replaced'];
0 ignored issues
show
Unused Code introduced by
$imgs_replaced is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
364
365
            $rels_file = $this->ReadOpenXmlFile('word/_rels/document.xml.rels', 'object');
366
367
            //do not remove until it is checked if the same img is used at a different position int he file as well, as otherwise broken images are produced.
368
            //$this->RemoveReplaceImages($imgs_replaced, $rels_file);
369
370
            //add jpg content type if not set
371
            $this->AddContentType('jpeg');
372
373
            $this->InsertImages($ns, $imgs, $rels_file, $main_file, $dpi);
374
375
            $this->SaveOpenXmlObjectToFile($rels_file, 'word/_rels/document.xml.rels', 'word/_rels');
376
377
            if ($main_file_xml = $main_file->asXML()) {
378
                $this->word_doc = $main_file_xml;
379
            } else {
380
                throw new Exception('Cannot generate xml for word/document.xml.');
381
            }
382
        } else {
383
            $xmlerror = '';
384
            $errors = libxml_get_errors();
385
            foreach ($errors as $error) {
386
                // handle errors here
387
                $xmlerror .= $this->display_xml_error($error, explode("\n", $this->word_doc));
388
            }
389
            libxml_clear_errors();
390
            $this->Log('Error: Could not load XML file. '.$xmlerror);
391
            libxml_clear_errors();
392
        }
393
    }
394
395
    /*
396
    example for extracting xml errors from
397
    http://php.net/manual/en/function.libxml-get-errors.php
398
    */
399
    protected function display_xml_error($error, $xml)
400
    {
401
        $return = $xml[$error->line - 1]."\n";
402
        $return .= str_repeat('-', $error->column)."^\n";
403
404
        switch ($error->level) {
405
            case LIBXML_ERR_WARNING:
406
                $return .= "Warning $error->code: ";
407
                break;
408
                case LIBXML_ERR_ERROR:
409
                $return .= "Error $error->code: ";
410
                break;
411
            case LIBXML_ERR_FATAL:
412
                $return .= "Fatal Error $error->code: ";
413
                break;
414
        }
415
416
        $return .= trim($error->message).
417
                    "\n  Line: $error->line".
418
                    "\n  Column: $error->column";
419
420
        if ($error->file) {
421
            $return .= "\n  File: $error->file";
422
        }
423
424
        return "$return\n\n--------------------------------------------\n\n";
425
    }
426
427
    /**
428
     * @param string $string
429
     */
430
    protected function AnalyseImgUrlString($string)
431
    {
432
        $string = (string) $string;
433
        $start = '[IMG-REPLACE]';
434
        $end = '[/IMG-REPLACE]';
435
        $start_local = '[LOCAL_IMG_REPLACE]';
436
        $end_local = '[/LOCAL_IMG_REPLACE]';
437
        $valid = false;
438
        $url = '';
439
        $path = '';
440
441
        if ($string != str_replace($start, '', $string) && $string == str_replace($start.$end, '', $string)) {
442
            $string = ' '.$string;
443
            $ini = strpos($string, $start);
444 View Code Duplication
            if ($ini == 0) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
445
                $url = '';
446
                $rest = $string;
447
            } else {
448
                $ini += strlen($start);
449
                $len = ((strpos($string, $end, $ini)) - $ini);
450
                $url = substr($string, $ini, $len);
451
452
                $ini = strpos($string, $start);
453
                $len = strpos($string, $end, $ini + strlen($start)) + strlen($end);
454
                $rest = substr($string, 0, $ini).substr($string, $len);
455
            }
456
457
            $valid = true;
458
459
            //TODO: create a better url validity check
460
            if (!trim(str_replace(['http', 'https', ':', ' '], '', $url)) || $url == str_replace('http', '', $url)) {
461
                $valid = false;
462
            }
463
            $mode = 'url';
464
        } elseif ($string != str_replace($start_local, '', $string) && $string == str_replace($start_local.$end_local, '', $string)) {
465
            $string = ' '.$string;
466
            $ini = strpos($string, $start_local);
467 View Code Duplication
            if ($ini == 0) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
468
                $path = '';
469
                $rest = $string;
470
            } else {
471
                $ini += strlen($start_local);
472
                $len = ((strpos($string, $end_local, $ini)) - $ini);
473
                $path = str_replace('..', '', substr($string, $ini, $len));
474
475
                $ini = strpos($string, $start_local);
476
                $len = strpos($string, $end_local, $ini + strlen($start)) + strlen($end_local);
477
                $rest = substr($string, 0, $ini).substr($string, $len);
478
            }
479
480
            $valid = true;
481
482
            //check if path starts with storage path
483
            if (!starts_with($path, storage_path())) {
484
                $valid = false;
485
            }
486
            $mode = 'path';
487
        } else {
488
            $mode = 'nothing';
489
            $url = '';
490
            $path = '';
491
            $rest = str_replace([$start, $end, $start_local, $end_local], '', $string);
492
        }
493
494
        return [
495
            'mode' => $mode,
496
            'url'  => trim($url),
497
            'path' => trim($path),
498
            'rest' => trim($rest),
499
            'valid' => $valid,
500
        ];
501
    }
502
503
    public function SaveAsPdf()
504
    {
505
        $this->Log('Converting DOCX to PDF');
506
        //convert to pdf with libre office
507
        $process = new \Symfony\Component\Process\Process([
508
            'soffice',
509
            '--headless',
510
            '--convert-to',
511
            'pdf',
512
            $this->StoragePath($this->local_path.$this->template_file_name),
513
            '--outdir',
514
            $this->StoragePath($this->local_path),
515
        ]);
516
        $process->start();
517
        while ($process->isRunning()) {
518
            //wait until process is ready
519
        }
520
        // executes after the command finishes
521
        if (!$process->isSuccessful()) {
522
            throw new \Symfony\Component\Process\Exception\ProcessFailedException($process);
523
        } else {
524
            $path_parts = pathinfo($this->StoragePath($this->local_path.$this->template_file_name));
525
526
            return $this->StoragePath($this->local_path.$path_parts['filename'].'pdf');
527
        }
528
    }
529
}
530