Passed
Push — master ( 7912aa...646bd0 )
by Tobias
02:37
created

DocxMustache::ReadOpenXmlFile()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 26
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
c 1
b 1
f 0
dl 0
loc 26
rs 8.5806
cc 4
eloc 12
nc 4
nop 2
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
    public function __construct($items, $local_template_file)
23
    {
24
        $this->items = $items;
25
        $this->template_file_name = basename($local_template_file);
26
        $this->template_file = $local_template_file;
27
        $this->word_doc = false;
28
        $this->zipper = new \Chumper\Zipper\Zipper;
29
30
        //name of disk for storage
31
        $this->storageDisk = 'local';
32
33
        //prefix within your storage path
34
        $this->storagePathPrefix = 'app/';
35
36
        //if you use img urls that support manipulation via parameter
37
        $this->imageManipulation = ''; //'&w=1800';
38
39
        $this->verbose = false;
40
    }
41
42
    public function execute()
43
    {
44
        $this->copyTmplate();
45
        $this->readTeamplate();
46
    }
47
48
    /**
49
     * @param string $file
50
     */
51
    protected function storagePath($file)
52
    {
53
        return storage_path($file);
54
    }
55
56
    /**
57
     * @param string $msg
58
     */
59
    protected function log($msg)
60
    {
61
        //introduce logging method here to keep track of process
62
        // can be overwritten in extended class to log with custom preocess logger
63
        if ($this->verbose)
64
            Log::error($msg);
65
    }
66
67
    public function cleanUpTmpDirs()
68
    {
69
        $now = time();
70
        $isExpired = ($now - (60 * 240));
71
        $disk = \Storage::disk($this->storageDisk);
72
        $all_dirs = $disk->directories($this->storagePathPrefix.'DocxMustache');
73
        foreach ($all_dirs as $dir) {
74
            //delete dirs older than 20min
75
            if ($disk->lastModified($dir) < $isExpired)
76
            {
77
                $disk->deleteDirectory($dir);
78
            }
79
        }
80
    }
81
82
    public function getTmpDir()
83
    {
84
        $this->cleanUpTmpDirs();
85
        $path = $this->storagePathPrefix.'DocxMustache/'.uniqid($this->template_file).'/';
86
        \File::makeDirectory($this->storagePath($path), 0775, true);
87
        return $path;
88
    }
89
90
    public function copyTmplate()
91
    {
92
        $this->log('Get Copy of Template');
93
        $this->local_path = $this->getTmpDir();
94
        \Storage::disk($this->storageDisk)->copy($this->storagePathPrefix.$this->template_file, $this->local_path.$this->template_file_name);
95
    }
96
97
    protected function exctractOpenXmlFile($file)
98
    {
99
        $this->zipper->make($this->storagePath($this->local_path.$this->template_file_name))
100
            ->extractTo($this->storagePath($this->local_path), array($file), \Chumper\Zipper\Zipper::WHITELIST);
101
    }
102
103
    protected function ReadOpenXmlFile($file, $type="file")
104
    {
105
        $this->exctractOpenXmlFile($file);
106
107
        if($type=="file")
108
        {
109
            if ($file_contents = \Storage::disk($this->storageDisk)->get($this->local_path.$file))
110
            {
111
                return $file_contents;
112
            } else
113
            {
114
                throw new Exception('Cannot not read file '.$file);
115
            }
116
        } else
117
        {
118
            if($xml_object = simplexml_load_file($this->storagePath($this->local_path.$file)))
119
            {
120
                return $xml_object;
121
            }
122
            else
123
            {
124
                throw new Exception('Cannot load XML Object from file '.$file);
125
            }
126
        }
127
128
    }
129
130
    protected function SaveOpenXmlFile($file,$folder,$content)
131
    {
132
        \Storage::disk($this->storageDisk)
133
            ->put($this->local_path.$file, $content);
134
        //add new content to word doc
135
        $this->zipper->folder($folder)
136
            ->add($this->storagePath($this->local_path.$file));
137
    }
138
139
    public function readTeamplate()
