Completed
Push — master ( 8fe13c...948046 )
by Kevin
13:36
created

BasePremailer::getRelevantStyleRules()   D

Complexity

Conditions 9
Paths 8

Size

Total Lines 27
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 1 Features 0
Metric Value
c 3
b 1
f 0
dl 0
loc 27
rs 4.909
cc 9
eloc 15
nc 8
nop 1
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
     * Gets all generally relevant style rules.
226
     * The selectors/declarations are checked in detail in prepareContent().
227
     *
228
     * @param $rules RuleAbstract[]
229
     * @return StyleRuleSet[]
230
     */
231
    protected function getRelevantStyleRules(array $rules)
232
    {
233
        $styleRules = [];
234
        foreach ($rules as $rule) {
235
            if ($rule instanceof StyleRuleSet) {
236
                $styleRules[] = $rule;
237
            } else if ($rule instanceof MediaRule) {
238
                foreach ($rule->getQueries() as $mediaQuery) {
239
                    // Only add styles in media rules, if the media rule is valid for "all" and "screen" media types
240
                    // @note: http://premailer.dialect.ca/ also supports "handheld", but this is really useless
241
                    $type = $mediaQuery->getType();
242
                    if ($type === MediaQuery::TYPE_ALL || $type === MediaQuery::TYPE_SCREEN) {
243
                        // ...and only if there are no additional conditions (like screen width etc.)
244
                        // which are dynamic and therefore need to be ignored.
245
                        $conditionCount = count($mediaQuery->getConditions());
246
                        if ($conditionCount === 0) {
247
                            foreach ($this->getRelevantStyleRules($rule->getRules()) as $styleRule) {
248
                                $styleRules[] = $styleRule;
249
                            }
250
                            break;
251
                        }
252
                    }
253
                }
254
            }
255
        }
256
        return $styleRules;
257
    }
258
259
    /**
260
     * Prepares the mail HTML/text content.
261
     *
262
     * @return void
263
     */
264
    protected function prepareContent()
