Passed
Push — master ( f0a2ea...d1a635 )
by Tobias
01:22 queued 12s
created

DocxMustache::doInplaceMustache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 10
rs 9.9332
c 0
b 0
f 0
cc 1
nc 1
nop 1
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
    {
106
        $filelist = [];
107
        $fileWhitelist = $this->fileWhitelist;
108
        $this->zipper
109
            ->make($this->StoragePath($this->local_path.$this->template_file_name))
110
            ->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...
111
                foreach ($fileWhitelist as $pattern) {
112
                    if (fnmatch($pattern, $file)) {
113
                        $filelist[] = $file;
114
                    }
115
                }
116
            });
117
        $this->filelist = $filelist;
118
    }
119
120
    public function doInplaceMustache($file)
121
    {
122
        $tempFileContent = $this->zipper
123
                            ->make($this->StoragePath($this->local_path.$this->template_file_name))
124
                            ->getFileContent($file);
125
        $tempFileContent = MustacheRender::render($this->items, $tempFileContent);
126
        $tempFileContent = HtmlConversion::convert($tempFileContent);
127
        $this->zipper->addString($file, $tempFileContent);
128
        $this->zipper->close();
129
    }
130
131
    public function CopyTmplate()
132
    {
133
        $this->Log('Get Copy of Template');
134
        $this->local_path = $this->GetTmpDir();
135
        \Storage::disk($this->storageDisk)->copy($this->storagePathPrefix.$this->template_file, $this->local_path.$this->template_file_name);
136
    }
137
138
    protected function exctractOpenXmlFile($file)
139
    {
140
        $this->zipper
141
            ->make($this->StoragePath($this->local_path.$this->template_file_name))
142
            ->extractTo($this->StoragePath($this->local_path), [$file], \Wrklst\Zipper\Zipper::WHITELIST);
143
    }
144
145
    protected function ReadOpenXmlFile($file, $type = 'file')
146
    {
147
        $this->exctractOpenXmlFile($file);
148
        if ($type == 'file') {
149
            if ($file_contents = \Storage::disk($this->storageDisk)->get($this->local_path.$file)) {
150
                return $file_contents;
151
            } else {
152
                throw new Exception('Cannot not read file '.$file);
153
            }
154
        } else {
155
            if ($xml_object = simplexml_load_file($this->StoragePath($this->local_path.$file))) {
156
                return $xml_object;
157
            } else {
158
                throw new Exception('Cannot load XML Object from file '.$file);
159
            }
160
        }
161
    }
162
163
    protected function SaveOpenXmlFile($file, $folder, $content)
164
    {
165
        \Storage::disk($this->storageDisk)
166
            ->put($this->local_path.$file, $content);
167
        //add new content to word doc
168
        if ($folder) {
169
            $this->zipper->folder($folder)
170
                ->add($this->StoragePath($this->local_path.$file));
171
        } else {
172
            $this->zipper
173
                ->add($this->StoragePath($this->local_path.$file));
174
        }
175
    }
176
177
    protected function SaveOpenXmlObjectToFile($xmlObject, $file, $folder)
178
    {
179
        if ($xmlString = $xmlObject->asXML()) {
180
            $this->SaveOpenXmlFile($file, $folder, $xmlString);
181
        } else {
182
            throw new Exception('Cannot generate xml for '.$file);
183
        }
184
    }
185
186
    public function ReadTeamplate($dpi)
187
    {
188
        $this->Log('Analyze Template');
189
        //get the main document out of the docx archive
190
        $this->word_doc = $this->ReadOpenXmlFile('word/document.xml', 'file');
191
192
        $this->Log('Merge Data into Template');
193
194
        $this->word_doc = MustacheRender::render($this->items, $this->word_doc);
195
196
        $this->word_doc = HtmlConversion::convert($this->word_doc);
197
198
        $this->ImageReplacer($dpi);
199
200
        $this->Log('Compact Template with Data');
201
202
        $this->SaveOpenXmlFile('word/document.xml', 'word', $this->word_doc);
203
        $this->zipper->close();
204
    }
205
206
    protected function AddContentType($imageCt = 'jpeg')
