Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
CssInlinerService
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
3 / 3
14
100.00% covered (success)
100.00%
1 / 1
 inline
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 parseCssRules
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 applyRuleToHtml
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Template\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11/**
12 * Lightweight Email CSS Inliner Service.
13 *
14 * Converts global CSS stylesheets and <style> declarations into inline
15 * style attributes on HTML tags for email client rendering fidelity.
16 *
17 * @package App\Core\Template\Application\Service
18 */
19final class CssInlinerService
20{
21    /**
22     * Inlines CSS stylesheet into HTML body elements.
23     *
24     * @param string $htmlBody   HTML document content.
25     * @param string $cssContent Additional CSS rules to inline.
26     * @return string Inlined HTML document.
27     */
28    public function inline(string $htmlBody, string $cssContent = ''): string
29    {
30        $allCss = $cssContent;
31
32        if (preg_match_all('/<style\b[^>]*>(.*?)<\/style>/is', $htmlBody, $matches)) {
33            foreach ($matches[1] as $styleBlock) {
34                $allCss .= "\n" . $styleBlock;
35            }
36        }
37
38        $rules = $this->parseCssRules($allCss);
39        if ($rules === []) {
40            return $htmlBody;
41        }
42
43        $inlinedHtml = $htmlBody;
44        foreach ($rules as $selector => $declaration) {
45            $inlinedHtml = $this->applyRuleToHtml($inlinedHtml, $selector, $declaration);
46        }
47
48        return (string)preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $inlinedHtml);
49    }
50
51    /**
52     * Parses CSS text into associative map of selector => style declaration string.
53     *
54     * @param string $css Raw CSS stylesheet.
55     * @return array<string, string> Map of selector to style attributes.
56     */
57    private function parseCssRules(string $css): array
58    {
59        $cleanCss = (string)preg_replace('/\/\*.*?\*\//s', '', $css);
60        $rules = [];
61
62        if (preg_match_all('/([^{]+)\{([^}]+)\}/s', $cleanCss, $matches, PREG_SET_ORDER)) {
63            foreach ($matches as $match) {
64                $selector = trim($match[1]);
65                $declarations = trim(preg_replace('/\s+/', ' ', $match[2]));
66                if ($selector !== '' && $declarations !== '') {
67                    $rules[$selector] = $declarations;
68                }
69            }
70        }
71
72        return $rules;
73    }
74
75    /**
76     * Applies declaration to simple class, tag, or ID selectors in HTML string.
77     *
78     * @param string $html        Target HTML markup.
79     * @param string $selector    CSS selector string.
80     * @param string $declaration CSS style rule string.
81     * @return string Transformed HTML markup.
82     */
83    private function applyRuleToHtml(string $html, string $selector, string $declaration): string
84    {
85        if (str_starts_with($selector, '.')) {
86            $className = preg_quote(substr($selector, 1), '/');
87            return (string)preg_replace_callback(
88                '/<([a-z0-9]+)([^>]*?)class=(["\'])([^"\']*?\b' . $className . '\b[^"\']*?)\3([^>]*?)>/i',
89                static function (array $m) use ($declaration): string {
90                    $existingStyle = '';
91                    if (preg_match('/style=(["\'])(.*?)\1/i', $m[0], $sm)) {
92                        $existingStyle = rtrim($sm[2], '; ') . '; ';
93                    }
94                    $newStyle = 'style="' . htmlspecialchars($existingStyle . $declaration, ENT_QUOTES, 'UTF-8') . '"';
95                    if ($existingStyle !== '') {
96                        return (string)preg_replace('/style=(["\']).*?\1/i', $newStyle, $m[0]);
97                    }
98                    return '<' . $m[1] . $m[2] . 'class=' . $m[3] . $m[4] . $m[3] . ' ' . $newStyle . $m[5] . '>';
99                },
100                $html
101            );
102        }
103
104        return $html;
105    }
106}