265
    {
266
        if ( ! class_exists('\DOMDocument'))
267
        {
268
            throw new RuntimeException("Required extension 'dom' seems to be missing.");
269
        }
270
271
        $this->loadDocument();
272
273
        $xpath      = new DOMXPath($this->doc);
274
        $stylesheet = (new Parser\StylesheetParser($this->doc))->extract();
275
276
        $parser     = new Parser\RelevantSelectorParser($stylesheet);
277
        $selectors  = $parser->extract();
278
279
        foreach ($xpath->query("descendant-or-self::*[@style]") as $element)
280
        {
281
            if ($element->attributes !== null)
282
            {
283
                $styleAttribute = $element->attributes->getNamedItem("style");
284
285
                $styleValue = "";
286
287
                if ($styleAttribute !== null)
288
                {
289
                    $styleValue = (string) $styleAttribute->nodeValue;
290
                }
291
292
                if ($styleValue !== "")
293
                {
294
                    $element->setAttribute('data-premailer-original-style', $styleValue);
295
                    $element->removeAttribute('style');
296
                }
297
            }
298
        }
299
300
        // Get all specificity values (to process the declarations in the correct order,
301
        // without sorting the array by key, which perhaps could result in a changed
302
        // order of selectors within the specificity).
303
        $specificities = array_keys($selectors);
304
        sort($specificities);
305
306
        // Process all style declarations in the correct order
307
        foreach ($specificities as $specificity)
308
        {
309
            /** @var StyleDeclaration[] $declarations */
310
            foreach ($selectors[$specificity] as $selector => $declarations)
311
            {
312
                $xpathQuery = (new CssSelector())->toXPath($selector);
313
                $elements   = $xpath->query($xpathQuery);
314
315
                foreach ($elements as $element)
316
                {
317
                    if ($element->attributes !== null)
318
                    {
319
                        $styleAttribute = $element->attributes->getNamedItem("style");
320
321
                        $styleValue = "";
322
323
                        if ($styleAttribute !== null)
324
                        {
325
                            $styleValue = (string) $styleAttribute->nodeValue;
326
                        }
327
328
                        $concat = ($styleValue === "") ? "" : ";";
329
330
                        foreach ($declarations as $declaration)
331
                        {
332
                            $styleValue .= $concat . $declaration->getProperty() . ":" . $declaration->getValue();
333
                            $concat = ";";
334
                        }
335
336
                        $element->setAttribute('style', $styleValue);
337
                    }
338
                }
339
            }
340
        }
341
342
        // Add temporarily removed style attributes again, after all styles have been applied to the elements
343
        $elements = $xpath->query("descendant-or-self::*[@data-pre-mailer-original-style]");
344
        /** @var \DOMElement $element */
345
        foreach ($elements as $element) {
346
            if ($element->attributes !== null) {
347
                $styleAttribute = $element->attributes->getNamedItem("style");
348
                $styleValue = "";
349
                if ($styleAttribute !== null) {
350
                    $styleValue = (string)$styleAttribute->nodeValue;
351
                }
352
353
                $originalStyleAttribute = $element->attributes->getNamedItem("data-pre-mailer-original-style");
354
                $originalStyleValue = "";
355
                if ($originalStyleAttribute !== null) {
356
                    $originalStyleValue = (string)$originalStyleAttribute->nodeValue;
357
                }
358
359
                if ($styleValue !== "" || $originalStyleValue !== "") {
360
                    $styleValue = ($styleValue !== "" ? $styleValue . ";" : "") . $originalStyleValue;
361
                    $element->setAttribute('style', $styleValue);
362
                    $element->removeAttribute('data-pre-mailer-original-style');
363
                }
364
            }
365
        }
366
367
        // Optionally remove class attributes in HTML tags
368
        $optionHtmlClasses = $this->getOption(self::OPTION_HTML_CLASSES);
369
        if ($optionHtmlClasses === self::OPTION_HTML_CLASSES_REMOVE) {
370
            $nodesWithClass = [];
371
            foreach ($xpath->query('descendant-or-self::*[@class]') as $nodeWithClass) {
372
                $nodesWithClass[] = $nodeWithClass;
373
            }
374
            /** @var \DOMElement $nodeWithClass */
375
            foreach ($nodesWithClass as $nodeWithClass) {
376
                $nodeWithClass->removeAttribute('class');
377
            }
378
        }
379
380
        // Optionally remove HTML comments
381
        $optionHtmlComments = $this->getOption(self::OPTION_HTML_COMMENTS);
382
        if ($optionHtmlComments === self::OPTION_HTML_COMMENTS_REMOVE) {
383
            $commentNodes = [];
384
            foreach ($xpath->query('//comment()') as $comment) {
385
                $commentNodes[] = $comment;
386
            }
387
            foreach ($commentNodes as $commentNode) {
388
                $commentNode->parentNode->removeChild($commentNode);
389
            }
390
        }
391
392
        // Write XPath document back to DOM document
393
        $newDoc = $xpath->document;
394
395
        // Generate text version (before adding the styles again)
396
        $this->text = $this->prepareText($newDoc);
397
398
        // Optionally add styles tag to the HEAD or the BODY of the document
399
        $optionStyleTag = $this->getOption(self::OPTION_STYLE_TAG);
400
        if ($optionStyleTag === self::OPTION_STYLE_TAG_BODY || $optionStyleTag === self::OPTION_STYLE_TAG_HEAD) {
401
            $cssWriterClass = $this->getOption(self::OPTION_CSS_WRITER_CLASS);
402
            /** @var WriterAbstract $cssWriter */
403
            $cssWriter = new $cssWriterClass($parser->getStylesheetReader()->getStyleSheet());
404
            $styleNode = $newDoc->createElement("style");
405
            $styleNode->nodeValue = $cssWriter->getContent();
406
407
            if ($optionStyleTag === self::OPTION_STYLE_TAG_BODY) {
408
                /** @var \DOMNode $bodyNode */
409
                foreach($newDoc->getElementsByTagName('body') as $bodyNode) {
410
                    $bodyNode->insertBefore($styleNode, $bodyNode->firstChild);
411
                    break;
412
                }
413
            } elseif ($optionStyleTag === self::OPTION_STYLE_TAG_HEAD) {
414
                /** @var \DOMNode $headNode */
415
                foreach($newDoc->getElementsByTagName('head') as $headNode) {
416
                    $headNode->appendChild($styleNode);
417
                    break;
418
                }
419
            }
420
        }
421
422
        // Generate HTML version
423
        $this->html = $newDoc->saveHTML();
424
    }
425
426
    /**
427
     * Prepares the mail text content.
428
     *
429
     * @param  \DOMDocument  $doc
430
     * @return string
431
     */
432
    protected function prepareText(DOMDocument $doc)