140
    {
141
        $this->log('Analyze Template');
142
        //get the main document out of the docx archive
143
        $this->word_doc = ReadOpenXmlFile('word/document.xml','file');
144
145
        $this->log('Merge Data into Template');
146
        $this->word_doc = $this->MustacheRender($this->items, $this->word_doc);
147
        $this->word_doc = $this->convertHtmlToOpenXML($this->word_doc);
148
149
        $this->ImageReplacer();
150
151
        $this->log('Compact Template with Data');
152
153
        $this->SaveOpenXmlFile('word/document.xml','word',$this->word_doc);
154
        $this->zipper->close();
155
    }
156
157
    protected function MustacheTagCleaner($content)
158
    {
159
        //kills all xml tags within curly mustache brackets
160
        //this is necessary, as word might produce unnecesary xml tage inbetween curly backets.
161
        return preg_replace_callback(
162
            '/{{(.*?)}}/',
163
            function($match) {
164
                return strip_tags($match[0]);
165
            },
166
            $content
167
        );
168
    }
169
170
    protected function MustacheRender($items, $mustache_template, $clean_tags = true)
171
    {
172
        if($clean_tags)
173
            $mustache_template = $this->MustacheTagCleaner($mustache_template);
174
175
        $m = new \Mustache_Engine(array('escape' => function($value) {
176
            if (str_replace('*[[DONOTESCAPE]]*', '', $value) != $value) {
177
                            return str_replace('*[[DONOTESCAPE]]*', '', $value);
178
            }
179
            return htmlspecialchars($value, ENT_COMPAT, 'UTF-8');
180
        }));
181
        return $m->render($mustache_template, $items);
182
    }
183
184
    protected function AddContentType($imageCt = "jpeg")
185
    {
186
        $ct_file = $this->ReadOpenXmlFile('[Content_Types].xml','object');
187
188
        //check if content type for jpg has been set
189
        $i = 0;
190
        $ct_already_set = false;
191
        foreach ($ct_file as $ct)
0 ignored issues
show
Bug introduced by
The expression $ct_file of type string|object<SimpleXMLElement> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
192
        {
193
            if ((string) $ct_file->Default[$i]['Extension'] == $imageCt) {
194
                            $ct_already_set = true;
195
            }
196
            $i++;
197
        }
198
199
        //if content type for jpg has not been set, add it to xml
200
        // and save xml to file and add it to the archive
201
        if (!$ct_already_set)
202
        {
203
            $sxe = $ct_file->addChild('Default');
204
            $sxe->addAttribute('Extension', $imageCt);
205
            $sxe->addAttribute('ContentType', 'image/'.$imageCt);
206
207
            if ($ct_file_xml = $ct_file->asXML())
208
            {
209
                $this->SaveOpenXmlFile('[Content_Types].xml',false,$ct_file_xml);
210
            } else
211
            {
212
                throw new Exception('Cannot generate xml for [Content_Types].xml.');
213
            }
214
        }
215
    }
216
217
    protected function FetchReplaceableImages(&$main_file, $ns)
218
    {
219
        //set up basic arrays to keep track of imgs
220
        $imgs = array();
221
        $imgs_replaced = array(); // so they can later be removed from media and relation file.
222
        $newIdCounter = 1;
223
224
        //iterate through all drawing containers of the xml document
225
        foreach ($main_file->xpath('//w:drawing') as $k=>$drawing)
226
        {
227
            $ueid = "wrklstId".$newIdCounter;
228
            $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"];
229
            $imgs_replaced[$wasId] = $wasId;
230
            $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;
231
232
            $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"];
233
            $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"];
234
235
            //figure out if there is a URL saved in the description field of the img
236
            $img_url = $this->analyseImgUrlString((string) $drawing->children($ns['wp'])->xpath('wp:docPr')[0]->attributes()["descr"]);
237
            $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->xpath('wp:docPr')[0]->attributes()["descr"] = $img_url["rest"];
238
239
            //check https://startbigthinksmall.wordpress.com/2010/01/04/points-inches-and-emus-measuring-units-in-office-open-xml/
240
            // for EMUs calculation
241
            /*
242
            295px @72 dpi = 1530350 EMUs = Multiplier for 72dpi pixels 5187.627118644067797
243
            413px @72 dpi = 2142490 EMUs = Multiplier for 72dpi pixels 5187.627118644067797
244
245
            */
246
247
            //if there is a url, save this img as a img to be replaced
248
            if (trim($img_url["url"]))
249
            {
250
                $imgs[] = array(
251
                    "cx" => $cx,
252
                    "cy" => $cy,
253
                    "width" => (int) ($cx / 5187.627118644067797),
254
                    "height" => (int) ($cy / 5187.627118644067797),
255
                    "wasId" => $wasId,
256
                    "id" => $ueid,
257
                    "url" => $img_url["url"],
258
                );
259
260
                $newIdCounter++;
261
            }
262
        }
263
        return array(
264
            'imgs' => $imgs,
265
            'imgs_replaced' => $imgs_replaced
266
        );
267
    }
