PageRenderTime 30ms CodeModel.GetById 9ms RepoModel.GetById 0ms app.codeStats 0ms

/Jyxo/Css.php

http://github.com/jyxo/php
PHP | 685 lines | 527 code | 69 blank | 89 comment | 45 complexity | 3b7722a5d10f2deb5c830eb8577bf4fd MD5 | raw file
  1. <?php declare(strict_types = 1);
  2. /**
  3. * Jyxo PHP Library
  4. *
  5. * LICENSE
  6. *
  7. * This source file is subject to the new BSD license that is bundled
  8. * with this package in the file license.txt.
  9. * It is also available through the world-wide-web at this URL:
  10. * https://github.com/jyxo/php/blob/master/license.txt
  11. */
  12. namespace Jyxo;
  13. use LogicException;
  14. use function array_diff;
  15. use function array_filter;
  16. use function array_flip;
  17. use function array_pop;
  18. use function array_search;
  19. use function array_values;
  20. use function count;
  21. use function end;
  22. use function explode;
  23. use function in_array;
  24. use function preg_match;
  25. use function preg_match_all;
  26. use function preg_replace;
  27. use function preg_replace_callback;
  28. use function preg_split;
  29. use function rtrim;
  30. use function sprintf;
  31. use function str_replace;
  32. use function strpos;
  33. use function strtolower;
  34. use function strtr;
  35. use function trim;
  36. use function uksort;
  37. use const PREG_SET_ORDER;
  38. /**
  39. * Class for working with CSS stylesheets.
  40. *
  41. * @copyright Copyright (c) 2005-2011 Jyxo, s.r.o.
  42. * @license https://github.com/jyxo/php/blob/master/license.txt
  43. * @author Jaroslav HanslĂ­k
  44. */
  45. class Css
  46. {
  47. /**
  48. * Constructor preventing from creating static class instances.
  49. */
  50. final public function __construct()
  51. {
  52. throw new LogicException(sprintf('Cannot create an instance of a static class %s.', static::class));
  53. }
  54. /**
  55. * Cleans up a CSS stylesheet.
  56. *
  57. * @param string $css Stylesheet definition
  58. * @return string
  59. */
  60. public static function repair(string $css): string
  61. {
  62. // Convert properties to lowercase
  63. $css = preg_replace_callback('~((?:^|\{|;)\\s*)([\-a-z]+)(\\s*:)~i', static function ($matches) {
  64. return $matches[1] . strtolower($matches[2]) . $matches[3];
  65. }, $css);
  66. // Convert rgb() and url() to lowercase
  67. $css = preg_replace_callback('~(rgb|url)(?=\\s*\()~i', static function ($matches) {
  68. return strtolower($matches[1]);
  69. }, $css);
  70. // Remove properties without values
  71. $css = preg_replace_callback('~\\s*[\-a-z]+\\s*:\\s*([;}]|$)~i', static function ($matches) {
  72. return $matches[1] === '}' ? '}' : '';
  73. }, $css);
  74. // Remove MS Office properties
  75. $css = preg_replace('~\\s*mso-[\-a-z]+\\s*:[^;}]*;?~i', '', $css);
  76. // Convert color definitions to lowercase
  77. $css = preg_replace_callback('~(:[^:]*?)(#[abcdef0-9]{3,6})~i', static function ($matches) {
  78. return $matches[1] . strtolower($matches[2]);
  79. }, $css);
  80. // Convert colors from RGB to HEX
  81. $css = preg_replace_callback('~rgb\\s*\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\)~', static function ($matches) {
  82. return sprintf('#%02x%02x%02x', $matches[1], $matches[2], $matches[3]);
  83. }, $css);
  84. return $css;
  85. }
  86. /**
  87. * Filters given properties.
  88. *
  89. * @param string $css Stylesheet definition
  90. * @param array $properties Filtered properties
  91. * @param bool $exclude If true, $properties will be removed from the stylesheet; if false, only $properties will be left
  92. * @return string
  93. */
  94. public static function filterProperties(string $css, array $properties, bool $exclude = true): string
  95. {
  96. $properties = array_flip($properties);
  97. return preg_replace_callback('~\\s*([\-a-z]+)\\s*:[^;}]*;?~i', static function ($matches) use ($properties, $exclude) {
  98. if ($exclude) {
  99. return isset($properties[$matches[1]]) ? '' : $matches[0];
  100. }
  101. return isset($properties[$matches[1]]) ? $matches[0] : '';
  102. }, $css);
  103. }
  104. /**
  105. * Removes unnecessary characters from a CSS stylesheet.
  106. *
  107. * It is recommended to use the repair() method on the stylesheet definition first.
  108. *
  109. * @param string $css Stylesheet definition
  110. * @return string
  111. */
  112. public static function minify(string $css): string
  113. {
  114. // Comments
  115. $minified = preg_replace('~/\*.*\*/~sU', '', $css);
  116. // Whitespace
  117. $minified = preg_replace('~\\s*([>+\~,{:;}])\\s*~', '\\1', $minified);
  118. $minified = preg_replace('~\(\\s+~', '(', $minified);
  119. $minified = preg_replace('~\\s+\)~', ')', $minified);
  120. $minified = trim($minified);
  121. // Convert colors from #ffffff to #fff
  122. $minified = preg_replace('~(:[^:]*?#)([abcdef0-9]{1})\\2([abcdef0-9]{1})\\3([abcdef0-9]{1})\\4~', '\\1\\2\\3\\4', $minified);
  123. // Empty selectors
  124. $minified = preg_replace('~(?<=})[^{]+\{\}~', '', $minified);
  125. // Remove units when 0
  126. $minified = preg_replace('~([\\s:]0)(?:px|pt|pc|in|mm|cm|em|ex|%)~', '\\1', $minified);
  127. // Unnecessary semicolons
  128. $minified = str_replace(';}', '}', $minified);
  129. $minified = trim($minified, ';');
  130. return $minified;
  131. }
  132. /**
  133. * Converts HTML styles inside <style> elements to inline styles.
  134. *
  135. * Supported selectors:
  136. * * a {...}
  137. * * #header {...}
  138. * * .icon {...}
  139. * * h1#header {...}
  140. * * a.icon.small {...}
  141. * * a#remove.icon.small {...}
  142. * * a img {...}
  143. * * a > img {...}
  144. * * li + li {...}
  145. * * a#remove.icon.small img {...}
  146. * * h1, h2 {...}
  147. * * p a:first-child {...}
  148. * * p a:last-child {...}
  149. * * p a:nth-child(...) {...}
  150. * * p a:nth-last-child(...) {...}
  151. * * p a:first-of-type {...}
  152. * * p a:last-of-type {...}
  153. * * p a:nth-of-type(...) {...}
  154. * * p a:nth-last-of-type(...) {...}
  155. * * a:link {...} - converts to a {...}
  156. *
  157. * @param string $html Processed HTML source
  158. * @return string
  159. */
  160. public static function convertStyleToInline(string $html): string
  161. {
  162. // Extract styles from the source
  163. $cssList = self::parseStyle($html);
  164. // If no styles were found, return the original HTML source
  165. if (empty($cssList)) {
  166. return $html;
  167. }
  168. // Parse the HTML source
  169. preg_match_all(
  170. '~(?:<\\w+[^>]*(?:\\s*/)?>)|(?:</\\w+>)|(?:<!--)|(?:<!\[endif\]-->)|(?:<!\[CDATA\[.+?\]\]>)|(?:<!DOCTYPE[^>]+>)|(?:[^<]+)~s',
  171. $html,
  172. $matches
  173. );
  174. $level = 0;
  175. $path = [];
  176. $nodeNo = 0;
  177. $nodes = [];
  178. foreach ($matches[0] as $match) {
  179. if (strpos($match, '</') === 0) {
  180. $level--;
  181. array_pop($path[$level]);
  182. $nodes[$nodeNo] = [
  183. 'number' => $nodeNo,
  184. 'type' => 'closing-tag',
  185. 'content' => $match,
  186. 'level' => $level,
  187. ];
  188. } elseif ($match[0] === '<' && strpos($match, '<!') !== 0) {
  189. [$tag, $attributes] = preg_split('~(?:\\s+|/|$)~', trim($match, '<>'), 2);
  190. $tag = strtolower($tag);
  191. $id = null;
  192. $class = [];
  193. if (preg_match('~(?:^|\\s)id=(?:(?:(["\'])([^\\1]+?)\\1)|(\\S+))~', $attributes, $matches)) {
  194. $id = $matches[3] ?? $matches[2];
  195. }
  196. if (preg_match('~(?:^|\\s)class=(?:(?:(["\'])([^\\1]+?)\\1)|(\\S+))~', $attributes, $matches)) {
  197. $class = preg_split('~\\s+~', $matches[3] ?? $matches[2]);
  198. }
  199. $path[$level][] = $nodeNo;
  200. $parent = null;
  201. if ($level > 0) {
  202. $parent = end($path[$level - 1]);
  203. $nodes[$parent]['children'][] = $nodeNo;
  204. }
  205. $nodes[$nodeNo] = [
  206. 'number' => $nodeNo,
  207. 'type' => 'opening-tag',
  208. 'content' => $match,
  209. 'level' => $level,
  210. 'parent' => $parent,
  211. 'children' => [],
  212. 'tag' => $tag,
  213. 'id' => $id,
  214. 'class' => $class,
  215. ];
  216. static $emptyTags = ['br', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'param', 'area', 'command', 'col', 'base', 'keygen', 'wbr'];
  217. if (!in_array($tag, $emptyTags, true)) {
  218. $level++;
  219. }
  220. } else {
  221. $nodes[$nodeNo] = [
  222. 'number' => $nodeNo,
  223. 'type' => 'other',
  224. 'content' => $match,
  225. 'level' => $level,
  226. ];
  227. }
  228. $nodeNo++;
  229. }
  230. $checkIfNodeMatchesSelector = static function (array $node, array $selector) use ($nodes): bool {
  231. if (
  232. (
  233. $selector['tag'] !== null
  234. && $node['tag'] !== $selector['tag']
  235. )
  236. || (
  237. $selector['id'] !== null
  238. && $node['id'] !== $selector['id']
  239. )
  240. || count(array_diff($selector['class'], $node['class'])) > 0
  241. ) {
  242. return false;
  243. }
  244. if (count($selector['pseudoClass']) === 0) {
  245. return true;
  246. }
  247. if ($node['parent'] === null) {
  248. return false;
  249. }
  250. $siblings = $nodes[$node['parent']]['children'];
  251. $positionAmongSiblings = array_search($node['number'], $siblings, true);
  252. if ($positionAmongSiblings === false) {
  253. return false;
  254. }
  255. $sameTypeSiblings = array_values(array_filter($siblings, static function (int $siblingNo) use ($nodes, $node): bool {
  256. return $node['tag'] === $nodes[$siblingNo]['tag'];
  257. }));
  258. $positionAmongSameTypeSiblings = array_search($node['number'], $sameTypeSiblings, true);
  259. // CSS is counting from one
  260. $positionAmongSiblings++;
  261. if ($positionAmongSameTypeSiblings !== false) {
  262. $positionAmongSameTypeSiblings++;
  263. }
  264. foreach ($selector['pseudoClass'] as $pseudoClass) {
  265. $match = false;
  266. if ($pseudoClass === 'first-child') {
  267. $match = $positionAmongSiblings === 1;
  268. } elseif ($pseudoClass === 'first-of-type') {
  269. $match = $positionAmongSameTypeSiblings === 1;
  270. } elseif ($pseudoClass === 'last-child') {
  271. $match = count($siblings) === $positionAmongSiblings;
  272. } elseif ($pseudoClass === 'last-of-type') {
  273. $match = count($sameTypeSiblings) === $positionAmongSameTypeSiblings;
  274. } elseif (preg_match('~^nth-(child|of-type)\(([^\)]+)\)$~', $pseudoClass, $matches)) {
  275. if ($matches[1] === 'child') {
  276. $position = $positionAmongSiblings;
  277. } else {
  278. if ($positionAmongSameTypeSiblings === false) {
  279. return false;
  280. }
  281. $position = $positionAmongSameTypeSiblings;
  282. }
  283. $figure = $matches[2];
  284. if ($figure === 'odd') {
  285. $match = $position % 2 === 1;
  286. } elseif ($figure === 'even') {
  287. $match = $position % 2 === 0;
  288. } elseif (preg_match('~^(\\d+)n(?:\+(\\d+))?$~', $figure, $figureMatches)
  289. || preg_match('~^\+?(\\d+)$~', $figure, $figureMatches)
  290. || preg_match('~^-(\\d+)n\+(\\d+)$~', $figure, $figureMatches)) {
  291. $a = (int) $figureMatches[1];
  292. $b = (int) ($figureMatches[2] ?? 0);
  293. $difference = $b - $position;
  294. $match = ($a === 0 ? $difference : $difference % $a) === 0;
  295. }
  296. } elseif (preg_match('~^nth-last-(child|of-type)\(([^\)]+)\)$~', $pseudoClass, $matches)) {
  297. if ($matches[1] === 'child') {
  298. $position = $positionAmongSiblings;
  299. $siblingsCount = count($siblings);
  300. } else {
  301. if ($positionAmongSameTypeSiblings === false) {
  302. return false;
  303. }
  304. $position = $positionAmongSameTypeSiblings;
  305. $siblingsCount = count($sameTypeSiblings);
  306. }
  307. $figure = $matches[2];
  308. if ($figure === 'even') {
  309. $match = ($siblingsCount % 2 === 0 ? 1 : 0) === $position % 2;
  310. } elseif ($figure === 'odd') {
  311. $match = ($siblingsCount % 2 === 0 ? 0 : 1) === $position % 2;
  312. } elseif (preg_match('~^(\\d+)n(?:\+(\\d+))?$~', $figure, $figureMatches)
  313. || preg_match('~^\+?(\\d+)$~', $figure, $figureMatches)
  314. || preg_match('~^-(\\d+)n\+(\\d+)$~', $figure, $figureMatches)) {
  315. $a = (int) $figureMatches[1];
  316. $b = (int) ($figureMatches[2] ?? 0);
  317. $difference = $siblingsCount + 1 - $position - $b;
  318. $match = ($a === 0 ? $difference : $difference % $a) === 0;
  319. }
  320. }
  321. if (!$match) {
  322. return false;
  323. }
  324. }
  325. return true;
  326. };
  327. $html = '';
  328. foreach ($nodes as $nodeNo => $node) {
  329. if ($node['type'] === 'opening-tag') {
  330. $inlineStyles = [];
  331. $styles = [];
  332. $addStyle = static function (string $rule, ?array $selectors = null) use (&$styles): void {
  333. [$property, $propertyValue] = explode(':', $rule, 2);
  334. $styles[$property][] = [
  335. 'value' => $propertyValue,
  336. 'selectors' => $selectors,
  337. ];
  338. };
  339. if (preg_match('~\\s+style=((?:(["\'])([^\\2]+?)\\2)|(\\S+))~', $node['content'], $matches)) {
  340. $styleContent = $matches[4] ?? $matches[3];
  341. if ($matches[2] === "'") {
  342. $styleContent = strtr($styleContent, ['"' => "'", "\\'" => "'"]);
  343. }
  344. $inlineStyles = explode(';', self::minify($styleContent));
  345. }
  346. // Walk through the CSS definition list and add applicable properties
  347. foreach ($cssList as $css) {
  348. $selectorPartsCount = count($css['selector']);
  349. // Selectors have to have equal or less parts than the HTML element nesting level
  350. if ($selectorPartsCount > $node['level'] + 1) {
  351. continue;
  352. }
  353. // The last selector part must correspond to the last processed tag
  354. $lastSelector = end($css['selector']);
  355. if (!$checkIfNodeMatchesSelector($node, $lastSelector)) {
  356. continue;
  357. }
  358. $selectorMatched = true;
  359. if ($selectorPartsCount > 1) {
  360. $previousSelector = $lastSelector;
  361. $currentNode = $node;
  362. // Skip last selector, it was already checked
  363. for ($i = $selectorPartsCount - 2; $i >= 0; $i--) {
  364. $selector = $css['selector'][$i];
  365. if ($previousSelector['type'] === 'sibling') {
  366. $siblings = $nodes[$currentNode['parent']]['children'];
  367. $positionAmongSiblings = array_search($currentNode['number'], $siblings, true);
  368. if (
  369. $positionAmongSiblings !== 0
  370. && $checkIfNodeMatchesSelector($nodes[$siblings[$positionAmongSiblings - 1]], $selector)
  371. ) {
  372. $currentNode = $nodes[$siblings[$positionAmongSiblings - 1]];
  373. $previousSelector = $selector;
  374. continue;
  375. }
  376. } else {
  377. $startSearchLevel = $currentNode['level'] - 1;
  378. $endSearchLevel = $previousSelector['type'] === 'child' ? $startSearchLevel : 0;
  379. for ($j = $startSearchLevel; $j >= $endSearchLevel; $j--) {
  380. $currentNode = $nodes[$currentNode['parent']];
  381. if ($checkIfNodeMatchesSelector($currentNode, $selector)) {
  382. $previousSelector = $selector;
  383. continue 2;
  384. }
  385. }
  386. }
  387. $selectorMatched = false;
  388. break;
  389. }
  390. }
  391. if (!$selectorMatched) {
  392. continue;
  393. }
  394. foreach (explode(';', $css['rules']) as $rule) {
  395. $addStyle($rule, $css['selector']);
  396. }
  397. }
  398. // Adds inline styles to the end
  399. foreach ($inlineStyles as $rule) {
  400. $addStyle($rule);
  401. }
  402. // Adds styles to HTML part
  403. if (count($styles) > 0) {
  404. $styleContent = '';
  405. foreach ($styles as $property => $propertyData) {
  406. uksort($propertyData, static function (int $a, int $b) use ($propertyData): int {
  407. $aHasImportant = strpos($propertyData[$a]['value'], '!important') !== false;
  408. $bHasImportant = strpos($propertyData[$b]['value'], '!important') !== false;
  409. if ($aHasImportant && !$bHasImportant) {
  410. return 1;
  411. }
  412. if (!$aHasImportant && $bHasImportant) {
  413. return -1;
  414. }
  415. $aIsInline = $propertyData[$a]['selectors'] === null;
  416. $bIsInline = $propertyData[$b]['selectors'] === null;
  417. if ($aIsInline && !$bIsInline) {
  418. return 1;
  419. }
  420. if (!$aIsInline && $bIsInline) {
  421. return -1;
  422. }
  423. $priority = static function (array $selectors): int {
  424. $priority = 0;
  425. foreach ($selectors as $selector) {
  426. if ($selector['id'] !== null) {
  427. $priority += 10000;
  428. }
  429. $classCount = count($selector['class']) + count($selector['pseudoClass']);
  430. if ($classCount > 0) {
  431. $priority += 100 * $classCount;
  432. }
  433. if ($selector['tag'] !== null) {
  434. $priority++;
  435. }
  436. }
  437. return $priority;
  438. };
  439. $aPriority = $priority($propertyData[$a]['selectors']);
  440. $bPriority = $priority($propertyData[$b]['selectors']);
  441. return $aPriority !== $bPriority ? $aPriority <=> $bPriority : $a <=> $b;
  442. });
  443. $styleContent .= sprintf('%s:%s;', $property, rtrim(end($propertyData)['value'], ';'));
  444. }
  445. $styleAttribute = sprintf('style="%s"', rtrim($styleContent, ';'));
  446. $node['content'] = preg_replace_callback(
  447. '~(?:(\\s+)style=(?:(?:(["\'])(?:[^\\2]+?)\\2)|(?:\\S+)))|(\\s*/?>$)~',
  448. static function (array $matches) use ($styleAttribute) {
  449. $before = $matches[1];
  450. if (isset($matches[3])) {
  451. $before = ' ';
  452. }
  453. $after = $matches[3] ?? '';
  454. return $before . $styleAttribute . $after;
  455. },
  456. $node['content'],
  457. 1
  458. );
  459. }
  460. }
  461. // Append the part to the HTML source
  462. $html .= $node['content'];
  463. }
  464. return $html;
  465. }
  466. /**
  467. * Helper method for searching and parsing <style> definitions inside a HTML source.
  468. *
  469. * @see \Jyxo\Css::convertStyleToInline()
  470. * @param string $html Processed HTML source
  471. * @return array
  472. */
  473. private static function parseStyle(string $html): array
  474. {
  475. // Remove conditional comments
  476. $html = preg_replace('~<!--\[if[^\]]*\]>.*?<!\[endif\]-->~s', '', $html);
  477. // Find <style> elements
  478. if (!preg_match_all('~<style\\s+(?:[^>]+\\s+)*type="text/css"[^>]*>(.*?)</style>~s', $html, $styles)) {
  479. return [];
  480. }
  481. $cssList = [];
  482. foreach ($styles[1] as $style) {
  483. // Remove CDATA and comments
  484. $style = str_replace(['<![CDATA[', ']]>', '<!--', '-->'], '', $style);
  485. $style = preg_replace('~/\*.*\*/~sU', '', $style);
  486. // Optimize the parsed definitions
  487. $style = self::minify($style);
  488. if (empty($style)) {
  489. continue;
  490. }
  491. // Replace double quotes with single quotes
  492. $style = strtr($style, ['"' => "'", "\\'" => "'"]);
  493. // Remove the last empty part
  494. $definitions = explode('}', $style, -1);
  495. foreach ($definitions as $definition) {
  496. // Allows only supported selectors with valid rules
  497. if (!preg_match('~^(?:(?:(?:(?:[#.]?[-\\w]+)+(?::[-\\w\(\)+]+)?)[\\s>+]*)+,?)+{(?:[-\\w]+:[^;]+[;]?)+$~', $definition)) {
  498. continue;
  499. }
  500. [$selector, $rules] = explode('{', $definition);
  501. foreach (explode(',', $selector) as $part) {
  502. // Convert a:link to a
  503. $part = str_replace(':link', '', $part);
  504. $parsedSelector = [];
  505. $type = null;
  506. if (!preg_match_all('~((?:[#.]?[-\\w]+)+(?::[-\\w\(\)+]+)?)|([+>\\s])~', $part, $matches, PREG_SET_ORDER)) {
  507. continue;
  508. }
  509. foreach ($matches as $match) {
  510. if (isset($match[2])) {
  511. switch ($match[2]) {
  512. case '+':
  513. $type = 'sibling';
  514. break;
  515. case '>':
  516. $type = 'child';
  517. break;
  518. default:
  519. $type = 'descendant';
  520. break;
  521. }
  522. continue;
  523. }
  524. $selectorPart = $match[1];
  525. if (strpos($selectorPart, ':') !== false) {
  526. [$selectorPart, $pseudoClass] = explode(':', $selectorPart, 2);
  527. // There can be multiple pseudo-classes
  528. $pseudoClass = explode(':', $pseudoClass);
  529. } else {
  530. $pseudoClass = [];
  531. }
  532. if (strpos($selectorPart, '.') !== false) {
  533. [$selectorPart, $class] = explode('.', $selectorPart, 2);
  534. // There can be multiple classes
  535. $class = explode('.', $class);
  536. } else {
  537. $class = [];
  538. }
  539. if (strpos($selectorPart, '#') !== false) {
  540. [$selectorPart, $id] = explode('#', $selectorPart, 2);
  541. } else {
  542. $id = null;
  543. }
  544. $tag = strtolower(trim($selectorPart));
  545. if ($tag === '') {
  546. $tag = null;
  547. }
  548. $parsedSelector[] = [
  549. 'type' => $type,
  550. 'tag' => $tag,
  551. 'id' => $id,
  552. 'class' => $class,
  553. 'pseudoClass' => $pseudoClass,
  554. ];
  555. }
  556. $cssList[] = [
  557. 'selector' => $parsedSelector,
  558. 'rules' => $rules,
  559. ];
  560. }
  561. }
  562. }
  563. return $cssList;
  564. }
  565. }