InlineCssLib.php 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. <?php
  2. /**
  3. * Wrapper for Inline CSS replacement.
  4. * Useful for sending HTML emails.
  5. *
  6. * Note: requires vendors CssToInline or emogrifier!
  7. * Default engine: CssToInline
  8. *
  9. * @author Mark Scherer
  10. * @copyright Mark Scherer
  11. * @license http://opensource.org/licenses/mit-license.php MIT
  12. */
  13. class InlineCssLib {
  14. const ENGINE_CSS_TO_INLINE = 'cssToInline';
  15. const ENGINE_EMOGRIFIER = 'emogrifier';
  16. /**
  17. * Default config
  18. *
  19. * @var array
  20. */
  21. protected $_defaults = [
  22. 'engine' => self::ENGINE_EMOGRIFIER,
  23. 'cleanup' => true,
  24. 'responsive' => false, // If classes/ids should not be remove, only relevant for cleanup=>true
  25. 'useInlineStylesBlock' => true,
  26. 'debug' => false, // only cssToInline
  27. 'xhtmlOutput' => false, // only cssToInline
  28. 'removeCss' => true, // only cssToInline
  29. 'correctUtf8' => false // only cssToInline
  30. ];
  31. public $config = [];
  32. /**
  33. * Inits with auto merged config.
  34. *
  35. * @param array $config
  36. */
  37. public function __construct($config = []) {
  38. $defaults = (array)Configure::read('InlineCss') + $this->_defaults;
  39. $this->config = $config + $defaults;
  40. if (!method_exists($this, '_process' . ucfirst($this->config['engine']))) {
  41. throw new InternalErrorException('Engine does not exist: ' . $this->config['engine']);
  42. }
  43. }
  44. /**
  45. * Processes HTML and CSS.
  46. *
  47. * @param string $html
  48. * @param string|null $css
  49. * @return string Result
  50. */
  51. public function process($html, $css = null) {
  52. $html = trim($html);
  53. if ($html === '') {
  54. return $html;
  55. }
  56. $method = '_process' . ucfirst($this->config['engine']);
  57. return $this->{$method}($html, $css);
  58. }
  59. /**
  60. * @param string $html
  61. * @param string $css
  62. * @return string Result
  63. */
  64. protected function _processEmogrifier($html, $css) {
  65. if (class_exists('\Pelago\Emogrifier')) {
  66. $Emogrifier = new \Pelago\Emogrifier($html, $css);
  67. } else {
  68. App::import('Vendor', 'Tools.Emogrifier', ['file' => 'Emogrifier/Emogrifier.php']);
  69. $Emogrifier = new Emogrifier($html, $css);
  70. }
  71. if (method_exists($Emogrifier, 'enableCssToHtmlMapping')) {
  72. $Emogrifier->enableCssToHtmlMapping();
  73. }
  74. if (method_exists($Emogrifier, 'disableInvisibleNodeRemoval')) {
  75. $Emogrifier->disableInvisibleNodeRemoval();
  76. }
  77. $result = @$Emogrifier->emogrify();
  78. if ($this->config['cleanup']) {
  79. // Remove comments and whitespace
  80. $result = preg_replace('/<!--(.|\s)*?-->/', '', $result);
  81. //$result = preg_replace( '/\s\s+/', '\s', $result);
  82. // Result classes and ids
  83. if (!$this->config['responsive']) {
  84. $result = preg_replace('/\bclass="[^"]*"/', '', $result);
  85. $result = preg_replace('/\bid="[^"]*"/', '', $result);
  86. }
  87. }
  88. return $result;
  89. }
  90. /**
  91. * Process css blocks to inline css
  92. * Also works for html snippets (without <html>)
  93. *
  94. * @param string $html
  95. * @param string $css
  96. * @return string HTML output
  97. */
  98. protected function _processCssToInline($html, $css) {
  99. App::import('Vendor', 'Tools.CssToInlineStyles', ['file' => 'CssToInlineStyles' . DS . 'CssToInlineStyles.php']);
  100. //fix issue with <html> being added
  101. $separator = '~~~~~~~~~~~~~~~~~~~~';
  102. if (strpos($html, '<html') === false) {
  103. $incomplete = true;
  104. $html = $separator . $html . $separator;
  105. }
  106. $CssToInlineStyles = new CssToInlineStyles($html, $css);
  107. if ($this->config['cleanup']) {
  108. $CssToInlineStyles->setCleanup();
  109. }
  110. if ($this->config['useInlineStylesBlock']) {
  111. $CssToInlineStyles->setUseInlineStylesBlock();
  112. }
  113. if ($this->config['removeCss']) {
  114. $CssToInlineStyles->setStripOriginalStyleTags();
  115. }
  116. if ($this->config['correctUtf8']) {
  117. $CssToInlineStyles->setCorrectUtf8();
  118. }
  119. if ($this->config['debug']) {
  120. CakeLog::write('css', $html);
  121. }
  122. $html = $CssToInlineStyles->convert($this->config['xhtmlOutput']);
  123. if ($this->config['removeCss']) {
  124. $html = $this->stripOnly($html, ['style', 'script'], true);
  125. }
  126. if (!empty($incomplete)) {
  127. $html = substr($html, strpos($html, $separator) + 20);
  128. $html = substr($html, 0, strpos($html, $separator));
  129. $html = trim($html);
  130. }
  131. return $html;
  132. }
  133. /**
  134. * Some reverse function of strip_tags with blacklisting instead of whitelisting
  135. * //maybe move to Tools.Utility/String/Text?
  136. *
  137. * @param string $str
  138. * @param array|string $tags
  139. * @param bool $stripContent
  140. * @return string Cleaned string
  141. */
  142. public function stripOnly($str, $tags, $stripContent = false) {
  143. $content = '';
  144. if (!is_array($tags)) {
  145. $tags = (strpos($str, '>') !== false ? explode('>', str_replace('<', '', $tags)) : [$tags]);
  146. if (end($tags) === '') {
  147. array_pop($tags);
  148. }
  149. }
  150. foreach ($tags as $tag) {
  151. if ($stripContent) {
  152. $content = '(.+</' . $tag . '[^>]*>|)';
  153. }
  154. $str = preg_replace('#</?' . $tag . '[^>]*>' . $content . '#is', '', $str);
  155. }
  156. return $str;
  157. }
  158. /**
  159. * _extractAndRemoveCss - extracts any CSS from the rendered view and
  160. * removes it from the $html
  161. *
  162. * @param string $html
  163. * @return string
  164. */
  165. protected function _extractAndRemoveCss($html) {
  166. $css = null;
  167. $DOM = new DOMDocument();
  168. $DOM->loadHTML($html);
  169. // DOM removal queue
  170. $removeDoms = [];
  171. // catch <link> style sheet content
  172. $links = $DOM->getElementsByTagName('link');
  173. foreach ($links as $link) {
  174. if ($link->hasAttribute('href') && preg_match("/\\.css$/i", $link->getAttribute('href'))) {
  175. // find the css file and load contents
  176. if ($link->hasAttribute('media')) {
  177. // FOR NOW
  178. continue;
  179. foreach ($this->mediaTypes as $cssLinkMedia) {
  180. if (strstr($link->getAttribute('media'), $cssLinkMedia)) {
  181. $css .= $this->_findAndLoadCssFile($link->getAttribute('href')) . "\n\n";
  182. $removeDoms[] = $link;
  183. }
  184. }
  185. } else {
  186. $css .= $this->_findAndLoadCssFile($link->getAttribute('href')) . "\n\n";
  187. $removeDoms[] = $link;
  188. }
  189. }
  190. }
  191. // Catch embedded <style> and @import CSS content
  192. $styles = $DOM->getElementsByTagName('style');
  193. // Style
  194. foreach ($styles as $style) {
  195. if ($style->hasAttribute('media')) {
  196. foreach ($this->mediaTypes as $cssLinkMedia) {
  197. if (strstr($style->getAttribute('media'), $cssLinkMedia)) {
  198. $css .= $this->_parseInlineCssAndLoadImports($style->nodeValue);
  199. $removeDoms[] = $style;
  200. }
  201. }
  202. } else {
  203. $css .= $this->_parseInlineCssAndLoadImports($style->nodeValue);
  204. $removeDoms[] = $style;
  205. }
  206. }
  207. // Remove
  208. if ($this->config['removeCss']) {
  209. foreach ($removeDoms as $removeDom) {
  210. try {
  211. $removeDom->parentNode->removeChild($removeDom);
  212. } catch (DOMException $e) {
  213. }
  214. }
  215. $html = $DOM->saveHTML();
  216. }
  217. return $html;
  218. }
  219. /**
  220. * _findAndLoadCssFile - finds the appropriate css file within the CSS path
  221. *
  222. * @param string $cssHref
  223. * @return string Content
  224. */
  225. protected function _findAndLoadCssFile($cssHref) {
  226. $cssFilenames = array_merge($this->_globRecursive(CSS . '*.Css'), $this->_globRecursive(CSS . '*.CSS'), $this->_globRecursive(CSS . '*.css'));
  227. // Build an array of the ever more path specific $cssHref location
  228. $cssHref = str_replace(['\\', '/'], '/', $cssHref);
  229. $cssHrefs = explode(DS, $cssHref);
  230. $cssHrefPaths = [];
  231. for ($i = count($cssHrefs) - 1; $i > 0; $i--) {
  232. if (isset($cssHrefPaths[count($cssHrefPaths) - 1])) {
  233. $cssHrefPaths[] = $cssHrefs[$i] . DS . $cssHrefPaths[count($cssHrefPaths) - 1];
  234. } else {
  235. $cssHrefPaths[] = $cssHrefs[$i];
  236. }
  237. }
  238. // the longest string match will be the match we are looking for
  239. $bestCssFilename = null;
  240. $bestCssMatchLength = 0;
  241. foreach ($cssFilenames as $cssFilename) {
  242. foreach ($cssHrefPaths as $cssHrefPath) {
  243. $regex = '/' . str_replace('/', '\/', str_replace('.', '\.', $cssHrefPath)) . '/';
  244. if (preg_match($regex, $cssFilename, $match)) {
  245. if (strlen($match[0]) > $bestCssMatchLength) {
  246. $bestCssMatchLength = strlen($match[0]);
  247. $bestCssFilename = $cssFilename;
  248. }
  249. }
  250. }
  251. }
  252. $css = null;
  253. if (!empty($bestCssFilename) && is_file($bestCssFilename)) {
  254. $context = stream_context_create(
  255. ['http' => ['header' => 'Connection: close']]);
  256. $css = file_get_contents($bestCssFilename, 0, $context);
  257. }
  258. return $css;
  259. }
  260. /**
  261. * _globRecursive
  262. *
  263. * @param string $pattern
  264. * @param int $flags
  265. * @return array
  266. */
  267. protected function _globRecursive($pattern, $flags = 0) {
  268. $files = glob($pattern, $flags);
  269. foreach (glob(dirname($pattern) . '/*', GLOB_ONLYDIR | GLOB_NOSORT) as $dir) {
  270. $files = array_merge($files, $this->_globRecursive($dir . '/' . basename($pattern), $flags));
  271. }
  272. return $files;
  273. }
  274. /**
  275. * _parseInlineCssAndLoadImports
  276. *
  277. * @param string $css Input
  278. * @return string Result
  279. */
  280. protected function _parseInlineCssAndLoadImports($css) {
  281. // Load up the @import CSS if any exists
  282. preg_match_all("/\@import.*?url\((.*?)\)/i", $css, $matches);
  283. if (isset($matches[1]) && is_array($matches[1])) {
  284. // First remove the @imports
  285. $css = preg_replace("/\@import.*?url\(.*?\).*?;/i", '', $css);
  286. $context = stream_context_create(
  287. ['http' => ['header' => 'Connection: close']]);
  288. foreach ($matches[1] as $url) {
  289. if (preg_match("/^http/i", $url)) {
  290. $css .= file_get_contents($url, 0, $context);
  291. } else {
  292. $css .= $this->_findAndLoadCssFile($url);
  293. }
  294. }
  295. }
  296. return $css;
  297. }
  298. }