268
269
    protected function RemoveReplaceImages($imgs_replaced, &$rels_file)
270
    {
271
        //iterate through replaced images and clean rels files from them
272
        foreach ($imgs_replaced as $img_replaced)
273
        {
274
            $i = 0;
275
            foreach ($rels_file as $rel)
276
            {
277
                if ((string) $rel->attributes()['Id'] == $img_replaced)
278
                {
279
                    $this->zipper->remove('word/'.(string) $rel->attributes()['Target']);
280
                    unset($rels_file->Relationship[$i]);
281
                }
282
                $i++;
283
            }
284
        }
285
    }
286
287
    protected function AllowedContentTypeImages()
288
    {
289
        return array(
290
            'image/gif' => '.gif',
291
            'image/jpeg' => '.jpeg',
292
            'image/png' => '.png',
293
            'image/bmp' => '.bmp',
294
        );
295
    }
296
297
    protected function GetImageFromUrl($url)
298
    {
299
        $allowed_imgs = $this->AllowedContentTypeImages();
300
301
        if ($img_file_handle = fopen($url.$this->imageManipulation, "rb"))
302
        {
303
            $img_data = stream_get_contents($img_file_handle);
304
            fclose($img_file_handle);
305
            $fi = new \finfo(FILEINFO_MIME);
306
307
            $image_mime = strstr($fi->buffer($img_data), ';', true);
308
            //dd($image_mime);
309
            if (isset($allowed_imgs[$image_mime]))
310
            {
311
                return array(
312
                    'data' => $img_data,
313
                    'mime' => $image_mime,
314
                );
315
            }
316
        }
317
        return false;
318
    }
319
320
    protected function ResampleImage($imgs, $k, $data)
321
    {
322
        \Storage::disk($this->storageDisk)->put($this->local_path.'word/media/'.$imgs[$k]['img_file_src'], $data);
323
324
        //rework img to new size and jpg format
325
        $img_rework = \Image::make($this->storagePath($this->local_path.'word/media/'.$imgs[$k]['img_file_src']));
326
327
        $w = $imgs[$k]['width'];
328
        $h = $imgs[$k]['height'];
329
330
        if ($w > $h)
331
        {
332
            $h = null;
333
        } else
334
        {
335
            $w = null;
336
        }
337
338
        $img_rework->resize($w, $h, function($constraint) {
339
            $constraint->aspectRatio();
340
            $constraint->upsize();
341
        });
342
343
        $new_height = $img_rework->height();
344
        $new_width = $img_rework->width();
345
        $img_rework->save($this->storagePath($this->local_path.'word/media/'.$imgs[$k]['img_file_dest']));
346
347
        $this->zipper->folder('word/media')->add($this->storagePath($this->local_path.'word/media/'.$imgs[$k]['img_file_dest']));
348
349
        return array(
350
            'height' => $new_height,
351
            'width' => $new_width,
352
        );
353
    }
354
355
    protected function InsertImages($ns, &$imgs, &$rels_file, &$main_file)
