BasePremailer   D
last analyzed

Complexity

Total Complexity 94

Size/Duplication

Total Lines 609
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 4

Importance

Changes 0
Metric Value
wmc 94
lcom 1
cbo 4
dl 0
loc 609
rs 4.836
c 0
b 0
f 0

13 Methods

Rating   Name   Duplication   Size   Complexity  
A setCharset() 0 6 1
A getCharset() 0 4 1
A setOption() 0 17 3
A getOption() 0 19 4
A getHtml() 0 9 2
A getText() 0 9 2
getHtmlContent() 0 1 ?
A loadDocument() 0 12 2
F prepareContent() 0 161 32
C prepareText() 0 68 9
F convertHtmlToText() 0 81 19
C validateScalarOptionValue() 0 24 8
C validatePossibleOptionValue() 0 41 11

How to fix   Complexity   

Complex Class

Complex classes like BasePremailer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use BasePremailer, and based on these observations, apply Extract Interface, too.

1
<?php namespace Luminaire\Premailer;
2
3
/**
4
 * Created by Sublime Text 3
5
 *
6
 * @user     Kevin Tanjung
7
 * @website  http://kevintanjung.github.io
8
 * @email    [email protected]
9
 * @date     02/08/2016
10
 * @time     11:05
11
 */
12
13
use Crossjoin\Css\Format\Rule\AtMedia\MediaQuery;
14
use Crossjoin\Css\Format\Rule\AtMedia\MediaRule;
15
use Crossjoin\Css\Format\Rule\RuleAbstract;
16
use Crossjoin\Css\Format\Rule\Style\StyleDeclaration;
17
use Crossjoin\Css\Format\Rule\Style\StyleRuleSet;
18
use Crossjoin\Css\Format\Rule\Style\StyleSelector;
19
use Crossjoin\Css\Reader\CssString;
20
use Crossjoin\Css\Writer\WriterAbstract;
21
use Symfony\Component\CssSelector\CssSelectorConverter as CssSelector;
22
use DOMDocument, DOMElement, DOMXPath;
23
use LengthException, RuntimeException, InvalidArgumentException;
24
use Illuminate\Support\Arr;
25
26
/**
27
 * The base Premailer class
28
 *
29
 * @package  \Luminaire\Premailer
30
 */
