InlineCssLib.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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 MIT
  12. */
  13. class InlineCssLib {
  14. const ENGINE_CSS_TO_INLINE = 'cssToInline';
  15. const ENGINE_EMOGRIFIER = 'emogrifier';
  16. protected $_defaults = array(
  17. 'engine' => self::ENGINE_CSS_TO_INLINE,
  18. 'cleanup' => true,
  19. 'useInlineStylesBlock' => true,
  20. 'xhtmlOutput' => false,
  21. 'removeCss' => true,
  22. 'debug' => false,
  23. 'correctUtf8' => false
  24. );
  25. public $settings = array();
  26. /**
  27. * startup
  28. */
  29. public function __construct($settings = array()) {
  30. $this->_defaults['correctUtf8'] = version_compare(PHP_VERSION, '5.4.0') >= 0;
  31. $defaults = am($this->_defaults, (array) Configure::read('InlineCss'));
  32. $this->settings = array_merge($defaults, $settings);
  33. if (!method_exists($this, '_process' . ucfirst($this->settings['engine']))) {
  34. throw new InternalErrorException('Engine does not exist');
  35. }
  36. }
  37. /**
  38. * @return string Result
  39. */
  40. public function process($html, $css = null) {
  41. if (($html = trim($html)) === '') {
  42. return $html;
  43. }
  44. $method = '_process' . ucfirst($this->settings['engine']);
  45. return $this->{$method}($html, $css);
  46. }
  47. /**
  48. * @return string Result
  49. */
  50. protected function _processEmogrifier($html, $css) {
  51. $css .= $this->_extractAndRemoveCss($html);
  52. App::import('Vendor', 'Emogrifier', array('file' => 'emogrifier' . DS . 'emogrifier.php'));
  53. $Emogrifier = new Emogrifier($html, $css);
  54. return @$Emogrifier->emogrify();
  55. }
  56. /**
  57. * Process css blocks to inline css
  58. * Also works for html snippets (without <html>)
  59. *
  60. * @return string HTML output
  61. */
  62. protected function _processCssToInline($html, $css) {
  63. App::import('Vendor', 'Tools.CssToInlineStyles', array('file' => 'CssToInlineStyles' . DS . 'CssToInlineStyles.php'));
  64. //fix issue with <html> being added
  65. $separator = '~~~~~~~~~~~~~~~~~~~~';
  66. if (strpos($html, '<html>') === false) {
  67. $incomplete = true;
  68. $html = $separator . $html . $separator;
  69. }
  70. $CssToInlineStyles = new CssToInlineStyles($html, $css);
  71. if ($this->settings['cleanup']) {
  72. $CssToInlineStyles->setCleanup();
  73. }
  74. if ($this->settings['useInlineStylesBlock']) {
  75. $CssToInlineStyles->setUseInlineStylesBlock();
  76. }
  77. if ($this->settings['removeCss']) {
  78. $CssToInlineStyles->setStripOriginalStyleTags();
  79. }
  80. if ($this->settings['correctUtf8']) {
  81. $CssToInlineStyles->setCorrectUtf8();
  82. }
  83. if ($this->settings['debug']) {
  84. CakeLog::write('css', $html);
  85. }
  86. $html = $CssToInlineStyles->convert($this->settings['xhtmlOutput']);
  87. if ($this->settings['removeCss']) {
  88. //$html = preg_replace('/\<style(.*)\>(.*)\<\/style\>/i', '', $html);
  89. $html = $this->stripOnly($html, array('style', 'script'), true);
  90. //CakeLog::write('css', $html);
  91. }
  92. if (!empty($incomplete)) {
  93. $html = substr($html, strpos($html, $separator) + 20);
  94. $html = substr($html, 0, strpos($html, $separator));
  95. $html = trim($html);
  96. }
  97. return $html;
  98. }
  99. /**
  100. * Some reverse function of strip_tags with blacklisting instead of whitelisting
  101. * //maybe move to Tools.Utility/String/Text?
  102. *
  103. * @return string cleanedStr
  104. */
  105. public function stripOnly($str, $tags, $stripContent = false) {
  106. $content = '';
  107. if (!is_array($tags)) {
  108. $tags = (strpos($str, '>') !== false ? explode('>', str_replace('<', '', $tags)) : array($tags));
  109. if (end($tags) === '') {
  110. array_pop($tags);
  111. }
  112. }
  113. foreach ($tags as $tag) {
  114. if ($stripContent) {
  115. $content = '(.+</' . $tag . '[^>]*>|)';
  116. }
  117. $str = preg_replace('#</?' . $tag . '[^>]*>' . $content . '#is', '', $str);
  118. }
  119. return $str;
  120. }
  121. /**
  122. * _extractAndRemoveCss - extracts any CSS from the rendered view and
  123. * removes it from the $html
  124. *
  125. * @return string
  126. */
  127. protected function _extractAndRemoveCss($html) {
  128. $css = null;
  129. $DOM = new DOMDocument;
  130. $DOM->loadHTML($html);
  131. // DOM removal queue
  132. $removeDoms = array();
  133. // catch <link> style sheet content
  134. $links = $DOM->getElementsByTagName('link');
  135. foreach ($links as $link) {
  136. if ($link->hasAttribute('href') && preg_match("/\.css$/i", $link->getAttribute('href'))) {
  137. // find the css file and load contents
  138. if ($link->hasAttribute('media')) {
  139. foreach ($this->mediaTypes as $cssLinkMedia) {
  140. if (strstr($link->getAttribute('media'), $cssLinkMedia)) {
  141. $css .= $this->_findAndLoadCssFile($link->getAttribute('href')) . "\n\n";
  142. $removeDoms[] = $link;
  143. }
  144. }
  145. } else {
  146. $css .= $this->_findAndLoadCssFile($link->getAttribute('href')) . "\n\n";
  147. $removeDoms[] = $link;
  148. }
  149. }
  150. }
  151. // Catch embeded <style> and @import CSS content
  152. $styles = $DOM->getElementsByTagName('style');
  153. // Style
  154. foreach ($styles as $style) {
  155. if ($style->hasAttribute('media')) {
  156. foreach ($this->mediaTypes as $cssLinkMedia) {
  157. if (strstr($style->getAttribute('media'), $cssLinkMedia)) {
  158. $css .= $this->_parseInlineCssAndLoadImports($style->nodeValue);
  159. $removeDoms[] = $style;
  160. }
  161. }
  162. } else {
  163. $css .= $this->_parseInlineCssAndLoadImports($style->nodeValue);
  164. $removeDoms[] = $style;
  165. }
  166. }
  167. // Remove
  168. if ($this->settings['removeCss']) {
  169. foreach ($removeDoms as $removeDom) {
  170. try {
  171. $removeDom->parentNode->removeChild($removeDom);
  172. } catch (DOMException $e) {}
  173. }
  174. $html = $DOM->saveHTML();
  175. }
  176. return $html;
  177. }
  178. /**
  179. * _findAndLoadCssFile - finds the appropriate css file within the CSS path
  180. *
  181. * @param string $cssHref
  182. * @return string Content
  183. */
  184. protected function _findAndLoadCssFile($cssHref) {
  185. $cssFilenames = array_merge($this->_globRecursive(CSS . '*.Css'), $this->_globRecursive(CSS . '*.CSS'), $this->_globRecursive(CSS . '*.css'));
  186. // Build an array of the ever more path specific $cssHref location
  187. $cssHrefs = split(DS, $cssHref);
  188. $cssHrefPaths = array();
  189. for ($i = count($cssHrefs) - 1; $i > 0; $i--) {
  190. if (isset($cssHrefPaths[count($cssHrefPaths) - 1])) {
  191. $cssHrefPaths[] = $cssHrefs[$i] . DS . $cssHrefPaths[count($cssHrefPaths) - 1];
  192. } else {
  193. $cssHrefPaths[] = $cssHrefs[$i];
  194. }
  195. }
  196. // the longest string match will be the match we are looking for
  197. $bestCssFilename = null;
  198. $bestCssMatchLength = 0;
  199. foreach ($cssFilenames as $cssFilename) {
  200. foreach ($cssHrefPaths as $cssHrefPath) {
  201. $regex = '/' . str_replace('/', '\/', str_replace('.', '\.', $cssHrefPath)) . '/';
  202. if (preg_match($regex, $cssFilename, $match)) {
  203. if (strlen($match[0]) > $bestCssMatchLength) {
  204. $bestCssMatchLength = strlen($match[0]);
  205. $bestCssFilename = $cssFilename;
  206. }
  207. }
  208. }
  209. }
  210. $css = null;
  211. if (!empty($bestCssFilename) && is_file($bestCssFilename)) {
  212. $css = file_get_contents($bestCssFilename);
  213. }
  214. return $css;
  215. }
  216. /**
  217. * _globRecursive
  218. *
  219. * @param string $pattern
  220. * @param integer $flags
  221. * @return array
  222. */
  223. protected function _globRecursive($pattern, $flags = 0) {
  224. $files = glob($pattern, $flags);
  225. foreach (glob(dirname($pattern) . '/*', GLOB_ONLYDIR | GLOB_NOSORT) as $dir) {
  226. $files = array_merge($files, $this->_globRecursive($dir . '/' . basename($pattern), $flags));
  227. }
  228. return $files;
  229. }
  230. /**
  231. * _parseInlineCssAndLoadImports
  232. *
  233. * @param string Input
  234. * @return string Result
  235. */
  236. protected function _parseInlineCssAndLoadImports($css) {
  237. // Remove any <!-- --> comment tags - they are valid in HTML but we probably
  238. // don't want to be commenting out CSS
  239. $css = str_replace('-->', '', str_replace('<!--', '', $css)) . "\n\n";
  240. // Load up the @import CSS if any exists
  241. preg_match_all("/\@import.*?url\((.*?)\)/i", $css, $matches);
  242. if (isset($matches[1]) && is_array($matches[1])) {
  243. // First remove the @imports
  244. $css = preg_replace("/\@import.*?url\(.*?\).*?;/i", '', $css);
  245. foreach ($matches[1] as $url) {
  246. if (preg_match("/^http/i", $url)) {
  247. if ($this->importExternalCss) {
  248. $css .= file_get_contents($url);
  249. }
  250. } else {
  251. $css .= $this->_findAndLoadCssFile($url);
  252. }
  253. }
  254. }
  255. return $css;
  256. }
  257. }