Passed
Push — master ( 646bd0...b04009 )
by Tobias
02:29
created

DocxMustache::cleanUpTmpDirs()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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