433
    {
434
        $text = $this->convertHtmlToText($doc->childNodes);
435
        $charset = $this->getCharset();
436
        $textLineMaxLength = $this->getOption(self::OPTION_TEXT_LINE_WIDTH);
437
438
        $text = preg_replace_callback('/^([^\n]+)$/m', function($match) use ($charset, $textLineMaxLength) {
439
                $break = "\n";
440
                $parts = preg_split('/((?:\(\t[^\t]+\t\))|[^\p{L}\p{N}])/', $match[0], -1, PREG_SPLIT_DELIM_CAPTURE);
441
442
                $return = "";
443
                $brLength = mb_strlen(trim($break, "\r\n"), $charset);
444
445
                $lineLength = $brLength;
446
                foreach ($parts as $part) {
447
                    // Replace character before/after links with a zero width space,
448
                    // and mark links as non-breakable
449
                    $breakLongLines = true;
450
                    if (strpos($part, "\t")) {
451
                        $part = str_replace("\t", mb_convert_encoding("\xE2\x80\x8C", $charset, "UTF-8"), $part);
452
                        $breakLongLines = false;
453
                    }
454
455
                    // Get part length
456
                    $partLength = mb_strlen($part, $charset);
457
458
                    // Ignore trailing space characters if this would cause the line break
459
                    if (($lineLength + $partLength) === ($textLineMaxLength + 1)) {
460
                        $lastChar = mb_substr($part, -1, 1, $charset);
461
                        if ($lastChar === " ") {
462
                            $part = mb_substr($part, 0, -1, $charset);
463
                            $partLength--;
464
                        }
465
                    }
466
467
                    // Check if enough chars left to add the part
468
                    if (($lineLength + $partLength) <= $textLineMaxLength) {
469
                        $return .= $part;
470
                        $lineLength += $partLength;
471
                    // Check if the part is longer than the line (so that we need to break it)
472
                    } elseif ($partLength > ($textLineMaxLength - $brLength)) {
473
                        if ($breakLongLines === true) {
474
                            $addPart = mb_substr($part, 0, ($textLineMaxLength - $lineLength), $charset);
475
                            $return .= $addPart;
476
                            $lineLength = $brLength;
477
478
                            for ($i = mb_strlen($addPart, $charset), $j = $partLength; $i < $j; $i+=($textLineMaxLength - $brLength)) {
479
                                $addPart = $break . mb_substr($part, $i, ($textLineMaxLength - $brLength), $charset);
480
                                $return .= $addPart;
481
                                $lineLength = mb_strlen($addPart, $charset) - 1;
482
                            }
483
                        } else {
484
                            $return .= $break . trim($part) . $break;
485
                            $lineLength = $brLength;
486
                        }
487
                    // Add a break to add the part in the next line
488
                    } else {
489
                        $return .= $break . rtrim($part);
490
                        $lineLength = $brLength + $partLength;
491
                    }
492
                }
493
                return $return;
494
            }, $text);
495
496
        $text = preg_replace('/^\s+|\s+$/', '', $text);
497
498
        return $text;
499
    }
500
501
    /**
502
     * Converts HTML tags to text, to create a text version of an HTML document.
503
     *
504
     * @param \DOMNodeList $nodes
505
     * @return string
506
     */
507
    protected function convertHtmlToText(\DOMNodeList $nodes)
508
    {
509
        $text = "";
510
511
        /** @var \DOMElement $node */
512
        foreach ($nodes as $node) {
513
            $lineBreaksBefore = 0;
514
            $lineBreaksAfter = 0;
515
            $lineCharBefore = "";
516
            $lineCharAfter = "";
517
            $prefix = "";
518
            $suffix = "";
519
520
            if (in_array($node->nodeName, ["h1", "h2", "h3", "h4", "h5", "h6", "h"])) {
521
                $lineCharAfter = "=";
522
                $lineBreaksAfter = 2;
523
            } elseif (in_array($node->nodeName, ["p", "td"])) {
524
                $lineBreaksAfter = 2;
525
            } elseif (in_array($node->nodeName, ["div"])) {
526
                $lineBreaksAfter = 1;
527
            }
528
529
            if ($node->nodeName === "h1") {
530
                $lineCharBefore = "*";
531
                $lineCharAfter = "*";
532
            } elseif ($node->nodeName === "h2") {
533
                $lineCharBefore = "=";
534
            }
535
536
            if ($node->nodeName === '#text') {
537
                $textContent = html_entity_decode($node->textContent, ENT_COMPAT | ENT_HTML401, $this->getCharset());
538
539
                // Replace tabs (used to mark links below) and other control characters
540
                $textContent = preg_replace("/[\r\n\f\v\t]+/", "", $textContent);
541
542
                if ($textContent !== "") {
543
                    $text .= $textContent;
544
                }
545
            } elseif ($node->nodeName === 'a') {
546
                $href = "";
547
                if ($node->attributes !== null) {
548
                    $hrefAttribute = $node->attributes->getNamedItem("href");
549
                    if ($hrefAttribute !== null) {
550
                        $href = (string)$hrefAttribute->nodeValue;
551
                    }
552
                }
553
                if ($href !== "") {
554
                    $suffix = " (\t" . $href . "\t)";
555
                }
556
            } elseif ($node->nodeName === 'b' || $node->nodeName === 'strong') {
557
                $prefix = "*";
558
                $suffix = "*";
559
            } elseif ($node->nodeName === 'hr') {
560
                $text .= str_repeat('-', 75) . "\n\n";
561
            }
562
563
            if ($node->hasChildNodes()) {
564
                $text .= str_repeat("\n", $lineBreaksBefore);
565
566
                $addText = $this->convertHtmlToText($node->childNodes);
567
568
                $text .= $prefix;
569
570
                $text .= $lineCharBefore ? str_repeat($lineCharBefore, 75) . "\n" : "";
571
                $text .= $addText;
572
                $text .= $lineCharAfter ? "\n" . str_repeat($lineCharAfter, 75) . "\n" : "";
573
574
                $text .= $suffix;
575
576
                $text .= str_repeat("\n", $lineBreaksAfter);
577
            }
578
        }
579
580
        // Remove unnecessary white spaces at he beginning/end of lines
581
        $text = preg_replace("/(?:^[ \t\f]+([^ \t\f])|([^ \t\f])[ \t\f]+$)/m", "\\1\\2", $text);
582
583
        // Replace multiple line-breaks
584
        $text = preg_replace("/[\r\n]{2,}/", "\n\n", $text);
585
586
        return $text;
587
    }
