Completed
Push — master ( f92db2...56c2c4 )
by Kevin
02:25
created

BasePremailer::getStyleSheet()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 17
rs 9.4285
cc 3
eloc 8
nc 3
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
25
/**
26
 * The base Premailer class
27
 *
28
 * @package  \Luminaire\Premailer
29
 */
30
abstract class BasePremailer
31
{
32
33
    const OPTION_STYLE_TAG                  = 'styleTag';
34
    const OPTION_STYLE_TAG_BODY             = 1;
35
    const OPTION_STYLE_TAG_HEAD             = 2;
36
    const OPTION_STYLE_TAG_REMOVE           = 3;
37
38
    const OPTION_HTML_COMMENTS              = 'htmlComments';
39
    const OPTION_HTML_COMMENTS_KEEP         = 1;
40
    const OPTION_HTML_COMMENTS_REMOVE       = 2;
41
42
    const OPTION_HTML_CLASSES               = 'htmlClasses';
43
    const OPTION_HTML_CLASSES_KEEP          = 1;
44
    const OPTION_HTML_CLASSES_REMOVE        = 2;
45
46
    const OPTION_TEXT_LINE_WIDTH            = 'textLineWidth';
47
48
    const OPTION_CSS_WRITER_CLASS           = 'cssWriterClass';
49
    const OPTION_CSS_WRITER_CLASS_COMPACT   = '\Crossjoin\Css\Writer\Compact';
50
    const OPTION_CSS_WRITER_CLASS_PRETTY    = '\Crossjoin\Css\Writer\Pretty';
51
52
    /**
53
     * The options for HTML/text generation
54
     *
55
     * @var array
56
     */
57
    protected $options = [
58
        self::OPTION_STYLE_TAG        => self::OPTION_STYLE_TAG_BODY,
59
        self::OPTION_HTML_CLASSES     => self::OPTION_HTML_CLASSES_KEEP,
60
        self::OPTION_HTML_COMMENTS    => self::OPTION_HTML_COMMENTS_REMOVE,
61
        self::OPTION_CSS_WRITER_CLASS => self::OPTION_CSS_WRITER_CLASS_COMPACT,
62
        self::OPTION_TEXT_LINE_WIDTH  => 75,
63
    ];
64
65
    /**
66
     * The pseudo classes that can be set in a style attribute and that are
67
     * supported by the Symfony CssSelector (doesn't support CSS4 yet).
68
     *
69
     * @var array
70
     */
71
    protected $allowed_pseudo_classes = [
72
        StyleSelector::PSEUDO_CLASS_FIRST_CHILD,
73
        StyleSelector::PSEUDO_CLASS_ROOT,
74
        StyleSelector::PSEUDO_CLASS_NTH_CHILD,
75
        StyleSelector::PSEUDO_CLASS_NTH_LAST_CHILD,
76
        StyleSelector::PSEUDO_CLASS_NTH_OF_TYPE,
77
        StyleSelector::PSEUDO_CLASS_NTH_LAST_OF_TYPE,
78
        StyleSelector::PSEUDO_CLASS_LAST_CHILD,
79
        StyleSelector::PSEUDO_CLASS_FIRST_OF_TYPE,
80
        StyleSelector::PSEUDO_CLASS_LAST_OF_TYPE,
81
        StyleSelector::PSEUDO_CLASS_ONLY_CHILD,
82
        StyleSelector::PSEUDO_CLASS_ONLY_OF_TYPE,
83
        StyleSelector::PSEUDO_CLASS_EMPTY,
84
        StyleSelector::PSEUDO_CLASS_NOT,
85
    ];
86
87
    /**
88
     * The charset for HTML/text output
89
     *
90
     * @var string
91
     */
92
    protected $charset = "UTF-8";
93
94
    /**
95
     * The prepared HTML content
96
     *
97
     * @var string
98
     */
99
    protected $html;
100
101
    /**
102
     * The prepared text content
103
     *
104
     * @var string
105
     */
106
    protected $text;
107
108
    /**
109
     * Sets the charset used in the HTML document and used for the output.
110
     *
111
     * @param  string  $charset
112
     * @return $this
113
     */
114
    public function setCharset($charset)
115
    {
116
        $this->charset = $charset;
117
118
        return $this;
119
    }
120
121
    /**
122
     * Gets the charset used in the HTML document and used for the output.
123
     *
124
     * @return string
125
     */
126
    public function getCharset()
127
    {
128
        return $this->charset;
129
    }
130
131
    /**
132
     * Sets an option for the generation of the mail.
133
     *
134
     * @param string $name
135
     * @param mixed $value
136
     */
137
    public function setOption($name, $value)
138
    {
139
        if ( ! is_string($name))
140
        {
141
            throw new InvalidArgumentException('The argument 0 of [setOption] method is expected to be a [string], but [' . gettype($name) . '] is given.');
142
        }
143
144
        if ( ! isset($this->options[$name]))
145
        {
146
            throw new InvalidArgumentException("An option with the name [{$name}] doesn't exist.");
147
        }
148
149
        $this->validateScalarOptionValue($name, $value);
150
        $this->validatePossibleOptionValue($name, $value);
151
152
        $this->options[$name] = $value;
153
    }
154
155
    /**
156
     * Gets an option for the generation of the mail.
157
     *
158
     * @param  string|null  $name
159
     * @return mixed
160
     *
161
     * @throws \InvalidArgumentException
162
     */
163
    public function getOption($name = null)
164
    {
165
        if (is_null($name))
166
        {
167
            return $this->options;
168
        }
169
170
        if ( ! is_string($name))
171
        {
172
            throw new InvalidArgumentException('The argument 0 of [setOption] method is expected to be a [string], but [' . gettype($name) . '] is given.');
173
        }
174
175
        if ( ! isset($this->options[$name]))
176
        {
177
            throw new InvalidArgumentException("An option with the name [{$name}] doesn't exist.");
178
        }
179
180
        return $this->options[$name];
181
    }
182
183
    /**
184
     * Gets the prepared HTML version of the mail.
185
     *
186
     * @return string
187
     */
188
    public function getHtml()
189
    {
190
        if ($this->html === null)
191
        {
192
            $this->prepareContent();
193
        }
194
195
        return $this->html;
196
    }
197
198
    /**
199
     * Gets the prepared text version of the mail.
200
     *
201
     * @return string
202
     */
203
    public function getText()
204
    {
205
        if ($this->text === null)
206
        {
207
            $this->prepareContent();
208
        }
209
210
        return $this->text;
211
    }
212
213
    /**
214
     * Gets the HTML content from the preferred source.
215
     *
216
     * @return string
217
     */
218
    abstract protected function getHtmlContent();
219
220
    /**
221
     * Get all DOM element that has "style" attribute
222
     *
223
     * @param  \DOMDocument  $doc
224
     * @return array
225
     */
226
    protected function getStyleNodes(DOMDocument $doc)
227
    {
228
        $nodes = [];
229
230
        foreach ($doc->getElementsByTagName('style') as $element)
231
        {
232
            $nodes[] = $element;
233
        }
234
235
        return $nodes;
236
    }
237
238
    /**
239
     * Get the HTML <style> tag CSS content
240
     *
241
     * @param  \DOMElement  $node
242
     * @return string|null
243
     */
244
    protected function getStyleTagContent(DOMElement $node)
245
    {
246
        if ( ! $this->isStyleTypeAllowed($node) || ! $this->isStyleMediaAllowed($node))
247
        {
248
            return null;
249
        }
250
251
        return (string) $node->nodeValue;
252
    }
253
254
    /**
255
     * Check if the HTML <style> tag has no [media] attribute or if it has a
256
     * [media] attribute, then it must either have a value of "all" or "screen".
257
     *
258
     * @param  \DOMElement  $style_node
259
     * @return bool
260
     */
261
    private function isStyleMediaAllowed(DOMElement $style_node)
262
    {
263
        $media = $style_node->attributes->getNamedItem('media');
264
265
        if (is_null($media)) return true;
266
267
        $media       = str_replace(' ', '', (string) $media->nodeValue);
0 ignored issues
show
Unused Code introduced by
$media is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
268
        $media_types = explode(',', $media_types);
0 ignored issues
show
Bug introduced by
The variable $media_types seems only to be defined at a later point. Did you maybe move this code here without moving the variable definition?

This error can happen if you refactor code and forget to move the variable initialization.

Let’s take a look at a simple example:

function someFunction() {
    $x = 5;
    echo $x;
}

The above code is perfectly fine. Now imagine that we re-order the statements:

function someFunction() {
    echo $x;
    $x = 5;
}

In that case, $x would be read before it is initialized. This was a very basic example, however the principle is the same for the found issue.

Loading history...
269
270
        return in_array('all', $media_types) || in_array('screen', $media_types);
271
    }
272
273
    /**
274
     * Check if the HTML <style> tag has the default [type] attribute or the value
275
     * of the [type] attribute is set to "text/css".
276
     *
277
     * @param  \DOMElement  $style_node
278
     * @return bool
279
     */
280
    private function isStyleTypeAllowed(DOMElement $style_node)
281
    {
282
        $type = $style_node->attributes->getNamedItem('type');
283
284
        return is_null($type) || (string) $type->nodeValue == 'text/css';
285
    }
286
287
    /**
288
     * Get all CSS in the mail template
289
     *
290
     * @param  \DOMDocument  $doc
291
     * @return string
292
     */
293
    protected function getStyleSheet(DOMDocument $doc)
294
    {
295
        $css = "";
296
        $nodes = $this->getStyleNodes($doc);
297
298
        foreach ($nodes as $node)
299
        {
300
            if ($content = $this->getStyleTagContent($node))
301
            {
302
                $css .= $content . "\r\n";
303
            }
304
305
            $node->parentNode->removeChild($node);
306
        }
307
308
        return $css;
309
    }
310
311
    /**
312
     * Prepares the mail HTML/text content.
313
     *
314
     * @return void
315
     */
316
    protected function prepareContent()
317
    {
318
        if ( ! class_exists('\DOMDocument'))
319
        {
320
            throw new RuntimeException("Required extension 'dom' seems to be missing.");
321
        }
322
323
        $doc = new DOMDocument();
324
        $doc->loadHTML($this->getHtmlContent());
325
326
        $xpath  = new DOMXPath($doc);
327
        $reader = (new CssString($this->getStyleSheet($doc)))->setEnvironmentEncoding($this->getCharset());
328
        $rules  = $reader->getStyleSheet()->getRules();
329
330
        // Extract all relevant style declarations
331
        $selectors = [];
332
333
        foreach ($this->getRelevantStyleRules($rules) as $styleRule)
334
        {
335
            foreach ($styleRule->getSelectors() as $selector) {
336
                // Check if the selector contains pseudo classes/elements that cannot
337
                // be mapped to elements
338
                $skip = false;
339
                foreach($selector->getPseudoClasses() as $pseudoClass) {
340
                    if (!in_array($pseudoClass, $this->allowed_pseudo_classes)) {
341
                        $skip = true;
342
                        break;
343
                    }
344
                }
345
                if ($skip === false) {
346
                    $specificity = $selector->getSpecificity();
347
                    if (!isset($selectors[$specificity])) {
348
                        $selectors[$specificity] = [];
349
                    }
350
                    $selectorString = $selector->getValue();
351
                    if (!isset($selectors[$specificity][$selectorString])) {
352
                        $selectors[$specificity][$selectorString] = [];
353
                    }
354
                    foreach ($styleRule->getDeclarations() as $declaration) {
355
                        $selectors[$specificity][$selectorString][] = $declaration;
356
                    }
357
                }
358
            }
359
        }
360
361
        // Get all specificity values (to process the declarations in the correct order,
362
        // without sorting the array by key, which perhaps could result in a changed
363
        // order of selectors within the specificity).
364
        $specificityKeys = array_keys($selectors);
365
        sort($specificityKeys);
366
367
        // Temporary remove all existing style attributes, because they always have the highest priority
368
        // and are added again after all styles have been applied to the elements
369
        $elements = $xpath->query("descendant-or-self::*[@style]");
370
        /** @var \DOMElement $element */
371
        foreach ($elements as $element) {
372
            if ($element->attributes !== null) {
373
                $styleAttribute = $element->attributes->getNamedItem("style");
374
375
                $styleValue = "";
376
                if ($styleAttribute !== null) {
377
                    $styleValue = (string)$styleAttribute->nodeValue;
378
                }
379
380
                if ($styleValue !== "") {
381
                    $element->setAttribute('data-pre-mailer-original-style', $styleValue);
382
                    $element->removeAttribute('style');
383
                }
384
            }
385
        }
386
387
        // Process all style declarations in the correct order
388
        foreach ($specificityKeys as $specificityKey) {
389
            /** @var StyleDeclaration[] $declarations */
390
            foreach ($selectors[$specificityKey] as $selectorString => $declarations) {
391
                $xpathQuery = (new CssSelector())->toXPath($selectorString);
392
                $elements = $xpath->query($xpathQuery);
393
                /** @var \DOMElement $element */
394
                foreach ($elements as $element) {
395
                    if ($element->attributes !== null) {
396
                        $styleAttribute = $element->attributes->getNamedItem("style");
397
398
                        $styleValue = "";
399
                        if ($styleAttribute !== null) {
400
                            $styleValue = (string)$styleAttribute->nodeValue;
401
                        }
402
403
                        $concat = ($styleValue === "") ? "" : ";";
404
                        foreach ($declarations as $declaration) {
405
                            $styleValue .= $concat . $declaration->getProperty() . ":" . $declaration->getValue();
406
                            $concat = ";";
407
                        }
408
409
                        $element->setAttribute('style', $styleValue);
410
                    }
411
                }
412
            }
413
        }
414
415
        // Add temporarily removed style attributes again, after all styles have been applied to the elements
416
        $elements = $xpath->query("descendant-or-self::*[@data-pre-mailer-original-style]");
417
        /** @var \DOMElement $element */
418
        foreach ($elements as $element) {
419
            if ($element->attributes !== null) {
420
                $styleAttribute = $element->attributes->getNamedItem("style");
421
                $styleValue = "";
422
                if ($styleAttribute !== null) {
423
                    $styleValue = (string)$styleAttribute->nodeValue;
424
                }
425
426
                $originalStyleAttribute = $element->attributes->getNamedItem("data-pre-mailer-original-style");
427
                $originalStyleValue = "";
428
                if ($originalStyleAttribute !== null) {
429
                    $originalStyleValue = (string)$originalStyleAttribute->nodeValue;
430
                }
431
432
                if ($styleValue !== "" || $originalStyleValue !== "") {
433
                    $styleValue = ($styleValue !== "" ? $styleValue . ";" : "") . $originalStyleValue;
434
                    $element->setAttribute('style', $styleValue);
435
                    $element->removeAttribute('data-pre-mailer-original-style');
436
                }
437
            }
438
        }
439
440
        // Optionally remove class attributes in HTML tags
441
        $optionHtmlClasses = $this->getOption(self::OPTION_HTML_CLASSES);
442
        if ($optionHtmlClasses === self::OPTION_HTML_CLASSES_REMOVE) {
443
            $nodesWithClass = [];
444
            foreach ($xpath->query('descendant-or-self::*[@class]') as $nodeWithClass) {
445
                $nodesWithClass[] = $nodeWithClass;
446
            }
447
            /** @var \DOMElement $nodeWithClass */
448
            foreach ($nodesWithClass as $nodeWithClass) {
449
                $nodeWithClass->removeAttribute('class');
450
            }
451
        }
452
453
        // Optionally remove HTML comments
454
        $optionHtmlComments = $this->getOption(self::OPTION_HTML_COMMENTS);
455
        if ($optionHtmlComments === self::OPTION_HTML_COMMENTS_REMOVE) {
456
            $commentNodes = [];
457
            foreach ($xpath->query('//comment()') as $comment) {
458
                $commentNodes[] = $comment;
459
            }
460
            foreach ($commentNodes as $commentNode) {
461
                $commentNode->parentNode->removeChild($commentNode);
462
            }
463
        }
464
465
        // Write XPath document back to DOM document
466
        $newDoc = $xpath->document;
467
468
        // Generate text version (before adding the styles again)
469
        $this->text = $this->prepareText($newDoc);
470
471
        // Optionally add styles tag to the HEAD or the BODY of the document
472
        $optionStyleTag = $this->getOption(self::OPTION_STYLE_TAG);
473
        if ($optionStyleTag === self::OPTION_STYLE_TAG_BODY || $optionStyleTag === self::OPTION_STYLE_TAG_HEAD) {
474
            $cssWriterClass = $this->getOption(self::OPTION_CSS_WRITER_CLASS);
475
            /** @var WriterAbstract $cssWriter */
476
            $cssWriter = new $cssWriterClass($reader->getStyleSheet());
477
            $styleNode = $newDoc->createElement("style");
478
            $styleNode->nodeValue = $cssWriter->getContent();
479
480
            if ($optionStyleTag === self::OPTION_STYLE_TAG_BODY) {
481
                /** @var \DOMNode $bodyNode */
482
                foreach($newDoc->getElementsByTagName('body') as $bodyNode) {
483
                    $bodyNode->insertBefore($styleNode, $bodyNode->firstChild);
484
                    break;
485
                }
486
            } elseif ($optionStyleTag === self::OPTION_STYLE_TAG_HEAD) {
487
                /** @var \DOMNode $headNode */
488
                foreach($newDoc->getElementsByTagName('head') as $headNode) {
489
                    $headNode->appendChild($styleNode);
490
                    break;
491
                }
492
            }
493
        }
494
495
        // Generate HTML version
496
        $this->html = $newDoc->saveHTML();
497
    }
498
499
    /**
500
     * Prepares the mail text content.
501
     *
502
     * @param \DOMDocument $doc
503
     * @return string
504
     */
505
    protected function prepareText(\DOMDocument $doc)
506
    {
507
        $text = $this->convertHtmlToText($doc->childNodes);
508
        $charset = $this->getCharset();
509
        $textLineMaxLength = $this->getOption(self::OPTION_TEXT_LINE_WIDTH);
510
511
        $text = preg_replace_callback('/^([^\n]+)$/m', function($match) use ($charset, $textLineMaxLength) {
512
                $break = "\n";
513
                $parts = preg_split('/((?:\(\t[^\t]+\t\))|[^\p{L}\p{N}])/', $match[0], -1, PREG_SPLIT_DELIM_CAPTURE);
514
515
                $return = "";
516
                $brLength = mb_strlen(trim($break, "\r\n"), $charset);
517
518
                $lineLength = $brLength;
519
                foreach ($parts as $part) {
520
                    // Replace character before/after links with a zero width space,
521
                    // and mark links as non-breakable
522
                    $breakLongLines = true;
523
                    if (strpos($part, "\t")) {
524
                        $part = str_replace("\t", mb_convert_encoding("\xE2\x80\x8C", $charset, "UTF-8"), $part);
525
                        $breakLongLines = false;
526
                    }
527
528
                    // Get part length
529
                    $partLength = mb_strlen($part, $charset);
530
531
                    // Ignore trailing space characters if this would cause the line break
532
                    if (($lineLength + $partLength) === ($textLineMaxLength + 1)) {
533
                        $lastChar = mb_substr($part, -1, 1, $charset);
534
                        if ($lastChar === " ") {
535
                            $part = mb_substr($part, 0, -1, $charset);
536
                            $partLength--;
537
                        }
538
                    }
539
540
                    // Check if enough chars left to add the part
541
                    if (($lineLength + $partLength) <= $textLineMaxLength) {
542
                        $return .= $part;
543
                        $lineLength += $partLength;
544
                    // Check if the part is longer than the line (so that we need to break it)
545
                    } elseif ($partLength > ($textLineMaxLength - $brLength)) {
546
                        if ($breakLongLines === true) {
547
                            $addPart = mb_substr($part, 0, ($textLineMaxLength - $lineLength), $charset);
548
                            $return .= $addPart;
549
                            $lineLength = $brLength;
550
551
                            for ($i = mb_strlen($addPart, $charset), $j = $partLength; $i < $j; $i+=($textLineMaxLength - $brLength)) {
552
                                $addPart = $break . mb_substr($part, $i, ($textLineMaxLength - $brLength), $charset);
553
                                $return .= $addPart;
554
                                $lineLength = mb_strlen($addPart, $charset) - 1;
555
                            }
556
                        } else {
557
                            $return .= $break . trim($part) . $break;
558
                            $lineLength = $brLength;
559
                        }
560
                    // Add a break to add the part in the next line
561
                    } else {
562
                        $return .= $break . rtrim($part);
563
                        $lineLength = $brLength + $partLength;
564
                    }
565
                }
566
                return $return;
567
            }, $text);
568
569
        $text = preg_replace('/^\s+|\s+$/', '', $text);
570
571
        return $text;
572
    }
573
574
    /**
575
     * Converts HTML tags to text, to create a text version of an HTML document.
576
     *
577
     * @param \DOMNodeList $nodes
578
     * @return string
579
     */
580
    protected function convertHtmlToText(\DOMNodeList $nodes)
581
    {
582
        $text = "";
583
584
        /** @var \DOMElement $node */
585
        foreach ($nodes as $node) {
586
            $lineBreaksBefore = 0;
587
            $lineBreaksAfter = 0;
588
            $lineCharBefore = "";
589
            $lineCharAfter = "";
590
            $prefix = "";
591
            $suffix = "";
592
593
            if (in_array($node->nodeName, ["h1", "h2", "h3", "h4", "h5", "h6", "h"])) {
594
                $lineCharAfter = "=";
595
                $lineBreaksAfter = 2;
596
            } elseif (in_array($node->nodeName, ["p", "td"])) {
597
                $lineBreaksAfter = 2;
598
            } elseif (in_array($node->nodeName, ["div"])) {
599
                $lineBreaksAfter = 1;
600
            }
601
602
            if ($node->nodeName === "h1") {
603
                $lineCharBefore = "*";
604
                $lineCharAfter = "*";
605
            } elseif ($node->nodeName === "h2") {
606
                $lineCharBefore = "=";
607
            }
608
609
            if ($node->nodeName === '#text') {
610
                $textContent = html_entity_decode($node->textContent, ENT_COMPAT | ENT_HTML401, $this->getCharset());
611
612
                // Replace tabs (used to mark links below) and other control characters
613
                $textContent = preg_replace("/[\r\n\f\v\t]+/", "", $textContent);
614
615
                if ($textContent !== "") {
616
                    $text .= $textContent;
617
                }
618
            } elseif ($node->nodeName === 'a') {
619
                $href = "";
620
                if ($node->attributes !== null) {
621
                    $hrefAttribute = $node->attributes->getNamedItem("href");
622
                    if ($hrefAttribute !== null) {
623
                        $href = (string)$hrefAttribute->nodeValue;
624
                    }
625
                }
626
                if ($href !== "") {
627
                    $suffix = " (\t" . $href . "\t)";
628
                }
629
            } elseif ($node->nodeName === 'b' || $node->nodeName === 'strong') {
630
                $prefix = "*";
631
                $suffix = "*";
632
            } elseif ($node->nodeName === 'hr') {
633
                $text .= str_repeat('-', 75) . "\n\n";
634
            }
635
636
            if ($node->hasChildNodes()) {
637
                $text .= str_repeat("\n", $lineBreaksBefore);
638
639
                $addText = $this->convertHtmlToText($node->childNodes);
640
641
                $text .= $prefix;
642
643
                $text .= $lineCharBefore ? str_repeat($lineCharBefore, 75) . "\n" : "";
644
                $text .= $addText;
645
                $text .= $lineCharAfter ? "\n" . str_repeat($lineCharAfter, 75) . "\n" : "";
646
647
                $text .= $suffix;
648
649
                $text .= str_repeat("\n", $lineBreaksAfter);
650
            }
651
        }
652
653
        // Remove unnecessary white spaces at he beginning/end of lines
654
        $text = preg_replace("/(?:^[ \t\f]+([^ \t\f])|([^ \t\f])[ \t\f]+$)/m", "\\1\\2", $text);
655
656
        // Replace multiple line-breaks
657
        $text = preg_replace("/[\r\n]{2,}/", "\n\n", $text);
658
659
        return $text;
660
    }
661
662
    /**
663
     * Gets all generally relevant style rules.
664
     * The selectors/declarations are checked in detail in prepareContent().
665
     *
666
     * @param $rules RuleAbstract[]
667
     * @return StyleRuleSet[]
668
     */
669
    protected function getRelevantStyleRules(array $rules)
670
    {
671
        $styleRules = [];
672
673
        foreach ($rules as $rule) {
674
            if ($rule instanceof StyleRuleSet) {
675
                $styleRules[] = $rule;
676
            } else if ($rule instanceof MediaRule) {
677
                foreach ($rule->getQueries() as $mediaQuery) {
678
                    // Only add styles in media rules, if the media rule is valid for "all" and "screen" media types
679
                    // @note: http://premailer.dialect.ca/ also supports "handheld", but this is really useless
680
                    $type = $mediaQuery->getType();
681
                    if ($type === MediaQuery::TYPE_ALL || $type === MediaQuery::TYPE_SCREEN) {
682
                        // ...and only if there are no additional conditions (like screen width etc.)
683
                        // which are dynamic and therefore need to be ignored.
684
                        $conditionCount = count($mediaQuery->getConditions());
685
                        if ($conditionCount === 0) {
686
                            foreach ($this->getRelevantStyleRules($rule->getRules()) as $styleRule) {
687
                                $styleRules[] = $styleRule;
688
                            }
689
                            break;
690
                        }
691
                    }
692
                }
693
            }
694
        }
695
696
        return $styleRules;
697
    }
698
699
    /**
700
     * Validate the scalar value of the passed option
701
     *
702
     * @param  string      $name
703
     * @param  string|int  $value
704
     * @return void
705
     *
706
     * @throws \InvalidArgumentException
707
     */
708
    protected function validateScalarOptionValue($name, $value)
709
    {
710
        switch ($name)
711
        {
712
            case self::OPTION_STYLE_TAG:
713
            case self::OPTION_HTML_CLASSES:
714
            case self::OPTION_HTML_COMMENTS:
715
            case self::OPTION_TEXT_LINE_WIDTH:
716
                if ( ! is_int($value))
717
                {
718
                    throw new InvalidArgumentException("The argument 1 of [setOption] method is expected to be a [integer] for option [{name}], but [" . gettype($value) . '] is given.');
719
                }
720
721
                break;
722
723
            case self::OPTION_CSS_WRITER_CLASS:
724
                if ( ! is_string($value))
725
                {
726
                    throw new InvalidArgumentException("The argument 1 of [setOption] method is expected to be a [string] for option [{name}], but [" . gettype($value) . '] is given.');
727
                }
728
729
                break;
730
        }
731
    }
732
733
    /**
734
     * Validate the possible value of the passed option
735
     *
736
     * @param  string      $name
737
     * @param  string|int  $value
738
     * @return void
739
     *
740
     * @throws \InvalidArgumentException
741
     */
742
    protected function validatePossibleOptionValue($name, $value)
743
    {
744
        switch ($name)
745
        {
746
            case self::OPTION_STYLE_TAG:
747
                if ( ! in_array($value, [self::OPTION_STYLE_TAG_BODY, self::OPTION_STYLE_TAG_HEAD, self::OPTION_STYLE_TAG_REMOVE]))
748
                {
749
                    throw new InvalidArgumentException("Invalid value [$value] for option [$name].");
750
                }
751
                break;
752
753
            case self::OPTION_HTML_CLASSES:
754
                if ( ! in_array($value, [self::OPTION_HTML_CLASSES_REMOVE, self::OPTION_HTML_CLASSES_KEEP]))
755
                {
756
                    throw new InvalidArgumentException("Invalid value [$value] for option [$name].");
757
                }
758
                break;
759
760
            case self::OPTION_HTML_COMMENTS:
761
                if ( ! in_array($value, [self::OPTION_HTML_COMMENTS_REMOVE, self::OPTION_HTML_COMMENTS_KEEP]))
762
                {
763
                    throw new InvalidArgumentException("Invalid value [$value] for option [$name].");
764
                }
765
                break;
766
767
            case self::OPTION_TEXT_LINE_WIDTH:
768
                if ($value <= 0)
769
                {
770
                    throw new LengthException("Value '" . gettype($value) . "' for option '$name' is to small.");
771
                }
772
                break;
773
774
            case self::OPTION_CSS_WRITER_CLASS:
775
                if (is_subclass_of($value, '\Crossjoin\Css\Writer\WriterAbstract', true) === false) {
776
                    throw new \InvalidArgumentException(
777
                        "Invalid value '$value' for option '$name'. " .
778
                        "The given class has to be a subclass of \\Crossjoin\\Css\\Writer\\WriterAbstract."
779
                    );
780
                }
781
        }
782
    }
783
784
}
785