207
    {
208
        $ct_file = $this->ReadOpenXmlFile('[Content_Types].xml', 'object');
209
210
        if (! ($ct_file instanceof \Traversable)) {
211
            throw new Exception('Cannot traverse through [Content_Types].xml.');
212
        }
213
214
        //check if content type for jpg has been set
215
        $i = 0;
216
        $ct_already_set = false;
217
        foreach ($ct_file as $ct) {
218
            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...
219
                $ct_already_set = true;
220
            }
221
            $i++;
222
        }
223
224
        //if content type for jpg has not been set, add it to xml
225
        // and save xml to file and add it to the archive
226
        if (! $ct_already_set) {
227
            $sxe = $ct_file->addChild('Default');
228
            $sxe->addAttribute('Extension', $imageCt);
229
            $sxe->addAttribute('ContentType', 'image/'.$imageCt);
230
            $this->SaveOpenXmlObjectToFile($ct_file, '[Content_Types].xml', false);
231
        }
232
    }
233
234
    protected function FetchReplaceableImages(&$main_file, $ns)
235
    {
236
        //set up basic arrays to keep track of imgs
237
        $imgs = [];
238
        $imgs_replaced = []; // so they can later be removed from media and relation file.
239
        $newIdCounter = 1;
240
241
        //iterate through all drawing containers of the xml document
242
        foreach ($main_file->xpath('//w:drawing') as $k=>$drawing) {
243
            //figure out if there is a URL saved in the description field of the img
244
            $img_url = $this->AnalyseImgUrlString($drawing->children($ns['wp'])->xpath('wp:docPr')[0]->attributes()['descr']);
245
            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->xpath('wp:docPr')[0]->attributes()['descr'] = $img_url['rest'];
246
247
            //if there is a url, save this img as a img to be replaced
248
            if ($img_url['valid']) {
249
                $ueid = 'wrklstId'.$newIdCounter;
250
                $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'];
251
252
                //get dimensions
253
                $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'];
254
                $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'];
255
256
                //remember img as being replaced
257
                $imgs_replaced[$wasId] = $wasId;
258
259
                //set new img id
260
                $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;
261
262
                $imgs[] = [
263
                    'cx'     => (int) $cx,
264
                    'cy'     => (int) $cy,
265
                    'wasId'  => $wasId,
266
                    'id'     => $ueid,
267
                    'url'    => $img_url['url'],
268
                    'path'    => $img_url['path'],
269
                    'mode'    => $img_url['mode'],
270
                ];
271
272
                $newIdCounter++;
273
            }
274
        }
275
276
        return [
277
            'imgs'          => $imgs,
278
            'imgs_replaced' => $imgs_replaced,
279
        ];
280
    }
281
282
    protected function RemoveReplaceImages($imgs_replaced, &$rels_file)
283
    {
284
        //TODO: check if the same img is used at a different position int he file as well, as otherwise broken images are produced.
285
        //iterate through replaced images and clean rels files from them
286
        foreach ($imgs_replaced as $img_replaced) {
287
            $i = 0;
288
            foreach ($rels_file as $rel) {
289
                if ((string) $rel->attributes()['Id'] == $img_replaced) {
290
                    $this->zipper->remove('word/'.(string) $rel->attributes()['Target']);
291
                    unset($rels_file->Relationship[$i]);
292
                }
293
                $i++;
294
            }
295
        }
296
    }
297
298
    protected function InsertImages($ns, &$imgs, &$rels_file, &$main_file, $dpi)
