Passed
Pull Request — master (#30)
by
unknown
01:44
created

DocxMustache::ImageReplacer()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 47

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 47
rs 9.1563
c 0
b 0
f 0
cc 4
nc 4
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 $useStoragePath = false;
19
    public $zipper;
20
    public $imageManipulation;
21
    public $verbose;
22
23
    public function __construct($items, $local_template_file)
24
    {
25
        $this->items = $items;
26
        $this->template_file_name = basename($local_template_file);
27
        $this->template_file = $local_template_file;
28
        $this->word_doc = false;
29
        $this->zipper = new \Wrklst\Zipper\Zipper();
30
31
        //name of disk for storage
32
        $this->storageDisk = 'local';
33
34
        //prefix within your storage path
35
        $this->storagePathPrefix = 'app/';
36
37
        //if you use img urls that support manipulation via parameter
38
        $this->imageManipulation = ''; //'&w=1800';
39
40
        $this->verbose = false;
41
    }
42
43
    public function Execute($dpi = 72)
44
    {
45
        $this->CopyTmplate();
46
        $this->zipper->make($this->StoragePath($this->local_path.$this->template_file_name));
47
        $this->ReadTeamplate($dpi);
48
    }
49
50
    /**
51
     * @param string $file
52
     */
53
    public function StoragePath($file)
54
    {
55
        if($this->useStoragePath) {
56
            return \Storage::disk($this->storageDisk)->path($file);
57
        }
58
        return storage_path($file);
59
    }
60
61
    /**
62
     * @param string $msg
63
     */
64
    protected function Log($msg)
65
    {
66
        //introduce logging method here to keep track of process
67
        // can be overwritten in extended class to log with custom preocess logger
68
        if ($this->verbose) {
69
            Log::error($msg);
70
        }
71
    }
72
73
    public function CleanUpTmpDirs()
74
    {
75
        $now = time();
76
        $isExpired = ($now - (60 * 240));
77
        $disk = \Storage::disk($this->storageDisk);
78
        $all_dirs = $disk->directories($this->storagePathPrefix.'DocxMustache');
79
        foreach ($all_dirs as $dir) {
80
            //delete dirs older than 20min
81
            if ($disk->lastModified($dir) < $isExpired) {
82
                $disk->deleteDirectory($dir);
83
            }
84
        }
85
    }
86
87
    public function GetTmpDir()
88
    {
89
        $this->CleanUpTmpDirs();
90
        $path = $this->storagePathPrefix.'DocxMustache/'.uniqid($this->template_file).'/';
91
        \File::makeDirectory($this->StoragePath($path), 0775, true);
92
93
        return $path;
94
    }
95
96
    public function CopyTmplate()
97
    {
98
        $this->Log('Get Copy of Template');
99
        $this->local_path = $this->GetTmpDir();
100
        \Storage::disk($this->storageDisk)->copy($this->storagePathPrefix.$this->template_file, $this->local_path.$this->template_file_name);
101
    }
102
103
    protected function exctractOpenXmlFile($file)
104
    {
105
        $this->zipper
106
            ->make($this->StoragePath($this->local_path.$this->template_file_name))
107
            ->extractTo($this->StoragePath($this->local_path), [$file], \Wrklst\Zipper\Zipper::WHITELIST);
108
    }
109
110
    protected function ReadOpenXmlFile($file, $type = 'file')
111
    {
112
        if ($type == 'file') {
113
            if ($file_contents = \Storage::disk($this->storageDisk)->get($this->local_path.$file)) {
114
                return $file_contents;
115
            } else {
116
                throw new Exception('Cannot not read file '.$file);
117
            }
118
        } else {
119
            if ($xml_object = simplexml_load_file($this->StoragePath($this->local_path.$file))) {
120
                return $xml_object;
121
            } else {
122
                throw new Exception('Cannot load XML Object from file '.$file);
123
            }
124
        }
125
    }
126
127
    protected function SaveOpenXmlFile($file, $folder, $content)
128
    {
129
        \Storage::disk($this->storageDisk)
130
            ->put($this->local_path.$file, $content);
131
        //add new content to word doc
132
        if ($folder) {
133
            $this->zipper->folder($folder)
134
                ->add($this->StoragePath($this->local_path.$file));
135
        } else {
136
            $this->zipper
137
                ->add($this->StoragePath($this->local_path.$file));
138
        }
139
    }
140
141
    protected function SaveOpenXmlObjectToFile($xmlObject, $file, $folder)
142
    {
143
        if ($xmlString = $xmlObject->asXML()) {
144
            $this->SaveOpenXmlFile($file, $folder, $xmlString);
145
        } else {
146
            throw new Exception('Cannot generate xml for '.$file);
147
        }
148
    }
149
150
    public function ReadTeamplate($dpi)
151
    {
152
        $this->Log('Analyze Template');
153
154
        $this->relevant_files = [];
0 ignored issues
show
Bug introduced by
The property relevant_files does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

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