356
    {
357
        //define what images are allowed
358
        $allowed_imgs = $this->AllowedContentTypeImages();
359
360
        //iterate through replacable images
361
        foreach ($imgs as $k=>$img)
362
        {
363
            //get file type of img and test it against supported imgs
364
            if($imgageData = $this->GetImageFromUrl($img['url']))
365
            {
366
                $imgs[$k]['img_file_src'] = str_replace("wrklstId", "wrklst_image", $img['id']).$allowed_imgs[$imgageData['mime']];
367
                $imgs[$k]['img_file_dest'] = str_replace("wrklstId", "wrklst_image", $img['id']).'.jpeg';
368
369
                $resampled_img = $this->ResampleImage($imgs, $k, $imgageData['data']);
370
371
                $sxe = $rels_file->addChild('Relationship');
372
                $sxe->addAttribute('Id', $img['id']);
373
                $sxe->addAttribute('Type', "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image");
374
                $sxe->addAttribute('Target', "media/".$imgs[$k]['img_file_dest']);
375
376
                //update height and width of image in document.xml
377
                $new_height_emus = (int) ($resampled_img['height'] * 5187.627118644067797);
378
                $new_width_emus = (int) ($resampled_img['width'] * 5187.627118644067797);
379
380
                foreach ($main_file->xpath('//w:drawing') as $k=>$drawing)
381
                {
382
                    if ($img['id'] == $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
383
                        ->graphic->graphicData->children($ns['pic'])->pic->blipFill->children($ns['a'])
384
                        ->blip->attributes($ns['r'])["embed"])
385
                    {
386
                        $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
387
                            ->graphic->graphicData->children($ns['pic'])->pic->spPr->children($ns['a'])
388
                            ->xfrm->ext->attributes()["cx"] = $new_width_emus;
389
                        $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->children($ns['a'])
390
                            ->graphic->graphicData->children($ns['pic'])->pic->spPr->children($ns['a'])
391
                            ->xfrm->ext->attributes()["cy"] = $new_height_emus;
392
393
                        //the following also changes the contraints of the container for the img.
394
                        // probably not wanted, as this will make images larger than the constraints of the placeholder
395
                        /*
396
                        $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->inline->extent->attributes()["cx"] = $new_width_emus;
397
                        $main_file->xpath('//w:drawing')[$k]->children($ns['wp'])->inline->extent->attributes()["cy"] = $new_height_emus;
398
                        */
399
                        break;
400
                    }
401
                }
402
            }
403
        }
404
    }
405
406
    protected function ImageReplacer()
407
    {
408
        $this->log('Merge Images into Template');
409
410
        //load main doc xml
411
        $main_file = simplexml_load_string($this->word_doc);
412
413
        //get all namespaces of the document
414
        $ns = $main_file->getNamespaces(true);
415
416
        $replaceableImage = $this->FetchReplaceableImages($main_file, $ns);
417
        $imgs = $replaceableImage['imgs'];
418
        $imgs_replaced = $replaceableImage['imgs_replaced'];
419
420
        $rels_file = $this->ReadOpenXmlFile('word/_rels/document.xml.rels','object');
421
422
        $this->RemoveReplaceImages($imgs_replaced, $rels_file);
423
424
        //add jpg content type if not set
425
        $this->AddContentType('jpeg');
426
427
        $this->InsertImages($ns, $imgs, $rels_file, $main_file);
428
429
        if ($rels_file_xml = $rels_file->asXML())
430
        {
431
            $this->SaveOpenXmlFile('word/_rels/document.xml.rels','word/_rels',$rels_file_xml);
432
        } else
433
        {
434
            throw new Exception('Cannot generate xml for word/_rels/document.xml.rels.');
435
        }
436
437
        if ($main_file_xml = $main_file->asXML())
438
        {
439
            $this->word_doc = $main_file_xml;
440
        } else
441
        {
442
            throw new Exception('Cannot generate xml for word/document.xml.');
443
        }
444
    }
445
446
    /**
447
     * @param string $string
448
     */
449
    protected function analyseImgUrlString($string)