299
    {
300
        $docimage = new DocImage();
301
        $allowed_imgs = $docimage->AllowedContentTypeImages();
302
        $image_i = 1;
303
        //iterate through replacable images
304
        foreach ($imgs as $k=>$img) {
305
            $this->Log('Merge Images into Template - '.round($image_i / count($imgs) * 100).'%');
306
            //get file type of img and test it against supported imgs
307
            if ($imgageData = $docimage->GetImageFromUrl($img['mode'] == 'url' ? $img['url'] : $img['path'], $img['mode'] == 'url' ? $this->imageManipulation : '')) {
308
                $imgs[$k]['img_file_src'] = str_replace('wrklstId', 'wrklst_image', $img['id']).$allowed_imgs[$imgageData['mime']];
309
                $imgs[$k]['img_file_dest'] = str_replace('wrklstId', 'wrklst_image', $img['id']).'.jpeg';
310
311
                $resampled_img = $docimage->ResampleImage($this, $imgs, $k, $imgageData['data'], $dpi);
312
313
                $sxe = $rels_file->addChild('Relationship');
314
                $sxe->addAttribute('Id', $img['id']);
315
                $sxe->addAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image');
316
                $sxe->addAttribute('Target', 'media/'.$imgs[$k]['img_file_dest']);
317
318
                foreach ($main_file->xpath('//w:drawing') as $k=>$drawing) {
319
                    if (null !== $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
320
                        ->graphic->graphicData->children($ns['pic'])->pic->blipFill &&
321
                        $img['id'] == $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
322
                        ->graphic->graphicData->children($ns['pic'])->pic->blipFill->children($ns['a'])
323
                        ->blip->attributes($ns['r'])['embed']) {
324
                        $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
325
                            ->graphic->graphicData->children($ns['pic'])->pic->spPr->children($ns['a'])
326
                            ->xfrm->ext->attributes()['cx'] = $resampled_img['width_emus'];
327
                        $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
328
                            ->graphic->graphicData->children($ns['pic'])->pic->spPr->children($ns['a'])
329
                            ->xfrm->ext->attributes()['cy'] = $resampled_img['height_emus'];
330
                        //anchor images
331
                        if (isset($main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->anchor)) {
332
                            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->anchor->extent->attributes()['cx'] = $resampled_img['width_emus'];
333
                            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->anchor->extent->attributes()['cy'] = $resampled_img['height_emus'];
334
                        }
335
                        //inline images
336
                        elseif (isset($main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->inline)) {
337
                            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->inline->extent->attributes()['cx'] = $resampled_img['width_emus'];
338
                            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->inline->extent->attributes()['cy'] = $resampled_img['height_emus'];
339
                        }
340
341
                        break;
342
                    }
343
                }
344
            }
345
            $image_i++;
346
        }
347
    }
348
349
    protected function ImageReplacer($dpi)
350
    {
351
        $this->Log('Load XML Document to Merge Images');
352
353
        //load main doc xml
354
        libxml_use_internal_errors(true);
355
        $main_file = simplexml_load_string($this->word_doc);
356
357
        if (gettype($main_file) == 'object') {
358
            $this->Log('Merge Images into Template');
359
360
            //get all namespaces of the document
361
            $ns = $main_file->getNamespaces(true);
362
363
            $replaceableImage = $this->FetchReplaceableImages($main_file, $ns);
364
            $imgs = $replaceableImage['imgs'];
365
            $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...
366
367
            $rels_file = $this->ReadOpenXmlFile('word/_rels/document.xml.rels', 'object');
368
369
            //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.
370
            //$this->RemoveReplaceImages($imgs_replaced, $rels_file);
371
372
            //add jpg content type if not set
373
            $this->AddContentType('jpeg');
374
375
            $this->InsertImages($ns, $imgs, $rels_file, $main_file, $dpi);
376
377
            $this->SaveOpenXmlObjectToFile($rels_file, 'word/_rels/document.xml.rels', 'word/_rels');
378
379
            if ($main_file_xml = $main_file->asXML()) {
380
                $this->word_doc = $main_file_xml;
381
            } else {
382
                throw new Exception('Cannot generate xml for word/document.xml.');
383
            }
384
        } else {
385
            $xmlerror = '';
386
            $errors = libxml_get_errors();
387
            foreach ($errors as $error) {
388
                // handle errors here
389
                $xmlerror .= $this->display_xml_error($error, explode("\n", $this->word_doc));
390
            }
391
            libxml_clear_errors();
392
            $this->Log('Error: Could not load XML file. '.$xmlerror);
393
            libxml_clear_errors();
394
        }
395
    }
396
397
    /*
398
    example for extracting xml errors from
399
    http://php.net/manual/en/function.libxml-get-errors.php
400
    */
401
    protected function display_xml_error($error, $xml)