31
abstract class BasePremailer
32
{
33
34
    const OPTION_STYLE_TAG                  = 'styleTag';
35
    const OPTION_STYLE_TAG_BODY             = 1;
36
    const OPTION_STYLE_TAG_HEAD             = 2;
37
    const OPTION_STYLE_TAG_REMOVE           = 3;
38
39
    const OPTION_HTML_COMMENTS              = 'htmlComments';
40
    const OPTION_HTML_COMMENTS_KEEP         = 1;
41
    const OPTION_HTML_COMMENTS_REMOVE       = 2;
42
43
    const OPTION_HTML_CLASSES               = 'htmlClasses';
44
    const OPTION_HTML_CLASSES_KEEP          = 1;
45
    const OPTION_HTML_CLASSES_REMOVE        = 2;
46
47
    const OPTION_TEXT_LINE_WIDTH            = 'textLineWidth';
48
49
    const OPTION_CSS_WRITER_CLASS           = 'cssWriterClass';
50
    const OPTION_CSS_WRITER_CLASS_COMPACT   = '\Crossjoin\Css\Writer\Compact';
51
    const OPTION_CSS_WRITER_CLASS_PRETTY    = '\Crossjoin\Css\Writer\Pretty';
52
53
    /**
54
     * The options for HTML/text generation
55
     *
56
     * @var array
57
     */
58
    protected $options = [
59
        self::OPTION_STYLE_TAG        => self::OPTION_STYLE_TAG_BODY,
60
        self::OPTION_HTML_CLASSES     => self::OPTION_HTML_CLASSES_KEEP,
61
        self::OPTION_HTML_COMMENTS    => self::OPTION_HTML_COMMENTS_REMOVE,
62
        self::OPTION_CSS_WRITER_CLASS => self::OPTION_CSS_WRITER_CLASS_COMPACT,
63
        self::OPTION_TEXT_LINE_WIDTH  => 75,
64
    ];
65
66
    /**
67
     * The charset for HTML/text output
68
     *
69
     * @var string
70
     */
71
    protected $charset = "UTF-8";
72
73
    /**
74
     * The prepared HTML content
75
     *
76
     * @var string
77
     */
78
    protected $html;
79
80
    /**
81
     * The prepared text content
82
     *
83
     * @var string
84
     */
85
    protected $text;
86
87
    /**
88
     * The loaded DOM Document
89
     *
90
     * @var \DOMDocument
91
     */
92
    protected $doc;
93
94
    /**
95
     * Sets the charset used in the HTML document and used for the output.
96
     *
97
     * @param  string  $charset
98
     * @return $this
99
     */
100
    public function setCharset($charset)
101
    {
102
        $this->charset = $charset;
103
104
        return $this;
105
    }
106
107
    /**
108
     * Gets the charset used in the HTML document and used for the output.
109
     *
110
     * @return string
111
     */
112
    public function getCharset()
113
    {
114
        return $this->charset;
115
    }
116
117
    /**
118
     * Sets an option for the generation of the mail.
119
     *
120
     * @param string $name
121
     * @param mixed $value
122
     */
123
    public function setOption($name, $value)
124
    {
125
        if ( ! is_string($name))
126
        {
127
            throw new InvalidArgumentException('The argument 0 of [setOption] method is expected to be a [string], but [' . gettype($name) . '] is given.');
128
        }
129
130
        if ( ! isset($this->options[$name]))
131
        {
132
            throw new InvalidArgumentException("An option with the name [{$name}] doesn't exist.");
133
        }
134
135
        $this->validateScalarOptionValue($name, $value);
136
        $this->validatePossibleOptionValue($name, $value);
137
138
        $this->options[$name] = $value;
139
    }
140
141
    /**
142
     * Gets an option for the generation of the mail.
143
     *
144
     * @param  string|null  $name
145
     * @return mixed
146
     *
147
     * @throws \InvalidArgumentException
148
     */
149
    public function getOption($name = null)
150
    {
151
        if (is_null($name))
152
        {
153
            return $this->options;
154
        }
155
156
        if ( ! is_string($name))
157
        {
158
            throw new InvalidArgumentException('The argument 0 of [setOption] method is expected to be a [string], but [' . gettype($name) . '] is given.');
159
        }
160
161
        if ( ! isset($this->options[$name]))
162
        {
163
            throw new InvalidArgumentException("An option with the name [{$name}] doesn't exist.");
164
        }
165
166
        return $this->options[$name];
167
    }
168
169
    /**
170
     * Gets the prepared HTML version of the mail.
171
     *
172
     * @return string
173
     */
174
    public function getHtml()
175
    {
176
        if ($this->html === null)
177
        {
178
            $this->prepareContent();
179
        }
180
181
        return $this->html;
182
    }
183
184
    /**
185
     * Gets the prepared text version of the mail.
186
     *
187
     * @return string
188
     */
189
    public function getText()
190
    {
191
        if ($this->text === null)
192
        {
193
            $this->prepareContent();
194
        }
195
196
        return $this->text;
197
    }
198
199
    /**
200
     * Gets the HTML content from the preferred source.
201
     *
202
     * @return string
203
     */
204
    abstract protected function getHtmlContent();
205
206
    /**
207
     * Load the DOM Document
208
     *
209
     * @return \DOMDocument
210
     */
211
    protected function loadDocument()
212
    {
213
        if ($this->doc)
214
        {
215
            return $this->doc;
216
        }
217
218
        $this->doc = new DOMDocument();
219
        $this->doc->loadHTML($this->getHtmlContent());
220
221
        return $this->doc;
222
    }
223
224
    /**
225
     * Prepares the mail HTML/text content.
226
     *
227
     * @return void
228
     */
229
    protected function prepareContent()
230
    {
231
        if ( ! class_exists('\DOMDocument'))
232
        {
233
            throw new RuntimeException("Required extension 'dom' seems to be missing.");
234
        }
235
236
        $this->loadDocument();
237
238
        $xpath      = new DOMXPath($this->doc);
239
        $stylesheet = (new Parser\StylesheetParser($this->doc))->extract();
240
241
        $parser     = new Parser\RelevantSelectorParser($stylesheet);
242
        $selectors  = $parser->extract();
243
244
        foreach ($xpath->query("descendant-or-self::*[@style]") as $element)
245
        {
246
            if ($element->attributes !== null)
247
            {
248
                $styleAttribute = $element->attributes->getNamedItem("style");
249
250
                $styleValue = "";
251
252
                if ($styleAttribute !== null)
253
                {
254
                    $styleValue = (string) $styleAttribute->nodeValue;
255
                }
256
257
                if ($styleValue !== "")
258
                {
259
                    $element->setAttribute('data-premailer-original-style', $styleValue);
260
                    $element->removeAttribute('style');
261
                }
262
            }
263
        }
264
265
        // Get all specificity values (to process the declarations in the correct order,
266
        // without sorting the array by key, which perhaps could result in a changed
267
        // order of selectors within the specificity).
268
        $specificities = array_keys($selectors);
269
        sort($specificities);
270
271
        // Process all style declarations in the correct order
272
        foreach ($specificities as $specificity)
273
        {
274
            /** @var StyleDeclaration[] $declarations */
275
            foreach ($selectors[$specificity] as $selector => $declarations)
276
            {
277
                $xpathQuery = (new CssSelector())->toXPath($selector);
278
                $elements   = $xpath->query($xpathQuery);
279
280
                foreach ($elements as $element)
281
                {
282
                    if ($element->attributes !== null)
283
                    {
284
                        $styleAttribute = $element->attributes->getNamedItem("style");
285
286
                        $styleValue = "";
287
288
                        if ($styleAttribute !== null)
289
                        {
290
                            $styleValue = (string) $styleAttribute->nodeValue;
291
                        }
292
293
                        $concat = ($styleValue === "") ? "" : ";";
294
295
                        foreach ($declarations as $declaration)
296
                        {
297
                            $styleValue .= $concat . $declaration->getProperty() . ":" . $declaration->getValue();
298
                            $concat = ";";
299
                        }
300
301
                        $element->setAttribute('style', $styleValue);
302
                    }
303
                }
304
            }
305
        }
306
307
        // Add temporarily removed style attributes again, after all styles have been applied to the elements
308
        $elements = $xpath->query("descendant-or-self::*[@data-premailer-original-style]");
309
        /** @var \DOMElement $element */
310
        foreach ($elements as $element) {
311
            if ($element->attributes !== null) {
312
                $styleAttribute = $element->attributes->getNamedItem("style");
313
                $styleValue = "";
314
                if ($styleAttribute !== null) {
315
                    $styleValue = (string)$styleAttribute->nodeValue;
316
                }
317
318
                $originalStyleAttribute = $element->attributes->getNamedItem("data-premailer-original-style");
319
                $originalStyleValue = "";
320
                if ($originalStyleAttribute !== null) {
321
                    $originalStyleValue = (string)$originalStyleAttribute->nodeValue;
322
                }
323
324
                if ($styleValue !== "" || $originalStyleValue !== "") {
325
                    $styleValue = ($styleValue !== "" ? $styleValue . ";" : "") . $originalStyleValue;
326
                    $element->setAttribute('style', $styleValue);
327
                    $element->removeAttribute('data-premailer-original-style');
328
                }
329
            }
330
        }
331
332
        // Optionally remove class attributes in HTML tags
333
        $optionHtmlClasses = $this->getOption(self::OPTION_HTML_CLASSES);
334
        if ($optionHtmlClasses === self::OPTION_HTML_CLASSES_REMOVE) {
335
            $nodesWithClass = [];
336
            foreach ($xpath->query('descendant-or-self::*[@class]') as $nodeWithClass) {
337
                $nodesWithClass[] = $nodeWithClass;
338
            }
339
            /** @var \DOMElement $nodeWithClass */
340
            foreach ($nodesWithClass as $nodeWithClass) {
341
                $nodeWithClass->removeAttribute('class');
342
            }
343
        }
344
345
        // Optionally remove HTML comments
346
        $optionHtmlComments = $this->getOption(self::OPTION_HTML_COMMENTS);
347
        if ($optionHtmlComments === self::OPTION_HTML_COMMENTS_REMOVE) {
348
            $commentNodes = [];
349
            foreach ($xpath->query('//comment()') as $comment) {
350
                $commentNodes[] = $comment;
351
            }
352
            foreach ($commentNodes as $commentNode) {
353
                $commentNode->parentNode->removeChild($commentNode);
354
            }
355
        }
356
357
        // Write XPath document back to DOM document
358
        $newDoc = $xpath->document;
359
360
        // Generate text version (before adding the styles again)
361
        $this->text = $this->prepareText($newDoc);
362
363
        // Optionally add styles tag to the HEAD or the BODY of the document
364
        $optionStyleTag = $this->getOption(self::OPTION_STYLE_TAG);
365
        if ($optionStyleTag === self::OPTION_STYLE_TAG_BODY || $optionStyleTag === self::OPTION_STYLE_TAG_HEAD) {
366
            $cssWriterClass = $this->getOption(self::OPTION_CSS_WRITER_CLASS);
367
            /** @var WriterAbstract $cssWriter */
368
            $cssWriter = new $cssWriterClass($parser->getStylesheetReader()->getStyleSheet());
369
            $styleNode = $newDoc->createElement("style");
370
            $styleNode->nodeValue = $cssWriter->getContent();
371
372
            if ($optionStyleTag === self::OPTION_STYLE_TAG_BODY) {
373
                /** @var \DOMNode $bodyNode */
374
                foreach($newDoc->getElementsByTagName('body') as $bodyNode) {
375
                    $bodyNode->insertBefore($styleNode, $bodyNode->firstChild);
376
                    break;
377
                }
378
            } elseif ($optionStyleTag === self::OPTION_STYLE_TAG_HEAD) {
379
                /** @var \DOMNode $headNode */
380
                foreach($newDoc->getElementsByTagName('head') as $headNode) {
381
                    $headNode->appendChild($styleNode);
382
                    break;
383
                }
384
            }
385
        }
386
387
        // Generate HTML version
388
        $this->html = $newDoc->saveHTML();
389
    }
390
391
    /**
392
     * Prepares the mail text content.
393
     *
394
     * @param  \DOMDocument  $doc
395
     * @return string
396
     */
397
    protected function prepareText(DOMDocument $doc)
398
    {
399
        $text = $this->convertHtmlToText($doc->childNodes);
400
        $charset = $this->getCharset();
401
        $textLineMaxLength = $this->getOption(self::OPTION_TEXT_LINE_WIDTH);
402
403
        $text = preg_replace_callback('/^([^\n]+)$/m', function($match) use ($charset, $textLineMaxLength) {
404
                $break = "\n";
405
                $parts = preg_split('/((?:\(\t[^\t]+\t\))|[^\p{L}\p{N}])/', $match[0], -1, PREG_SPLIT_DELIM_CAPTURE);
406
407
                $return = "";
408
                $brLength = mb_strlen(trim($break, "\r\n"), $charset);
409
410
                $lineLength = $brLength;
411
                foreach ($parts as $part) {
412
                    // Replace character before/after links with a zero width space,
413
                    // and mark links as non-breakable
414
                    $breakLongLines = true;
415
                    if (strpos($part, "\t")) {
416
                        $part = str_replace("\t", mb_convert_encoding("\xE2\x80\x8C", $charset, "UTF-8"), $part);
417
                        $breakLongLines = false;
418
                    }
419
420
                    // Get part length
421
                    $partLength = mb_strlen($part, $charset);
422
423
                    // Ignore trailing space characters if this would cause the line break
424
                    if (($lineLength + $partLength) === ($textLineMaxLength + 1)) {
425
                        $lastChar = mb_substr($part, -1, 1, $charset);
426
                        if ($lastChar === " ") {
427
                            $part = mb_substr($part, 0, -1, $charset);
428
                            $partLength--;
429
                        }
430
                    }
431
432
                    // Check if enough chars left to add the part
433
                    if (($lineLength + $partLength) <= $textLineMaxLength) {
434
                        $return .= $part;
435
                        $lineLength += $partLength;
436
                    // Check if the part is longer than the line (so that we need to break it)
437
                    } elseif ($partLength > ($textLineMaxLength - $brLength)) {
438
                        if ($breakLongLines === true) {
439
                            $addPart = mb_substr($part, 0, ($textLineMaxLength - $lineLength), $charset);
440
                            $return .= $addPart;
441
                            $lineLength = $brLength;
442
443
                            for ($i = mb_strlen($addPart, $charset), $j = $partLength; $i < $j; $i+=($textLineMaxLength - $brLength)) {
444
                                $addPart = $break . mb_substr($part, $i, ($textLineMaxLength - $brLength), $charset);
445
                                $return .= $addPart;
446
                                $lineLength = mb_strlen($addPart, $charset) - 1;
447
                            }
448
                        } else {
449
                            $return .= $break . trim($part) . $break;
450
                            $lineLength = $brLength;
451
                        }
452
                    // Add a break to add the part in the next line
453
                    } else {
454
                        $return .= $break . rtrim($part);
455
                        $lineLength = $brLength + $partLength;
456
                    }
457
                }
458
                return $return;
459
            }, $text);
460
461
        $text = preg_replace('/^\s+|\s+$/', '', $text);
462
463
        return $text;
464
    }
465
466
    /**
467
     * Converts HTML tags to text, to create a text version of an HTML document.
468
     *
469
     * @param \DOMNodeList $nodes
470
     * @return string
471
     */
472
    protected function convertHtmlToText(\DOMNodeList $nodes)
473
    {
474
        $text = "";
475
476
        /** @var \DOMElement $node */
477
        foreach ($nodes as $node) {
478
            $lineBreaksBefore = 0;
479
            $lineBreaksAfter = 0;
480
            $lineCharBefore = "";
481
            $lineCharAfter = "";
482
            $prefix = "";
483
            $suffix = "";
484
485
            if (in_array($node->nodeName, ["h1", "h2", "h3", "h4", "h5", "h6", "h"])) {
486
                $lineCharAfter = "=";
487
                $lineBreaksAfter = 2;
488
            } elseif (in_array($node->nodeName, ["p", "td"])) {
489
                $lineBreaksAfter = 2;
490
            } elseif (in_array($node->nodeName, ["div"])) {
491
                $lineBreaksAfter = 1;
492
            }
493
494
            if ($node->nodeName === "h1") {
495
                $lineCharBefore = "*";
496
                $lineCharAfter = "*";
497
            } elseif ($node->nodeName === "h2") {
498
                $lineCharBefore = "=";
499
            }
500
501
            if ($node->nodeName === '#text') {
502
                $textContent = html_entity_decode($node->textContent, ENT_COMPAT | ENT_HTML401, $this->getCharset());
503
504
                // Replace tabs (used to mark links below) and other control characters
505
                $textContent = preg_replace("/[\r\n\f\v\t]+/", "", $textContent);
506
507
                if ($textContent !== "") {
508
                    $text .= $textContent;
509
                }
510
            } elseif ($node->nodeName === 'a') {
511
                $href = "";
512
                if ($node->attributes !== null) {
513
                    $hrefAttribute = $node->attributes->getNamedItem("href");
514
                    if ($hrefAttribute !== null) {
515
                        $href = (string)$hrefAttribute->nodeValue;
516
                    }
517
                }
518
                if ($href !== "") {
519
                    $suffix = " (\t" . $href . "\t)";
520
                }
521
            } elseif ($node->nodeName === 'b' || $node->nodeName === 'strong') {
522
                $prefix = "*";
523
                $suffix = "*";
524
            } elseif ($node->nodeName === 'hr') {
525
                $text .= str_repeat('-', 75) . "\n\n";
526
            }
527
528
            if ($node->hasChildNodes()) {
529
                $text .= str_repeat("\n", $lineBreaksBefore);
530
531
                $addText = $this->convertHtmlToText($node->childNodes);
532
533
                $text .= $prefix;
534
535
                $text .= $lineCharBefore ? str_repeat($lineCharBefore, 75) . "\n" : "";
536
                $text .= $addText;
537
                $text .= $lineCharAfter ? "\n" . str_repeat($lineCharAfter, 75) . "\n" : "";
538
539
                $text .= $suffix;
540
541
                $text .= str_repeat("\n", $lineBreaksAfter);
542
            }
543
        }
544
545
        // Remove unnecessary white spaces at he beginning/end of lines
546
        $text = preg_replace("/(?:^[ \t\f]+([^ \t\f])|([^ \t\f])[ \t\f]+$)/m", "\\1\\2", $text);
547
548
        // Replace multiple line-breaks
549
        $text = preg_replace("/[\r\n]{2,}/", "\n\n", $text);
550
551
        return $text;
552
    }
553
554
    /**
555
     * Validate the scalar value of the passed option
556
     *
557
     * @param  string      $name
558
     * @param  string|int  $value
559
     * @return void
560
     *
561
     * @throws \InvalidArgumentException
562
     */
563
    protected function validateScalarOptionValue($name, $value)
564
    {
565
        switch ($name)
566
        {
567
            case self::OPTION_STYLE_TAG:
568
            case self::OPTION_HTML_CLASSES:
569
            case self::OPTION_HTML_COMMENTS:
570
            case self::OPTION_TEXT_LINE_WIDTH:
571
                if ( ! is_int($value))
572
                {
573
                    throw new InvalidArgumentException("The argument 1 of [setOption] method is expected to be a [integer] for option [{name}], but [" . gettype($value) . '] is given.');
574
                }
575
576
                break;
577
578
            case self::OPTION_CSS_WRITER_CLASS:
579
                if ( ! is_string($value))
580
                {
581
                    throw new InvalidArgumentException("The argument 1 of [setOption] method is expected to be a [string] for option [{name}], but [" . gettype($value) . '] is given.');
582
                }
583
584
                break;
585
        }
586
    }
587
588
    /**
589
     * Validate the possible value of the passed option
590
     *
591
     * @param  string      $name
592
     * @param  string|int  $value
593
     * @return void
594
     *
595
     * @throws \InvalidArgumentException
596
     */
597
    protected function validatePossibleOptionValue($name, $value)
598
    {
599
        switch ($name)
600
        {
601
            case self::OPTION_STYLE_TAG:
602
                if ( ! in_array($value, [self::OPTION_STYLE_TAG_BODY, self::OPTION_STYLE_TAG_HEAD, self::OPTION_STYLE_TAG_REMOVE]))
603
                {
604
                    throw new InvalidArgumentException("Invalid value [$value] for option [$name].");
605
                }
606
                break;
607
608
            case self::OPTION_HTML_CLASSES:
609
                if ( ! in_array($value, [self::OPTION_HTML_CLASSES_REMOVE, self::OPTION_HTML_CLASSES_KEEP]))
610
                {
611
                    throw new InvalidArgumentException("Invalid value [$value] for option [$name].");
612
                }
613
                break;
614
615
            case self::OPTION_HTML_COMMENTS:
616
                if ( ! in_array($value, [self::OPTION_HTML_COMMENTS_REMOVE, self::OPTION_HTML_COMMENTS_KEEP]))
617
                {
618
                    throw new InvalidArgumentException("Invalid value [$value] for option [$name].");
619
                }
620
                break;
621
622
            case self::OPTION_TEXT_LINE_WIDTH:
623
                if ($value <= 0)
624
                {
625
                    throw new LengthException("Value '" . gettype($value) . "' for option '$name' is to small.");
626
                }
627
                break;
628
629
            case self::OPTION_CSS_WRITER_CLASS:
630
                if (is_subclass_of($value, '\Crossjoin\Css\Writer\WriterAbstract', true) === false) {
631
                    throw new \InvalidArgumentException(
632
                        "Invalid value '$value' for option '$name'. " .
633
                        "The given class has to be a subclass of \\Crossjoin\\Css\\Writer\\WriterAbstract."
634
                    );
635
                }
636
        }
637
    }
638
639
}
640