450
    {
451
        $start = "[IMG-REPLACE]";
452
        $end = "[/IMG-REPLACE]";
453
        $string = ' '.$string;
454
        $ini = strpos($string, $start);
455
        if ($ini == 0)
456
        {
457
            $url = '';
458
            $rest = $string;
459
        } else
460
        {
461
            $ini += strlen($start);
462
            $len = ((strpos($string, $end, $ini)) - $ini);
463
            $url = substr($string, $ini, $len);
464
465
            $ini = strpos($string, $start);
466
            $len = strpos($string, $end, $ini + strlen($start)) + strlen($end);
467
            $rest = substr($string, 0, $ini).substr($string, $len);
468
        }
469
        return array(
470
            "url" => $url,
471
            "rest" => $rest,
472
        );
473
    }
474
475
    protected function convertHtmlToOpenXMLTag($value, $tag = "b")
476
    {
477
        $value_array = array();
478
        $run_again = false;
479
        //this could be used instead if html was already escaped
480
        /*
481
        $bo = "&lt;";
482
        $bc = "&gt;";
483
        */
484
        $bo = "<";
485
        $bc = ">";
486
487
        //get first BOLD
488
        $tag_open_values = explode($bo.$tag.$bc, $value, 2);
489
490
        if (count($tag_open_values) > 1)
491
        {
492
            //save everything before the bold and close it
493
            $value_array[] = $tag_open_values[0];
494
            $value_array[] = '</w:t></w:r>';
495
496
            //define styling parameters
497
            $wrPr_open = strrpos($tag_open_values[0], '<w:rPr>');
498
            $wrPr_close = strrpos($tag_open_values[0], '</w:rPr>', $wrPr_open);
499
            $neutral_style = '<w:r><w:rPr>'.substr($tag_open_values[0], ($wrPr_open + 7), ($wrPr_close - ($wrPr_open + 7))).'</w:rPr><w:t>';
500
            $tagged_style = '<w:r><w:rPr><w:'.$tag.'/>'.substr($tag_open_values[0], ($wrPr_open + 7), ($wrPr_close - ($wrPr_open + 7))).'</w:rPr><w:t>';
501
502
            //open new text run and make it bold, include previous styling
503
            $value_array[] = $tagged_style;
504
            //get everything before bold close and after
505
            $tag_close_values = explode($bo.'/'.$tag.$bc, $tag_open_values[1], 2);
506
            //add bold text
507
            $value_array[] = $tag_close_values[0];
508
            //close bold run
509
            $value_array[] = '</w:t></w:r>';
510
            //open run for after bold
511
            $value_array[] = $neutral_style;
512
            $value_array[] = $tag_close_values[1];
513
514
            $run_again = true;
515
        } else {
516
            $value_array[] = $tag_open_values[0];
517
        }
518
519
        $value = implode('', $value_array);
520
521
        if ($run_again) {
522
                    $value = $this->convertHtmlToOpenXMLTag($value, $tag);
523
        }
524
525
        return $value;
526
    }
527
528
    /**
529
     * @param string $value
530
     */
531
    protected function convertHtmlToOpenXML($value)
532
    {
533
        $line_breaks = array("&lt;br /&gt;", "&lt;br/&gt;", "&lt;br&gt;", "<br />", "<br/>", "<br>");
534
        $value = str_replace($line_breaks, '<w:br/>', $value);
535
536
        $value = $this->convertHtmlToOpenXMLTag($value, "b");
537
        $value = $this->convertHtmlToOpenXMLTag($value, "i");
538
        $value = $this->convertHtmlToOpenXMLTag($value, "u");
539
540
        return $value;
541
    }
542
543
    public function saveAsPdf()
544
    {
545
        $this->log('Converting DOCX to PDF');
546
        //convert to pdf with libre office
547
        $command = "soffice --headless --convert-to pdf ".$this->storagePath($this->local_path.$this->template_file_name).' --outdir '.$this->storagePath($this->local_path);
548
        $process = new \Symfony\Component\Process\Process($command);
549
        $process->start();
550
        while ($process->isRunning()) {
551
            //wait until process is ready
552
        }
553
        // executes after the command finishes
554
        if (!$process->isSuccessful()) {
555
            throw new \Symfony\Component\Process\Exception\ProcessFailedException($process);
556
        } else
557
        {
558
            $path_parts = pathinfo($this->storagePath($this->local_path.$this->template_file_name));
559
            return $this->storagePath($this->local_path.$path_parts['filename'].'pdf');
560
        }
561
    }
562
}
563