588
589
    /**
590
     * Validate the scalar value of the passed option
591
     *
592
     * @param  string      $name
593
     * @param  string|int  $value
594
     * @return void
595
     *
596
     * @throws \InvalidArgumentException
597
     */
598
    protected function validateScalarOptionValue($name, $value)
599
    {
600
        switch ($name)
601
        {
602
            case self::OPTION_STYLE_TAG:
603
            case self::OPTION_HTML_CLASSES:
604
            case self::OPTION_HTML_COMMENTS:
605
            case self::OPTION_TEXT_LINE_WIDTH:
606
                if ( ! is_int($value))
607
                {
608
                    throw new InvalidArgumentException("The argument 1 of [setOption] method is expected to be a [integer] for option [{name}], but [" . gettype($value) . '] is given.');
609
                }
610
611
                break;
612
613
            case self::OPTION_CSS_WRITER_CLASS:
614
                if ( ! is_string($value))
615
                {
616
                    throw new InvalidArgumentException("The argument 1 of [setOption] method is expected to be a [string] for option [{name}], but [" . gettype($value) . '] is given.');
617
                }
618
619
                break;
620
        }
621
    }
622
623
    /**
624
     * Validate the possible value of the passed option
625
     *
626
     * @param  string      $name
627
     * @param  string|int  $value
628
     * @return void
629
     *
630
     * @throws \InvalidArgumentException
631
     */
632
    protected function validatePossibleOptionValue($name, $value)
633
    {
634
        switch ($name)
635
        {
636
            case self::OPTION_STYLE_TAG:
637
                if ( ! in_array($value, [self::OPTION_STYLE_TAG_BODY, self::OPTION_STYLE_TAG_HEAD, self::OPTION_STYLE_TAG_REMOVE]))
638
                {
639
                    throw new InvalidArgumentException("Invalid value [$value] for option [$name].");
640
                }
641
                break;
642
643
            case self::OPTION_HTML_CLASSES:
644
                if ( ! in_array($value, [self::OPTION_HTML_CLASSES_REMOVE, self::OPTION_HTML_CLASSES_KEEP]))
645
                {
646
                    throw new InvalidArgumentException("Invalid value [$value] for option [$name].");
647
                }
648
                break;
649
650
            case self::OPTION_HTML_COMMENTS:
651
                if ( ! in_array($value, [self::OPTION_HTML_COMMENTS_REMOVE, self::OPTION_HTML_COMMENTS_KEEP]))
652
                {
653
                    throw new InvalidArgumentException("Invalid value [$value] for option [$name].");
654
                }
655
                break;
656
657
            case self::OPTION_TEXT_LINE_WIDTH:
658
                if ($value <= 0)
659
                {
660
                    throw new LengthException("Value '" . gettype($value) . "' for option '$name' is to small.");
661
                }
662
                break;
663
664
            case self::OPTION_CSS_WRITER_CLASS:
665
                if (is_subclass_of($value, '\Crossjoin\Css\Writer\WriterAbstract', true) === false) {
666
                    throw new \InvalidArgumentException(
667
                        "Invalid value '$value' for option '$name'. " .
668
                        "The given class has to be a subclass of \\Crossjoin\\Css\\Writer\\WriterAbstract."
669
                    );
670
                }
671
        }
672
    }
673
674
}
675