File: /home/xedaptot/be.naniguide.com/app/Library/InlineStyleWrapper.php
<?php
namespace Acelle\Library;
use Symfony\Component\CssSelector\Exception\ParseException;
use function Acelle\Helpers\url_get_contents_ssl_safe;
/**
* Parses a html file and applies all embedded and external stylesheets inline
*/
class InlineStyleWrapper
{
/**
* @var \DOMDocument the HTML as DOMDocument
*/
private $_dom;
/**
* Prepare all the necessary objects
*
* @param string $html
*/
public function __construct($html = '')
{
if ($html) {
if ($html instanceof \DOMDocument) {
$this->loadDomDocument(clone $html);
} elseif (strlen($html) <= PHP_MAXPATHLEN && file_exists($html)) {
$this->loadHTMLFile($html);
} else {
$this->loadHTML($html);
}
}
}
/**
* Load HTML file
*
* @param string $filename
*/
public function loadHTMLFile($filename)
{
$this->loadHTML(file_get_contents($filename));
}
/**
* Load HTML string (UTF-8 encoding assumed)
*
* @param string $html
*/
public function loadHTML($html)
{
$document = new \DOMDocument();
$document->encoding = 'utf-8';
$document->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_HTML_NODEFDTD);
$this->loadDomDocument($document);
}
/**
* Load the HTML as a DOMDocument directly
*
* @param \DOMDocument $domDocument
*/
public function loadDomDocument(\DOMDocument $domDocument)
{
$this->_dom = $domDocument;
foreach ($this->_getNodesForCssSelector('[style]') as $node) {
$node->setAttribute(
'inlinestyle-original-style',
$node->getAttribute('style')
);
}
}
/**
* Applies one or more stylesheets to the current document
*
* @param string $stylesheet
* @return InlineStyle self
*/
public function applyStylesheet($stylesheet)
{
$stylesheet = (array) $stylesheet;
foreach ($stylesheet as $ss) {
$parsed = $this->parseStylesheet($ss);
$parsed = $this->sortSelectorsOnSpecificity($parsed);
foreach ($parsed as $arr) {
list($selector, $style) = $arr;
$this->applyRule($selector, $style);
}
}
return $this;
}
/**
* @param string $sel Css Selector
* @return array|\DOMNodeList|\DOMElement[]
*/
private function _getNodesForCssSelector($sel)
{
try {
if (class_exists('Symfony\Component\CssSelector\CssSelectorConverter')) {
$converter = new \Symfony\Component\CssSelector\CssSelectorConverter(true);
$xpathQuery = $converter->toXPath($sel);
} else {
$xpathQuery = \Symfony\Component\CssSelector\CssSelector::toXPath($sel);
}
return $this->_getDomXpath()->query($xpathQuery);
} catch (ParseException $e) {
// ignore css rule parse exceptions
}
return array();
}
/**
* Applies a style rule on the document
* @param string $selector
* @param string $style
* @return InlineStyle self
*/
public function applyRule($selector, $style)
{
if ($selector) {
$nodes = $this->_getNodesForCssSelector($selector);
$style = $this->_styleToArray($style);
foreach ($nodes as $node) {
$current = $node->hasAttribute("style") ?
$this->_styleToArray($node->getAttribute("style")) :
array();
$current = $this->_mergeStyles($current, $style);
$node->setAttribute("style", $this->_arrayToStyle($current));
}
}
return $this;
}
/**
* Returns the DOMDocument as html
*
* @return string the HTML
*/
public function getHTML()
{
$clone = $this;
foreach ($clone->_getNodesForCssSelector('[inlinestyle-original-style]') as $node) {
$current = $node->hasAttribute("style") ?
$this->_styleToArray($node->getAttribute("style")) :
array();
$original = $node->hasAttribute("inlinestyle-original-style") ?
$this->_styleToArray($node->getAttribute("inlinestyle-original-style")) :
array();
$current = $clone->_mergeStyles($current, $original);
$node->setAttribute("style", $this->_arrayToStyle($current));
$node->removeAttribute('inlinestyle-original-style');
}
return StringHelper::saveHTMLWithUTF8AndDoctype($clone->_dom);
}
/**
* Recursively extracts the stylesheet nodes from the DOMNode
*
* This cannot be done with XPath or a CSS selector because the order in
* which the elements are found matters
*
* @param \DOMNode $node leave empty to extract from the whole document
* @param string $base The base URI for relative stylesheets
* @param array $devices Considered devices
* @param boolean $remove Should it remove the original stylesheets
* @return array the extracted stylesheets
*/
public function extractStylesheets($node = null, $base = '', $devices = array('all', 'screen', 'handheld'), $remove = true)
{
if (null === $node) {
$node = $this->_dom;
}
$stylesheets = array();
if ($node->hasChildNodes()) {
$removeQueue = array();
/** @var $child \DOMElement */
foreach ($node->childNodes as $child) {
$nodeName = strtolower($child->nodeName);
if ($nodeName === "style" && $this->isForAllowedMediaDevice($child->getAttribute('media'), $devices)) {
$stylesheets[] = $child->nodeValue;
$removeQueue[] = $child;
} elseif ($nodeName === "link" && strtolower($child->getAttribute('rel')) === 'stylesheet' && $this->isForAllowedMediaDevice($child->getAttribute('media'), $devices)) {
if ($child->hasAttribute("href")) {
$href = $child->getAttribute("href");
if ($base && false === strpos($href, "://")) {
$href = "{$base}/{$href}";
}
$ext = @url_get_contents_ssl_safe($href);
if ($ext) {
$removeQueue[] = $child;
$stylesheets[] = $ext;
}
}
} else {
$stylesheets = array_merge(
$stylesheets,
$this->extractStylesheets($child, $base, $devices, $remove)
);
}
}
if ($remove) {
foreach ($removeQueue as $child) {
$child->parentNode->removeChild($child);
}
}
}
return $stylesheets;
}
/**
* Extracts the stylesheet nodes nodes specified by the xpath
*
* @param string $xpathQuery xpath query to the desired stylesheet
* @return array the extracted stylesheets
*/
public function extractStylesheetsWithXpath($xpathQuery)
{
$stylesheets = array();
$nodes = $this->_getDomXpath()->query($xpathQuery);
foreach ($nodes as $node) {
$stylesheets[] = $node->nodeValue;
$node->parentNode->removeChild($node);
}
return $stylesheets;
}
/**
* Parses a stylesheet to selectors and properties
* @param string $stylesheet
* @return array
*/
public function parseStylesheet($stylesheet)
{
$parsed = array();
$stylesheet = $this->_stripStylesheet($stylesheet);
$stylesheet = trim(trim($stylesheet), "}");
foreach (explode("}", $stylesheet) as $rule) {
//Don't parse empty rules
if (!trim($rule)) {
continue;
}
list($selector, $style) = explode("{", $rule, 2);
foreach (explode(',', $selector) as $sel) {
$parsed[] = array(trim($sel), trim(trim($style), ";"));
}
}
return $parsed;
}
public function sortSelectorsOnSpecificity($parsed)
{
usort($parsed, array($this, 'sortOnSpecificity'));
return $parsed;
}
private function sortOnSpecificity($a, $b)
{
$a = $this->getScoreForSelector($a[0]);
$b = $this->getScoreForSelector($b[0]);
foreach (range(0, 2) as $i) {
if ($a[$i] !== $b[$i]) {
return $a[$i] < $b[$i] ? -1 : 1;
}
}
return -1;
}
public function getScoreForSelector($selector)
{
return array(
preg_match_all('/#\w/i', $selector, $result), // ID's
preg_match_all('/\.\w/i', $selector, $result), // Classes
preg_match_all('/^\w|\ \w|\(\w|\:[^not]/i', $selector, $result) // Tags
);
}
/**
* Parses style properties to a array which can be merged by mergeStyles()
* @param string $style
* @return array
*/
private function _styleToArray($style)
{
$styles = array();
$style = trim(trim($style), ";");
if ($style) {
foreach (explode(";", $style) as $props) {
$props = trim(trim($props), ";");
//Don't parse empty props
if (!trim($props)) {
continue;
}
//Only match valid CSS rules
if (preg_match('#^([-a-z0-9\*]+):(.*)$#i', $props, $matches) && isset($matches[0], $matches[1], $matches[2])) {
list(, $prop, $val) = $matches;
$styles[$prop] = $val;
}
}
}
return $styles;
}
private function _arrayToStyle($array)
{
$st = array();
foreach ($array as $prop => $val) {
$st[] = "{$prop}:{$val}";
}
return \implode(';', $st);
}
/**
* Merges two sets of style properties taking !important into account
* @param array $styleA
* @param array $styleB
* @return array
*/
private function _mergeStyles(array $styleA, array $styleB)
{
foreach ($styleB as $prop => $val) {
if (!isset($styleA[$prop])
|| substr(str_replace(" ", "", strtolower($styleA[$prop])), -10) !== "!important") {
$styleA[$prop] = $val;
}
}
return $styleA;
}
private function _stripStylesheet($s)
{
// strip comments
$s = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $s);
// strip keyframes rules
$s = preg_replace('/@[-|keyframes].*?\{.*?\}[ \r\n]*\}/s', '', $s);
return $s;
}
private function _getDomXpath()
{
return new \DOMXPath($this->_dom);
}
public function __clone()
{
$this->_dom = clone $this->_dom;
}
private function isForAllowedMediaDevice($mediaAttribute, array $devices)
{
$mediaAttribute = strtolower($mediaAttribute);
$mediaDevices = explode(',', $mediaAttribute);
foreach ($mediaDevices as $device) {
$device = trim($device);
if (!$device || in_array($device, $devices)) {
return true;
}
}
return false;
}
}