402
    {
403
        $return = $xml[$error->line - 1]."\n";
404
        $return .= str_repeat('-', $error->column)."^\n";
405
406
        switch ($error->level) {
407
            case LIBXML_ERR_WARNING:
408
                $return .= "Warning $error->code: ";
409
                break;
410
                case LIBXML_ERR_ERROR:
411
                $return .= "Error $error->code: ";
412
                break;
413
            case LIBXML_ERR_FATAL:
414
                $return .= "Fatal Error $error->code: ";
415
                break;
416
        }
417
418
        $return .= trim($error->message).
419
                    "\n  Line: $error->line".
420
                    "\n  Column: $error->column";
421
422
        if ($error->file) {
423
            $return .= "\n  File: $error->file";
424
        }
425
426
        return "$return\n\n--------------------------------------------\n\n";
427
    }
428
429
    /**
430
     * @param string $string
431
     */
432
    protected function AnalyseImgUrlString($string)
433
    {
434
        $string = (string) $string;
435
        $start = '[IMG-REPLACE]';
436
        $end = '[/IMG-REPLACE]';
437
        $start_local = '[LOCAL_IMG_REPLACE]';
438
        $end_local = '[/LOCAL_IMG_REPLACE]';
439
        $valid = false;
440
        $url = '';
441
        $path = '';
442
443
        if ($string != str_replace($start, '', $string) && $string == str_replace($start.$end, '', $string)) {
444
            $string = ' '.$string;
445
            $ini = strpos($string, $start);
446 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...
447
                $url = '';
448
                $rest = $string;
449
            } else {
450
                $ini += strlen($start);
451
                $len = ((strpos($string, $end, $ini)) - $ini);
452
                $url = substr($string, $ini, $len);
453
454
                $ini = strpos($string, $start);
455
                $len = strpos($string, $end, $ini + strlen($start)) + strlen($end);
456
                $rest = substr($string, 0, $ini).substr($string, $len);
457
            }
458
459
            $valid = true;
460
461
            //TODO: create a better url validity check
462
            if (! trim(str_replace(['http', 'https', ':', ' '], '', $url)) || $url == str_replace('http', '', $url)) {
463
                $valid = false;
464
            }
465
            $mode = 'url';
466
        } elseif ($string != str_replace($start_local, '', $string) && $string == str_replace($start_local.$end_local, '', $string)) {
467
            $string = ' '.$string;
468
            $ini = strpos($string, $start_local);
469 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...
470
                $path = '';
471
                $rest = $string;
472
            } else {
473
                $ini += strlen($start_local);
474
                $len = ((strpos($string, $end_local, $ini)) - $ini);
475
                $path = str_replace('..', '', substr($string, $ini, $len));
476
477
                $ini = strpos($string, $start_local);
478
                $len = strpos($string, $end_local, $ini + strlen($start)) + strlen($end_local);
479
                $rest = substr($string, 0, $ini).substr($string, $len);
480
            }
481
482
            $valid = true;
483
484
            //check if path starts with storage path
485
            if (! starts_with($path, storage_path())) {
486
                $valid = false;
487
            }
488
            $mode = 'path';
489
        } else {
490
            $mode = 'nothing';
491
            $url = '';
492
            $path = '';
493
            $rest = str_replace([$start, $end, $start_local, $end_local], '', $string);
494
        }
495
496
        return [
497
            'mode' => $mode,
498
            'url'  => trim($url),
499
            'path' => trim($path),
500
            'rest' => trim($rest),
501
            'valid' => $valid,
502
        ];
503
    }
504
505
    public function SaveAsPdf()
506
    {
507
        $this->Log('Converting DOCX to PDF');
508
        //convert to pdf with libre office
509
        $process = new \Symfony\Component\Process\Process([
510
            'soffice',
511
            '--headless',
512
            '--convert-to',
513
            'pdf',
514
            $this->StoragePath($this->local_path.$this->template_file_name),
515
            '--outdir',
516
            $this->StoragePath($this->local_path),
517
        ]);
518
        $process->start();
519
        while ($process->isRunning()) {
520
            //wait until process is ready
521
        }
522
        // executes after the command finishes
523
        if (! $process->isSuccessful()) {
524
            throw new \Symfony\Component\Process\Exception\ProcessFailedException($process);
525
        } else {
526
            $path_parts = pathinfo($this->StoragePath($this->local_path.$this->template_file_name));
527
528
            return $this->StoragePath($this->local_path.$path_parts['filename'].'pdf');
529
        }
530
    }
531
}
532